Starting a Decompilation Project from Zero: Claude Code and 51% of a 2001 GBA Game
From Klonoa's raw ROM to byte-matched C (and an easter egg nobody had found in 25 years)
In the previous chapter, we got the data: an LLM-powered pipeline matched 74% of the benchmark functions. Now it’s time to see how it performs on a whole game.
After one year of studying and building tooling for matching decompilation with AI, I finally stopped postponing and started to decompile “Klonoa: Empire of Dreams” (KEoD)! It's a Game Boy Advance game that I really love and, as we’ll see, a challenging one: its functions are unusually big.
⚙️
What is Matching Decompilation?Matching decompilation is the art of converting assembly back into C source code that, when compiled, produces byte-for-byte identical machine code. It’s popular in the retro gaming community for recreating the source code of classic games. For example, Super Mario 64 and The Legend of Zelda: Ocarina of Time have been fully match-decompiled.
At the time I’m writing this chapter, 51% of the game’s code bytes are decompiled. Many thanks to Felipe Sanches and testyourmine for helping to reach this milestone!
Spoiler: We’ll see how Claude Code autonomously found an easter egg that had been hidden for 25 years!
Setting up the project
The first step is, of course, scaffolding the project. There is no single standard for how a decomp project should be structured, although there are some tacit structures that the community usually follows. In the case of GBA, one of the most popular is the one used by pret.
My initial idea was to follow the same code organization as Sonic Advance 3, since I was more familiar with it, but I quickly diverged because I had set the following goals:
- It should have no assets or assembly code from the ROM. That's the case with Snowboard Kids 2 and Animal Forest, for instance.
- I want to make the setup reproducible, as a paved path that other GBA games will follow, since I want to decompile the other Klonoa GBA games. Building a foundation to replicate later is important.
- And more importantly, the project organization should make it easy for an AI agent to work with.
These goals have two important implications:
- Setup based on a script:We need to have an automated script to, given a-
.gbaROM, produce the-.sfiles from it, since they aren't versioned. This implies that any change to the-.sfiles must be made in the script that generated them, not to the-.sfiles directly.- For example, renaming functions or splitting them into modules should be done in the generator script. This diverges from how pret projects work, since the assembly is committed. - As little manual work as possible:Since this project should be AI-friendly, it should be possible to easily spawn a-
git worktreeto enable parallel work, and it should be easy to add a new matched function.
Disassembly
With those goals set, the first artifact is the assembly itself. For that, I used Luvdis. It’s a GBA disassembler designed for matching decompilation. It reads KEoD's ROM and outputs a single big .s file.
Since I don’t want to have any assembly source code from the original game in Git, I included Luvdis as a git submodule and a shell script to call it, ./setup.sh. Besides calling Luvdis, this script will grow to do all the transformations we’ll discuss next: compiling the compiler, refining the disassembled code, moving the .s files, etc.
Finding the compiler
We need to find the compiler the developers used. Since many GBA games used agbcc (a fork of GCC 2.95), that's likely the case for KEoD too.
It’s worth mentioning that KEoD was released just 4 months after the GBA itself, in July 2001. It ranks as the oldest GBA game that is actively being decompiled!1
With this release date in mind, we can rule out two popular compilers used for GBA game development: ADS 1.2, which was released in November 2001, and Metrowerks’ CodeWarrior, which was released in April 2002.
Of course, the developers could have been unconventional and used a different compiler, such as ADS 1.1. In any case, I started with agbcc, and since it enabled the match for simple functions with clean C code, I stuck with it. It was a no-brainer.2
To compile these first simple functions, we needed to have a Makefile and a linker script working. They were written by Claude Code using a few other decomp projects as inspiration.
Refining the assembly code
Although Luvdis is great to start with, we still need to make many improvements to make the disassembled code ready to be decompiled. Let's talk about them.
Function splitting
Luvdis automatically detected 93 functions, but the game surely has way more than that. So, I used Ghidra to enrich the function list, which found ~300 new functions. After using Claude Code to find even more functions, merging the results, and cleaning up the false positives, we ended up with a total of 663 functions.
I was surprised that this game has only a few hundred functions. Other GBA games have many more functions. On the other hand, the functions from KEoD are usually bigger, much bigger. That’ll make the game a challenge to decompile since bigger functions are usually harder than smaller ones.
Most of the function splitting was driven by Claude Code. It wrote a reproducible Python script, generate_asm.py, that takes the initial code output by Luvdis and splits it into smaller functions, and each function is in its own .s module.
Although most of the work was done by AI, let me briefly explain one interesting technique it used and one of the bugs we had. It took many back-and-forths to arrive at a sound function split, but these two sections will give a glimpse of how it works.
Function pointer table
Ghidra found functions that are directly referenced by the code. For example, ones that had a bl <function>. But many functions are still called without any bl appearing in the assembly. This happens because the code uses a function table. For example:
void CmdMove(void) { /* ... */ }
void CmdJump(void) { /* ... */ }
void CmdWait(void) { /* ... */ }
void (*const gCommandTable[3])(void) = { CmdMove, CmdJump, CmdWait };
void RunCommand(u32 op) {
gCommandTable[op]();
}
It compiles into:
08000028 <RunCommand>:
push {lr}
ldr r1, .L12 @ =0x08000100 — the table's address
lsls r0, r0, #2
adds r0, r0, r1
ldr r0, [r0] @ fetch handler pointer from the table
bl _call_via_r0 @ the only bl in the function
pop {pc}
0800003C <_call_via_r0>:
bx r0 @ one instruction; libgcc's indirect-call stub
So, we need to find the table's address in the literal pool (here, 0x08000100) and unpack the words stored there into function addresses.
08000100: 01 00 00 08 11 00 00 08 1d 00 00 08
─────┬───── ─────┬───── ─────┬─────
0x08000001 0x08000011 0x0800001D
CmdMove CmdJump CmdWait
This pattern repeats frequently in this game since it has a big callback machine and many dispatch tables. Take gCallbackQueue as an example. Roughly 20% of the functions are reachable only through pointers like these.
Function fragment
When Claude Code was splitting the functions, it defined the address at which each function starts. But when an address is misplaced, it produces a function fragment. In other words, a function that isn't self-contained. For example:
FragmentedFunction:
push {lr}
cmp r0, #9
bls _08000008
mov r0, #9
FallthroughFunction:
_08000008:
add r0, r0, #1
pop {pc}
There is no return in FragmentedFunction. Thus, it falls through to the next function, running the code from FallthroughFunction. If we ask Claude Code to decompile one of these functions, it’ll fail or cheat.
This happens because the above split is bugged. By merging these mis-split functions, we can write C code that matches the merged version:
u32 ClampedIncrement(u32 x) {
if (x > 9) {
x = 9;
}
return x + 1;
}
It compiles to:
ClampedIncrement:
push {lr}
cmp r0, #9
bls _skip
mov r0, #9
_skip:
add r0, r0, #1
pop {pc}
A fragmented function can happen if:
- We had a bug in our function-splitting algorithm (almost always the case).
- The assembly was handwritten (unlikely)
- The original C code used the naked attribute (unlikely)
Split into modules
After splitting the functions, the next step is splitting the monolithic assembly file into logical modules. I used Ghidra to find good boundaries (see the Ghidra script here) and then updated generate_asm.py to automatically move .s modules into their respective folders. The boundaries are defined in the TOML file.
Renaming
The renaming is also performed in generate_asm.py, based on the names defined in TOML.
Sanches used Claude Code to automatically give semantic names to the functions. Even though they aren't decompiled yet, Claude provides a reasonable guess based on the assembly patterns.
Identifying data-as-code
When a function is compiled, in addition to its code, it might include data that it references. For example:
u32 GetActionCost(u32 action) {
switch (action) {
case 0: return 10;
case 1: return 25;
case 2: return 30;
case 3: return 45;
case 4: return 60;
}
return action;
}
Compiles into:
GetActionCost:
push {lr}
cmp r0, #4
bhi _0800003A
lsls r0, r0, #2
ldr r1, _08000010 @ =_08000014
adds r0, r0, r1
ldr r0, [r0]
mov pc, r0
_08000010: .4byte _08000014 @ pool: address of the table
_08000014: @ the jump table
.4byte _08000028 @ case 0
.4byte _0800002C @ case 1
.4byte _08000030 @ case 2
.4byte _08000034 @ case 3
.4byte _08000038 @ case 4
_08000028:
movs r0, #10
b _0800003A
_0800002C:
movs r0, #25
b _0800003A
@ ... cases 2-4 likewise ...
_08000038:
movs r0, #60
_0800003A:
pop {pc}
Without a proper disassembler that identifies that a given address is read as data rather than as code, it would be disassembled as:
GetActionCost:
0: push {lr}
2: cmp r0, #4
4: bhi 0x3a
6: lsls r0, r0, #2
8: ldr r1, [pc, #4] @ reads offset 0x10 — as data
a: adds r0, r0, r1
c: ldr r0, [r0]
e: mov pc, r0
10: movs r4, r2 ; lsrs r0, r0, #32 @ table address
14: movs r0, r5 ; lsrs r0, r0, #32 @ case 0
18: movs r4, r5 ; lsrs r0, r0, #32 @ case 1
1c: movs r0, r6 ; lsrs r0, r0, #32 @ case 2
20: movs r4, r6 ; lsrs r0, r0, #32 @ case 3
24: movs r0, r7 ; lsrs r0, r0, #32 @ case 4
28: movs r0, #10
2a: b 0x3a
2c: movs r0, #25
2e: b 0x3a
30: movs r0, #30
32: b 0x3a
34: movs r0, #45
36: b 0x3a
38: movs r0, #60
3a: pop {pc}
Although the content is the same and both compile to byte-identical machine code, this spelling is bad for many reasons:
- Programmatic decompilers like m2c and asmlift produce wrong C code or decline, since they’ll try to convert the nonsense instructions into C code. LLMs also struggle for the same reason
- objdiff, the tool we use to count how closely our C code matches the target assembly, counts data as mismatched instructions. It inflates the diff and makes it harder to identify real code differences
- Remember the previous section “Function pointer table”? Well, without seeing the actual function addresses, there is no way to find the pointer table in the assembly
Luvdis already does a fair amount of work recovering every pool it can prove is data, but it misses a few cases that are relevant when we want to fully decompile the game. Thus, I ran a couple of rounds asking Claude Code to identify the data-as-code sections and save them in the TOML file. It's consumed by generate_asm.py to update the assembly code.
Wiring with the C code
We need to have a smooth process to replace an assembly function with its respective decompiled C code. One function at a time.
So, I structured it with two folders: asm/matchings and asm/nonmatchings. When a function is matched, it’s automatically moved to asm/matchings. By always preserving the original assembly functions (and not relying on the compiled ones from build/src), we have a single assembly spelling.
Otherwise, we would have the spelling from generate_asm.py for the non-matched functions and the spelling from agbcc for the matched ones. Ensuring that we have a single one helps reduce the mental gymnastics when comparing the functions, and it's especially important when Claude Code is reading through the codebase.
Also with Claude Code in mind, and inspired by Snowboard Kids 2, I organized the C code with the INCLUDE_ASM macro. Each function that isn't decompiled yet appears as a stub like:
INCLUDE_ASM("asm/nonmatchings/gfx", InitGfxState);This way, the agent can easily add a new matched function: normally, it just needs to replace the INCLUDE_ASM call with the equivalent C code.
Later, I learned that this approach using the INCLUDE_ASM stub is informally named “splat-style”, since that's how splat scaffolds the project. This tool splits a binary into source files that are easier to work with. It supports N64, PSX, PS2, PSP… but not GBA. Thus, funnily, I’m replicating the splat-style without splat.
Converging with the community: kleod
After I matched a set of simple functions, testyourmine created his own repository to decompile this game (kleod). He was willing to contribute but didn't like my project's structure, and my code was too messy (I agree 😅).
So, I imported most of the functions he decompiled and part of the project structure. Even though the underlying structure is different, it's good to follow some community standards, since it’ll make it easier to share code and get inspiration.
For example, using the same names for the m4a functions and utility macros common to every GBA game (e.g., the ones for DMA) helps reduce the variance in the codebase and reduce the cognitive overhead when reading the code from other GBA decomp projects while looking for references.
That matters for LLMs: Claude Code reads the code from them to find references, and reducing the discrepancy allows one to make better use of these references.
On top of that, his code quality is great. His C code is much cleaner than the AI-generated one. Porting his functions to my repository sets a better standard for Claude Code to follow when decompiling other functions.
Decompiling
The hard work of decompilation is mostly done by Claude Code. But just asking it to decompile and hoping it does everything well is expecting too much from AI.
So, let's talk about what we're going to plug into the AI loop to make it work: new tools and improvements to agbcc.
New tools
As you probably already noticed, I really like to build tools, and aiming to accelerate the decompilation process, I’ve grown the family! The following tools joined the party: asmlift, Transmuter and gba-kit.
Each one would deserve a dedicated chapter, but let's talk briefly about them.
asmlift
The driving question that led to building asmlift is: What if, instead of making a match, we build a machine that does the match for us?
asmlift is a programmatic decompiler. It's much like m2c. In fact, much of the learning from m2c was used to build asmlift. The twist is how it's developed.
Most of the matching decompilation projects heavily based on AI focus on matching a single function at a time. They ask the AI to match a function, and as an additional output, it might produce markdown files with learnings, hoping these will help the LLM to decompile the next functions.
But I was keen to explore a different approach: using AI to design, from scratch, a modular matching decompiler, plus using an AI loop to automatically iterate on the decompiler itself.
asmlift is the result of this exploration. While developing asmlift, I’m much more focused on the benchmark and evaluation side than on the decompilation process itself.
Curiously, while I was building asmlift, mahaloz was building Kuna, an agent-first decompiler designed to be refined by other agents. You can read more about Kuna in its announcement post. It's interesting to see that the same broad idea was developed by two people independently, even though the decompilation scene isn't big. It's worth noting the difference: asmlift is designed for matching decompilation and focuses on the compilers used by retro games, while Kuna is a more generic decompiler, and the matching is just one of its metrics, not the primary one.
I’m using asmlift while decompiling KEoD, and it's been very useful. Sometimes, it even matches a non-trivial function like ProcessFrameAnimation in one shot. You can run its playground and check the benchmark here.
Transmuter
One of the most widely used decomp tools is Permuter. I already mentioned it in the last 2 chapters, but just a refresher: it programmatically permutes C source code to find a better match.
But I really wanted a coding-agent-friendly permuter. Instead of mostly random permutations that Permuter makes, I want to have Claude Code actively monitoring, guiding the process, and testing hypotheses.
Developing a new spiritual successor to Permuter seemed to be a suitable approach. I believe that, to achieve this, it would need to be rebuilt from scratch with a new design. Besides that, it wasn’t actually a novel idea, since Simon, the creator of Permuter, suggested it on Discord.
So, I started (vibe) coding Transmuter. I also chased the following stretch goals:
-
Library-first design.I wanted to have deeper integration into Mizuchi (the AI decompilation platform from the previous chapter).
-
Multi-language support.Permuter supports only C, since it’s heavily dependent on pycparser. Since I have good experience with ast-grep, adding multi-language support sounds like a good addition.
- Post-match cleanup.It’s something that I miss: if I have code that already matches, but it’s ugly, I want to clean it up, and having quick programmatic mutations can speed it up.
- Web app for session reports.Why not? It’s cool and helps to understand what’s going on when debugging or running it manually. Also, the same data that feeds the session report can be read by coding agents, offering more input for future work.
Although Transmuter works, it’s still an early-stage tool. I’m dogfooding it while decompiling KEoD and sometimes it helps find a match, though most of the time it doesn't provide any useful insights. It might become a more useful tool in the future.
gba-kit
gba-kit is a GBA emulator focused on scripting. I initially started to develop it to explore behavioral decompilation, as explained briefly in the last chapter. But then I noticed that it's useful as a programmatic emulator that an AI agent can use to explore the game and make discoveries. Then, I threw away all the features for behavioral decompilation and focused only on the scripting side.
For matching decompilation, the main use case is improving the code quality using runtime analysis. For example, asking the coding agent to explore the game and replace an unknown property with a meaningful name. The same goes for functions, their parameters, constants, etc.3
At the beginning of the tests, it was funny watching Claude Code explore Klonoa. During my first experiments, I just asked it to finish the first level without giving it any instructions about how to play. Here are three fun anecdotes:
- It thought that the stars required to finish the level were locked behind breakable blocks and that it needed to throw enemies at them.
- Claude screamed “MASSIVE PROGRESS”, “SCREEN TRANSITION!” and “COMPLETELY NEW AREA!” when it saw a black transition, thinking that it had moved to a new area. But it was actually just Klonoa dying after being hit by an enemy three times.
- It thought that Klonoa was a Sonic-like game. It said “ - Reading 0x0300526C in real gameplay and watchpointing it confirmed the field is live and that it’s decremented by the gameplay VBlank handler when Klonoa takes damage (0x800d2c2) — matching Klonoa’s signature mechanic of dropping a dream stone when hit.”
After these initial fun conversations, I taught it how to play the game, giving it recorded scripts as examples, and it learned quickly and wrote many scripts to evaluate some scenarios. The first contributions were adding meaningful names for the properties of a global game-state struct.
Additionally, a big win was finding an easter egg that had been hidden for 25 years! Nobody had found it before: you can play a minigame on the “Clear Save Data” screen. Surprisingly, it was found autonomously by Claude Code. When it claimed that it had found an easter egg, I initially thought that it was hallucinating, but it turned out to be real.
The decompilation loop
I’m decompiling KEoD while improving these tools. The core of the flow is:
- Ask Claude Code to select X functions to decompile.
- Use asmlift and, if it doesn't match automatically, continue from its result. Use Transmuter to help find the match.
- After matching the function, use gba-kit to improve the code quality.
- Launch adversarial subagents to review the code.
- Merge all the X decompiled functions into a single PR.
- Write a report.
I hope to turn it into a self-improving AI loop. But for now, I still need to read the report to guide the improvements.
You can check the prompt for my AI loop, wiring these tools to decompile the functions from KEoD (it’s in the asmlift repository for historical reasons). Note that this prompt is tailored for my use, but it might be useful as inspiration for you.
You may notice Mizuchi is absent from that loop. That's deliberate: its inner loop and test environment, which are useful for isolating the execution to make the runs comparable with each other, are troublesome when contributing to a decompilation project.
I still used Mizuchi for benchmarking; for example, I was curious about Fable 5 and benchmarked it. But to be a participant in this loop, I would need to extract a few features from it, mostly the ones from Atlas (e.g., function embeddings to find similar functions), so they can be reused independently and called autonomously by a coding agent. That's something that I’m planning to do in the future.
Improving agbcc
agbcc is our oracle. We want to have C code as close as possible to what the developers originally wrote. Thus, you'd expect us not to change the oracle, right? Well, it depends™️
Let's see one case where we shouldn't, and one where it's fair.
AI cheating
I faced an annoying problem at the beginning of the project. When the AI agent gets stuck decompiling a function, it’ll start trying to cheat, for example, by forking agbcc.
At first, I believed that the fork was justified (PR 1), since it was plausible: KEoD is one of the first GBA titles, and I believed that it could have used an older, unknown version of the compiler. But it was just me having AI psychosis, and it was not necessary at all.
The cheats applied by Claude Code, like forking agbcc or relying too much on register pins, which it justified by saying that the compiler ordered the registers in a way that's “impossible” to reproduce in C, were quickly dismissed by more experienced decompers.
Adding instrumentation to the compiler
On the other hand, there is a fair and safe case for customizing the compiler: to add instrumentation that Claude Code can read.
I asked Claude Code to improve the code quality (e.g., remove asm barriers, register pins, and orphan blocks), and during the process I noticed that adding instrumentation to the compiler would be useful and more viable than adding ad-hoc logs that would be removed later.
Thus, Claude Code added a couple of flags that don't change the compiler's output semantics but only add comments in the assembly or output to stderr.
For example, the flag -finstrument-src-locs emits @ src:file.c:LINE asm comments, which is useful for backtracking to the C code by just reading the assembly. These new flags were helpful, mostly to improve the code quality.
You can check the fork here.
Decomp Academy
Parallel to everything above, Jack Price-Burns released Decomp Academy. This website fills a gap: a structured way to learn the basics. It made a big splash when published and ranked in the second position on Hacker News!
Jack initially made it only for GameCube, focusing on Star Fox Adventures. But I think it's so cool that I pushed a commit adding Game Boy Advance lessons! The GBA course is still very raw, and contributions are welcome.
As Jack explained on Hacker News, learning the fundamentals is essential so you don't rely only on AI, since it might hit a wall and be unable to finish the last mile without proper guidance.
Closing words
Although this game is 51% decompiled, the second half will include harder functions, the ones that take the longest to decompile. Also, although we have many functions decompiled manually by testyourmine, and gba-kit helped to add meaningful names for many of them, the code still needs a cleanup. For example, the comments are far too verbose, and the code needs to be split into semantic modules.
Beyond that, I think the main risk when working on a project heavily reliant on AI is the cognitive debt reaching a level that is impossible to pay off. I’m still learning (as are likely almost all developers) how to enjoy the speed-up that LLMs offer without taking on too much cognitive debt.
An instance of this issue was my initial reaction to the AI cheating that I mentioned earlier. On the other hand, Decomp Academy is an example of investing in the fundamentals to reduce that debt.
Since the biggest risk in this experiment is going insolvent on cognitive debt, the next chapter will probably focus on fundamental learnings.
Still, it's wonderful to see the current results and the foundation that's being built to help other matching decompilation projects. When I started this journey, I had no idea how fun this challenge would be, nor that we would actually be able to automatically decompile many functions purely using AI.
See you in the next chapter!