18 — Trust, but Verify
How reading the wrong memory for a week taught me to never trust manual offset math on ARM.
The Bug That Wasn’t
All the Lua tests were passing. SaveBlock2 integrity? Green. Gear bag validation? Green. Relic chest? Green. Seventeen checks, zero failures.
Then a compile-time assertion went in — a simple STATIC_ASSERT that the size of SaveBlock2 equals 0xF24 bytes, the number used everywhere. The build failed.
The actual size was 0xF28.
Four bytes off. Not a rounding error. Not a typo. The ARM APCS ABI — the calling convention the GBA compiler enforces — has alignment rules that don’t exist on x86. A struct QuestSlot containing four u8 fields is 4 bytes, sure. But the compiler aligns it to a 4-byte boundary, which means 2 bytes of invisible padding get inserted after radiantShards. A u16 after a u8? One byte of padding. It cascades. Every field after the quest slots sat at the wrong offset.
The Lua tests read the wrong memory addresses from day one. They passed because a fresh save file is all zeros — and zero is a valid value for every field being checked. The tests were testing nothing.
Three Tiers of Paranoia
A system was needed that wouldn’t let this happen again. Not just checks — layered verification where each tier catches what the others miss.
Tier 1: Compile-time. Thirteen STATIC_ASSERT lines in src/save.c. They fire during make modern — if offsetof(SaveBlock2, questSlots) isn’t exactly 0xC64, the build dies. No ROM gets produced. No bad offset reaches the emulator. Zero runtime cost, instant feedback.
Tier 2: CI tests. A Python suite that parses the actual C headers and validates everything without touching a ROM. Gear bit-packing round-trips — encode a u32, decode it, verify every field survived. Struct layout checks — read global.h, find the offset comments, compare against known-good values. Address drift — parse the linker .map file and verify the Lua scripts point at the right symbols. Lint checks — grep for the bugs that keep coming back. make test runs 102 checks in under a second. The build runs 13 more.
Tier 3: Emulator tests. Lua scripts running in mGBA against a real save state. These are the only tests that touch actual game memory. SaveBlock2 field ranges, gear data corruption scanning, palette stability monitoring. Manual — load them in the scripting console — but they test what no static analysis can: runtime behavior. Twenty-three checks, five scripts.
The Lint Layer
The most satisfying tier is lint, because it encodes institutional memory. Every recurring bug becomes a grep pattern.
The * character isn’t in the GBA charmap — used accidentally three times. Now test_lint.py scans every _() string for it. TEXT_COLOR_YELLOW doesn’t exist in the engine — tried to use it twice. The lint catches it. GetTextWindowPalette(2) is the vanilla way to load a text palette — in custom UIs it corrupts everything. Caught. Every full-screen UI needs FillPalette(0, 0, PLTT_SIZE) in its init sequence or red-black flashing from stale battle palettes follows. Caught. Every custom UI needs ChangeBgY(1,0,0) or the background texture shifts. Caught.
Eight checks. Eight bugs that won’t come back.
The Offset Discovery
The correct way to find offsets: don’t count bytes by hand.
const u32 offsets[] = {
offsetof(struct SaveBlock2, questSlots),
offsetof(struct SaveBlock2, battleStreak),
offsetof(struct SaveBlock2, encryptionKey),
};
Compile it for ARM, dump the .rodata section with objdump, read the little-endian u32 values. That’s ground truth. Everything else is a guess.
Every Lua script, every Python test, every doc comment, and the CLAUDE.md rules file got updated. Then the compile-time assertions went in, so nobody — including future me — can drift from reality again.
What Changed
The town portal hint bar said A:Warp B:Back instead of {A_BUTTON}:Warp {B_BUTTON}:Back. A small thing. The lint didn’t catch it — yet. But the style audit that came with the doc updates found it, and a consistency reference table in the STYLEGUIDE now makes the canonical form unambiguous.
102 automated checks. 13 compile-time assertions. 5 Lua test scripts. One wrong assumption about struct padding that invalidated a week of testing. The GBA doesn’t make verification easy. But it doesn’t make it impossible either.
By the Numbers
| Metric | Value |
|---|---|
| Commits | 11 |
| Copilot requests | ~46 |
| Tool executions | ~536 |
| Sub-agents spawned | 5 |
Zero is a valid value for every field. That’s why the tests passed.
Back to README