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 I tried to add a compile-time assertion — a simple STATIC_ASSERT that the size of SaveBlock2 equals 0xF24 bytes, the number I’d been using 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 uses — enforces 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 was at the wrong offset.

The Lua tests had been reading the wrong memory addresses since day one. They passed because a fresh save file is all zeros — and zero is a valid value for every field I was checking. The tests were testing nothing.

Three Tiers of Paranoia

I needed a system that wouldn’t let this happen again. Not just “run some checks,” but 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. This is the cheapest possible check: 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. And lint checks — grep for the bugs that keep coming back.

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. They’re manual — you load them in the scripting console — but they test what no static analysis can: runtime behavior.

make test runs 102 checks in under a second. The build runs 13 more. The Lua tests add another 23. No emulator needed for CI. No human interaction needed for the first two tiers.

The Lint Layer

The most satisfying tier is the lint checks, because they encode institutional memory. Every recurring bug becomes a grep pattern:

The * character isn’t in the GBA charmap — we’ve accidentally used it three times. Now test_lint.py scans every _() string for it. TEXT_COLOR_YELLOW doesn’t exist in the engine — we’ve tried to use it twice. The lint catches it. GetTextWindowPalette(2) is the vanilla way to load a text palette — in our custom UIs it corrupts everything. Caught. Every full-screen UI needs FillPalette(0, 0, PLTT_SIZE) in its init sequence or you get red-black flashing from stale battle palettes. 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 real lesson was how to find the correct offsets in the first place. You can’t trust manual byte-counting on ARM. You need the compiler to tell you:

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.

I updated every Lua script, every Python test, every doc comment, and the CLAUDE.md rules file. Then I added the compile-time assertions 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 now there’s a consistency reference table in the STYLEGUIDE that 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. You just have to decide that “the tests pass on a fresh save” isn’t good enough.

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