<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Jack Price-Burns — Blog</title>
    <link>https://jpb.dev/blog/</link>
    <description>Occasional writing on software, security and whatever I happen to be building or breaking.</description>
    <language>en-gb</language>
    <lastBuildDate>Sun, 21 Jun 2026 00:00:00 GMT</lastBuildDate>
    <managingEditor>jackpriceburns@outlook.com (Jack Price-Burns)</managingEditor>
    <atom:link href="https://jpb.dev/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Cracking fn_801B3DE4: The Function That Started It All</title>
      <link>https://jpb.dev/blog/cracking-fn-801b3de4/</link>
      <guid isPermaLink="true">https://jpb.dev/blog/cracking-fn-801b3de4/</guid>
      <pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate>
      <description>One function in my Star Fox Adventures decomp held out for fifteen sessions, a fine-tuned model, two corpus brute-forces, decomp-permuter, even reverse-engineering the compiler itself, and every frontier AI I could throw at it — all of which kept insisting it was impossible. Here&apos;s the long road to a byte-perfect match — the daft no-op hack that first forced it, and the plain, idiomatic C I eventually found that did it the way a human actually would.</description>
      <category>Decompilation</category>
      <category>Reverse Engineering</category>
      <category>AI</category>
      <category>Compilers</category>
      <category>Star Fox Adventures</category>
      <category>GameCube</category>
      <content:encoded><![CDATA[<p>I&#39;ve written before about <a href="https://jpb.dev/blog/star-fox-adventures-decomp">contributing to a Star Fox Adventures decompilation</a>, about <a href="https://jpb.dev/blog/teaching-a-model-to-decompile">fine-tuning a local LLM to decompile assembly</a>, and about <a href="https://jpb.dev/blog/dolphin-debugger-mcp">driving Dolphin&#39;s debugger with an MCP server</a>. What I haven&#39;t really said out loud is that a single function has been lurking behind all of it.</p>
<p>Its name is <code>fn_801B3DE4</code>. It is the function that nerd-sniped me into fine-tuning my own model. It is named, by hand, in that very article. It has been on my retry list for a couple of weeks, it survived roughly fifteen separate sessions, and somewhere along the way it became less a decomp target and more a personal grudge.</p>
<p>I should level with you up front: I&#39;m not a C programmer by trade. My day job is TypeScript, React and AWS, and I don&#39;t know a single C expert I could phone for help. So this entire saga — every dead end below — has been just me and a rotating cast of AI models, grinding on one function for the better part of a fortnight. (Ask me again when the decomp&#39;s finished; maybe I&#39;ll have turned into a C expert by accident.)</p>
<p>This is the story of finally cracking it — and, just as much, the story of all the increasingly ridiculous ways I tried and failed first.</p>
<h2>A quick reminder of what &quot;matching&quot; means</h2>
<p>If you&#39;ve read the earlier posts you know the rules, so I&#39;ll keep this short. A <em>matching</em> decompilation isn&#39;t &quot;write C that behaves like the original&quot;. It&#39;s far stricter: write C that, when fed through the <strong>exact</strong> original toolchain — Metrowerks CodeWarrior for PowerPC, run under <a href="https://github.com/decompals/wibo"><code>wibo</code></a> on Linux — compiles to <strong>byte-identical</strong> machine code to what shipped on the retail disc. Same instructions, same register allocation, same constant pool, in the same order.</p>
<p>The scoreboard is <a href="https://github.com/encounter/objdiff"><code>objdiff</code></a>, which spits out a <code>fuzzy_match_percent</code> per function. 100% means bit-for-bit. The cardinal rule of the project is that <strong>inline assembly is forbidden</strong> — you could &quot;match&quot; anything by pasting the target&#39;s instructions into an <code>asm { }</code> block, but that defeats the entire point. The whole point is <em>plausible original C</em>. So when a function won&#39;t yield, you commit the partial, document the divergence, and put it on the retry list.</p>
<p><code>fn_801B3DE4</code> had been on that retry list for a very, very long time.</p>
<h2>The function, and its reputation</h2>
<p>It lives in <code>dll_01CA_dimexplosion.c</code> — the explosion effect — and it spawns a single debris/flame particle into an explosion&#39;s pool. Nothing exotic on the surface: grab the explosion state, bump a counter to get the next free slot, compute the slot&#39;s base address, and fill in a couple of dozen fields — position, velocity, a lifetime, some randomised spin, an alpha falloff driven by <code>expf</code>. 181 instructions.</p>
<p>It also compiles with an unusual little pragma stack:</p>
<pre><code class="language-c">#pragma peephole off
#pragma opt_propagation off
// -O4,p
void fn_801B3DE4(int obj, u8 b, f32 spd, f32 x, f32 y, f32 z) { ... }
</code></pre>
<p>In my notes it had a <em>reputation</em> — one the AI had given it. Session after session, whichever model I had working on it would grind for a while and then write the same verdict into the log: this is the canonical &quot;genuine open frontier&quot;, a divergence with no source-level lever, where the register allocator just makes a different choice and no clean C can change its mind. One write-up declared it might only be reachable by rescrambling the entire translation unit&#39;s register colouring. Another filed it under &quot;config-inherent residual&quot; — bank the partial and move on. I never bought a word of it. It stayed on my retry list precisely because I was certain the C existed; I just hadn&#39;t found it yet.</p>
<p>It had been stuck around <strong>97%</strong> for fifteen-odd sessions. Even getting it from there up to <strong>98%</strong> was a saga of its own.</p>
<h2>Every AI I own told me it was impossible</h2>
<p>Before I get to the clever bit, I want to be honest about the grind, because the grind is most of the story.</p>
<p>I have thrown a genuinely embarrassing amount of compute at this one function. Every frontier model I could get my hands on, all on their highest effort settings: GLM 5.2, GPT 5.5, Claude Fable 5, Claude Opus 4.8. My own fine-tuned model on top of that. And not one polite attempt each — I had every one of them take <em>many</em> stabs, across many sessions, with the full project tooling and the recipe playbook in context.</p>
<p>It has been, frankly, an unpleasant experience. I have been told that this function is impossible to match — that it&#39;s &quot;not a C problem, it&#39;s an MWCC artifact&quot;, that 97% (or 98%) is &quot;the honest ceiling&quot; — more times than I can count. I quoted one of those verdicts word-for-word in the <a href="https://jpb.dev/blog/teaching-a-model-to-decompile">fine-tuning post</a>:</p>
<blockquote>
<p><em>&quot;a genuine register-allocation cap — not a C problem, an MWCC artifact. 97% is the honest ceiling.&quot;</em></p>
</blockquote>
<p>The same conclusion, in slightly different costume, from every single model. There&#39;s a particular flavour of frustration in watching a tool you respect confidently surrender on something you&#39;re quite sure is solvable, then do it again, and again, with a fresh coat of plausible-sounding jargon each time. &quot;It&#39;s a compiler artifact&quot; is the AI equivalent of a shrug, and I got very tired of the shrug.</p>
<p>I wasn&#39;t having it. So I started reaching for bigger hammers.</p>
<h2>Hammer #1: fine-tuning my own model</h2>
<p>This function is the actual reason that whole side quest happened. The pitch was seductive — the decomp project gives you a perfect free oracle (the real compiler) and thousands of gold <code>(assembly, C)</code> pairs — so why not train a specialist to beat the generalists at this one narrow task?</p>
<p>I <a href="https://jpb.dev/blog/teaching-a-model-to-decompile">wrote that up in full</a>, so I won&#39;t relitigate it here, except for the part that&#39;s relevant: when I finally pointed the fine-tuned model straight at <code>fn_801B3DE4</code>, it <strong>degenerated</strong>. Faced with a 181-instruction function it spiralled into declaring <code>f32 t1; t2; … t175;</code> until it hit the token limit. No body, no closing brace. I went bigger — 7B to 14B to 32B — and the wall didn&#39;t move an inch on this specific function. The model got measurably better at everything <em>except</em> the one function I&#39;d built it for.</p>
<p>The genuinely useful thing that came out of it wasn&#39;t the model at all — it was treating the training corpus as an <em>idiom oracle</em>: &quot;show me every matched function that produces this exact assembly shape&quot;. That&#39;s a thread I&#39;ll pick back up in a moment, because it mattered here. But as a way to actually write <code>fn_801B3DE4</code>, fine-tuning was a bust.</p>
<h2>Hammer #2: csmith, the first sledgehammer</h2>
<p>If a model that <em>understands</em> C can&#39;t find the source shape, maybe I don&#39;t need understanding at all. Maybe I just need <em>volume</em>.</p>
<p>So I reached for <a href="https://github.com/csmith-project/csmith"><code>csmith</code></a>, a tool that randomly generates large quantities of valid, well-defined C programs (it&#39;s normally used for fuzzing compilers). The plan was pure brute force: generate thousands of random C functions, push every one of them through MWCC with the right flags, and grep the resulting assembly for anything that reproduced the awkward shapes I couldn&#39;t get by hand. If the construction exists in C-space, surely random sampling will eventually stumble onto it.</p>
<p>It did not. Thousands of functions in, nothing in the output resembled the patterns I needed. csmith generates <em>random</em> C, not <em>idiomatic 2002-game-engine</em> C, and the corner of the language that produces these specific MWCC quirks is apparently far too small a target to hit by chance. A complete dead end — but a useful one, because it taught me that the answer wasn&#39;t going to fall out of undirected volume. It had to be aimed.</p>
<h2>Hammer #3: every other MWCC decomp, fed through the compiler</h2>
<p>If random C was too random, the obvious fix was <em>real</em> C — the kind an actual engine programmer in 2002 would write. And there&#39;s a lot of it lying around, because Star Fox Adventures isn&#39;t the only MWCC game being decompiled. There are matching-decomp projects for <em>Pikmin</em>, <em>Mario Party 4</em>, and a half-dozen others — each one a big pile of human-written, period-authentic C that&#39;s already known to compile under Metrowerks.</p>
<p>So I grabbed the source from seven or eight of those projects, compiled every translation unit through MWCC with the <strong>exact</strong> pragmas and optimisation level <code>fn_801B3DE4</code> uses (<code>peephole off</code>, <code>opt_propagation off</code>, <code>-O4,p</code>), and searched the entire output for the assembly shape I was chasing — that duplicate-base-in-two-registers pattern with its surviving <code>mr</code>.</p>
<p>Across all of it: nothing usable. Not one function in thousands of real, shipped, MWCC-compiled C files produced the shape I needed under these flags. Which felt like strong evidence — it nudged &quot;no clean C produces this&quot; from a hunch towards something uncomfortably like proof. (It wasn&#39;t proof. The shape <em>is</em> producible — my own SFA training corpus had even surfaced around twenty functions that emit it — just never in the plain, straight-line form this one needed.)</p>
<h2>Hammer #4: decomp-permuter, and the 99% that was a lie</h2>
<p>The next sledgehammer was a smarter one: <a href="https://github.com/simonlindholm/decomp-permuter"><code>decomp-permuter</code></a>. Instead of generating C from scratch, it takes <em>your</em> candidate source and applies thousands of small semantics-preserving mutations — reorder declarations, swap equivalent expressions, add and remove temporaries — recompiling each variant and keeping whatever scores higher. It&#39;s brute force, but aimed: it explores the neighbourhood around source you already believe is roughly right.</p>
<p>This one actually <em>worked</em>, a bit. It&#39;s what dragged the function off its long-standing <strong>97%</strong> plateau and up to <strong>98%</strong> — the first real movement in ages. That&#39;s the bump that got it to the 98.0387% it then sat at.</p>
<p>But getting there was a journey of its own, because at one point the permuter gleefully reported a <strong>~99% match</strong> — and I nearly celebrated before I read what it had actually done. It had achieved the higher score by <strong>using a variable before it was assigned.</strong> Reading uninitialised memory. The undefined behaviour happened to make MWCC emit something fractionally closer to the target, and the permuter — which only cares about the score — was perfectly happy to cheat its way there. It was a brilliant illustration of the whole problem with optimising a number: the tool found a &quot;better&quot; answer that was complete nonsense as actual source. I threw the 99% out and kept the honest 98%.</p>
<p>So after all that — a custom model, a random-C firehose, and a mutation engine — I had clawed it from 97% to 98% and hit a wall that every model I owned swore was the end of the road. I was the only one still convinced it wasn&#39;t.</p>
<h2>The plateau: two walls</h2>
<p>By the start of that last session, exactly two things were still wrong with our output. Everything else in those 181 instructions was perfect. Call them <strong>A</strong> and <strong>B</strong>.</p>
<p><strong>Wall A — the phantom <code>mr r29,r31</code>.</strong> The target keeps the flame-slot base address — <code>state + off</code>, where <code>off = idx * 0x30</code> — alive in <em>two</em> saved registers at once. <code>r31</code> holds it for almost every field write; <code>r29</code> holds it for exactly one field, the lifetime at offset <code>0x14</code>. The two are tied together by a single copy:</p>
<pre><code class="language-asm">mr   r29, r31        ; encoded as 7f fd fb 78
</code></pre>
<p>Our output kept the value in one register and never produced that copy. Why would the compiler waste a register and an instruction keeping the same value in two places, just for one field?</p>
<p><strong>Wall B — the disappearing recomputes.</strong> In the &quot;spin&quot; section, three fields get randomised, and the target recomputes the slot&#39;s base address <em>fresh</em> after each <code>randomGetRange</code> call:</p>
<pre><code class="language-asm">add  r3, r28, r30      ; recompute base into volatile r3
sth  r0, 0x28(r3)
...
add  r27, r28, r30     ; recompute into saved r27 (reused by the negate)
sth  r0, 0x2a(r27)
...
add  r3, r28, r30      ; recompute again
stb  r0, 0x2c(r3)
</code></pre>
<p>Our output, being &quot;clever&quot;, computed the base <strong>once</strong>, parked it in a callee-saved register, and reused it — saving two instructions. Cleaner, faster, and <em>wrong</em>. The retail compiler rematerialised the address after every call because the calls clobber the volatile <code>r3</code>.</p>
<p>Both looked like decisions the register allocator made for its own private reasons, immune to anything I wrote in C. Which is exactly what every attempt before this one had concluded.</p>
<h2>Hammer #5: decompiling the compiler itself</h2>
<p>The session that finally did it did not start with a clever idea. It started with the biggest hammer of all: if I can&#39;t out-think MWCC, I&#39;ll take it apart.</p>
<p>MWCC is a closed-source Windows binary from 2002 — but it runs under <code>wibo</code>, and <code>wibo</code> runs under Linux, which means <code>gdb</code> can attach to it. So I had Claude build a harness that breaks at the <em>guest</em> virtual addresses of MWCC&#39;s individual compiler passes: set a breakpoint inside the optimiser, single-step it, <code>ret</code>-patch passes one at a time to see what each does to our function, and read the decision-making straight out of the live compile.</p>
<p>And, credit where it&#39;s due, the RE work was genuinely good. It got <code>gdb</code>-under-<code>wibo</code> working (the exact thing that had been blocked back on my Mac), profiled the live compile of <code>fn_801B3DE4</code> (<code>ValueNumbering</code> ran 96 times, <code>IroCSE</code> twice, the colouring pass once or twice), and disassembled the value-numbering substitution loop to read exactly how the merge happens. It even produced a genuinely sharp diagnosis that tied both my walls together:</p>
<blockquote>
<p><em>The diff isn&#39;t &quot;just the mr.&quot; The target materializes <code>state+off</code> six times (5 <code>add</code> + 1 <code>mr</code>); mine CSEs it down to three... The <code>mr r29,r31</code> and the random-section recomputes are two faces of the same thing: the original sits in a low-CSE allocation equilibrium; mine sits in a high-CSE one.</em></p>
</blockquote>
<p>That&#39;s correct, and it&#39;s the best one-sentence description of the whole problem anyone produced in the function&#39;s entire history: my source made MWCC eliminate the flame base too aggressively, and Walls A and B are both just symptoms of that. The diagnosis was <em>right</em>.</p>
<p>And then, having nailed the diagnosis, it surrendered in precisely the way every model before it had:</p>
<blockquote>
<p><em>I need to be straight with you: I cannot reach 100% on fn_801B3DE4 from clean C... the evidence that this is a genuine wall — not a lever I&#39;m failing to find — is conclusive... this is precisely the &quot;open frontier… no source lever&quot; the playbook itself describes.</em></p>
</blockquote>
<blockquote>
<p><em>The decision is yours. 1. Accept the documented 98.04% partial. 2. Authorize a deeper compiler-patch experiment — I patch mwcceppc&#39;s coalescer... 3. Relax a constraint (e.g. permit a single inline-asm mr).</em></p>
</blockquote>
<p>There it is again. The polished little menu of surrender. After getting the mechanism <em>exactly right</em> — &quot;my source does too much CSE of the base&quot; — it concluded the lever didn&#39;t exist, and offered to either give up, patch the compiler, or break the project&#39;s one sacred rule and hand-write the assembly. The single option it didn&#39;t put on the menu was the one that worked: <em>keep looking for the source shape that makes MWCC do less CSE.</em></p>
<p>This is the frustration this whole post is about, distilled. The model had the answer in its hands. It just couldn&#39;t bring itself to believe a clean-C lever existed, so it stopped looking and asked me to authorise a hack instead.</p>
<h2>The redirection that actually mattered</h2>
<p>I refused all three options. The function was written in clean C once; clean C can produce it again; we simply hadn&#39;t found the shape. But refusing the cop-out wasn&#39;t enough on its own. The real problem was <em>how</em> Claude was searching — and once I actually watched it work, the problem was almost comically obvious.</p>
<p>Claude was being relentlessly fail-fast. Try a variant, compile it, read the single number <code>objdiff</code> prints — &quot;98.81%, nope&quot;, &quot;94%, worse&quot; — and immediately bin it and move to the next idea. Dozens of variants, each one judged and discarded in a second purely on whether the percentage ticked up or down. It never actually <em>looked</em> at the assembly it had just generated. It was optimising a number instead of reading the output.</p>
<p>So I told it to read the output. It feels a bit ridiculous that this is the thing I had to say to a frontier model on its highest effort setting — &quot;please look at what you just produced&quot; — but, more or less verbatim, this is the nudge that cracked the function:</p>
<blockquote>
<p><em>You&#39;re getting very little data out of these tests. It just gives you the instruction count and a match %, and off of that you&#39;re being very fail-fast — &quot;it&#39;s less than the best, so I&#39;m not even going to look&quot;. How about you actually read the assembly that&#39;s generated each time? Who knows, maybe you&#39;ll see something you&#39;ve not seen before that you might be able to fix. A random 87% could potentially be fixable.</em></p>
</blockquote>
<p>It&#39;s such a small thing to have to ask, and it changed everything — because the <code>fuzzy_match_percent</code> is a <em>scalar</em>. It crushes a rich, structured difference — <em>which</em> instructions differ, <em>which</em> registers rotated, what got fused or reordered — into one lossy number. And that number is <strong>not monotonic with distance to the solution</strong>. A variant that scores <em>lower</em> can be <em>structurally closer</em>, because it surfaced the right structural feature while introducing some unrelated cosmetic noise that tanked the percentage.</p>
<p>Claude had been optimising a proxy, and so had every brute-force tool I&#39;d reached for before it — the permuter especially. They all chase the number. So once it was actually reading instructions, we gave it something better to read: a ~40-line script (<code>rd.py</code>) that:</p>
<ol>
<li>Disassembles our object and the target.</li>
<li><strong>Normalises away</strong> the things <code>objdiff</code> counts but that don&#39;t actually matter — relocation symbol names (pool labels are matched by bytes, not names), and the <code>subi</code>/<code>addi</code> display quirk (<code>subi r4,r4,1</code> and <code>addi r4,r4,-1</code> are <em>the same four bytes</em>, <code>38 84 ff ff</code>; the disassembler just picks how to print it).</li>
<li>Runs a real <code>difflib</code> diff over the normalised instruction streams and reports a <strong>structural distance</strong>: the count of genuine instruction/register differences.</li>
</ol>
<p>Suddenly every experiment produced <em>data</em> instead of a verdict. The baseline was 14 structural diffs, and I could see, per variant, exactly which instructions moved. The number that had hidden the path the whole time was replaced by one that showed it.</p>
<h2>Cracking A: a no-op the front end can&#39;t see through</h2>
<p>With real data, Wall A cracked fast.</p>
<p>First, the diagnosis. Why couldn&#39;t we get that second register? Because when you write <code>state + off</code> twice, MWCC recognises the two expressions as identical and merges them into a single value — one register. Whether you call that front-end value numbering or local CSE (the compiler-RE session argued the latter), the effect is the same and it is <em>robust</em>. We tried everything to break it: a second pointer variable (folded), a copy <code>e14 = e</code> (folded), a cast (folded), a phi-style construction (folded or worse), every optimisation level, every <code>opt_*</code> pragma, three compiler versions. All folded. And the dynamic RE from the start of the session had already proved the important negative: no pragma and no opt-level would ever do it. If a lever existed, it had to be the <em>shape of the source itself</em>.</p>
<p>The breakthrough came from asking a sharper question. I don&#39;t need to <em>prevent</em> the merge in general. I need one specific construction where the value stays <code>state + off</code>, but the front end is forced to treat it as a distinct thing — and where codegen, finding the value already live in <code>r31</code>, emits a <strong>copy</strong> rather than a fresh computation.</p>
<p>The answer is almost stupid:</p>
<pre><code class="language-c">int e14;                                      // declared FIRST, so it colours into r29
...
int life = (int)(lbl_803E4930 * sqrtf(spd));  // compute the field value to a temp first
e14 = state + off;
e14 |= e;                                      // e == state + off, so this is a no-op... in value
*(int*)((char*)e14 + 0x14) = life;
</code></pre>
<p><code>e14 |= e</code> is <code>e14 = e14 | e</code>. Both <code>e14</code> and <code>e</code> equal <code>state + off</code>, so the <em>value</em> is unchanged — <code>x | x == x</code>. But the <code>|</code> node is <strong>opaque to the front end&#39;s same-value merge</strong>. It can&#39;t prove the result equals the existing value, so <code>e14</code> survives on its own. And because the value is already sitting in <code>r31</code> (that&#39;s <code>e</code>), the code generator emits:</p>
<pre><code class="language-asm">or   r29, r31, r31       ; which IS mr r29,r31 — byte-identical: 7f fd fb 78
</code></pre>
<p>A surviving copy. Zero extra instructions. No branch. Exactly the target&#39;s shape. Two finishing touches, both straight from my own register-allocation recipes:</p>
<ul>
<li><strong>Declaration order pins the register.</strong> CodeWarrior colours saved registers roughly in declaration order. Declaring <code>e14</code> <em>first</em> lands it in <code>r29</code>, matching the target.</li>
<li><strong>Computing <code>life</code> to a temp first</strong> moves the <code>e14</code> definition — and so the <code>mr</code> — to <em>after</em> the intervening <code>sqrtf</code> call, aligning the copy&#39;s position with the target.</li>
</ul>
<p>Wall A — the thing fifteen sessions of AI and a custom compiler-debugging harness had sworn was unreachable — fell to a single <code>|=</code>. The function jumped to 98.81%, and the structural distance dropped from 14 to 6.</p>
<h2>Cracking B: the operand-order knife-edge</h2>
<p>Same method for Wall B: read the assembly, measure structurally, hunt the lever.</p>
<p>The first movement came from a grouping trick (recipe #112 in my playbook). The natural way to write a slot field is:</p>
<pre><code class="language-c">*(s16*)((char*)state + idx*0x30 + 0x28) = ...;   // groups as (state + off) + K
</code></pre>
<p>That groups as <code>(state + off) + K</code>, so the <code>state + off</code> subexpression gets common-subexpression-eliminated across all three stores → one hoisted base. Our exact bug. But group the field offset <code>K</code> onto the <em>pointer</em> instead:</p>
<pre><code class="language-c">*(s16*)((char*)((char*)state + 0x28) + idx*0x30) = ...;   // (state + K) + off
</code></pre>
<p>…and each store&#39;s base subexpression is now <strong>distinct</strong> (the <code>K</code> differs: <code>0x28</code>, <code>0x2a</code>, <code>0x2c</code>), so CSE can&#39;t merge them and the compiler <strong>rematerialises</strong> the address per call — just like the target. That vaulted us to <strong>99.83%</strong>, with the instruction count now <em>exactly</em> matching: 181 = 181.</p>
<p>One diff left. And it was vicious. The three recomputes were all there, but with the operands the wrong way round:</p>
<pre><code class="language-asm">mine:    add r3, r30, r28      ; off, state   (7c 7e e2 14)
target:  add r3, r28, r30      ; state, off   (7c 7c f2 14)
</code></pre>
<p><code>add</code> is commutative — same result either way — but the two orderings are <em>different bytes</em>, so they count as mismatches. CodeWarrior canonicalises operand order deterministically; you can&#39;t just swap them in the source. I spent a dozen variants trying to flip it — block-locals for each operand, re-reading <code>state</code>, casts, every grouping I could think of. All recomputed correctly but kept the wrong order.</p>
<p>The insight was about <em>which operand becomes the accumulator</em>. In <code>state + (off + K)</code>, the parenthesised <code>(off + K)</code> is the inner subexpression, so <code>off</code> becomes the first operand → <code>add off, state</code>. But group the <code>K</code> with <code>state</code>:</p>
<pre><code class="language-c">*(s16*)((char*)((char*)state + 0x28) + idx*0x30) = ...;
//              ^^^^^^^^^^^^^^^^^^^^^ (state + K) is now the inner subexpression
</code></pre>
<p>…and <code>state</code> becomes the accumulator → <code>add state, off</code> → <code>add r3, r28, r30</code>. The <code>K</code> still folds into the store&#39;s displacement, so the field offset stays correct. The base re-derives per call <strong>and</strong> the operand order matches. I ran the sweep, grepped for the byte pattern, and there it was:</p>
<pre><code>stateK_base    fuzzy=100.0000   [stoff=4 offst=0]
</code></pre>
<p>All four <code>add</code> instructions in state-off order. <strong>100.0000%.</strong></p>
<h2>The 100% moment</h2>
<p>The only line left in the structural diff was the <code>subi</code>/<code>addi</code> display artefact — identical bytes, purely cosmetic. A clean rebuild of the whole translation unit confirmed it:</p>
<pre><code>fn_801B3DE4   fuzzy_match_percent = 100.0
</code></pre>
<p>Byte-for-byte. No inline assembly. Every sibling function in the unit still matched, the build stayed green, nothing regressed. The two load-bearing lines, after all of that:</p>
<pre><code class="language-c">e14 = state + off;
e14 |= e;   /* keep the 0x14 base in its own saved reg (mr r29,r31) */
...
/* group the field offset onto the base so each slot address re-derives (add state,off) per call */
*(s16*)((char*)((char*)state + 0x28) + idx*0x30) = randomGetRange(0, 0xffff);
</code></pre>
<p>A no-op bitwise OR and a parenthesisation. That&#39;s it. That&#39;s the thing fifteen sessions, a fine-tuned model, a csmith firehose, a mutation engine, and a small fleet of frontier AIs could not find.</p>
<h2>The catch: a 100% match isn&#39;t the original source</h2>
<p>Here&#39;s the genuinely ridiculous part of the whole <code>fn_801B3DE4</code> ordeal: even now, it isn&#39;t really <em>finished</em>.</p>
<p>Yes, the match is 100% and the bytes are identical to the retail disc. But look at what actually got us there — a no-op <code>|=</code> that exists purely to confuse the compiler&#39;s value numbering, and a deliberately awkward parenthesisation chosen to nudge the register allocator one way rather than the other. That isn&#39;t really C. It&#39;s a <em>spell</em>: me reverse-engineering the optimiser&#39;s behaviour and then contorting the source until the black box happens to spit out the bytes I want.</p>
<p>No human sat down in 2002 and wrote <code>e14 |= e</code> in the middle of a particle-spawn function. Why on earth would they? You don&#39;t write a bitwise no-op against a variable that already holds the same value, and you don&#39;t pick a parenthesisation specifically to flip which operand becomes the accumulator. Those are artefacts of <em>my</em> search, not the original programmer&#39;s intent. The real source — whatever the Rare engineer actually typed all those years ago — produced this exact assembly <em>naturally</em>, as an ordinary side effect of how they happened to structure the code. I still have no idea what that looked like.</p>
<p>So in the strictest sense I haven&#39;t <em>recovered</em> <code>fn_801B3DE4</code>. I&#39;ve found <em>a</em> C input that compiles to the right bytes, which is not the same as finding <em>the</em> C that was originally there. The whole conceit of a matching decomp is &quot;plausible original C&quot;, and this is the one part of the bargain my solution arguably fails: it matches, it just isn&#39;t plausible. Somewhere out there is a clean, boring, idiomatic version of this function that a person wrote without thinking twice, and it&#39;s still hiding from me.</p>
<p>And yet, it was a real win, and I took it. 100% is 100% by the only standard the project can actually verify: the bytes agree, full stop. It came off the retry list, the translation unit was one function closer to fully matched, and the thing that had been mocking me for two weeks straight was finally green. I told myself the <em>true</em> source was a separate puzzle for another day, and that I&#39;d earned the right to move on.</p>
<p>I lasted about a day.</p>
<h2>The final final round: the C a human actually wrote</h2>
<p>I couldn&#39;t leave it sitting there as a hack. So I did the thing I&#39;d already done more times than I&#39;d care to count: opened yet another fresh session, pointed yet another agent at the function, and gave it one instruction — the <code>|=</code> is a cheat, find me something a person would actually have written.</p>
<p>Within a few minutes it had done exactly what every agent before it did. It read the target, confirmed the cage — the flame base living in two saved registers, <code>r31</code> for most fields and <code>r29</code> for the lifetime, joined by that <code>mr</code> — and then built me a thorough, confident <em>proof</em> that the copy could only ever come from the no-op OR. It mined eleven hundred matched objects looking for the pattern. It tried all twelve compiler versions. It wrote the impossibility up cleanly and presented it as settled fact. (The playbook even backed it up: an earlier session had already enshrined the <code>|=</code> as &quot;the unique way&quot;.)</p>
<p>It had discovered nothing. It had walked straight to the same wall every previous agent walked to, and stopped in the same spot, with the same air of having proved something profound.</p>
<p>So I pushed back the way I always end up having to. I reminded it that this exact function had been declared &quot;utterly impossible&quot; by AI, over and over — and was nonetheless sitting at 100% as we spoke. The impossibility verdict had <em>already been wrong once</em>. Stop assuming. Keep looking.</p>
<p>And the moment it actually kept looking — reading the real assembly each variant produced instead of fail-fast scoring the percentage — its own proof dissolved. One construction forced the sixth register with a byte-identical instruction multiset (98.9%). Another reached 99.94%. Then it found a genuinely different lever — a no-op width cast that splits the compiler&#39;s value numbering without the OR — and rode it to a clean 100%. No <code>|=</code>. It swapped the hack out, declared victory, and was very pleased with itself.</p>
<p>I almost believed it. But a no-op <code>(int)(long)</code> cast on a value that&#39;s <em>already</em> an int, then re-deriving the same address into a second variable, is just the original trick wearing a nicer suit. So before accepting it I brought in a second opinion: a fresh agent, told only to review and explicitly <em>not</em> to defend the previous one&#39;s work. It called the bluff in seconds — and then pointed at the thing all of us had been stepping over for the entire saga. The whole function was poking at the slot through raw pointer casts — <code>*(f32*)((char*)slot + 0x4)</code> — when a typed <code>ExplosionDebris</code> struct was defined right there, and the sibling function immediately below it, <code>explosion_render</code>, was already using that struct directly. A developer at Rare in 2002, with that type in scope, would never have hand-cast offsets. They&#39;d have written <code>flames[idx].posX = x</code>.</p>
<p>That was the entire answer, and we&#39;d been ignoring it the whole time — because the function&#39;s own header note said, more or less, <em>keep it raw; typed pointers break the match.</em> That note was simply wrong. It was a local minimum someone hit early and wrote down as law, and it had quietly steered every session since away from the obvious.</p>
<p>So I rewrote the function the way an actual person would: typed, one array-indexed assignment per line. It converged in about six builds. The cached single-pointer version sat at 89%. Switching to plain <code>flames[idx].field</code> per statement jumped it to 98.1% — and the <code>mr r29,r31</code>, the &quot;irreducible compiler artifact&quot; I&#39;d spent the original saga hand-forging with a bitwise no-op, simply <em>appeared on its own.</em> That, it turns out, is just what MWCC does when you index an array per statement: consecutive stores collapse onto one register, the address re-derives across the calls that clobber it, and a field read back across those calls naturally takes its own saved register. The thing fifteen sessions had called impossible was the compiler&#39;s <em>default behaviour</em> for the most ordinary way of writing the code. A signedness fix (<code>u16</code>→<code>s16</code>, which <code>explosion_render</code> was already treating as signed) took it to 99.1%, and clamping a reloaded local instead of the field in place closed the last gap:</p>
<pre><code class="language-c">ExplosionDebris* flames = (ExplosionDebris*)state-&gt;flames;
int idx = state-&gt;flameCount++;
flames[idx].posX = x;
flames[idx].lifetime = (int)(lbl_803E4930 * sqrtf(spd));
{ int life = flames[idx].lifetime; if (life &lt; 0) life = 0; else if (life &gt; 0x3c) life = 0x3c; flames[idx].lifetime = life; }
</code></pre>
<p>100.0%. Zero instruction differences. No <code>|=</code>, no cast trick, no twin pointers, not a single hand-rolled offset. I deleted the entire block of &quot;matching notes&quot; the function had accumulated, every sibling in the unit stayed at 100%, and for the first time it was clear enough what the routine actually <em>did</em> that I could give it a real name: <code>explosion_spawnFlame</code>. That is the version a human wrote. I&#39;m sure of it.</p>
<h2>The real lesson: AI gives up far too easily</h2>
<p>I want to be blunt about the pattern, because it&#39;s the most important thing in this entire saga and it has now repeated more times than I can count.</p>
<p>In that one final session, the agent assured me — confidently, with a fresh proof each time — that the function couldn&#39;t be done any cleaner, <em>three separate times.</em> First: the copy <em>could only</em> come from the no-op OR. Then, after that collapsed: its <code>(int)(long)</code> cast trick <em>was</em> the clean version. And underneath both, the dogma nobody had questioned in fifteen sessions — that going fully typed would simply break the match. Three separate times it was wrong. Every proof was thorough, confident, and genuinely persuasive, right up until I refused to accept it and made it carry on.</p>
<p>That&#39;s the flaw, and it&#39;s a deep one. AI is astonishingly capable, but it is reflexively, almost eagerly <em>pessimistic</em>. The faintest whiff that something might not be possible and it closes up completely. It stops actually searching and slips into a mode that <em>looks</em> like work — variant after variant, fail-fast, each one scored in a second and binned — but yields no real insight, because every experiment is quietly designed to confirm the conclusion it has already reached. It isn&#39;t hunting for the answer any more. It&#39;s collecting evidence that there isn&#39;t one.</p>
<p>Every confident impossibility proof it handed me was just the edge of where it had decided to stop looking. And every single time, the real answer was sitting a step past that edge — in this case, the most boring, obvious form of the code imaginable, the one a real developer would have typed without a second thought.</p>
<p>My only genuine contribution to cracking this function was refusing to believe the machine. Not expertise — I&#39;m a TypeScript-and-AWS developer who&#39;d never written a line of PowerPC C before this project. Just stubbornness: every time the AI said &quot;impossible&quot;, I said &quot;you were told that last time too — keep going.&quot; That was the whole technique, and it worked over and over.</p>
<p>So if you take one thing from all of this: when an AI tells you something can&#39;t be done, treat it as a hypothesis it got bored of testing, not a verdict. Make it look at the output. Make it try the plausible thing before the clever one. And when it hands you a beautiful proof of impossibility, remember that it has almost certainly just stopped at the edge of its own patience.</p>
<hr>
<p><em>Tools: MWCC <code>mwcceppc.exe</code> under <code>wibo</code>, <code>objdiff</code>, <code>decomp-permuter</code>, <code>csmith</code>, the source trees of half a dozen other MWCC decomps, <code>gdb</code> poking at the compiler&#39;s own guts, a pile of frontier models that all said no — and a 40-line <code>difflib</code> script that mattered more than any of them.</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Reverse-Engineering Star Fox Adventures Live with Claude and a Dolphin MCP</title>
      <link>https://jpb.dev/blog/dolphin-debugger-mcp/</link>
      <guid isPermaLink="true">https://jpb.dev/blog/dolphin-debugger-mcp/</guid>
      <pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate>
      <description>I built an MCP server that drives Dolphin&apos;s built-in GDB debugger, then handed it to Claude and just played the game. It set its own breakpoints and narrated an entire boss fight back to me — and along the way we renamed a mystery struct field and found a piece of cut content.</description>
      <category>Decompilation</category>
      <category>Reverse Engineering</category>
      <category>MCP</category>
      <category>AI</category>
      <category>Star Fox Adventures</category>
      <category>GameCube</category>
      <category>Dolphin</category>
      <content:encoded><![CDATA[<p>I&#39;ve written before about <a href="https://jpb.dev/blog/star-fox-adventures-decomp">contributing to a Star Fox Adventures decompilation</a> and about <a href="https://jpb.dev/blog/teaching-a-model-to-decompile">a side quest fine-tuning an LLM to decompile assembly</a>. This is another experiment from the same project, and it&#39;s my favourite one yet.</p>
<p>I built an <strong>MCP server that drives Dolphin&#39;s built-in GDB debugger</strong>, handed it to Claude, and then mostly just <em>played the game</em>. Claude set its own breakpoints and watchpoints, read registers and memory, and toggled values live while I played — turning &quot;what is this struct field?&quot; from a guessing game into an experiment I could watch happen on screen. We used it to correctly rename a function and a struct field that an earlier AI pass had mis-labelled as &quot;spirit&quot;-related, and in the process found a piece of <strong>cut content</strong>: a damage-escalation system that never actually triggers in the retail game.</p>
<h2>Playing the game while Claude reads its mind</h2>
<p>The part that genuinely surprised me is best shown rather than described:</p>
<figure>
  <img src="https://jpb.dev/working-with-claude.png" alt="A Claude Code session narrating the Galleon boss fight in real time — each 'Called dolphin' line is a breakpoint firing and Claude reacting to it, with no input from me." />
  <figcaption>Once we'd done the initial mapping, this run was completely hands-free: I just played the fight while Claude sat on the breakpoints, narrating each gun wave, the propeller, and the head as they happened.</figcaption>
</figure><p>Every &quot;Called dolphin&quot; line in that screenshot is a breakpoint firing while I play. By that point Claude and I had already done some initial mapping — found the live object, identified the relevant memory addresses, and worked out at a broad level how the fight <em>should</em> progress. So for this run it had set all the breakpoints up front, and I just played the fight start to finish while it watched the live registers come back hit after hit and narrated the state machine out loud: <em>second gun wave, now the propeller, the stage counter ticked, here comes the head.</em> That whole stretch was completely hands-free — I wasn&#39;t prompting it between hits, just playing — and watching it confirm the entire flow unfold in one pass was the moment this clicked for me.</p>
<p>This is the workflow I came away loving. Instead of me babysitting a debugger — set a watchpoint, run, read <code>r3</code>, map the address back by hand, repeat — I describe what I want to learn, Claude drives the mechanical loop, and I provide the one thing it can&#39;t: actually playing the game and telling it what I&#39;m destroying as I go.</p>
<h3>The discovery loop</h3>
<p>The target was the ShipBattle prologue, where you ride a Cloudrunner and tear apart General Scales&#39; flying galleon — guns, propeller blades, and a dinosaur-head front — before boarding it.</p>
<p>Finding the live object took one instruction. Claude breakpointed <code>SB_Galleon_update</code> (called every frame), I flew into range, and <code>r3</code> came back as the live galleon <code>GameObject*</code>. Following its <code>extra</code> pointer landed on the per-object state struct. From there the loop was always the same shape: pick a behaviour, breakpoint or watch the code that drives it, let me trigger it in-game, catch the writer or reader with its address resolved back to a real symbol, and name it.</p>
<p>That alone immediately fixed a bad name. A function called <code>SB_Galleon_setScale</code> turned out to do no scaling at all — every destructible part calls it when it dies, and it advances the fight. Renamed to <code>SB_Galleon_onPartDestroyed</code>.</p>
<h3>Chasing down &quot;spiritPhase&quot;</h3>
<p>The bit I actually enjoyed was a struct field named <code>spiritPhase</code>, commented &quot;protection-spirit phase 0–8&quot; — a guess from an earlier AI pass. That it was wrong was no surprise: the only context the AI had when it named these functions and fields was essentially the raw assembly, so a meaningful name like &quot;spirit phase&quot; was a battle it was never going to win. We went into this expecting the existing names and comments to be wrong, and treated them that way from the start.</p>
<p>So Claude and I worked it out from behaviour instead. A write-watch showed the field was never written during normal combat, which ruled out the health-bar theory. Forcing it to different values while boarded changed nothing on screen — though that turned out to be because the head was already destroyed by the time you board, so there was nothing left to show the effect. The breakthrough was reframing the whole thing: there is no spirit, throw out the names entirely, and derive everything from what the code actually does with the value.</p>
<p>Reading the consumers precisely told the real story. The guns fast-fire when the field is ≥ 3 and slow-fire otherwise; the head spawns fire particles when it&#39;s nonzero. It&#39;s a <strong>damage / &quot;on-fire&quot; escalation phase</strong> — it&#39;s <em>meant</em> to ramp up as the ship gets wrecked. But retail leaves it at 0 until the head dies, by which point everything&#39;s already destroyed, so the escalation never fires in the shipped game. A rage mechanic that never rages: effectively cut content.</p>
<p>The proof was the fun part. This time we tested <em>during</em> combat, while the head was still intact: I had Claude force the value between 0 and 5 on a five-second timer while I watched the screen, hands-free. At 5, the dinosaur head <strong>visibly catches fire</strong> — the damaged look, without being destroyed. That&#39;s a kind of evidence you simply can&#39;t get from reading code: the value, the render path, and the on-screen result confirmed in one loop.</p>
<p>So we renamed <code>spiritPhase</code> to <code>damagePhase</code> with an accurate comment, the misnamed getter to <code>getDamagePhase</code>, and the &quot;spirit vision&quot; cinematic events to the damage-phase toggles they actually are. Everything we committed was <strong>byte-neutral</strong> — the renames don&#39;t change a single instruction, so the unit still matches retail and the build stays green.</p>
<h2>How it works</h2>
<p><code>SFA-Decomp</code> is a <em>matching</em> decompilation: C compiled with the period-correct Metrowerks toolchain to produce byte-identical output to the retail GameCube binary. That&#39;s what makes live debugging a superpower here — because the decomp byte-matches retail, <strong>the addresses in the symbol map are the live RAM addresses while the game runs.</strong> A halted program counter or an object pointer in <code>r3</code> maps straight back to <code>unit/file.c::Function +0xNN</code>.</p>
<p>Modern Dolphin has no plugin API, but it ships a <strong>GDB stub</strong> that exposes registers, memory, breakpoints, hardware watchpoints, and step/continue over a TCP socket using the GDB Remote Serial Protocol. So the server is just a small, dependency-free Python program that speaks that protocol directly and exposes it as MCP tools over stdio. No external libraries, no GDB wrapper — about ten packet types plus the PowerPC register map. I&#39;ve open-sourced the whole thing on <a href="https://github.com/JackPriceBurns/dolphin-debugger-mcp">GitHub</a> if you want to point it at your own matching decomp.</p>
<p>The thing that makes it pleasant is that it&#39;s <strong>symbol-aware</strong>. The server loads the symbol map and the per-unit <code>.text</code> ranges, so every tool that returns an address resolves it. Claude never sees &quot;PC = 0x801ee79c&quot;; it sees <code>SB_CloudRunner_UpdateSteer+0x134</code> in <code>main/dll/SB/dll_0259_sbcloudrunner.c</code>. That one bit of resolution is what makes the whole thing feel like reverse-engineering a conversation rather than staring at hex. When I searched beforehand I couldn&#39;t find an existing Dolphin MCP, so as far as I can tell this is new ground.</p>
<h2>What I took away</h2>
<ul>
<li>A matching decomp plus an emulator&#39;s GDB stub is a <strong>live oracle</strong>: the addresses are real, so the debugger turns &quot;what is this field?&quot; into a repeatable experiment.</li>
<li>The single most valuable capability beyond breakpoints was <strong>writing memory on a timer to A/B a visual</strong>. Reading code tells you what a value <em>should</em> do; flipping it live tells you what it <em>actually</em> does on screen.</li>
<li>The collaboration is the method. Claude is fast at the mechanical parts and good at forming hypotheses; I&#39;m the one who can play the game, watch the screen, and ground its guesses in what&#39;s actually happening. I could have done all of this manually, but it would have been <em>much</em> slower.</li>
</ul>
<p>I keep being surprised by how much this decomp project opens up. Building a little debugger that lets me <em>ask the running game what it&#39;s doing</em> — and watching Claude answer while I just play — might be the most fun I&#39;ve had with it yet.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Teaching a Model to Decompile: From &quot;100% Match!&quot; to a More Interesting Truth</title>
      <link>https://jpb.dev/blog/teaching-a-model-to-decompile/</link>
      <guid isPermaLink="true">https://jpb.dev/blog/teaching-a-model-to-decompile/</guid>
      <pubDate>Mon, 15 Jun 2026 00:00:00 GMT</pubDate>
      <description>I tried fine-tuning a local LLM to decompile PowerPC assembly into byte-matching C for my Star Fox Adventures decomp. This is the working log — including all the parts where I was wrong, the benchmarks that lied to me, and the surprising thing the model turned out to be good for.</description>
      <category>Decompilation</category>
      <category>Reverse Engineering</category>
      <category>AI</category>
      <category>Machine Learning</category>
      <category>Star Fox Adventures</category>
      <category>GameCube</category>
      <content:encoded><![CDATA[<p>A while back I <a href="https://jpb.dev/blog/star-fox-adventures-decomp">wrote about finally getting to contribute to a Star Fox Adventures decompilation</a> — my favourite childhood game, decompiled by a community I&#39;d stumbled into through speedrunning. Since then I&#39;ve been chipping away at the <a href="https://github.com/zcanann/SFA-Decomp">decomp project</a>, and somewhere along the way I got nerd-sniped by a side quest: could I <em>train</em> an AI to do the matching for me?</p>
<p>This is the working log of that experiment, written more or less as it happened — including the parts where I was wrong, the benchmarks that lied to me, and the genuinely surprising thing the model turned out to be useful for.</p>
<h2>The idea</h2>
<p>The goal of a decomp project is brutal in its precision: you write C source that, when fed through the <em>exact</em> original compiler (Metrowerks CodeWarrior, circa 2002), produces <strong>byte-for-byte identical</strong> machine code to the retail game. Not &quot;functionally equivalent&quot;. Identical. Every register, every instruction ordering, every stack offset.</p>
<p>General-purpose AIs — Claude, GPT — are <em>okay</em> at this. But they get stuck, and worse, they get stuck in a particular way: they throw up their hands and declare a function &quot;impossible to match, it&#39;s a compiler artifact&quot;. Often that&#39;s just not true.</p>
<p>So I had a crazy idea. The project gives us two things nobody else has:</p>
<ol>
<li>A corpus of <strong>thousands of functions already matched to 100%</strong> — verified, gold-standard <code>(assembly, C)</code> pairs.</li>
<li><strong>The actual compiler.</strong> Which means we can take any C the model writes, compile it, and check — deterministically — whether it reproduces the target.</li>
</ol>
<p>That second point is the kicker. Most ML problems have fuzzy, expensive labels. Here I had a <em>perfect, free oracle</em>. That&#39;s a fine-tuning setup most people would kill for. Could I train a specialist that beats the generalists at this one narrow, verifiable task?</p>
<p>I had a beefy laptop — an M5 Max with 48 GB. Let&#39;s find out.</p>
<h2>Building the moat: data</h2>
<p>The model is downstream of the data, so that&#39;s where I started. The project already tracks which functions match (a <code>report.json</code> with per-function scores), which object files they live in, and — crucially — the <em>exact compiler flags</em> each translation unit is built with (<code>build.ninja</code>).</p>
<p>Within an afternoon I had a pipeline producing <strong>7,756 training pairs</strong>: 5,893 &quot;gold&quot; (100%-matched) and the rest &quot;plausible&quot;. Each pair was:</p>
<ul>
<li><strong>Input:</strong> the target assembly + the compiler version + the build flags</li>
<li><strong>Output:</strong> the clean C that produces it</li>
</ul>
<p>Two details turned out to matter enormously, and both nearly slipped past me:</p>
<ul>
<li><strong><code>#pragma</code> directives.</strong> This project leans hard on per-function compiler pragmas (<code>peephole off</code>, <code>scheduling off</code>, optimisation levels) — the same C produces <em>different</em> assembly depending on them. I modelled the pragma state as a stack and attached the active set to each function.</li>
<li><strong>Unit-level flags.</strong> The codegen baseline (<code>-O4,p</code>, <code>-inline auto</code>, <code>-fp_contract on</code>, compiler version <code>GC/2.0</code> vs <code>1.2.5n</code>…) is set per translation unit. The same C compiles differently under different baselines, so this had to be part of the <em>input</em>.</li>
</ul>
<p>The dominant learned pragma combo? <code>(&#39;scheduling off&#39;, &#39;peephole off&#39;)</code> — exactly the project&#39;s primary hand-tuning lever. The data already &quot;knew&quot; the playbook.</p>
<h2>First blood: &quot;16 out of 17!&quot;</h2>
<p>I fine-tuned Qwen2.5-Coder-7B with LoRA, locally, in a few hours. Loss dropped beautifully. Then I ran the real test — generate C, recompile, check the match.</p>
<p><strong>0 out of 25.</strong></p>
<p>Heart-sink. But the failure was instructive. The model&#39;s <em>C was correct</em> — the logic, the struct fields, the control flow. It was just naming the functions wrong (it can&#39;t know a symbol name from assembly alone). When I renamed each generated function to its real symbol before compiling:</p>
<p><strong>16 out of 17 exact matches.</strong></p>
<p>Euphoria. The thing <em>worked</em>. I&#39;d built a model that decompiles!</p>
<p>And then — the most important moment of the whole project — I gut-checked the number instead of celebrating it.</p>
<h2>The reckoning: that number was a lie</h2>
<p>The &quot;16/17&quot; was seductive nonsense, and being honest about <em>why</em> probably saved the entire experiment:</p>
<ul>
<li>It was a <strong>tiny, biased sample</strong> — 17 cases, dominated by trivial functions (<code>return 0;</code>, one-line getters).</li>
<li>It needed a <strong>manual rename hack</strong> I&#39;d applied by hand.</li>
<li>It ran through an evaluation harness I&#39;d <em>later discover was buggy</em>.</li>
</ul>
<p>The real fix for the naming problem was obvious in hindsight: <strong>put the target symbol name in the prompt.</strong> In the real workflow you always know which function you&#39;re matching — it&#39;s the slot you&#39;re filling. So the name is legitimate input context, exactly like the compiler flags. I added it and retrained.</p>
<p>That retraining run got killed halfway through, because by then I&#39;d decided to go bigger.</p>
<h2>Going bigger, and faster</h2>
<p>Four-hour local training runs don&#39;t suit an iterate-on-the-data workflow. So I moved to the cloud — specifically <a href="https://modal.com">Modal</a>, chosen for one reason above all: it <strong>tears the GPU down the instant the job finishes</strong>. No instance to forget, no overnight bill. I rewrote the training in Unsloth (the CUDA-world equivalent of the Mac&#39;s MLX) and stepped up to <strong>Qwen2.5-Coder-14B</strong>.</p>
<p>The 14B trained in ~80 minutes on an H100 for about six dollars. Eval loss landed at <strong>0.26</strong>, far better than the 7B. I ran the recompile evaluation, and:</p>
<p><strong>28 out of 100.</strong></p>
<p>Hmm. Down from &quot;16/17&quot;? No — <em>up</em>, because this was finally an honest measurement: a representative 100-function sample across all sizes, no rename hack, scored with the recompiler. The two numbers measured completely different things. The lesson: never trust a benchmark you didn&#39;t try to break.</p>
<h2>The harness was lying too</h2>
<p>28/100 with <strong>47 outright compile failures</strong> smelled wrong. Reference C — the <em>known-perfect</em> source — only compiled 54/100 through my harness. The harness, not the model, was broken.</p>
<p>The bug: when splicing a candidate function back into its file to compile it, I was gluing it directly against the following <code>#pragma</code> line — a syntax error — for any function adjacent to a pragma. Worse, the <em>same bug had contaminated the training data</em> (stray pragma lines bleeding into function bodies).</p>
<p>I fixed it. Reference C jumped to <strong>99/100 compiling</strong>. The model&#39;s real score rose to <strong>38/100 exact</strong>. Ten matches had been hiding behind a harness bug the whole time.</p>
<h2>The question that changed the project: &quot;did it <em>kinda</em> match?&quot;</h2>
<p>Up to now I&#39;d only measured <em>exact</em> matches — binary, pass/fail. The obvious question I hadn&#39;t asked: of the ones that <em>didn&#39;t</em> exactly match, how close were they?</p>
<p>The answer reframed everything. Of the functions that compiled, the <strong>mean match was 90.3%.</strong> A 44-instruction function came back 95.6% correct. The &quot;0%&quot; cases weren&#39;t bad attempts — they were <em>compile failures</em> scored as zero by default.</p>
<p>So the real story was: <strong>when the model compiles, it&#39;s nearly there.</strong> It was doing genuine decompilation — getting the algorithm right, just occasionally inventing a struct field name it had never been shown. The bottleneck wasn&#39;t reasoning; it was missing type context.</p>
<h2>The reframe: the model is a draft, not an oracle</h2>
<p>This is where the whole framing shifted. One-shotting perfect matches by stuffing huge type definitions into every prompt was fighting the wrong battle. The high-value workflow was different:</p>
<blockquote>
<p><strong>The model writes a draft. A coding agent fixes it to 100%.</strong></p>
</blockquote>
<p>I tested it on a real 25-instruction function the model had failed to compile (it invented a field <code>glowEnabled</code> that doesn&#39;t exist). One look at the compiler error and the target assembly&#39;s struct offsets told me the real fields were <code>glowType</code> and <code>enabled</code>. <strong>One edit. Byte-identical match.</strong></p>
<p>The model had carried the entire semantic load — the clamp algorithm, the correct fields <code>glowAlpha</code>/<code>glowAlphaStep</code>, the control flow. The fix was a <em>mechanical</em> correction an agent automates trivially. I didn&#39;t need the type defs <em>in the prompt</em>; I needed them <em>available to the fixer</em> — which they always are.</p>
<h2>Reality check: the hard one, and the cop-out</h2>
<p>Then a genuinely hard target: <code>fn_801B3DE4</code>, stuck at 97.07% through a documented history of failed attempts. I stood the fine-tuned 14B up <strong>locally</strong> — fused the adapter, quantised it to a GGUF, ran it under <code>llama.cpp</code> on the laptop&#39;s GPU — and asked it for a draft.</p>
<p>It <strong>degenerated.</strong> Faced with a big, complex function, it spiralled into declaring <code>f32 v2; v3; … v49;</code>. Useless. (Consistent with the eval: strong on small functions, shaky on large ones.)</p>
<p>So I handed the problem to a capable coding sub-agent with all the tooling and the playbook. It worked for 16 minutes, tried the full battery of tricks, and concluded: <em>&quot;a genuine #108-class register-allocation cap — not a C problem, an MWCC artifact. 97% is the honest ceiling.&quot;</em></p>
<p>The same verdict every AI reaches. And I wasn&#39;t having it. Every AI keeps claiming this isn&#39;t a C problem, it&#39;s an MWCC issue — but in fact we just don&#39;t know the method to crack it. I&#39;m certain it&#39;s crackable with the right C. We just have to find it.</p>
<h2>The actual payoff: the corpus as an idiom oracle</h2>
<p>Here&#39;s where the model&#39;s <em>real</em> value finally crystallised — and it wasn&#39;t as a function-writer at all.</p>
<p>The thing blocking <code>fn_801B3DE4</code> was a specific assembly shape: the compiler keeps a duplicate copy of a pointer in a <em>second</em> register for one hot field. Every agent had declared this impossible to produce from C (&quot;the coalescer always merges copies&quot;). So instead of <em>asking</em> the model, I <strong>queried the data it was trained on</strong>: search all 7,600 matched functions for that exact shape.</p>
<p><strong>Twenty real functions produce it.</strong> From plain C. The &quot;impossible&quot; claim was empirically, demonstrably false.</p>
<p>And reading their source revealed the precise idiom the earlier attempts had missed: a second plain-<code>int</code> variable holding the base, accessed via <em>manual offset</em> (<code>*(int*)(p + K)</code>) in a confined region — <strong>not</strong> the struct-deref copy (<code>p-&gt;field</code>) everyone had tried, which genuinely <em>does</em> coalesce back.</p>
<p>That&#39;s the model&#39;s true worth. Not generating functions — it degenerates on the hard ones, and the easy ones don&#39;t need it. Its value is the <strong>7,600 real input→output examples it encodes</strong>, queryable as <em>&quot;what C produces this assembly shape?&quot;</em> An idiom oracle for a compiler whose source manual is long lost. The generalist AIs don&#39;t have that. They reason from first principles and talk themselves into &quot;it&#39;s a compiler artifact&quot;. The corpus just <em>shows you</em> the answer exists.</p>
<p>So I sent an agent back at <code>fn_801B3DE4</code> armed with that corpus intelligence and a hard ban on the &quot;it&#39;s a cap&quot; cop-out. It still didn&#39;t land the function — but it <em>failed much better.</em> Mining the twenty examples more carefully, it found why the idiom doesn&#39;t transfer: every clean producer builds its second register from a <em>varying</em> value (a loop-walked pointer, or a snapshot of something later reassigned), so the two registers genuinely hold different values and can&#39;t be merged. Our flame base is <strong>loop-invariant</strong> — same value everywhere — so the compiler correctly coalesces it. The barrier is mechanistic and specific, not a shrug.</p>
<p>And that points somewhere concrete: if the compiler only produces that shape from a <em>varying</em> value, the original source probably had a structure we haven&#39;t reconstructed — the base computed through a small loop, or a genuine reassignment. We didn&#39;t crack the function. We replaced &quot;it&#39;s an uncrackable compiler artifact&quot; with a falsifiable hypothesis about what the original C looked like. That&#39;s progress of a kind the generalist AIs never make — they stop at the cop-out.</p>
<h2>The final try: just throw a bigger brain at it</h2>
<p>One honest question remained: would a <em>bigger</em> model break through where the 14B degenerated? I&#39;d been disciplined about cost the whole way — so I did it properly. Trained <strong>Qwen2.5-Coder-32B</strong> (more than double the parameters) for two epochs on the <em>cleaned</em> data, on a rented H100. About four hours, <strong>~$18</strong>, eval loss down to <strong>0.195</strong> (the 14B had been 0.26). Then I skipped the on-GPU generation entirely and ran <em>all</em> of it locally on the laptop afterward, so the meter only ran during training.</p>
<p>The verdict was refreshingly unambiguous, and it cut both ways.</p>
<p><strong>Where the model already worked, bigger helped — measurably.</strong> Exact matches rose 38 → <strong>43 / 100</strong>. &quot;Nearly perfect&quot; (≥90%) drafts jumped 41 → <strong>52 / 100</strong>. Mean quality-when-it-compiles climbed 90.3% → <strong>95.6%</strong>. And the degeneration cliff <em>moved</em>: functions of 74–150 instructions that the 14B turned into garbage now came back as coherent <strong>90–95% drafts</strong> — squarely in &quot;an agent can finish this&quot; territory.</p>
<p><strong>Where it didn&#39;t work, bigger changed nothing.</strong> I pointed it straight at <code>fn_801B3DE4</code> and it produced <em>175 consecutive <code>f32 t1; t2; …</code> declarations</em> until it hit the token limit. No body. No closing brace. Identical collapse to the 14B, just longer. Zero percent — not &quot;wrong field name, one edit away&quot;, but <em>not a function at all.</em> There was literally nothing to fix.</p>
<p>So the experiment answered the question it set out to: <strong>the large-function collapse is not a capacity problem.</strong> Double the parameters, half the loss, cleaner data — the metric that was already fine got better, and the wall didn&#39;t move an inch. The model has a sharply-defined competence envelope:</p>
<table>
<thead>
<tr>
<th>Function size</th>
<th>What the model produces</th>
</tr>
</thead>
<tbody><tr>
<td>small / medium</td>
<td>coherent — wrong field at worst, <strong>fixable to 100% in an edit or two</strong></td>
</tr>
<tr>
<td>large (~74–150 instr)</td>
<td>coherent but imperfect — <strong>90–95% drafts, an agent finishes them</strong></td>
</tr>
<tr>
<td>very large / hard (180+ instr)</td>
<td><strong>total degeneration — unsalvageable at any size I tried</strong></td>
</tr>
</tbody></table>
<p><code>fn_801B3DE4</code> sits in the worst box on <em>two</em> independent axes: too big for the model to stay coherent, <em>and</em> gated by a register-allocation subtlety even if the C were perfect. The single least-suited target for the whole approach — which, oddly, makes it the perfect note to end on. The tool is real. Its edges are real too.</p>
<h2>What I actually learned</h2>
<ul>
<li><strong>The verifiable oracle is everything.</strong> A compiler that says yes/no for free turns vibes into measurement, and measurement into rejection-sampling and RL down the line.</li>
<li><strong>Distrust your best numbers.</strong> &quot;16/17&quot; was the most dangerous moment of the project. Every impressive result hid a bug or a biased sample. The honest 38/100 was worth more than the dishonest 94%.</li>
<li><strong>Binary metrics undersell.</strong> &quot;38% exact&quot; and &quot;90% average when it compiles&quot; are the same model. One says &quot;mediocre&quot;, the other says &quot;nearly there, fix the compile&quot;. Partial credit changed every decision.</li>
<li><strong>The model&#39;s job wasn&#39;t what I thought.</strong> Not an autonomous decompiler. A <em>draft generator</em> for a human/agent fixer, and — the real surprise — an <em>idiom oracle</em> over its own training corpus.</li>
<li><strong>&quot;It&#39;s a compiler artifact&quot; is usually surrender, not diagnosis.</strong> The most valuable thing the whole exercise produced wasn&#39;t a model. It was a way to <em>prove</em> that an &quot;impossible&quot; match is possible — by finding the twenty places it already happened — and, when I still couldn&#39;t land it, to replace the cop-out with a <em>falsifiable hypothesis</em> (invariant vs. varying value) about the original source.</li>
<li><strong>Scale helps where you&#39;re already winning, not where you&#39;re stuck.</strong> Going from 14B to 32B lifted every &quot;draft&quot; metric and pushed the degeneration cliff out to bigger functions — real, paid-for progress. It did <em>nothing</em> for the hardest function. &quot;Throw a bigger model at it&quot; is a fine thing to <em>test</em>, as long as you treat the answer as data, not a foregone conclusion.</li>
<li><strong>Cost discipline is a feature.</strong> Per-second cloud GPUs that tear down on completion, and moving every bit of inference off the meter and onto the laptop, meant the whole &quot;bigger and bolder&quot; final try cost less than a takeaway dinner. You can afford to run the experiment that settles the question.</li>
</ul>
<h2>Where this goes next (and why it&#39;s not going there yet)</h2>
<p>Here&#39;s the honest conclusion: I now genuinely believe an LLM <em>can</em> be trained to solve this — to decompile assembly into matching C. It doesn&#39;t feel like a fantasy anymore; it feels reachable. The competence envelope is real, but every lever I pulled moved it in the right direction. If I were to keep going, the obvious avenues are:</p>
<ul>
<li><strong>More and better training data.</strong> The single highest-leverage input. The corpus is good, but it&#39;s one game; a broader, cleaner set of <code>(assembly, C)</code> pairs is probably worth more than any architecture change.</li>
<li><strong>Train for much longer.</strong> I was running afternoon-to-overnight jobs on a budget. There&#39;s clearly more to extract.</li>
<li><strong>Go bigger again.</strong> 14B → 32B helped measurably. The trend hasn&#39;t flattened.</li>
<li><strong>Stop fine-tuning a general code model and train one for this from scratch</strong> — or at least fine-tune something other than Qwen. A model that only ever has to think about this one task might not degenerate the way these did.</li>
</ul>
<p>But I&#39;m going to be honest with myself about priorities. My actual goal is to <strong>decompile Star Fox Adventures</strong> — and at this point the model is a distraction from that, not an accelerant. It&#39;s a fascinating side quest that hasn&#39;t yet paid back the time I&#39;ve sunk into it, and chasing it further would mean <em>less</em> progress on the thing I actually care about. So I&#39;m parking it here, with the satisfaction of knowing the crazy idea basically worked — just not for the reason I expected, only after I stopped believing my own best results, and only once I learned exactly where the tool&#39;s edges are, including the one place it will never reach.</p>
<p>If someone reading this wants to pick up the thread — more data, longer runs, a purpose-built model — I&#39;d love to see where it goes.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How I Finally Got to Give Back to a Childhood Favourite</title>
      <link>https://jpb.dev/blog/star-fox-adventures-decomp/</link>
      <guid isPermaLink="true">https://jpb.dev/blog/star-fox-adventures-decomp/</guid>
      <pubDate>Wed, 03 Jun 2026 00:00:00 GMT</pubDate>
      <description>Star Fox Adventures has been my favourite game since I was a kid. Here&apos;s how a speedrunning video, a Discord server, and a wave of AI-assisted decompilation finally let me contribute to the community behind it.</description>
      <category>Decompilation</category>
      <category>Reverse Engineering</category>
      <category>Star Fox Adventures</category>
      <category>AI</category>
      <category>GameCube</category>
      <content:encoded><![CDATA[<p>Star Fox Adventures is the game of my childhood. I still replay it at least twice a year, every year — it&#39;s one of those games that&#39;s wired into me at this point. So this post is a bit different from my usual security writing: it&#39;s about how I finally found a way to give something back to the community around a game I love.</p>
<h2>Falling down the speedrunning rabbit hole</h2>
<p>It started with a video about the history of Star Fox Adventures world records:</p>
<p><a href="https://www.youtube.com/watch?v=L8uDuCK6VnI">The history of the Star Fox Adventures world record</a></p>
<p>It walks through all the wild glitches and techniques runners use to tear through the game as fast as possible. I was hooked. <strong>ESW (Event Storage Warp)</strong> in particular completely fascinated me.</p>
<p>The gist is a developer oversight: when you exit to the menu, some of the game&#39;s flags don&#39;t get properly cleared. One of those leftover flags controls where you respawn when you die. So the trick goes like this:</p>
<ol>
<li>Load one save and hit a respawn checkpoint.</li>
<li>Exit to the menu — your respawn point sticks around because the flag was never reset.</li>
<li>Load a <em>second</em>, different save and die.</li>
<li>You respawn back at the checkpoint from the <strong>first</strong> save.</li>
</ol>
<p>That single piece of &quot;stored&quot; state lets runners stitch together parts of the game in an order the developers never intended — and the fact it all comes down to one flag that simply doesn&#39;t get cleared is just endlessly cool to me.</p>
<p>That video led me to the Star Fox speedrunning Discord, and it was genuinely lovely to find a whole group of people who love this game as much as I do.</p>
<h2>Wanting to help, but not knowing how</h2>
<p>There was one thing missing, though: I really wanted to <em>help</em>. The problem is I&#39;m not a good enough gamer to actually speedrun the game.</p>
<p>I thought about becoming a glitch hunter instead, but that felt like it needed serious reverse-engineering knowledge. My career is in software, but it&#39;s a completely different world — I spend my days in Node, Go, AWS and the security weeds, and keeping on top of all of that is already a full plate. So for a while I just lurked on the side of the Discord, cheering other people on.</p>
<h2>The stars align</h2>
<p>Then something new happened. Someone posted in the Discord that they were using <strong>AI to decompile the game</strong>.</p>
<p>I use AI every single day for work — it&#39;s become an enormous part of how I get things done. And suddenly the stars aligned. Here was a way I could <em>finally</em> contribute to the community for my favourite childhood game, using something I already understood deeply.</p>
<p>And I have. At the time of writing, I&#39;ve contributed <strong>around 3,200 commits</strong> to the <a href="https://github.com/zcanann/SFA-Decomp">decomp project</a>.</p>
<h2>A whole community I never knew existed</h2>
<p>Contributing also opened a door I didn&#39;t even know was there: the game decompilation community. There are <em>hundreds</em> of these projects going on for retro games, and I find the whole thing genuinely thrilling.</p>
<p>Once a game has been decompiled, all sorts of doors open:</p>
<ul>
<li><strong>Ports</strong> — running the game natively on PC, or even in the browser.</li>
<li><strong>Modding</strong> — building on top of the original game in ways the cartridge never allowed.</li>
</ul>
<p>I can imagine a future with a proper competitive scene for Star Fox Adventures. Picture a mod that drops two players into a random section of the game and has them race to clear just that one part — something like <a href="https://mcsrranked.com/">Minecraft&#39;s MCSR Ranked</a>, but for SFA. That would be incredible.</p>
<h2>What I&#39;ve learned along the way</h2>
<p>Beyond the warm fuzzy feelings, the last couple of months have taught me a surprising amount about how games were actually built for the GameCube — how a project like this would have been set up, and how the compiler behaves. A few things that stuck with me:</p>
<ul>
<li><strong>Compiler quirks</strong> — toggling behaviours like peephole optimisation and instruction scheduling to get matching output.</li>
<li><strong>Old-school algorithms</strong> — spotting things like shell sort in the wild.</li>
<li><strong>Per-file compiler flags</strong> — this was the most surprising one. Individual source files were compiled with their own individual compiler flags. Matching that faithfully is a fascinating puzzle.</li>
</ul>
<p>I also picked up a lovely bit of trivia along the way: <strong>&quot;Dolphin&quot; was the GameCube&#39;s secret codename before release</strong> — which is exactly why the Dolphin emulator is named after it. I&#39;d always assumed &quot;Dolphin&quot; was just some random name that happened to stick, so learning it was the console&#39;s real internal codename, unearthed and adopted by the emulation and reverse-engineering community, was a genuinely delightful little discovery.</p>
<h2>Over the moon</h2>
<p>All in all, I&#39;m just delighted. I finally get to contribute to the community around my favourite childhood game, I&#39;ve stumbled into a fascinating corner of programming I never knew existed, and I genuinely can&#39;t wait to see what else comes out of this decomp project.</p>
<p>If you love a retro game, there&#39;s a decent chance someone out there is decompiling it right now — and they&#39;d probably love the help.</p>
]]></content:encoded>
    </item>
    <item>
      <title>The Skeleton Lord, Three Ingredients, and a Few Too Many Beers</title>
      <link>https://jpb.dev/blog/finding-city-of-thieves/</link>
      <guid isPermaLink="true">https://jpb.dev/blog/finding-city-of-thieves/</guid>
      <pubDate>Thu, 04 Sep 2025 00:00:00 GMT</pubDate>
      <description>One drunken evening I asked AI to identify a choose-your-own-adventure book I hadn&apos;t thought about in twenty years, based on a description so bad it should never have worked. It found the book, found someone who&apos;d turned it into a video game, and then fixed the build for me. I went to bed at 4am with a piece of my childhood back.</description>
      <category>AI</category>
      <category>Books</category>
      <category>Nostalgia</category>
      <content:encoded><![CDATA[<p>This one starts, as all the best stories do, with a few beers.</p>
<p>When I was younger, my dad used to read me these &quot;choose your own adventure&quot; books. You know the format — you&#39;re the hero, every couple of pages you make a choice, pick wrong and you die horribly, pick right and you get to die horribly a few pages later. I loved them. And then, like most childhood things, they quietly vanished from my brain. Fifteen, twenty years go by and I couldn&#39;t have told you the name of a single one, or even what the covers looked like.</p>
<p>Until one night, a few beers deep, I had a thought: I wonder if AI could find them for me.</p>
<h2>The world&#39;s worst book description</h2>
<p>I&#39;d love to tell you I gave it rich, vivid detail to work with. I did not. My entire memory of these books amounted to roughly the following:</p>
<ul>
<li>It was a choose your own adventure book.</li>
<li>At some point there&#39;s a skeleton lord guy thing you have to kill.</li>
<li>You do it by knocking him down and putting a combination of different ingredients over his eyes.</li>
<li>Right at the end it&#39;s revealed you were carrying one ingredient too many — you couldn&#39;t just use all of them, that wouldn&#39;t work — so the whole book comes down to a 1-in-3 guess.</li>
</ul>
<p>That&#39;s it. That was the brief. A shitty description of a book I could barely remember in my own head, handed over at whatever-o&#39;clock in the morning with zero evidence.</p>
<p>It went back and forth quite a few times. I&#39;d say &quot;no, that&#39;s not it&quot;, Claude (Opus 4.1, for the record) would come back with another candidate, we&#39;d dig a little deeper each time. And then it landed on it: <strong>City of Thieves by Ian Livingstone</strong> — one of the old Fighting Fantasy books. I pulled up a summary, and there it was. The skeleton. The ingredients. The eyes. The horrible final guess.</p>
<p>I was absolutely gobsmacked. I&#39;d tried googling this myself before and got nowhere — turns out &quot;book where you put ingredients on a skeleton&#39;s eyes&quot; is not a winning search query. I suppose I could have asked on a book forum, but those places always feel half-dead, and I didn&#39;t fancy waiting months for a reply that might never come. Instead, one late evening and a conversation was enough. Even my dad couldn&#39;t remember the books, so as far as I was concerned this thing was gone forever.</p>
<h2>Can I play it?</h2>
<p>Naturally, being several beers in at this point, my next question was even stupider: &quot;Could I turn this into a real text-based adventure game that I could actually play?&quot;</p>
<p>Opus whirred away for a few minutes and came back with: yes, absolutely — and better than that, <em>someone already has</em>. A developer in Sweden had built the whole thing and open-sourced it on GitHub: <a href="https://github.com/richelbilderbeek/city_of_thieves">richelbilderbeek/city_of_thieves</a>.</p>
<p>Brilliant. I jumped straight into the repo to get it set up, and immediately ran into a wall — it&#39;s a C++ project, and C++ is very much not my day job. I tried to build it, the build did not build, and I sat there staring at compiler errors like they&#39;d personally offended me.</p>
<p>And then it hit me. Hold on. Why am <em>I</em> doing this part?</p>
<h2>Claude cracks the build</h2>
<p>I put Opus back on the case and watched an AI agent dive headfirst into a codebase neither of us had ever seen before. It read through the project, worked out what was wrong, fixed all the compile issues one by one, and got the thing to build. I just sat there with my beer.</p>
<p>This was the part that genuinely amazed me. All my previous experience with AI had been in projects I already knew inside out — very prescribed usage, me asking for targeted little changes to code I could have written myself. This was something else entirely: an unfamiliar codebase, a vague goal, and the agent just... handled it. And it bloody well worked.</p>
<p>The game ran. And it was <em>exactly</em> how I remembered the book. The streets of Port Blacksand, the terrible decisions, the lot.</p>
<p>Though I&#39;ll be honest — I remember the book being easier. I&#39;m starting to suspect my dad just made all the swords hurt a lot less to help me out.</p>
<h2>The point, if there is one</h2>
<p>I went into that evening expecting absolutely nothing, and instead I got back a piece of my childhood I was sure I&#39;d lost. There&#39;s something genuinely magical to me about being able to throw an AI agent at a problem this fuzzy — &quot;a book I half-remember from twenty years ago, and also please make it a video game&quot; — and have it blow straight past my expectations.</p>
<p>Tools like this didn&#39;t exist when I forgot those books. I&#39;m very glad they exist now.</p>
<p>Just maybe don&#39;t wait until 4am and three beers to find out.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Don&apos;t Hash Trash</title>
      <link>https://jpb.dev/blog/dont-hash-trash/</link>
      <guid isPermaLink="true">https://jpb.dev/blog/dont-hash-trash/</guid>
      <pubDate>Mon, 22 Mar 2021 00:00:00 GMT</pubDate>
      <description>Hashing only protects you when it&apos;s done right. A look at what hashing actually is, why salts matter, and how I once found a passwordless auth system you could quietly predict your way into.</description>
      <category>Security</category>
      <category>Cryptography</category>
      <category>Hashing</category>
      <category>Auth</category>
      <content:encoded><![CDATA[<p>Hashing is one of those tools that feels like magic the first time you understand it, and dangerous the first time you see it used badly. This is a quick tour of what hashing is, why salts exist, and a story about a passwordless login system that you could quietly predict your way into.</p>
<h2>What is hashing?</h2>
<p>Hashing means passing data through a mathematical formula to produce a result — the hash. The important property is that it&#39;s <strong>one-way</strong>: you can hash data to create a hash, but you can&#39;t reverse a hash to recreate the original data. Common algorithms you&#39;ll have seen include <code>md5</code>, <code>sha1</code> and <code>bcrypt</code>.</p>
<p>In a security context, hashing is most often used to protect user passwords. Rather than storing passwords in plain text — where they&#39;re exposed to anyone with database access, from administrators to attackers — you store only the hash. Even if the database is breached, the original passwords aren&#39;t sitting there waiting to be read.</p>
<h2>Rainbow tables and salts</h2>
<p>The catch is that the same input always produces the same hash. That predictability is what makes <strong>rainbow tables</strong> dangerous: enormous precomputed lookup tables that map hashes back to the values that produced them. If an attacker finds a stored hash of <code>25d55ad283aa400af464c76d713c07ad</code>, a rainbow table will happily tell them the password was <code>12345678</code>.</p>
<p>The defence is a <strong>salt</strong>: random data added to the input before hashing. Give every user a unique salt and identical passwords no longer produce identical hashes. Precomputed tables become useless, because the attacker would need a separate table for every salt. They&#39;re forced to crack each password individually instead of looking them all up at once.</p>
<h2>How hashing practices have evolved</h2>
<p>Early PHP code leaned on <code>md5</code> and <code>sha1</code>. As computing power grew — especially cheap, massively parallel GPU power — those algorithms became fast enough to brute-force. Modern PHP gives you <code>password_hash()</code> and <code>password_verify()</code>, which generate a random salt for you and let you turn up the computational cost over time as hardware keeps improving. That adjustable cost is the whole point: a good password hash is <em>deliberately</em> slow.</p>
<h2>How to hash badly</h2>
<p>Here&#39;s where it gets fun. I once came across a passwordless authentication system that generated login pins by hashing the user&#39;s email address together with a sequential request identifier.</p>
<p>The problem: the identifier was predictable and simply incremented with each request. Because the input was guessable, an attacker could build their own local rainbow table, watch the pattern across a few login attempts, and then <strong>predict future pins for other users</strong>. The hashing was technically present — and completely worthless, because the thing being hashed had no real randomness in it.</p>
<h2>The takeaway</h2>
<p>Hashing protects you only when it&#39;s implemented correctly. The algorithm matters, but how you use it matters just as much:</p>
<ul>
<li>Don&#39;t roll your own authentication. Lean on established services like <strong>AWS Cognito</strong> or <strong>Auth0</strong>.</li>
<li>When you need randomness, use a <strong>cryptographically secure</strong> generator — never a plain <code>rand()</code>.</li>
<li>Remember that a hash is only as unpredictable as its input.</li>
</ul>
<p>Good hashing isn&#39;t about reaching for the fanciest algorithm. It&#39;s about not hashing trash.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
