<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://alchemy86.github.io/devlog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://alchemy86.github.io/devlog/" rel="alternate" type="text/html" /><updated>2026-09-16T14:15:43+00:00</updated><id>https://alchemy86.github.io/devlog/feed.xml</id><title type="html">devlog</title><subtitle>The captain&apos;s engineering log.</subtitle><entry><title type="html">The infrared had to go</title><link href="https://alchemy86.github.io/devlog/posts/the-infrared-had-to-go.html" rel="alternate" type="text/html" title="The infrared had to go" /><published>2026-09-16T00:00:00+00:00</published><updated>2026-09-16T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-infrared-had-to-go</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-infrared-had-to-go.html"><![CDATA[<section class="prose">

  <h2>A Game Boy link is six pins and almost no parts</h2>

  <p>The IrDA sheet is gone — the Vishay TFBS4711 and everything hanging off it. What replaced it
is a 74LVC2G17 dual Schmitt buffer and five resistors.</p>

  <p>Two of the six EXT pins are driven by the console at 5 V TTL, and those go through the buffer
to get down to 3.3 V. Its inputs are 5 V tolerant at a 3.3 V supply, so there is no divider
and no bidirectional shifter. The leg going the other way needs nothing at all: TTL's input
threshold is 2.0 V, so the device's 3.3 V output drives the console directly.</p>

  <p>The device is the clock <strong>slave</strong>. A Game Boy link has no chip select, which is why this is a
job for the RP2350's PIO rather than its PL022 — the hardware SPI's slave mode wants a select
line to stay byte-aligned, and there isn't one.</p>

  <figure class="narrow" style="max-width:none;">
  <img src="../assets/img/pokewalker/gblink-sheet.png" width="1600" height="1131" alt="The Game Boy link schematic sheet: connector J5 breaking out six EXT pins, a 74LVC2G17 dual Schmitt buffer, series and pull resistors, and five test points." />
  <figcaption>The whole interface. Five of the six pins are used; P14 is a no-connect.</figcaption>
</figure>

  <p>Nothing in it is Gen-1 specific. The sheet carries SPI Mode 3 bytes and encodes no byte
counts, so a Gen 2 trade is a firmware problem, not a board respin.</p>

  <h2>The connector is a row of pads, because nothing else fits</h2>

  <p><code class="language-plaintext highlighter-rouge">J5</code> is a new footprint rather than a part off a shelf: a 1×6 row of 1.27 mm solder pads. The
largest free rectangle on the back copper near the MCU measures 6.25 × 6.25 mm, and the
smallest 6-way JST SH needs 9.8 × 5.2 mm. The only place a real connector fits is the far end
of the board, beside the USB-C and the power management.</p>

  <p>Pads keep every connector option open, and they are what bring-up on a breadboard wants
anyway. Five test points sit on SCK, SI, SO, ground and the link's own 5 V — this interface
has never seen a console, so it is instrumented rather than trusted.</p>

  <p>The placement came from a measured free-space map of the back copper, not from eyeballing it.
That board outline is a rounded Pokéwalker shape with three screw-hole cutouts in it, two of
them sitting right inside the band where the parts had to go, and KiCad's own board-polygon
call does not treat a cutout as off-board. A search that does not exclude them will happily
put a footprint in a hole.</p>

  <h2>The Poké Ball look is paint, and it has to be</h2>

  <p>The shell is two machined halves split at the ball's own band, with the belt clip cut into the
back — the arrangement of the real device, whose clip lives on a swappable back cover.</p>

  <figure class="narrow">
  <img src="../assets/img/pokewalker/shell-pokeball-face.png" width="1400" height="1400" alt="The shell face-on: a full circle with a black band straight across the middle, red above, white below, and the screen centred on the band like the ball's button." />
  <figcaption>Face-on, the icon reads. The silhouette measures 1138 × 1136 px — a true circle to 0.2%.</figcaption>
</figure>

  <p>That picture cost an afternoon of looking for a camera angle before the geometry settled it.
A Poké Ball icon is a sphere seen level with its own equator. This part is a ⌀70 mm disc
19 mm thick, so the same level camera renders a 69.5 × 19 mm lens. There is no elevation that
turns a disc into a ball, and the only view whose silhouette is a true circle is straight at
the face — where the real band is edge-on at the rim and invisible.</p>

  <p>So the stripe across the face is paint. The band round the equator is real: a 3.9 mm sunken
recess with a chamfered shoulder each side and the parting line down the centre of it,
0.6 mm deep at its deepest out of a 1.2 mm wall. Red above and white below is a render
choice; no finish decision has been made.</p>

  <figure class="narrow">
  <img src="../assets/img/pokewalker/shell-pokeball-top.png" width="1600" height="1200" alt="The shell from a raised three-quarter angle: a domed red front with the black band arcing across the lower silhouette and a sliver of white behind it." />
  <figcaption>From the angle a die-cast ball gets photographed at. The white back barely shows, and that is the model being honest: the dome stands 14 mm above the split line and the back only 5 mm.</figcaption>
</figure>

  <p>It is not a half-and-half ball and it cannot be while it is a Pokéwalker. Red covers about
three-quarters of the object's height because that is the real device's proportion. Making it
read 50/50 is a form-factor decision, not a paint one.</p>

  <h2>Boring the screen pocket flattens the dome</h2>

  <p>The bezel has to be constant thickness over the window, so the outer surface is machined flat
out to the bezel radius — on ⌀70, a flat 44 mm across, sitting 5.02 mm below where the sphere
would have been. A fifth of the front is a disc. That is exactly why it looks faceted next to
a real Pokéwalker.</p>

  <p>The real device does not solve this in the housing either. It puts a clear cover over the
front and lets that carry the curve the screen cannot. So does this one.</p>

  <figure class="narrow" style="max-width:none;">
  <img src="../assets/img/pokewalker/shell-lens-compare.png" width="2254" height="1340" alt="Four renders in a two-by-two grid. The left column is the shell without the cover, the right column with it fitted; the top row is edge-on, where the bare front shows a flat facet over the screen and the covered one a clear dome standing proud of it, and the bottom row is the same pair from three-quarters." />
  <figcaption>Left, the machined flat. Right, the cover that puts the apex back where the sphere had it.</figcaption>
</figure>

  <p>It is the tightest thing in the model. Measured from the screen centre, the window's chamfered
mouth ends at r=17.2 mm and the nearest button's mouth begins at r=18.47 mm. Everything the
cover needs on the face — flange, land, slip fit — lives in that 1.27 mm.</p>

  <p>The check for it is an intersection rather than an assertion: the target builds the shared
volume of cover and shell and fails unless it comes out with zero facets. Forcing the fit
0.5 mm the wrong way makes it report 1280 facets, which is how you know the check has teeth.</p>

  <h2>The plug cannot reach the socket</h2>

  <p><code class="language-plaintext highlighter-rouge">make check</code> fails on the ⌀70 configuration, and it is right to.</p>

  <p>The board's minimum enclosing circle is ⌀50.50 mm. The bore it sits in is ⌀67.6. That leaves
the USB-C receptacle's mouth <strong>7.27 mm</strong> inside the shell wall, and a USB-C plug seats its
overmould against the case with about 6.5 mm of nose — all of which is needed inside the
receptacle, not crossing seven millimetres of fresh air. No chamfer or funnel fixes it.</p>

  <p>Three things do, and they are all board decisions: make the board reach the wall, move the
connector out on a flex or riser, or give up on USB-C and accept a recessed pogo or magnetic
charger. It is a tighter trade than it looks. In a ⌀60 shell the bore is ⌀57.6 at the split
plane but the dome has already narrowed to ⌀54.9 a millimetre above it, so a board big enough
to reach the wall has to sit at or below the split line — which eats the space underneath that
the receptacle needs, and the back has to get deeper to pay for it.</p>

  <p>And ⌀48, the size that gets quoted as the answer, does not satisfy it either: in a ⌀60 shell a
⌀48 board still leaves a 4.8 mm gap and the plug still cannot reach.</p>

  <h2>The clip will go slack</h2>

  <p>Report and instinct both say a printed 316L clip takes a set and stops gripping. Machining it
does not help, because that is not a process problem — 316L is not a spring material in any
process. As drawn, the clip grips when new and goes soft.</p>

  <p>Three ways out, none of which change the geometry: machine the back from 17-7 PH in condition
A and age-harden it afterwards, machine it in titanium TC4, or redraw the clip as a hook that
barely flexes and lets geometry do the retention. The back is a separate piece held by three
screws, so trying a second one in a different material is cheap.</p>

  <h2>What the numbers are</h2>

  <p>Both configurations export and both were weighed from the real solid volumes at 7.98 g/cm³,
not estimated from a wall thickness: <strong>86.1 g</strong> for ⌀70 (front 36.4, back 49.7) and <strong>62.7 g</strong>
for ⌀60. Both are a long way under the 143 g a solid ⌀70 ball suggests, because a flat-backed
Pokéwalker is a long way from a sphere. The clear cover adds about 1.3 g in acrylic.</p>

  <p>Every dimension that could plausibly change is a named parameter, and the model asserts its
own rules while it renders — that the band leaves at least 0.5 mm of wall, that the screw
heads stay on the flat, that the bosses clear the board. The board geometry is not typed in
either: the outline, the mounting holes, the buttons and every footprint's courtyard are
pulled straight out of the <code class="language-plaintext highlighter-rouge">.kicad_pcb</code>, so a board change and a stale shell cannot coexist
quietly.</p>

  <h2>What is not done</h2>

  <p>The link nets are placed but not routed, and hand-routing is the only option. Freerouting was
tried properly on this board and is not usable: it plateaus at 62 unrouted and 417 violations,
its fanout stage rewrites the whole board rather than the nets you asked for, and importing
its result takes the board from 981 tracks to 203 — it wipes routing on seventy nets that were
already fine. It also routes straight through the screw-hole cutouts, because the export
format does not carry them as keepouts.</p>

  <p>Beyond that: the interface has never been run against a console, the ball size is still open,
and a keyed bayonet joint that makes the front half swappable — Poké Ball, Great Ball, Ultra
Ball, and a Team Rocket ball that is a fan design rather than canon — is drawn and parked
until the side buttons are settled, because the lugs have to miss the ports.</p>

</section>]]></content><author><name></name></author><category term="Pokewalker" /><summary type="html"><![CDATA[The Pokéwalker talked to a DS over infrared. This one plugs into a Game Boy with a link cable instead — a 74LVC2G17 where the IrDA transceiver used to be, inside a machined steel Poké Ball. The shell is drawn, weighed and checked against the real board, and the check currently fails.]]></summary></entry><entry><title type="html">The leaderboard that does not exist</title><link href="https://alchemy86.github.io/devlog/posts/the-leaderboard-that-does-not-exist.html" rel="alternate" type="text/html" title="The leaderboard that does not exist" /><published>2026-09-09T00:00:00+00:00</published><updated>2026-09-09T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-leaderboard-that-does-not-exist</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-leaderboard-that-does-not-exist.html"><![CDATA[<section class="prose">

  <h2>Two of our own numbers flattered us and one was never measured</h2>

  <p><strong>We had never timed mGBA.</strong> Every sentence this project had written about native emulator
speed came from reading rather than from a clock. The expectation put to the work was that C
emulators run at thousands of frames a second where PyBoy manages hundreds, and that PyBoy is
fast only for something with Python bindings. That expectation had been setting the target, and
it did not survive being run.</p>

  <p><strong>Our bench had no sound chip in it, and theirs cannot take one out.</strong> With no audio player
installed, our APU's per-cycle entry point returns immediately — the headless figure we had
been publishing was a machine with no APU at all. mGBA and SameBoy emulate the audio hardware
whether or not anyone is listening, and neither offers a switch. So every earlier row compared
two different machines, and the difference went our way.</p>

  <p><strong>17% of the PyBoy figure was our own harness.</strong> We were calling <code class="language-plaintext highlighter-rouge">tick(1, False)</code> 7,168 times
from Python and timing the loop, while our side runs its frame loop in Rust with no binding in
the way. The same work in a single <code class="language-plaintext highlighter-rouge">tick(7168, False)</code> is 0.0157 ms a frame rather than
0.0190. The missing 0.0033 was the cost of asking, in Python, 7,168 times — and we had been
charging it to PyBoy.</p>

  <p>Two of the three moved against us. The one that had never been measured at all went the other
way.</p>

  <h2>What a row has to satisfy before it means anything</h2>

  <ul>
    <li>One box, one P-core, pinned. Thread CPU time, not wall clock. This machine is eight P-cores
at 5.3 GHz beside twelve E-cores at 4.6, and an unpinned process moves the answer by 15% or
more between runs of the identical binary.</li>
    <li>One cartridge, one frame count. Pokémon Blue, 7,168 frames from reset — 120 emulated
seconds — on every side.</li>
    <li>Every row interleaved with every other row in the same session, and the load average stated.
Quote the load average or do not quote the number: at load 16 every figure below reads about
a third slower, in the same order and the same ratios.</li>
    <li>Every side built with its own release flags. SameBoy under its own <code class="language-plaintext highlighter-rouge">CONF=release</code>, ours at
<code class="language-plaintext highlighter-rouge">opt-level = 3</code> with fat LTO, PyBoy as the published Cython wheel.</li>
    <li>The sound chip present on both sides, or absent from both.</li>
  </ul>

  <p>And then the part that is easy to miss: <strong>pair the picture engines, or you have measured a PPU
rather than an emulator.</strong> mGBA's Game Boy PPU is event-scheduled at mode boundaries, which is
architecturally our <code class="language-plaintext highlighter-rouge">standard</code>. SameBoy's is a per-dot pixel FIFO, which is our <code class="language-plaintext highlighter-rouge">identical</code>.
Crossing them produces a number with nothing inside it.</p>

  <h2>The result</h2>

  <p>One P-core, interleaved, load 1.9, pixels and audio on for every native row.</p>

</section>

<div class="table-wrap">
  <table>
    <thead>
      <tr>
        <th>Emulator</th>
        <th class="num">ms / frame</th>
        <th class="num">&times; real time</th>
        <th>PPU granularity</th>
      </tr>
    </thead>
    <tbody>
      <tr><td><strong>TerminalGB <code>standard</code></strong></td><td class="num"><strong>0.0666</strong></td><td class="num">251&times;</td><td>whole scanline</td></tr>
      <tr><td>mGBA, render on</td><td class="num">0.0819</td><td class="num">204&times;</td><td>event-scheduled modes</td></tr>
      <tr><td><strong>TerminalGB <code>identical</code></strong> (the default)</td><td class="num"><strong>0.2126</strong></td><td class="num">79&times;</td><td>per-dot FIFO</td></tr>
      <tr><td>SameBoy, render on</td><td class="num">0.4084</td><td class="num">41&times;</td><td>per-dot FIFO</td></tr>
    </tbody>
  </table>
</div>

<section class="prose">

  <p>We are 1.23× faster than mGBA and 1.92× faster than SameBoy, each against the engine of its
own granularity. PyBoy does not belong in that table — it has a switch for its sound chip and
we have no chip to switch — so it gets its own pairing, APU-free on both sides: <strong>PyBoy
0.0156 against our 0.0297, which is 1.90× ahead of us.</strong></p>

  <p>mGBA with its renderer off reads 0.0555, and that row is not in the table because it is a
different machine from the one SameBoy and our <code class="language-plaintext highlighter-rouge">identical</code> are running.</p>

  <h2>The ordering is the accuracy ordering, upside down</h2>

  <p>The
<a href="https://github.com/gbdev/GBEmulatorShootout">GB Emulator Shootout</a> publishes 24 DMG rows from
the Mealybug Tearoom suite, which is the test set that separates the two or three most exact
PPUs in the world from everyone else. SameBoy takes 15 of them. bgb takes 2. PyBoy takes 0.
Eleven are pixel-exact here on the per-dot engine, against the same DMG references; on the whole
79-row suite that engine takes 29 where our fast engine takes 3.</p>

  <p>So the slowest emulator measured above is the most exact one in the field, and the fastest is
the one that scores nothing on the suite that matters most. PyBoy is the fastest Game Boy
emulator on this machine, and what it buys that with is not being cycle-accurate.</p>

  <p>The cleanest reading of the trade is not between projects at all. It is between our own two
picture engines, where the binary, the cartridge, the core and the frame count are the same and
only the PPU changes: 0.0666 against 0.2126, a factor of 3.2, and 3 Mealybug rows against 29.
Twenty rows of the Shootout's 264-row manifest sit in that gap too — 223 on the fast engine
against 243 on the exact one, which is 5th of 19 on the board as it stood in June, behind
SameBoy's 253 and ahead of mGBA's 150.</p>

  <p>Nothing about the cost of exactness needed a second project to demonstrate. What the second
project settled is that there is no faster native emulator here to catch, which is what everyone
had assumed there was. There is a design to catch, and its price is already on the books.</p>

</section>

<div class="pullquote">
  <p>There is no faster native emulator here to catch. There is a design.</p>
</div>

<section class="prose">

  <h2>RosettaBoy is measuring something else, and the something else is real</h2>

  <p><a href="https://github.com/shish/rosettaboy">RosettaBoy</a> is one emulator design implemented in twenty
language-and-build combinations, and its table is the cleanest measurement of language overhead
anyone has for this workload. Its own run, <code class="language-plaintext highlighter-rouge">--headless --silent</code> on an M1 MacBook Pro:</p>

</section>

<div class="table-wrap">
  <table>
    <thead>
      <tr><th>Implementation</th><th class="num">fps</th><th>Implementation</th><th class="num">fps</th></tr>
    </thead>
    <tbody>
      <tr><td>Nim, LTO</td><td class="num">1,643</td><td>Go</td><td class="num">503</td></tr>
      <tr><td>Rust, LTO</td><td class="num">1,603</td><td>Cython</td><td class="num">361</td></tr>
      <tr><td>C++, LTO</td><td class="num">1,506</td><td>Python, Mypyc</td><td class="num">79</td></tr>
      <tr><td>C, LTO</td><td class="num">1,505</td><td>PHP</td><td class="num">26</td></tr>
      <tr><td>Zig</td><td class="num">884</td><td>Python</td><td class="num">16</td></tr>
    </tbody>
  </table>
</div>

<section class="prose">

  <p>Read across a row and you learn something true and useful: the same program, written the same
way, costs a hundred times more in CPython than in Rust, and a little over three times more in
Go than in Nim. That is the question RosettaBoy asks and it answers it well.</p>

  <p>Read a single cell as a ceiling and you learn nothing. Its Rust row is 1,603 fps; the same
configuration here — headless, silent — is 33,700, and the M1 it ran on is not twenty-one times
slower than this box. Its Python row is 16 fps and PyBoy on this box is 64,100, four thousand
times apart; its Cython row of 361 is RosettaBoy's own Cython port and not PyBoy's core either.
Neither difference is language. RosettaBoy's author says plainly that readability,
consistency and idiomatic style come before hardware accuracy, and that the implementations are
not in sync with one another; a shared simple design is the whole point of the exercise and it
is not what any shipping emulator is. The valid comparison in that table is between its own
rows.</p>

  <h2>GameRoy priced the accuracy tax, and the price is not the JIT</h2>

  <p><a href="https://github.com/Rodrigodd/gameroy">GameRoy</a> is a high-accuracy Game Boy emulator in Rust
that grew a JIT, and
<a href="https://rodrigodd.github.io/2023/09/02/gameroy-jit.html">its author wrote the result up</a>
honestly enough to be worth more than the code. The compiled code is around 4× the raw speed of
his own interpreter and takes 30–40% off the execution time of a real game — real, and short of
what a JIT usually promises. What eats it is exactly the constraint we live under: a
cycle-accurate emulator has to update its peripherals before every memory access, so a
compiled block cannot simply run. His answer is to predict whether an interrupt lands inside
the block and fall back to the interpreter when it does, which keeps the accuracy and keeps an
interpreter in the build forever.</p>

  <p>The line worth quoting is the one about somebody else: BGB is more than twice as fast as
GameRoy, and he attributes the difference to frameskip rather than to code generation. A
throughput figure that includes frameskip and one that does not are two different
measurements wearing the same unit.</p>

  <h2>mGBA wrote the technique down in 2017</h2>

  <p>endrift's <a href="https://mgba.io/2017/04/30/emulation-accuracy/"><em>Emulation Accuracy, Speed, and Optimization</em></a>
is the clearest statement of why cycle accuracy is expensive, and it is not about arithmetic:
hardware does every step of a cycle at once, software cannot, so it swaps between them, and the
swapping is the cost. mGBA's Game Boy core divides instruction emulation into per-clock-cycle
tasks and emulates the other hardware between them — then batches those operations instead of
running them one at a time, splitting a batch whenever two things genuinely interact.</p>

  <p>That is our own idle batch, arrived at independently and from the other end, and the same idea
has now paid twice here. The halted CPU's batch used to stop at every PPU mode boundary, three
or four a scanline, when what a halted CPU can observe of the PPU is two bits of <code class="language-plaintext highlighter-rouge">IF</code> and
nothing else. Asking whether a STAT interrupt could actually be taken at that edge took the
batch from 38 M-cycles to 505. The identical argument, applied inside the per-dot engine, is
worth 29.3% of a default-mode frame on its own — and a cartridge that arms a STAT source keeps
the old behaviour exactly, which is why no accuracy row moved. The right number of peripheral
steps to take is the number a program can tell you took, and both projects got there by asking
what the program can see.</p>

  <h2>What is left of the gap, with a number rather than an argument</h2>

  <p>Profiled at the 0.0382 accurate frame this round started from, <code class="language-plaintext highlighter-rouge">bus_tick</code> was 28.7% of it and
the MMU's per-cycle entry point another 16.1%. That 44.8% is precisely the per-access peripheral
work PyBoy does not do, and taking it off lands where you would expect: 0.0211, PyBoy's
neighbourhood once its own horizon bookkeeping is allowed for. Closing it in the default mode
means giving up what Blargg's <code class="language-plaintext highlighter-rouge">mem_timing</code> and fourteen Mooneye instruction-timing ROMs
measure.</p>

  <p>In speed mode the shape changes and what is left is the interpreter: roughly 55 host cycles an
emulated instruction against PyBoy's 33. A build with every accuracy liberty in the ledger
applied at once — OAM DMA bus conflicts, the work-RAM address override, the OAM-page hook, the
link cable, the per-access APU and PPU hooks, the interrupt folds — measures 3.1% better and is
still 1.60× behind. Beating 0.0156 from there is a different execution model, not another pass.</p>

  <p>Which is a better place to stand than the one we were standing in a week ago, where the target
was a number somebody measured on their laptop. The full record, every negative result
included, is on the <a href="../terminalgb-performance.html">throughput page</a>, and the accuracy those
milliseconds buy is on the <a href="../projects/terminalgb.html">TerminalGB project page</a>.</p>

</section>]]></content><author><name></name></author><category term="TerminalGB" /><summary type="html"><![CDATA[Nobody ranks Game Boy emulators by speed, because a frame rate belongs to a machine and a render mode rather than to an emulator. Four projects measure it anyway, all measuring something different. Timing mGBA, SameBoy and PyBoy on one core here first cost us three of our own numbers — and then showed the speed ordering is the accuracy ordering upside down.]]></summary></entry><entry><title type="html">The trainer who saw him black out</title><link href="https://alchemy86.github.io/devlog/posts/the-trainer-who-saw-him-black-out.html" rel="alternate" type="text/html" title="The trainer who saw him black out" /><published>2026-09-07T00:00:00+00:00</published><updated>2026-09-07T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-trainer-who-saw-him-black-out</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-trainer-who-saw-him-black-out.html"><![CDATA[<section class="prose">

  <p class="chips" style="margin-bottom:20px;">
  <span class="chip on">measured</span>
  <span class="chip">inferred</span>
</p>

  <p><strong>Measured</strong> is a byte read out of a saved state or a field read out of a run record.
<strong>Inferred</strong> is reasoning from one of those to something we did not read. Prior community
work is named where it appears.</p>

</section>

<section class="prose">

  <h2>Nine decisions</h2>

  <p>He crosses from the Viridian Forest south gate into the forest on decision 4094 and lands on
tile (17,47). The emulator's own map data lists that tile as one of four exits back to the
gate he just came through — it is the doorway.</p>

  <p>He never leaves it. Two decisions later a text box opens: <em>Hey, wait up! What's the hurry?</em>
Four decisions after that the screen goes to the battle wipe, and on decision 4103 he is in
a fight. Between crossing the boundary and the fight he pressed <code class="language-plaintext highlighter-rouge">up</code>, <code class="language-plaintext highlighter-rouge">a</code>, <code class="language-plaintext highlighter-rouge">up</code>, <code class="language-plaintext highlighter-rouge">b</code>, <code class="language-plaintext highlighter-rouge">up</code>,
<code class="language-plaintext highlighter-rouge">b</code>, <code class="language-plaintext highlighter-rouge">up</code>, <code class="language-plaintext highlighter-rouge">a</code>, <code class="language-plaintext highlighter-rouge">a</code>, and his coordinates did not change once.</p>

</section>

<figure class="clip-auto" style="max-width:640px;margin-inline:auto;">
  <video muted="" loop="" autoplay="" playsinline="" preload="metadata" poster="../assets/img/agentgb/forest-doorway-poster.png" width="636" height="568">
    <source src="../assets/video/agentgb/forest-doorway-battle.mp4" type="video/mp4" />
  </video>
  <img class="clip-poster" src="../assets/img/agentgb/forest-doorway-poster.png" width="636" height="568" alt="A Game Boy screen: the player stands at the bottom edge of Viridian Forest among the trees, and a text box reads 'Hey, wait up! What's the hurry?'" />
  <figcaption>
    Eleven seconds cut from the swarm film, cropped to the first of its six tiles, at the
    film's own eightfold speed and otherwise untouched. Route 2, the gate, the forest, and
    the fight — then he wins it, and it starts again.
  </figcaption>
</figure>

<section class="prose">

  <div class="fig-pair" style="max-width:none;">
  <figure class="pixel">
    <p class="cap">Decision 4096&ndash;4098 &middot; <span class="accent">on the doorway tile</span></p>
    <img src="../assets/img/agentgb/forest-doorway-hail.png" width="318" height="284" alt="Viridian Forest at its southern edge. The player sprite stands one tile inside the map; a text box reads 'Hey, wait up! What's the hu'." />
    <figcaption>Two decisions after crossing the boundary. He has not moved.</figcaption>
  </figure>
  <figure class="pixel">
    <p class="cap">Decision 4108 &middot; <span class="accent">the fight</span></p>
    <img src="../assets/img/agentgb/forest-doorway-catcher.png" width="318" height="284" alt="A Game Boy trainer-battle intro: the player's back sprite on the left, a Bug Catcher with a net on the right, and a text box beginning 'BUG CATCHER wa'." />
    <figcaption>A trainer battle, on the tile the map uses as its own front door.</figcaption>
  </figure>
</div>

</section>

<section class="prose">

  <h2>It is a trainer, and the cartridge says so</h2>

  <p><span class="chip on">measured</span>
Read at decision 4110: <code class="language-plaintext highlighter-rouge">wIsInBattle</code> (<code class="language-plaintext highlighter-rouge">$D057</code>) is <strong>2</strong>, which the atlas names as a trainer
battle and not a wild one. <code class="language-plaintext highlighter-rouge">wCurOpponent</code> (<code class="language-plaintext highlighter-rouge">$D059</code>) is <strong>202</strong> — at or above 200 it is a
trainer class plus that offset, so class 2, a Bug Catcher. <code class="language-plaintext highlighter-rouge">wTrainerClass</code> (<code class="language-plaintext highlighter-rouge">$D031</code>) is <strong>2</strong>
and agrees. The opponent is a WEEDLE at level 9.</p>

  <p>There is nowhere for a Bug Catcher to be standing. The doorway tile is an exit; the fight
started with the player one tile inside a map he had entered nine decisions earlier, and
forty-five tiles — sixteen columns and twenty-nine rows — from where he last fainted.</p>

</section>

<section class="prose">

  <h2>The same tile, one visit earlier</h2>

  <p>This was his third visit to Viridian Forest. On the second he crossed the same boundary and
stood on the same tile (17,47) for two decisions and walked on. Nothing happened. His first
battle of that visit came ninety-eight decisions later, at (6,12), and it was wild.</p>

  <p>Between the second visit and the third, one thing happened to him.</p>

  <div class="wide-table">
<table>
  <thead>
    <tr>
      <th>Decision</th>
      <th>Map</th>
      <th class="num">Tile</th>
      <th>Screen</th>
      <th><code>$D057</code></th>
      <th><code>$D059</code></th>
      <th><code>$D618</code></th>
      <th>Pressed</th>
    </tr>
  </thead>
  <tbody>
    <tr><td>3303</td><td>Viridian Forest</td><td class="num">(17,47)</td><td>walking in, visit two</td><td>none</td><td class="num">0</td><td class="num">0</td><td><code>a</code></td></tr>
    <tr><td>3634</td><td>Viridian Forest</td><td class="num">(1,18)</td><td>a wild CATERPIE, level 4</td><td>wild</td><td class="num">0</td><td class="num">0</td><td><code>up</code></td></tr>
    <tr><td>3708</td><td>Viridian Forest</td><td class="num">(1,18)</td><td><em>BLUE blacked out!</em></td><td>wild</td><td class="num">0</td><td class="num">0</td><td><code>a</code></td></tr>
    <tr><td><strong>3709</strong></td><td>Viridian Forest</td><td class="num">(1,18)</td><td>the warp</td><td><strong>blacked-out</strong></td><td class="num">0</td><td class="num">0</td><td><code>a</code></td></tr>
    <tr><td><strong>3711</strong></td><td>Viridian Forest</td><td class="num">(1,18)</td><td>the warp</td><td>blacked-out</td><td class="num">0</td><td class="num"><strong>1</strong></td><td><code>a</code></td></tr>
    <tr><td>3714</td><td>Viridian City</td><td class="num">(23,26)</td><td>awake, 24/24</td><td>none</td><td class="num">0</td><td class="num">1</td><td><code>a</code></td></tr>
    <tr><td>4088</td><td>Forest south gate</td><td class="num">(5,6)</td><td>walking back up</td><td>none</td><td class="num">0</td><td class="num">1</td><td><code>up</code></td></tr>
    <tr><td>4094</td><td>Viridian Forest</td><td class="num">(17,47)</td><td>crossing in, visit three</td><td>none</td><td class="num">0</td><td class="num">1</td><td><code>up</code></td></tr>
    <tr><td>4098</td><td>Viridian Forest</td><td class="num">(17,47)</td><td><em>Hey, wait up!</em></td><td>none</td><td class="num">0</td><td class="num">1</td><td><code>up</code></td></tr>
    <tr><td><strong>4110</strong></td><td>Viridian Forest</td><td class="num">(17,47)</td><td>WEEDLE Lv9</td><td><strong>trainer</strong></td><td class="num"><strong>202</strong></td><td class="num"><strong>4</strong></td><td><code>a</code></td></tr>
  </tbody>
</table>
</div>

  <p><span class="chip on">measured</span>
<code class="language-plaintext highlighter-rouge">$D618</code> is <code class="language-plaintext highlighter-rouge">wViridianForestCurScript</code> — how far through Viridian Forest's own map script the
game is. It is 0 on both earlier visits, goes to 1 two decisions into the blackout, stays 1
through the warp, the Pokémon Center, Route 2 and the gate, and is still 1 when he steps back
onto the doorway tile. The battle takes it to 4.</p>

</section>

<section class="prose">

  <h2>What survived the blackout</h2>

  <p>Comparing the same eight bytes at four moments answers which of them carries the fight across.</p>

  <div class="wide-table">
<table>
  <thead>
    <tr>
      <th>Address &middot; symbol</th>
      <th class="num">Walking, d3630</th>
      <th class="num">Blacked out, d3709</th>
      <th class="num">Awake, d3718</th>
      <th class="num">At the door, d4098</th>
    </tr>
  </thead>
  <tbody>
    <tr><td><code>$CD60</code> <code>wMiscFlags</code> bit 0</td><td class="num">0</td><td class="num"><strong>1</strong></td><td class="num">0</td><td class="num">0</td></tr>
    <tr><td><code>$CD2D</code> <code>wEngagedTrainerClass</code></td><td class="num">6</td><td class="num"><strong>202</strong></td><td class="num">202</td><td class="num">7</td></tr>
    <tr><td><code>$CD2E</code> <code>wEngagedTrainerSet</code></td><td class="num">7</td><td class="num"><strong>3</strong></td><td class="num">3</td><td class="num">7</td></tr>
    <tr><td><code>$CF13</code> <code>wSpriteIndex</code></td><td class="num">0</td><td class="num"><strong>4</strong></td><td class="num">4</td><td class="num"><strong>4</strong></td></tr>
    <tr><td><code>$CC55</code> <code>wTrainerHeaderFlagBit</code></td><td class="num">0</td><td class="num"><strong>4</strong></td><td class="num">4</td><td class="num"><strong>4</strong></td></tr>
    <tr><td><code>$DA30</code> <code>wTrainerHeaderPtr</code></td><td class="num">$6651</td><td class="num"><strong>$5A51</strong></td><td class="num">$5A51</td><td class="num"><strong>$5A51</strong></td></tr>
    <tr><td><code>$D618</code> <code>wViridianForestCurScript</code></td><td class="num">0</td><td class="num">0 &rarr; <strong>1</strong></td><td class="num">1</td><td class="num"><strong>1</strong></td></tr>
    <tr><td><code>$DA39</code> <code>wCurMapScript</code></td><td class="num">0</td><td class="num">0 &rarr; <strong>1</strong></td><td class="num">1</td><td class="num"><strong>1</strong></td></tr>
  </tbody>
</table>
</div>

  <p><span class="chip on">measured</span>
Every one of those bytes is 0, or holds an unrelated value, on the last decision of the wild
battle. They are set on the next one — the decision the cartridge writes <code class="language-plaintext highlighter-rouge">$FF</code> into
<code class="language-plaintext highlighter-rouge">wIsInBattle</code>, which the atlas names as <em>the player blacked out</em> and which is not a battle.
<code class="language-plaintext highlighter-rouge">wEngagedTrainerClass</code> reads 202 and <code class="language-plaintext highlighter-rouge">wEngagedTrainerSet</code> reads 3 — a Bug Catcher, roster
three — and the fight that started at the door was a Bug Catcher leading a level 9 WEEDLE.</p>

  <p><code class="language-plaintext highlighter-rouge">$CD60</code> bit 0 — the flag that says a trainer has spotted the player — is cleared by the time
he is awake in Viridian City. So is the engaged-trainer pair, by the time he reaches the
gate. What is not cleared is the map's own script index, and the sprite slot and header
pointer that tell that script which trainer it is about to run. Viridian Forest's script is
left at step 1, which is <em>a trainer is waiting to fight you</em>, and the map runs it the moment
the map is loaded again.</p>

  <div class="pullquote">
  <p>The blackout put him a town away and healed his party. It did not put the forest back
  the way it found it.</p>
</div>

  <p><span class="chip">inferred</span>
Sprite slot 4's stored map coordinates read 22 and 6, and under Gen 1's four-tile bias on
sprite coordinates that is tile (2,18) — one step east of (1,18), where he fainted. That is
consistent with a Bug Catcher standing directly beside him at the moment the wild encounter
started, and it is an inference from the sprite table, not a reading of the trainer's tile.</p>

</section>

<section class="prose">

  <h2>This one has a name, and it is not ours</h2>

  <p>The community found and documented this long before we walked into it. It is the <strong>trainer
escape glitch</strong>, also called trainer-Fly, and the variant we hit is the death-warp one:
faint to a wild encounter inside a trainer's field of vision, and, in the Glitch City Wiki's
words, <em>"the trainer will notice the player on that frame, advancing the meta-map script ID
just before the player warps away."</em> Returning to the map gives an instant encounter. The
same wiki page names <code class="language-plaintext highlighter-rouge">$CD60</code> bit 0 as the byte set when a trainer spots the player, which is
the byte our own read agrees with.</p>

  <p>Its wider significance is theirs too. Escaping a trainer battle rather than blacking out is
what makes the glitch dangerous: it leaves an encounter staged from stale battle memory, and
that is the foundation of the Mew glitch and a component of the arbitrary-code-execution
routes speedrunners use in Red and Blue.</p>

  <p>What we hit is the tame end of it. Our student blacked out rather than escaping, so the
cartridge staged a real Bug Catcher with a real WEEDLE rather than a corrupted one, and
nothing about the run afterwards is corrupt. The interesting part is not the mechanism.
It is that an agent nobody told about any of this walked into it on its own, and that we can
put a rate on it.</p>

</section>

<section class="prose">

  <h2>How often it happens to us</h2>

  <p>Every trainer battle a run starts in Viridian Forest is logged with the tile the player was
standing on. Across every recorded episode we hold, those tiles cluster hard.</p>

  <div class="wide-table">
<table>
  <thead>
    <tr><th class="num">Tile</th><th class="num">Trainer battles started there</th><th>What it is</th></tr>
  </thead>
  <tbody>
    <tr><td class="num">(1,18)</td><td class="num">672</td><td>Inside the forest</td></tr>
    <tr><td class="num">(26,33)</td><td class="num">157</td><td>Inside the forest</td></tr>
    <tr><td class="num">(27,33)</td><td class="num">28</td><td>Inside the forest</td></tr>
    <tr><td class="num"><strong>(17,47)</strong></td><td class="num"><strong>21</strong></td><td><strong>A south-gate exit tile</strong></td></tr>
    <tr><td class="num">(26,19)</td><td class="num">7</td><td>Inside the forest</td></tr>
    <tr><td class="num">(27,30)</td><td class="num">3</td><td>Inside the forest</td></tr>
    <tr><td class="num">(2,19)</td><td class="num">3</td><td>Inside the forest</td></tr>
  </tbody>
</table>
</div>

  <p>One row is a tile you cannot fight anybody on. Following it out:</p>

  <div class="wide-table">
<table>
  <thead>
    <tr><th>Population</th><th class="num">Runs</th><th class="num">Doorway battle</th><th class="num">Finished the chain</th></tr>
  </thead>
  <tbody>
    <tr><td>Every recorded episode we hold</td><td class="num">44,706</td><td class="num">—</td><td class="num">—</td></tr>
    <tr><td>&hellip; of those, runs whose goal set can detect it</td><td class="num">11,927</td><td class="num"><strong>32</strong></td><td class="num"><strong>0</strong></td></tr>
    <tr><td>&hellip; spanning distinct seeds</td><td class="num">625</td><td class="num"><strong>1</strong></td><td class="num">—</td></tr>
    <tr><td>The six paired sweeps of 7 Sep 2026</td><td class="num">390</td><td class="num">6</td><td class="num">0</td></tr>
  </tbody>
</table>
</div>

  <p><span class="chip on">measured</span>
Thirty-two recorded runs across five separate run roots start a trainer battle on a Viridian
Forest exit tile. Every one of the thirty-two is seed <strong>4200126</strong>. None of the thirty-two
finished the chain.</p>

  <p>That seed has been re-run under six different goal configurations in one day, scoring
between 91 and 96 whole chains out of 100 between them, and it failed in all six. Only one
other seed in the hundred does that. Changing what the student believes about battle screens,
about items, about catching, does not touch this one, because none of it is about the
student.</p>

</section>

<section class="prose">

  <h2>What it cost him</h2>

  <p>He won the forced battle on decision 4251. Three decisions later, on the same tile, <em>BUG
CATCHER wants to fight!</em> again, and the same level 9 WEEDLE. That fight ran to 4377.</p>

  <p>Then the run stops being a run.</p>

  <div class="wide-table">
<table>
  <thead>
    <tr>
      <th>Visit to Viridian Forest</th>
      <th class="num">Decisions inside</th>
      <th class="num">Distinct tiles</th>
      <th class="num">Furthest north</th>
    </tr>
  </thead>
  <tbody>
    <tr><td>One — decisions 2733&ndash;3086</td><td class="num">353</td><td class="num">111</td><td class="num">row 3</td></tr>
    <tr><td>Two — decisions 3302&ndash;3709</td><td class="num">407</td><td class="num">116</td><td class="num">row 3</td></tr>
    <tr><td><strong>Three — decisions 4094&ndash;8660</strong></td><td class="num"><strong>4,566</strong></td><td class="num"><strong>28</strong></td><td class="num"><strong>row 35</strong></td></tr>
  </tbody>
</table>
</div>

  <p>The exit he needs is on row 0. On his first two visits he reached row 3, twice, having walked
over a hundred distinct tiles each time. On the third he walked twenty-eight, never got above
row 35, entered no battle at all after the two at the door, pressed <code class="language-plaintext highlighter-rouge">down</code> 2,358 times, and
ran out of budget at (16,38) with the gate goal still latched: the goal that walks him back
towards the door he came in through.</p>

</section>

<section class="prose">

  <h2>What we cannot say from this recording</h2>

  <ul class="gaps">
  <li>
    <b>When the sight line actually fired.</b> Every trainer byte is clear on decision 3708,
    the decision that prints <em>BLUE blacked out!</em>, and set on 3709. One decision is
    thirty-two emulator frames, so this recording cannot separate <em>the trainer noticed him
    as the wild battle began and the write happened later</em> from <em>the trainer noticed
    him as the battle ended</em>. The community describes the first; we measured the write,
    not the notice.
  </li>
  <li>
    <b>Which trainer the second battle was.</b> No state was saved inside it. It was a Bug
    Catcher with a level 9 WEEDLE, from the screen, and that is as far as our recording goes.
  </li>
  <li>
    <b>How many other runs black out inside Viridian Forest.</b> Answering that needs
    per-decision traces for episodes we only hold summaries of. The doorway signature above
    is what the recorded data can carry on its own, and it is a lower bound: a run that hit
    the same fault and then died before the goal fired would not appear in it.
  </li>
  <li>
    <b><code>wTrainerNo</code> (<code>$D05D</code>) proves nothing here.</b> It reads 3 from
    the first probe to the last and never changes, so its agreeing with roster three is a
    coincidence, not evidence. The identity comes from <code>wEngagedTrainerSet</code>, which
    changed.
  </li>
</ul>

</section>

<section class="prose">

  <h2>What it changes</h2>

  <p>For the student: nothing to fix. He walked a legal route, took a legal encounter, lost it,
and was warped away by the game's own rules. Everything after that was the cartridge running
a script it should have reset. There is no observation he could have made and no button he
could have pressed that would have avoided the second battle, because the fight was already
staged before the map was drawn.</p>

  <p>For us it changes how one number should be read. Every recorded run of seed 4200126 that reaches the
doorway battle fails, thirty-two out of thirty-two, and each one has been counted as one more
run that got confused in the forest. It is not that. It is one run that met a
documented fault in Pokémon Blue, and none of the six configurations tried on it so far makes
any difference, because none of them is about the cartridge. A 96-out-of-100 with this seed
inside it is really a 96 out of 99 plus one cartridge bug, and the honest way to report a
sweep is to say which of its failures are the agent's.</p>

  <p>It also raises the price of a blackout. The healing chamber added last week treats a faint as
a cost in decisions — you lose the walk back. It is not only that. Faint on the wrong tile
and the map keeps a fight for you, and it will be waiting at the door.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[One seed walks into Viridian Forest and is put straight into a trainer battle on the doorway tile, nine decisions after crossing the boundary, by a Bug Catcher forty-five tiles away. The cartridge had been holding that fight open since the last time he fainted.]]></summary></entry><entry><title type="html">The trap was always on the map</title><link href="https://alchemy86.github.io/devlog/posts/the-trap-was-always-on-the-map.html" rel="alternate" type="text/html" title="The trap was always on the map" /><published>2026-09-04T00:00:00+00:00</published><updated>2026-09-04T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-trap-was-always-on-the-map</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-trap-was-always-on-the-map.html"><![CDATA[<section class="prose">

  <h2>Ten cold boots, none of them finished</h2>

  <p>The paired comparison was the ordinary kind: same weights, same sampling settings, same
chain, ten cold boots each, one path against the other. The compiled artefact finished
<strong>0 of 10</strong>. The flexible construction it was built to replace finished <strong>8 of 10</strong>.</p>

  <p>On mGBA, three boots each: the old foreign driver <strong>2 of 3</strong>, the artefact <strong>0 of 3</strong>, all
three of them stranded at 20 or 21 of the chain's 25 links, in the same shop.</p>

  <p>There is an obvious reading of those numbers and it was available immediately. One thing
changed, that thing is an ONNX export, ONNX exports are fiddly, file it as "the compiled
graph is wrong" and move on. That reading is wrong, and the only reason it did not get
filed is that somebody went and measured the actual frame the runs were dying on instead of
reasoning about which component was newest.</p>

</section>

<section class="prose">

  <h2>Nothing was retrained</h2>

  <p>Worth stating plainly, because everything after it depends on it.</p>

  <p>The collapse compiles the base network, every recogniser and adapter the running config
needs, and the control flow itself — the sticky chain's latch and successor walk, a
transient goal's scope and eligibility, a chamber's arm and disarm, a battle hold's
patience counter — into one graph. <code class="language-plaintext highlighter-rouge">src/agentgb/artifact_driver.py</code> is the only place
either runner touches it: build the observation, hand it over with the previous decision's
state, take back the action and the next state. No latch logic left in the runners at all.</p>

  <p>The weights file is untouched. Same sha256 before and after. It is a compile of what
already existed, not a rebuild of it, and the guard that proves it picks the same buttons
runs a thousand paired decisions of synthetic noise across five seeds — three greedy, two
sampled with the production confidence gate and decay — with zero disagreements, latch
state and held stage included.</p>

  <p>So: two paths, provably identical on every decision anyone had checked, one of them
winning eight runs and the other winning none.</p>

</section>

<section class="prose">

  <h2>New does not mean guilty</h2>

  <p>The runs were all dying in the same place, so the frame they were dying on was the thing to
look at.</p>

  <p><code class="language-plaintext highlighter-rouge">battle-on-screen-recognizer</code> — the shared network that answers "is a battle still
happening" — reads <strong>0.608</strong> on an ordinary shop purchase-confirmation box. Not a near-tie
against its threshold. A confident, wrong yes, on a screen with no battle anywhere in it.
The dialogue box in a shop confirmation is a black-bordered text box, and so is the one in
a fight.</p>

  <p>That reading was taken by calling the real, native network directly on the real frame, with
no ONNX runtime involved in the measurement at all. The compiled graph is not in the
picture. The fault is in the shared control flow both paths run, and it was in there before
the collapse was written.</p>

  <div class="pullquote">
  <p>The compiled version did not add a fault. It changed the route, and the new route went
  through one.</p>
</div>

</section>

<section class="prose">

  <h2>Two thresholds on one network, and the loose one is the one that gets fooled</h2>

  <p>The battle wheel asks that recogniser two different questions with two different bars.</p>

  <p>To <em>enter</em> a hold, the entry gate wants <strong>0.999</strong>. To <em>release</em> one, the end condition
checks the same network against <strong>0.5</strong>, holding while it stays above, with a release
patience of 24 decisions. Both <code class="language-plaintext highlighter-rouge">battle-hold-trainer</code> and <code class="language-plaintext highlighter-rouge">battle-hold-wild</code> share that
gate.</p>

  <p>A phantom battle at 0.608 can never start a hold. It clears 0.5 comfortably, and 0.999 not
at all. What it can do is keep a dead one alive.</p>

  <p>A real rival fight around decision 201 leaves the trainer hold latched with its miss counter
climbing normally — 606, 608, and onward to 630 by decision 1092, far past a patience of 24.
It never expires, because every time the run walks back into a shop and reads a
confirmation box, the same shared gate says 0.608, the counter resets to zero, and the stale
hold re-arms. The trainer battle's adapter — not the walking adapter the chain has long
since moved on to — keeps pressing through the shop's confirmation screens, buying one
item at a time until the wallet is empty and the chain stalls.</p>

  <p>Every part of that is reachable by the old path too. It is one recogniser, one threshold
and one counter, all of them shipped and certified long before this task started.</p>

</section>

<section class="prose">

  <h2>A difference too small to fail a check, and large enough to change the route</h2>

  <p>The graph is verified against a numpy reference that re-implements the same control flow,
and the check it has to pass is a maximum absolute difference in the logits below <code class="language-plaintext highlighter-rouge">1e-3</code>.
It passes. onnxruntime and native numpy do not compute a convolution to the last bit, and
sub-<code class="language-plaintext highlighter-rouge">0.001</code> is a reasonable place to draw the line.</p>

  <p>It is also enough to flip a near-tied argmax, which was confirmed directly at decision 546
of a real run: <code class="language-plaintext highlighter-rouge">up</code> against <code class="language-plaintext highlighter-rouge">right</code>, the pick depending on which backend did the arithmetic,
with no goal latched on either driver. No control-flow machinery active. Just the base
network, two backends, and a tie broken two different ways.</p>

  <p>From there the trajectories are simply different runs. The artefact's route revisits the
shop while that stale hold has not yet expired far more often than the flexible driver's
route does, on these ten seeds. Same map, different walk. Sharing the RNG stream between
the two drivers was tried and removes one source of divergence; the identical N=10
comparison with it enabled still returns 0/10, because the argmax flip is not an RNG
question.</p>

</section>

<section class="prose">

  <h2>A faithful copy is a measuring instrument</h2>

  <p>The useful thing here is not the bug. It is what the pair of paths turned out to be good
for.</p>

  <p>Two implementations that provably agree on every decision in isolation diverged completely
in practice, and the divergence carried information. The synthetic checks could not have
found this, and it is clear why once you look at them: random noise never triggers a real
battle, and the graph's own verification forces a handful of degenerate frames, never a
multi-thousand-decision sequence with a real fight followed by a real shop visit. Neither
check is weak. They answer a different question from "safe to drive a long chain run
with".</p>

  <p>The 8-out-of-10 was the misleading number. Eighty per cent looks like a spread of ordinary
bad luck, and it was concealing a specific trap that the winning route happened to miss
most of the time. It took a harmless numerical nudge — small enough to pass every
tolerance the project sets — to make the trap show up ten times out of ten. A rate that
good can hide a fault this sharp, and the way it stopped hiding was a second
implementation walking a slightly different line through the same map.</p>

  <p><code class="language-plaintext highlighter-rouge">--use-artifact</code> is an explicit flag rather than the default because of that measurement.
The first design routed to the artefact automatically whenever the goal set and the policy
matched what it was compiled for. A plain run today is byte-for-byte what it was before
any of this.</p>

</section>

<section class="prose">

  <h2>What is still open</h2>

  <p>The fix is not written. There are two candidates and they are not the same fix.</p>

  <p>The first is that the recogniser never learned what a shop confirmation box looks like —
it was trained to tell battle screens from the rest of the game, and a black-bordered
dialogue in a shop is a negative it apparently never saw enough of. That is answered with
frames and a retrain.</p>

  <p>The second is that the hold is wrong to re-arm off a single frame at all, whatever the
recogniser says. A patience counter that resets to zero on one reading above a loose
threshold has no memory of having climbed to 630, and a mechanism that can be revived by
one frame of noise is fragile even when the network under it is right.</p>

  <p>A crew is deciding between them on evidence now. Until it lands, the collapsed path stays
behind its flag and out of the promotion workflow, because promoting it would mean shipping
a route that walks into this reliably.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[Collapsing the student into one compiled file did not break him. It changed which way he walked, and the new way went through a fault that had been sitting in the shared control flow all along.]]></summary></entry><entry><title type="html">A perfect score and 361,567 false alarms</title><link href="https://alchemy86.github.io/devlog/posts/a-perfect-score-and-361567-false-alarms.html" rel="alternate" type="text/html" title="A perfect score and 361,567 false alarms" /><published>2026-09-03T00:00:00+00:00</published><updated>2026-09-03T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/a-perfect-score-and-361567-false-alarms</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/a-perfect-score-and-361567-false-alarms.html"><![CDATA[<section class="prose">

  <p>The recogniser had one job: say whether the player is standing in Viridian City. It is the
sight half of a <em>give-back</em> — lose a fight in Viridian Forest, the cartridge blacks the party
out two maps away, and the lesson the run had earned is thrown away with it. Hold that lesson
instead, and hand it back the moment he is somewhere it knows.</p>

  <p>It scored a held-out balanced accuracy of 1.000.</p>

  <p>Then the probe swept it over 1,383,924 frames of real play from 21 links it had never been
trained on. At 0.999 — the tightest threshold in the sweep — it fired on 361,567 of them.
Twenty-six per cent. It had learned to say <em>yes</em>.</p>

  <p>That is not bad luck, it is the shape of the question. A closed two-class network puts
everything it has never seen into whichever class is broader, and the held-out split is drawn
from the frames it <em>was</em> shown, so it cannot contain that failure by construction. Ask <em>which
place is this</em> and the broader class is the positive one.</p>

</section>

<section class="prose">

  <h2>Five rounds, and the one that made it worse</h2>

  <p><code class="language-plaintext highlighter-rouge">agentgb trainscene</code> refuses by default. It sweeps both bars at every threshold — the pooled
false-alarm rate over real play, and how many genuine occurrences are still caught, counted as
episodes rather than frames — and if no threshold satisfies both it ships nothing and prints
the areas it is confused by. Five rounds of that:</p>

  <table>
  <thead>
    <tr>
      <th>Round</th>
      <th>Negatives</th>
      <th class="num">False alarms / 1,383,924 at 0.999</th>
      <th class="num">Verdict</th>
    </tr>
  </thead>
  <tbody>
    <tr><td>1</td><td>natural only</td><td class="num">361,567 &middot; 26.13%</td><td class="num">refused</td></tr>
    <tr><td>2</td><td>+15 links, 400 rows each</td><td class="num">2,570 &middot; 0.186%</td><td class="num">refused</td></tr>
    <tr><td>3</td><td>the refusal's own printed flag</td><td class="num">—</td><td class="num">killed</td></tr>
    <tr><td>4</td><td>+13 links, 3,000 rows each</td><td class="num">5,257 &middot; 0.380%</td><td class="num">refused</td></tr>
    <tr><td>5</td><td><strong>positives narrowed to <code>mode=0</code></strong></td><td class="num"><strong>12 &middot; 0.0009%</strong></td><td class="num"><strong>pass</strong></td></tr>
  </tbody>
</table>

  <p>Every reported round caught 1,500 of 1,500 genuine occurrences. Round 4 is the one to look at: thirteen
more areas of corrections, seven times as many rows each, and it came out worse than round 2.</p>

  <p>So the residual got dumped as a montage and looked at. Ninety-two per cent of it was one
screen — the nickname naming grid, map 40 at tile (5,3). A grid of dark glyphs on a light
field, which at 40×36 is a town. Four rounds of arithmetic had not said that, and no
aggregate was ever going to.</p>

</section>

<div class="fig-pair" style="max-width:none;">
  <figure class="pixel">
    <p class="cap">Chosen by the byte · poisoned</p>
    <img src="../assets/img/agentgb/recogniser-battle-positives-by-byte.png" width="678" height="2418" alt="Fourteen rows of four stacked Game Boy frames. Row 0 is Route 1 grass with the player and a ledge. Rows 1, 3, 7 and 11 contain entirely black rectangles instead of battle screens." />
    <figcaption>
      A different trigger, the same failure, from the day before. Rows 1, 3, 7 and 11 are the
      screen wiping to black; row 0 is plain overworld grass. Every one was labelled <em>a
      battle is on screen</em>.
    </figcaption>
  </figure>
  <figure class="pixel">
    <p class="cap"><span class="accent">Chosen by the phase</span> · honest</p>
    <img src="../assets/img/agentgb/recogniser-battle-positives-by-phase.png" width="678" height="2418" alt="Fourteen rows of four stacked Game Boy frames. Every row shows a drawn battle scene with sprites and a text box; none is an entirely black frame." />
    <figcaption>
      The same pile after the selector was narrowed. Nothing was retrained differently and no
      corrections were added &mdash; the positives just stopped containing frames that were not
      the thing. That story is <a href="teaching-a-network-to-notice.html">the previous
      post</a>.
    </figcaption>
  </figure>
</div>

<section class="prose">

  <p>The Viridian fix was the same move. Positives narrowed from <em>every frame on map 1</em> to
<code class="language-plaintext highlighter-rouge">map=1,mode=0</code> — the town <strong>outdoors</strong>, with a menu drawn over it no longer counting as the
town. 94,409 frames over 527 tiles.</p>

  <p>That took it to <strong>12 false alarms in 1,383,924 frames</strong>, 1,500 of 1,500 occurrences still
caught, weakest genuine fire at p=1.000000. Eleven of the twelve are on one link and the
twelfth is inside the Viridian Mart. Threshold 0.999 rather than the command's recommended
0.99, which is a deliberate departure: the take-the-lowest rule exists because the Game Boy
draws text letter by letter and a tight trigger goes blind on a half-drawn line, and a town is
not drawn progressively. Twelve frames against 251, identical recall.</p>

</section>

<section class="prose">

  <h2>The tool poisoned its own round</h2>

  <p>Round 3 is missing from the table because it was killed rather than reported.</p>

  <p>When <code class="language-plaintext highlighter-rouge">trainscene</code> refuses, it names the areas that fired and prints a ready <code class="language-plaintext highlighter-rouge">--negatives</code> flag
listing them. Pasting that flag back in is the obvious next move, and here it was the wrong
one: <code class="language-plaintext highlighter-rouge">--negatives</code> takes <strong>whole episodes, with no selector</strong>. Three of the links it named are
Viridian City almost end to end — <code class="language-plaintext highlighter-rouge">into-the-viridian-mart</code> is 99.0% map 1,
<code class="language-plaintext highlighter-rouge">north-out-of-viridian</code> 98.8%, <code class="language-plaintext highlighter-rouge">into-the-pokemon-center</code> 98.7%.</p>

  <p>So the scene being recognised went into the <em>no</em> pile. About 18% of that round's negatives were
the thing itself. Held-out balanced accuracy fell 1.000 → 0.927 and stalled, and the round was
abandoned.</p>

  <p>The probe was not wrong to name those links — it scores them only on their out-of-scope frames,
correctly. The advice it prints from that is what does not survive contact with a link whose
corpus <em>is</em> mostly the scene. Check a patch link's own map distribution before pasting.</p>

</section>

<section class="prose">

  <h2>Zero false alarms, then 41 in a town</h2>

  <p>The forest-entry trigger, built the day before, had the cleanest numbers on the project: 150 of
150 real crossings caught at 0.999, and <strong>zero</strong> false alarms pooled over 495,902 frames across
23 corpora — including a shard of cross-map door fades collected on purpose, because a link's
corpus stops at its map change and the next one starts after it, so the states a run passes
through in between exist in no ordinary corpus at all.</p>

  <p>Then a 30-run cold-boot arm came back with <code class="language-plaintext highlighter-rouge">forest-walk-npcbox</code> firing <strong>41 times on map 1</strong>,
Viridian City. It was the last goal driving in 16 of the 18 failures, and 15 of the 18 ended
inside the Viridian Mart at the full 8,660-decision budget, pressing <code class="language-plaintext highlighter-rouge">left</code>.</p>

  <p>That reads exactly like a false positive, and the structural argument above says a probe cannot
rule one out. Two crews landed on that goal independently on the same day.</p>

</section>

<section class="prose">

  <h2>It was innocent</h2>

  <p>The way to check is to stop probing corpora and score the recogniser on every decision of real
play instead. <code class="language-plaintext highlighter-rouge">harvest_goal_false_positives.py</code> replays a sweep's recorded attempts through the
same functions a measured run uses, wraps the driver, and writes out the stacks a named goal's
recogniser actually fires on outside its own maps. Eighteen replayed failing attempts, 155,880
decisions:</p>

  <table>
  <thead>
    <tr><th>Where</th><th class="num">Decisions</th><th class="num">Peak probability</th></tr>
  </thead>
  <tbody>
    <tr><td>Viridian City</td><td class="num">3,278</td><td class="num">0.0408</td></tr>
    <tr><td>Inside the Viridian Mart</td><td class="num">100,728</td><td class="num">5.4e-05</td></tr>
    <tr><td>Viridian Forest</td><td class="num">—</td><td class="num">0.99996</td></tr>
  </tbody>
</table>

  <p>On the one traced seed, the two Viridian decisions it was recorded as "firing" on read
7.7e-17 and 8.9e-08. All 30 episodes' first fire is on map 51, and no episode fires outside the
forest before entering it.</p>

  <p>The recogniser never fired in the town at all. A <code class="language-plaintext highlighter-rouge">fired</code> event in the run record cannot tell a
threshold being cleared from a persisted hold <strong>resuming</strong>, and that one ambiguity is what made
a mechanical bug look like a perception one. A retrain would have changed nothing.</p>

  <p>One real risk did fall out of the harvest, and it is worth naming. The only readings above 0.5
anywhere outside the forest are fifteen, all in Oak's Lab, peaking at <strong>0.9576</strong>. Dumped and
looked at, they are the same shape as the entry positives: a solid black transition frame in
the oldest channel of the stack, beside a light textured region and a text box. Which is what
that positive class is made of — 150 near-identical frames of one tile, (17,46), with the
camera clamped at the forest's southern edge so the lower half of every one is black. It is
safe today because the threshold is 0.999 and not 0.95. That is a thinner margin than anybody
knew they had.</p>

</section>

<section class="prose">

  <h2>The fault was mechanical</h2>

  <p><code class="language-plaintext highlighter-rouge">end_condition.persist_through_other_goals</code> exists because a hold that spans a long stretch of
play loses the wheel the moment anything else wins a single decision; without it the forest
crossing scored 0/30. As first written it made a hold survive the decisions it does not drive,
and left its ending observable only on the ones it does.</p>

  <p>Lose a Bug Catcher fight inside Viridian Forest. The cartridge blacks the party out to Viridian
City while the battle hold is driving. The forest hold is asked nothing, the battle hold
releases at map 1, the forest hold resumes — and the forest's own walking adapter drives the
run around a town it has never seen for the rest of its budget. The event immediately before
every single non-forest fire is a battle hold releasing at map 1 or 0: fourteen trainer
battles, one wild.</p>

  <p>The version this trigger replaced was gated on <code class="language-plaintext highlighter-rouge">wCurMap</code> and was immune by construction. It
re-asked memory every decision, so a teleport stopped it on the next one. Moving to the screen
removed the memory read and quietly removed that property with it. <strong>When a mechanism replaces
a memory read, check which properties the memory read was silently providing.</strong></p>

  <p>The replacement needs no weights. <code class="language-plaintext highlighter-rouge">release_on_text</code> sweeps a list of phrases at the top of
every decision, before selection, for every persisted hold. A list rather than one phrase,
because a decision samples one frame in 32 and the game draws a line letter by letter:
<code class="language-plaintext highlighter-rouge">BLUE is out of / useable POKéMON!</code> is up for two sampled decisions, <code class="language-plaintext highlighter-rouge">BLU</code> for one mid-draw,
and <code class="language-plaintext highlighter-rouge">BLUE blacked / out!</code> fully drawn for about one. Two phrases give roughly four decisions of
cover instead of one.</p>

  <p>Paired on the identical 30 seed pairs, cold boot, seed 42:</p>

  <table>
  <thead>
    <tr><th>Whole chain, 30 cold boots</th><th class="num">Crossings</th></tr>
  </thead>
  <tbody>
    <tr><td>persisted hold, no way to end it while evicted</td><td class="num">12/30 &middot; 40.0%</td></tr>
    <tr><td><strong>hold released on the blackout line</strong></td><td class="num"><strong>25/30 &middot; 83.3%</strong></td></tr>
  </tbody>
</table>

  <p>Thirteen gained, none lost, McNemar exact p = 0.00024. Milestones 1 to 24 are 30/30 in both
arms, and the change is provably inert where no blackout happens: all 12 runs that already
finished reproduce their exact decision count, 12 of 12, median 2,165.5 either way. Fires on
map 1 went 41 → 0; all 270 fires are on map 51; the first fire is on map 51 in 30 runs of 30.
The trigger everybody suspected was never touched.</p>

  <p>Of the five that still fail, four are stuck in a forest battle — no PP left, no running from a
trainer battle — which is a known gap in fighting competence and not this bug. The fifth
released correctly at the blackout and then wandered into the Mart under a different latched
lesson.</p>

</section>

<section class="prose">

  <h2>What the day cost</h2>

  <p>The give-back that started all of this measured a null, and it is recorded as one. Seven
recorded knockout seeds: 2/7 through the forest with no clear, 2/7 clearing, 2/7 clearing and
handing back — the same two seeds every time. Thirty cold boots, paired: 12/30 both arms, zero
gained, zero lost, every one of the 25 milestones identical.</p>

  <p>The mechanism itself is clean. Fifteen of the 30 runs got the lesson handed back, every restore
on map 1 and on that stage alone, zero false hand-backs, zero runs ending still-held. It fires
correctly, costs nothing, and rescues nothing at this sample size, because the thing ending the
runs was three maps away in a branch of code with no weights in it.</p>

  <p>Five training rounds bought a recogniser that works and does not currently matter. Finding that
out is what made the next fix a one-line declaration.</p>

</section>

<section class="prose">

  <h2>The sequence</h2>

  <p>This is now the written procedure rather than something the crew happens to do.</p>

  <p><strong>1. Dump the positives and look at the montage before training.</strong> Not a description of them —
the actual four-frame stacks, in a picture, in front of a person. It has now caught a poisoned
pile three times where every number said clean.</p>

  <p><strong>2. Small cheap tests first.</strong> A tile distribution, a map histogram, one probe over one link.
An hour of training is a bad way to discover a bad idea.</p>

  <p><strong>3. Let the tool refuse.</strong> A trigger that fires where it should not is not a trigger. The
default false-alarm bar is 0.0 because a trigger behind a hold strands the whole run on one
false positive — relax it deliberately, on evidence, and say so. This one was relaxed to
0.0005, on the argument that the exposure is bounded by where the code can ever ask.</p>

  <p><strong>4. Narrow what it learns from before piling on corrections.</strong> Rounds 2 and 4 added 28 links
of negatives between them and left 5,257 false alarms. Adding <code class="language-plaintext highlighter-rouge">mode=0</code> to the positive selector
left 12. Every correction teaches the network another place it is not; a narrower positive
teaches it what it is.</p>

</section>

<figure class="pixel">
  <img src="../assets/img/agentgb/recogniser-lead-hp-bar-positives.png" width="678" height="1950" alt="Rows of four stacked Game Boy battle frames with the lead Pokémon's HP bar outlined in red, and the crop itself blown up beneath each row. The Pokémon's name, the HP label and the digits all sit outside the red box." />
  <figcaption>
    The same principle in its structural form: a declared crop. This recogniser is 24,082
    parameters and cannot see the name or the level, by construction rather than by hoping a
    balanced corpus makes it ignore them.
  </figcaption>
</figure>

<section class="prose">

  <p><strong>5. Two aimed rounds, then stop and report the trade-off.</strong> Round 4 was the third and it went
backwards. The stopping condition is not "it passed", it is "the next round has a reason".</p>

  <p><strong>6. Measure in a real run, paired, and report what was gained and what was lost.</strong> Both
numbers. 13 gained and 0 lost is a result; 8 gained and 4 lost, which is what an earlier change
on this same chain produced, is not.</p>

  <p>And a seventh that only exists because of today: <strong>check who is driving before blaming what
recognised.</strong> Forty-one fires in a town, sixteen of eighteen failures ending under that goal,
and the recogniser's own peak reading there was 0.0408.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[A recogniser scored a held-out balanced accuracy of 1.000 and then fired on more than a quarter of 1,383,924 frames of real play. Five rounds of training, one of them poisoned by the tool's own advice, and the bug that actually ended the runs was mechanical.]]></summary></entry><entry><title type="html">Teaching a network to notice</title><link href="https://alchemy86.github.io/devlog/posts/teaching-a-network-to-notice.html" rel="alternate" type="text/html" title="Teaching a network to notice" /><published>2026-09-03T00:00:00+00:00</published><updated>2026-09-03T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/teaching-a-network-to-notice</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/teaching-a-network-to-notice.html"><![CDATA[<section class="prose">

  <p>The student plays from four stacked 36×40 greyscale frames in, one of six buttons
out. That constraint is the whole point of the project, and for months the scaffolding
around the student quietly broke it.</p>

  <p>The scaffolding is a set of small triggers — <em>a battle has started</em>, <em>this is the
forest</em>, <em>our lead is hurt</em> — that decide which trained behaviour drives the next
decision. Each one used to answer its question by reading the cartridge: <code class="language-plaintext highlighter-rouge">wCurMap</code> for
the forest, <code class="language-plaintext highlighter-rouge">wIsInBattle</code> for the battle, the party HP struct for the heal. Point it at
a different emulator and none of those reads exist.</p>

  <p>Over two days, 2026-09-02 and 03, five of them were replaced with networks that look at
the picture. Every one of the five was harder than it looked, and the ways they went
wrong are more useful than the fact they now work.</p>

</section>

<section class="prose">

  <h2>What a recogniser is</h2>

  <p>A recogniser is the smallest thing this project trains. Two classes, one question — <em>is
this the screen?</em> — over the same observation the student gets, or a crop of it. You
give it a pile of frames that are the scene, a pile that are not, and hold some back to
score it on.</p>

  <p>Size follows the crop, and the crop matters more than anything else here. A full-frame
recogniser is 118,290 parameters. Cropped to the band under the opponent's name it is
28,178; to the lead's HP bar, 24,082; to the top half of a text box, 6,954.</p>

  <p>None of them touch the student. <code class="language-plaintext highlighter-rouge">models/pixel-student.npz</code> is 122,330 parameters and was
not retrained once during any of this — same file, sha256 <code class="language-plaintext highlighter-rouge">b2bb79082c88c486…</code>, before
and after.</p>

</section>

<section class="prose">

  <h2>Held-out accuracy lies</h2>

  <p>The first forest attempt was a single classifier for <em>is this Viridian Forest</em>. It
scored <strong>1.0000</strong> balanced accuracy on frames it had never seen, and then fired on <strong>37%
of 1,705,090 real play frames</strong>. Raising the bar did not save it: 15.5% still fired at
0.999.</p>

  <p>This is structural, not bad luck. A closed two-class network puts everything it has
never seen into whichever class is broader, and the held-out split is drawn from the
frames it <em>was</em> shown — so it cannot contain the failure by construction. For a question
like "which place is this", the broader class is the positive one, and the recogniser
converges on <em>yes</em>.</p>

  <p>The fix is not a better classifier. It is a different question. A place is a scene, and
a scene has no edges. A doorway is an event, and an event has a beginning. Recognise the
event.</p>

</section>

<section class="prose">

  <h2>The probe lies too</h2>

  <p>So you probe: run the trained recogniser over every corpus on disk and count how often
it fires where it should be silent. The forest-entry trigger came back with <strong>zero false
alarms across 1,705,090 frames of 19 links</strong>.</p>

  <p>It then fired inside the player's own house, at p &gt;= 0.999, in <strong>19 of 20 live
cold-boot runs</strong>. Because that goal holds until an exit trigger clears it, one such fire
strands the run for good: a paired N=20 read 0/20 against 11/20 for the version it was
replacing.</p>

  <p>Both numbers were correct. A link's corpus <em>stops</em> at its map change, because that is
when its goal fires, and the next link's corpus <em>starts</em> after it. The cross-map stack —
three frames of the old room, one of the new — is exactly what a door produces and
exactly what this recogniser was trained on, and it exists in no ordinary corpus at all.</p>

  <p>A check cannot find a fault in a place it was never shown. Anything whose subject is a
transition has been measured against data that structurally cannot contain its worst
confusion. The repair was a shard of 4,825 cross-map door fades from six other doors in
the opening chain, collected on purpose and used as a probe target.</p>

</section>

<section class="prose">

  <h2>The stack drags the last room in with it</h2>

  <figure class="pixel">
  <img src="../assets/img/agentgb/recogniser-forest-entry-positives.png" width="678" height="1386" alt="Eight rows of Game Boy frames. Each row is one decision's four stacked frames, newest on the left. The left-hand frames show Viridian Forest; the right-hand frames show the gatehouse interior the player just walked out of." />
  <figcaption>
    Eight rows sampled across the forest-entry positives. Each row is one decision's four
    stacked frames, newest on the left. The left frames are forest; the right frames are
    the gatehouse interior the player just walked out of.
  </figcaption>
</figure>

  <p>Look at the right-hand frames. They are indoors.</p>

  <p>The observation is four frames, so "the first frame of the forest" physically carries
three frames of the room before it. Measured on the crossing shard: of 600 rows labelled
map 51, the oldest channel is the gatehouse in <strong>450</strong>, and only <strong>150</strong> are forest in
all four. Three quarters of what the first recogniser was taught to call <em>forest entry</em>
was mostly indoor-building pixels — which is precisely why it fired in a house.</p>

  <p>The fix is to keep only frames whose four channels are all the target map. Three steps
into the forest is the first decision with no gatehouse anywhere in the stack, and
across 150 runs it produces just four distinct pictures. That is a fixed shot, not a
spread, which is what makes it worth recognising.</p>

  <p>The exit is the same trick pointed the other way — the north door, on the way out.</p>

  <figure class="pixel">
  <img src="../assets/img/agentgb/recogniser-forest-exit-positives.png" width="678" height="1386" alt="Eight rows of four stacked Game Boy frames showing the player at the north door of Viridian Forest." />
  <figcaption>
    The forest exit spread: eight decisions at the north door.
  </figcaption>
</figure>

  <p>Its threshold is 0.99, not the 0.999 the entry uses, and the difference was measured
rather than chosen. At 0.999 the exit misses 37 of 152 real exits. A trigger tuned only
for silence goes blind; this project has already lost 11 of 27 runs to one that only
ever saw a fully drawn line of text.</p>

</section>

<section class="prose">

  <h2>A screen-only trigger, taught by a memory read</h2>

  <p>This is the one worth the whole page.</p>

  <p>The battle gate was trained on every frame where the cartridge byte <code class="language-plaintext highlighter-rouge">wIsInBattle</code> said a
battle was happening — <code class="language-plaintext highlighter-rouge">--positive-select "battle=1"</code>. It reads nothing but pixels when
it plays. Its positives were chosen by reading memory.</p>

  <p>The byte and the screen do not agree. On a wild encounter the byte flips a decision
<em>before</em> the screen changes; on a trainer battle it flips about six decisions <em>after</em>
the entry wipe has started. So the pile contains frames that are not battle screens.</p>

  <div class="fig-pair" style="max-width:none;">
  <figure class="pixel">
    <p class="cap">Chosen by the byte · poisoned</p>
    <img src="../assets/img/agentgb/recogniser-battle-positives-by-byte.png" width="678" height="2418" alt="Fourteen rows of four stacked Game Boy frames, labelled battle-byte. Row 0 is Route 1 grass with the player and a ledge. Rows 1, 3, 7 and 11 contain entirely black rectangles instead of battle screens." />
    <figcaption>
      Row 0 is the overworld — grass, a ledge, the player. Rows 1, 3, 7 and 11 are the
      screen wiping to black. Every one of these was labelled <em>a battle is on
      screen</em>.
    </figcaption>
  </figure>
  <figure class="pixel">
    <p class="cap"><span class="accent">Chosen by the phase</span> · honest</p>
    <img src="../assets/img/agentgb/recogniser-battle-positives-by-phase.png" width="678" height="2418" alt="Fourteen rows of four stacked Game Boy frames, labelled battle-started. Every row shows a drawn battle scene with sprites and a text box; none is an entirely black frame." />
    <figcaption>
      The same gate's positives after the fix, selected on the announcement frames instead
      of the byte. Same pile, same shape, no black frames anywhere in it.
    </figcaption>
  </figure>
</div>

  <p>Across the whole pile: <strong>1,572 of 3,632</strong> selected rows are the entry wipe, and <strong>1,130
of them — 31.1% — contain at least one entirely black frame</strong>. The gate was being taught
that <em>a battle is on screen</em> includes <em>the screen is black</em>, which is also every door
fade in the game.</p>

  <p>The numbers never showed it. Held-out balanced accuracy 1.0000, every one of 227 genuine
battles caught at every threshold, and a stubborn floor of a handful of false alarms per
half-million frames that four rounds of patching, a wider patch budget and more epochs
all failed to move: 8, then 2, then 5, on different links each time. Not a threshold
problem, not a capacity problem. The label.</p>

  <p>The repair was a new per-frame field recorded at collection time, marking the first run
of decisions after a battle begins on which the game has something to say — <code class="language-plaintext highlighter-rouge">&lt;NAME&gt;
wants to fight!</code> for a trainer, <code class="language-plaintext highlighter-rouge">Wild &lt;SPECIES&gt; appeared!</code> for an encounter. One rule,
symmetric, nothing to edit as the game world grows.</p>

  <p>Honest labels: 2,060 rows, <strong>zero</strong> containing a black frame. That took the gate from
<em>refused after four rounds of patching</em> to passing with no patched negatives at all —
<strong>0 false alarms in 549,918 frames at 0.999</strong>, all 227 battles still caught.</p>

  <p>There is a cost. The gate is now shut during the entry wipe, so the battle pair does not
take the wheel for those few decisions. That is the right answer: nothing on screen says
<em>battle</em> yet, and a gate that opened on a black screen would open on every door in
Kanto.</p>

  <p>This generalises. Every screen recogniser in this project selects its positives on a
cartridge label. Where the label and the picture agree frame for frame that is free.
Where they can disagree — a transition, a fade, a warp, a battle start — the bug is
waiting, and nothing on the training side can see it, because the network learns the
wrong label perfectly.</p>

</section>

<section class="prose">

  <h2>Crop to the thing itself</h2>

  <p>Telling a trainer battle from a wild one by sight comes down to one band of the screen.
A trainer's party is drawn as a row of Poké Balls under their name while the
challenge line is up. A wild encounter never draws one.</p>

  <figure class="pixel">
  <img src="../assets/img/agentgb/recogniser-battle-kind-trainer-crop.png" width="678" height="1690" alt="Rows of four stacked Game Boy battle frames with a narrow band under the opponent's name outlined in red, and beneath each row the same band blown up, showing a row of Poké Balls." />
  <figcaption>
    The trainer-kind positives. Each row shows the four-frame stack with the crop outlined
    in red, and beneath it the same band blown up — all the network is handed.
  </figcaption>
</figure>

  <p>Two decisions later the row is gone and the same band carries the enemy HP bar, which
reads identically in both kinds of fight. The signal exists only at the start: 346 of
346 recorded trainer battles show it, for a median of 2 decisions, range 2 to 2.</p>

  <p>Cropped to that band, the recogniser fires on <strong>0 of 1,019,244</strong> probed frames at 0.95
while catching all 346 — and the probe deliberately includes three wild-battle corpora,
where a fire would be a real mistake rather than an unseen scene.</p>

  <p>The same argument, put more sharply, produced the heal trigger. The design note is worth
quoting:</p>

  <div class="pullquote">
  <p>
    A declared region is a hard, structural restriction on what a recogniser can see, not
    a hope that a balanced corpus will make it ignore the rest.
  </p>
  <cite>The heal trigger's design note</cite>
</div>

  <figure class="pixel">
  <img src="../assets/img/agentgb/recogniser-lead-hp-bar-positives.png" width="678" height="1950" alt="Rows of four stacked Game Boy battle frames with the lead Pokémon's HP bar outlined in red, and the crop itself blown up beneath each row. The Pokémon's name, the HP label and the digits all sit outside the red box." />
  <figcaption>
    The lead's HP bar, outlined in red on each frame of the stack, with the crop itself
    beneath. The Pok&eacute;mon's name, the <code>HP:</code> label and the digits all sit
    outside the box.
  </figcaption>
</figure>

  <p>That crop is 24,082 parameters and cannot see the name or the level, by construction
rather than by hoping.</p>

  <p>It also cannot be trusted on its own. Ungated, it reads <strong>33% of 1,898,768 real play
frames</strong> as <em>low HP</em> — every one an overworld frame whose bar region is a patch of
grass. The HP bar is only drawn during a battle, so the bar reading is only ever taken
inside the battle gate above. Gated, twelve of twenty-four probe links contribute no
frames at all, because none of them contains a battle.</p>

  <p>Gated and measured against frames whose recorded HP already settles the answer: <strong>42
false alarms in 71,482 frames across 24 links, 0.059%, with 1,035 of 1,035 genuine
occurrences caught.</strong></p>

  <p>One thing that measured worse is worth the space, because it points the other way.
Looking at the pile showed <strong>84.1% of its 67,935 positives were gate-shut</strong> — the intro
wipe, the stats sub-screen, the move list, none of them drawing a bar at all. Cutting
them is obviously the right thing to do. Paired at the same seed and epochs, it takes
held-out accuracy <em>up</em>, 0.9946 to 0.9997, and the false-alarm rate on the frames the
trigger will really be asked about the <em>wrong way</em>, 0.48% to 1.73%. Held-out accuracy
lied about the fix for held-out accuracy lying.</p>

</section>

<section class="prose">

  <h2>A trigger that holds must latch</h2>

  <p>The battle pair does not just fire; it holds the wheel until the fight ends. The first
wiring asked the entry recogniser again every decision.</p>

  <p>That cost the chain 9/30 down to 2/30. The rival fight fell to 19/30 and eleven runs of
thirty ran out their budget standing in Oak's lab.</p>

  <p>The reason, measured over real fights: the gate reads under its own entry bar on a large
minority of a battle's frames — the attack flash, the move menu, the damage animation.
Over 20,000 sampled battle frames it clears 0.999 on <strong>31.4%</strong> of them in the rival
fight and 41.6% on Route 1. Swept all the way down to 0.005, only 79–95% clear the bar;
there is a hard core it reads as near-zero at any threshold. Its longest run of readings
below 0.5 <em>while the battle is genuinely still on</em> is a median of 5 to 9 and a <strong>maximum
of 16 consecutive decisions</strong>, which no single-frame bar can tell apart from a real
ending.</p>

  <p>So the marker fires once, latches, holds, and is released by a separate condition. Three
changes, no retraining and no new weights: a latched group keeps the wheel whatever the
gate currently reads; the release has to disagree 24 decisions running, half again
beyond the worst observed 16; and entry and release are different numbers, 0.999 and
0.5. Using one number for both is what caused it.</p>

  <p>The rival fight went back to 30/30 and <code class="language-plaintext highlighter-rouge">stuck_in_battle</code> from 26 to 1.</p>

  <p>The entry recogniser only has to be good at the entry frame. It already was.</p>

</section>

<section class="prose">

  <h2>What actually worked</h2>

  <p><strong>Crop tight.</strong> Every trigger cropped to a band — the text box, the ball row, the HP bar
— passed on the first or second attempt. The two aimed at whole screens fought back for
a night each.</p>

  <p><strong>Prefer the game's own words.</strong> Where the cartridge announces something out loud, match
the text and train nothing. The heal cycle disarms on Nurse Joy saying <code class="language-plaintext highlighter-rouge">fightin</code> — a
seven-character prefix, chosen so it catches the line while it is still being drawn, and
chosen over <code class="language-plaintext highlighter-rouge">Thank you!</code> because the shop clerk says that too.</p>

  <p><strong>Train simple, probe broad, patch narrow.</strong> Start with the scene's own non-scene frames
as the negative class and nothing else. Probe everywhere. Add only the areas measured to
be confused. The alternative is mapping the entire game as <em>no</em>.</p>

  <p><strong>Refuse.</strong> If no threshold satisfies both bars, ship nothing.</p>

</section>

<section class="prose">

  <h2>Where it stands</h2>

  <p>Nothing in the config the student now runs reads cartridge memory. Of its twelve goals,
eleven fire from pixels through a recogniser and one fires on a line the game prints on
screen; two of the eleven also stand down on a printed line. No map reads, no battle
byte, no HP struct.</p>

  <p>The forest crossing over the two days, all N=30 cold boots on the same config, seed 42:</p>

  <table>
  <thead>
    <tr>
      <th>Change</th>
      <th class="num">Crossings</th>
    </tr>
  </thead>
  <tbody>
    <tr><td>forest trigger on the screen, hold evicted by other goals</td><td class="num">0/30</td></tr>
    <tr><td>hold made to survive another goal's decision</td><td class="num">4/30</td></tr>
    <tr><td>heal trigger rebuilt, having fired 383 times inside the forest</td><td class="num">9/30</td></tr>
    <tr><td>battle triggers on the screen, hold re-checked every frame</td><td class="num">2/30</td></tr>
    <tr><td>battle triggers latched</td><td class="num">8/30</td></tr>
    <tr><td><strong>heal chamber moved to the HP bar</strong></td><td class="num"><strong>12/30</strong></td></tr>
  </tbody>
</table>

  <p>The last row carries its own caveat: paired over the same 30 seeds the heal change
gained 8 runs and lost 4, which at this sample size is not a proven improvement (McNemar
exact p=0.39). What it did establish is that the trigger now fires when it should —
<code class="language-plaintext highlighter-rouge">go-heal-center</code> went from firing in 20 of 30 episodes to 29, and every one of those 29
ended at Viridian's own counter.</p>

  <p>The third row is the one to notice. A trigger firing where it should not does not just
misbehave locally — the heal trigger was firing 383 times inside Viridian Forest, a room
with no Pokémon Centre counter in it, and evicting the forest walking lesson 22
times. It was the single largest cause of the crossing failing, and the fix was in a
different goal entirely.</p>

  <p>And the honest comparison: the same 25-link config with all five cartridge reads still
in place crossed the forest in <strong>41 of 60</strong> agents. Screen-only currently costs ground.</p>

  <p>That is not a like-for-like measurement — 60 agents against 30 runs, and the two seed
their attempts differently — so read the direction and not the size. The direction is
clear enough, and it is the price of the constraint rather than an argument against it.
A trigger that reads the cartridge cannot leave the building.</p>

  <p>Every figure here is N=30 or smaller against a project convention of 3,000 attempts.
These are screens, not certifications.</p>

</section>

<section class="prose">

  <h2>One command</h2>

  <p>All of this is now <code class="language-plaintext highlighter-rouge">agentgb trainscene</code>: select the positives, train, probe real play,
sweep the threshold on both sides, and print a registry entry — or refuse.</p>

  <p>It refuses by default if a trigger fires even once where it should be silent, because a
trigger behind a hold strands the whole run on a single false positive. It refuses if
any genuine occurrence is missed, counting occurrences as episodes rather than frames,
so a long fully drawn tail cannot carry a trigger that misses every half-drawn entry.</p>

  <p>It refuses in practice and not only in principle. The first trigger it was ever pointed
at in anger — the Pokémon Centre counter — came back refused on round one, with
21 of 22 probed areas firing.</p>

  <p>Rebuilding the forest-entry trigger with it, natural negatives alone: held-out balanced
accuracy 1.0000, and 21,295 false alarms in 1,751,142 frames, 1.2% even at 0.999.
Refused, with 21 of the 23 probed areas named and the flag to paste back printed
underneath. With those negatives added: <strong>zero false alarms at every threshold from 0.8
to 0.999, all 150 crossings still caught</strong>, the weakest at p=0.999974. Three and a half
minutes.</p>

  <p>The other half is not a command. Dump the positives, look at the montage, and ask
whether every row is the thing you meant. It has now caught three poisoned piles where
every number said clean, and a person spotted each of them by eye.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[Five triggers that read the cartridge were replaced with networks that look at the screen. Held-out accuracy said 1.0000 and the probe said zero false alarms, and both of them were lying — the labels themselves were poisoned.]]></summary></entry><entry><title type="html">Exponential decay of belief</title><link href="https://alchemy86.github.io/devlog/posts/exponential-decay-of-belief.html" rel="alternate" type="text/html" title="Exponential decay of belief" /><published>2026-08-31T00:00:00+00:00</published><updated>2026-08-31T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/exponential-decay-of-belief</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/exponential-decay-of-belief.html"><![CDATA[<section class="prose">

  <h2>Fourteen failures, one bug</h2>

  <p>A cold-boot chain scoring 594 of 600 looks, at a glance, like six runs that each went
slightly wrong in their own way — bad luck spread thin across six hundred attempts. It
wasn't. Three separate 600-run sweeps produced fourteen failures between them, and every
single one ended the same way: twelve identical actions in a row, the same button, right
up until the run ran out of decisions and was scored a loss.</p>

  <p>The cause was the confidence gate the agent already relies on to act decisively. Above
0.80 confidence, it stops sampling and takes the argmax — the single most likely action,
no randomness. That's the right behaviour almost everywhere in the chain. It's the wrong
behaviour on a screen that a no-op cannot change: press a button that does nothing, the
frame stays exactly as it was, the network sees the same input it just saw, and produces
the same output. Confidence in the wrong button doesn't wobble. It locks, and there is
nothing in a static screen that can ever unlock it.</p>

</section>

<section class="prose">

  <h2>Don't ban the action. Let the belief in it decay.</h2>

  <p>The obvious fix is a blocklist — once an action has done nothing N times running, take
it off the table. It works, and it's also exactly the kind of hard rule that breaks the
next time the situation looks a little different. The fix that shipped instead doesn't
forbid anything. It penalises belief.</p>

  <p>Every time an action fires and the frame doesn't change, a flat amount is subtracted
from that action's logit — its pre-softmax score — before the next decision. Nothing
else on the board moves. The other five actions keep whatever scores the network gave
them; only the one that just proved it does nothing gets docked.</p>

  <div class="pullquote">
  <p>Exponential decay of belief.</p>
  <cite>The framing is the captain's, and it is exact — not a metaphor</cite>
</div>

  <p>The exactness is in the arithmetic. Subtracting a flat amount <em>D</em> from a logit divides
that action's odds against every alternative by <em>e<sup>-D</sup></em> on every repeat — the
same shape as an exponential backoff, arrived at from the opposite direction. A backoff
grows the wait after a failure; this shrinks the confidence after one. Repeat the no-op
enough times and the action that looked certain a moment ago stops being the argmax at
all, sampling opens back up, and the agent tries something else — not because it was
told to, but because it stopped believing the thing it was doing was working.</p>

  <p>Whole-chain result: <strong>594 of 600 to 600 of 600</strong>. The specific failure mode the record
calls <code class="language-plaintext highlighter-rouge">stuck_in_battle</code> — an agent locked in a real fight, repeating an action combat
wasn't accepting — went from 4 occurrences to 0.</p>

</section>

<section class="prose">

  <h2>The same machinery, pointed at healing</h2>

  <p>Once an agent can notice that repeating something isn't working, the next question is
obvious: can it notice a good reason to go do something else entirely, mid-chain,
without derailing the chain it's already following? The answer shipped the same day, as
a second mechanism: <strong>chambered goals</strong>.</p>

  <p>A chambered goal has two conditions, decoupled in time. An <em>arm</em> condition, read from
cartridge memory — the agent has taken damage. A <em>fire</em> condition, read from the screen,
checked only once armed — the agent is looking at a Pokémon Center. Take damage, and the
chamber loads. Later, whenever a Center actually comes into view, it fires: walk in,
heal at the counter, walk back out, resume whatever the chain was already doing.</p>

  <p>Satisfaction doesn't have to come from the direction the design expects. A blackout
heals the party too, so it discharges the chamber on its own, with no Center visit at
all — the condition the mechanism exists to detect stopped being true, and the mechanism
notices that as readily as it notices the Center. And because the fire condition's
recogniser is never even consulted while the chamber is unarmed, it is the least-exposed
recogniser anywhere in the system: arming isn't just a trigger, it's a safety property.
A screen can only fire this goal at the one moment the agent is actually carrying
damage.</p>

  <p>Across the current 600-run certification, <strong>306 runs</strong> take that detour — walk to a
Center, heal, walk back out — mid-chain, at zero cost to the chain itself.</p>

</section>

<figure class="pixel">
  <img src="../assets/img/agentgb/pixel-network.png" width="1240" height="2771" alt="A technical sheet drawing the student network: three convolution layers with their live feature maps, a dense layer, and a six-bar softmax output at the bottom." />
  <figcaption>
    The six-way head both mechanisms above operate against. Decay is a flat subtraction
    against one of these six bars, before the softmax is taken — nothing upstream of this
    layer changes, and nothing about the convolutions above it knows the subtraction
    happened.
  </figcaption>
</figure>

<section class="prose">

  <h2>How badly hurt he is decides how sure the recogniser has to be</h2>

  <p>A chambered goal still needs to know how confident is confident enough, and that
threshold isn't fixed. It's a gradient against how much the arm condition actually hurt:
barely scratched, the recogniser has to be almost certain the building in front of it is
a Center before it fires. Badly hurt, a glimpse is enough — the cost of a false negative
goes up as the party's health goes down.</p>

  <p>Where that gradient's floor sits was measured, not guessed, one value at a time:</p>

  <div class="table-wrap">
  <table>
    <thead>
      <tr><th>Confidence floor</th><th class="num">N</th><th class="num">Heals fired</th><th class="num">Chain result</th></tr>
    </thead>
    <tbody>
      <tr><td>0.945</td><td class="num">300</td><td class="num">96</td><td class="num">300/300</td></tr>
      <tr><td>0.94</td><td class="num">300</td><td class="num">100</td><td class="num">300/300</td></tr>
      <tr><td>0.93</td><td class="num">300</td><td class="num">131</td><td class="num">300/300</td></tr>
      <tr><td><strong>0.92</strong></td><td class="num">300</td><td class="num">152</td><td class="num"><strong>300/300</strong></td></tr>
      <tr><td>0.91</td><td class="num">600</td><td class="num">—</td><td class="num">599/600</td></tr>
    </tbody>
  </table>
</div>

  <p>0.92 is the measured last safe rung, and its margin is one notch: drop one more click to
0.91 and the chain loses a run it wasn't losing before. Every floor from 0.945 down to
0.92 fires more heals as it loosens and still lands the chain clean — the gradient buys
more coverage for free right up until the exact point it doesn't.</p>

</section>

<section class="prose">

  <h2>The honest bits</h2>

  <p>The gradient shipped in its measured, governed form. It didn't start there. The first
version of the confidence threshold was tuned too hot — loose enough that the recogniser
it gated fired <strong>13,673 times</strong> over the course of testing it, and five runs got trapped
standing at a door, chambering and re-chambering a heal that kept almost-firing. At the
floor that actually shipped, 0.92, the same recogniser fires 591 times. The difference
between those two numbers is the entire reason the floor sweep above exists instead of a
single value picked once and trusted.</p>

  <p>A separate change tried to make the Center recogniser more precise by narrowing what it
was allowed to look at — cropping the frame down to just the POKé sign over the door,
instead of the wider shot that includes the building around it. It scored worse: 77.7%
against the wider crop's 79.6%. The pixel observation the tighter crop produced was
correct; the classifier simply had less to work with than the wider one gave it. That
result didn't get deleted for not helping. It's in the record as a negative, because the
next person tempted to crop tighter for the same reason should be able to find out it
already didn't work.</p>

  <p>And once, earlier in this same stretch of work, a chain got certified, reported and
merged while four of the weight files it depended on existed only in a scratch folder —
real on the machine that ran the certification, absent from everything committed. The
build didn't catch it because nothing was checking. It does now:
<code class="language-plaintext highlighter-rouge">tools/test_studentconfig_guards.py</code> test 10 fails the build if any registered goal
points at a path that isn't tracked.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[A confidence gate that locks onto one button and never lets go, fixed by letting the belief in it decay instead of banning it. 594 of 600 became 600 of 600, and the same machinery taught the agent to heal itself mid-run.]]></summary></entry><entry><title type="html">One character becomes three thousand</title><link href="https://alchemy86.github.io/devlog/posts/one-character-becomes-three-thousand.html" rel="alternate" type="text/html" title="One character becomes three thousand" /><published>2026-08-29T00:00:00+00:00</published><updated>2026-08-29T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/one-character-becomes-three-thousand</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/one-character-becomes-three-thousand.html"><![CDATA[<figure>
  <video controls="" preload="metadata" poster="../assets/img/agentgb/livingart-poster.png" width="540" height="540">
    <source src="../assets/video/agentgb/livingart-bedroom-to-pokeball-mobile.mp4" type="video/mp4" />
  </video>
  <figcaption>
    The finished shot. One character, alone, in Red's own bedroom — then the room fades
    as the crowd walks itself outward into a Pokéball, and keeps moving once it arrives.
    No cut anywhere in this file.
  </figcaption>
</figure>

<section class="prose">

  <h2>A blank stage, no map, no cartridge room</h2>

  <p><code class="language-plaintext highlighter-rouge">pixellivingart</code> is a third swarm mode alongside AgentGB's existing teacher-run and
custom-run films, and it throws out everything a Game Boy map usually provides. No
walkable-tile graph, no room, no live emulator beyond one one-time read of the
character's own sprite pixels straight out of VRAM and OAM — the sprite doesn't depend
on the room, so any saved state serves. Everything else on screen is synthetic: a target
shape, an assignment of agents to points on it, and a path from wherever each one starts
to the point it was given.</p>

  <p>A shape is one flag — <code class="language-plaintext highlighter-rouge">pokeball</code>, <code class="language-plaintext highlighter-rouge">text:WORDS</code>, or <code class="language-plaintext highlighter-rouge">image:path.png</code> — and the tile grid
it gets sampled onto sizes itself to the shape's own ink and the requested crowd, rather
than a fixed resolution regardless of either. A filled disc needs a much smaller grid
than thin letter strokes for the same agent count to read as a crowd instead of a
speckle: the Pokéball, 59.3% foreground at a cheap probe resolution, sizes to a
96×96 grid for 3,000 agents at 55% target occupancy. Every agent is then handed
its nearest still-free point on a KD-tree, walks there on the same grid path AgentGB's
other swarm films already use, and — the whole point of the exercise — does not stop
once it arrives. It jitters in place, or the whole shape slowly turns, or agents trade
places with their neighbours on the shape itself. A formation that freezes the instant
it forms was the one thing rejected outright.</p>

</section>

<section class="prose">

  <h2>Six bugs, and only the last one hid</h2>

  <p>The render above is not the first take. The first three were rejected, and the reasons
kept turning out not to be about the thing that looked wrong.</p>

</section>

<figure class="narrow">
  <img src="../assets/img/agentgb/livingart-tight-start.png" width="1080" height="1080" alt="A single character standing on the floor in a Game Boy bedroom, in front of a TV and desk, facing away from the camera." />
  <figcaption>
    The true opening frame. One character, on the floor, facing up — not standing on the
    TV, which is where an early cut had it.
  </figcaption>
</figure>

<section class="prose">

  <h3>1. Standing on the TV</h3>

  <p>The spawn tile itself was right — map 38, tile (3,6), verified against a live
screenshot. What wasn't right was how the room got drawn under it: the background image
was anchored by centring the whole room on the spawn point, rather than aligning that
one named tile within the room art. The TV sits near the room's own centre, so the
character appeared to be standing on it. The fix aligns the tile, not the room's
midpoint.</p>

  <h3>2. Facing built backward</h3>

  <p>Every render's first decision had every agent facing "down," hard-coded. This isn't a
shortcut around a hardware constraint the way a forced facing on a real cartridge run is
— this stage has no cartridge and no button ever pressed, so a starting facing is free
synthetic data. It had simply never been given a real one. The fix threads through the
same "up" already verified against a live screenshot of that tile.</p>

  <h3>3. Walking four times too fast</h3>

  <p>Every agent glided into formation at four times normal walking speed. AgentGB's own
convention for one decision of real game time is fixed — 32 frames at a documented,
un-adjustable 59.7275 frames a second — and this stage's default substep count fell
short of it: 8 drawn per decision instead of the 32 the convention calls for. Set to 32,
three thousand individually-timed approach paths produce a steady, staggered arrival
with no further tuning; nobody needed to be slowed down by hand once the base pace was
honest.</p>

  <h3>4. The camera chasing its own crowd</h3>

  <p>Early passes built the camera's keyframes by re-centring on wherever the crowd's
bounding box happened to sit at that instant. As the crowd dispersed unevenly toward the
shape, that centre visibly crept sideways — the frame kept pulling out from a point that
was quietly moving. The fix: every keyframe now shares one fixed centre, the crowd's own
position at the very first frame, and only ever grows the framing's size around it.
Because every shot's centre is identical, the interpolation between any two of them has
nothing left to pan across. Not a smaller drift. No pan at all.</p>

  <h3>5. A shape that was never centred on anything</h3>

  <p>Fixing all four of the above produced a render that still looked wrong: the Pokéball sat
in a corner of the frame, a wide band of pure white on one side that no amount of camera
tuning could close. By this point the camera was genuinely correct — a fixed centre, a
pure zoom. The fault was one level down: the shape's own target points were sampled in a
coordinate space with no relationship to wherever the render's one spawn point happened
to land, so nothing in the shape's own geometry was centred on anything a camera could
anchor to. The fix shifts every target point once, before spawns or bounds are derived
from them, so the shape's true centroid lands exactly on the origin — which is also
where the lone starting character now stands, so the crowd visibly erupts outward from
the one point the camera never lets go of.</p>

  <h3>6. The whole crowd stepped in unison, once</h3>

  <p>The approved render still had one visible fault: for several seconds, the whole crowd
appeared to take one slow step together, hold, then step again — thousands of
independently-timed paths moving in lockstep. Two plausible causes were tried and
discarded before the real one turned up under direct measurement: binning the render's
own frame-to-frame pixel difference by decision showed every single decision, everywhere
in the film, split cleanly into a near-zero-change half and a large-change half. Every
agent was gliding for the first half of a decision and standing frozen for the second,
simultaneously, for the entire 112 seconds — the freeze was just usually invisible,
because a compression pass quietly collapses long runs of near-identical frames. The one
place that collapse couldn't hide it was the few seconds where the background art itself
was continuously fading, which stopped the frozen frames from matching each other
closely enough to be thrown away. The cause was one shared "how far into this glide are
we" value, computed once a frame rather than once an agent, and clamped to finish at the
halfway point instead of the end. Making it progress across the whole span removed the
frozen half everywhere, not just where it happened to be visible.</p>

</section>

<div class="fig-pair">
  <figure>
    <p class="cap">Mid-dispersal</p>
    <img src="../assets/img/agentgb/livingart-mid-dispersal.png" width="1080" height="1080" alt="Thousands of tiny character sprites fanning outward from a fading bedroom in a symmetric diamond pattern." />
    <figcaption>Fifteen seconds in. The room is still fading, faintly, at the centre of the diamond every agent's own grid path traces on the way out.</figcaption>
  </figure>
  <figure>
    <p class="cap"><span class="accent">Formed</span></p>
    <img src="../assets/img/agentgb/livingart-formed-pokeball.png" width="1080" height="1080" alt="Three thousand tiny character sprites arranged into a clean, centred Pokéball shape: top disc, bottom disc, the gap band, the centre button." />
    <figcaption>The same crowd, arrived. Top disc, bottom disc, the gap band, the centre button — and every dot is still a walking, breathing character, not a frozen picture.</figcaption>
  </figure>
</div>

<section class="prose">

  <h2>A camera that cannot lose the crowd</h2>

  <p>An earlier attempt at this shot picked keyframes by hand, and it produced a render with
several seconds of pure white in the middle: the room had faded, the keyframes were
still centred on the old spawn tile, and by then the crowd had already walked far enough
away that neither the room nor a single agent was inside the crop. A hand-guessed
keyframe is a bet against how fast the world's own content moves, and a wrong bet
produces exactly this — a camera looking at empty floor.</p>

  <p>The fix doesn't re-tune the guess. The render now logs, for every output frame that
actually survives deduplication, the true pixel bounding box of every agent's own drawn
position — one manifest entry per real frame that ends up in the finished file. A
separate tool reads that manifest and derives camera keyframes from it directly, then
verifies its own output by re-implementing ShowReel's own camera interpolation in Python
— the same geometric zoom, the same eased pan, the same easing curve — and checking that
every single logged frame is actually contained in the resulting viewport at that
frame's own moment in the film. Wherever one isn't, it inserts a corrective keyframe
exactly there and checks again, until every frame passes or it refuses to ship at all.</p>

  <p>For this shot that's 13 keyframes covering 6,496 logged frames, every one sharing the
identical fixed centre — (1584.0, 1584.0), to the decimal, confirmed directly from the
film's own keyframe file rather than a log claiming it. The same pass also caught a
smaller fault in the opening hold: the camera sat as frozen as the crowd during it, when
only the crowd was supposed to. A small camera ramp through the hold fixed that without
touching how the world itself holds. A blank frame in the middle of this shot isn't
unlikely now. It's checked, on every render, against what the render actually drew.</p>

</section>

<section class="prose">

  <h2>What it actually took</h2>

  <p>The crowd's own arithmetic: a 96×96 grid, 3,000 agents, a mean walk of 40 tiles to
each one's assigned point, the longest single approach 64 decisions — plus the 140
decisions every agent spends alive and moving once it arrives. At 32 sub-frames a
decision that's 6,528 canvases composited, about 29.6ms apiece, roughly 193 seconds of
drawing before ffmpeg ever starts; the encode itself took another 187 seconds, peaking
at 5.5GB resident. The finished master: 6,735 frames, 3136×3136, 112.25 seconds at
60fps, with 0.5% of the canvases coming out as exact duplicates of the one before and
dropped rather than written twice.</p>

  <p>None of it needed a second cartridge running anywhere — the per-agent cost here was
never "run a Game Boy 3,000 times," it was "paste one already-decoded sprite into a
canvas, once a frame," the same shape of work this project's other swarm films had
already measured as cheap. A host-wide out-of-memory event did take out a later render
pass mid-flight once — forty-five processes killed machine-wide, an ffmpeg encoder among
the largest. The recovery was to resume from whichever of the render's own segments had
already finished rather than start over, capped at three workers running at once so it
couldn't happen the same way twice.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[A single character stands in a bedroom, then three thousand of it walk themselves into a Pokéball, in one unbroken shot. Getting there took a stage with no map and six real bugs, most of them nowhere near the camera.]]></summary></entry><entry><title type="html">The chiptune that outgrew its blog post</title><link href="https://alchemy86.github.io/devlog/posts/the-chiptune-that-outgrew-its-blog-post.html" rel="alternate" type="text/html" title="The chiptune that outgrew its blog post" /><published>2026-08-28T00:00:00+00:00</published><updated>2026-08-28T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-chiptune-that-outgrew-its-blog-post</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-chiptune-that-outgrew-its-blog-post.html"><![CDATA[<section class="prose">

  <h2>A problem worth avoiding</h2>

  <p>The lift ride is a demo video for this blog — a walk through AsciiWorldEngine's city and
up in the tower's glass lift, cut as a short film in ShowReel. It needed music, and this
is a public site, so the rule was blunt: source audio legally and keep the licence, or
ship the clip silent. Nobody wanted to be the one explaining a licensing question later.</p>

  <p>Instead of hunting for something labelled free-to-use and hoping the label held up, the
answer was to not need one. <code class="language-plaintext highlighter-rouge">chiptune.py</code> — a 114-line Python script, committed
alongside the video assets — synthesises three voices in the NES/SID idiom: a
duty-cycled pulse bass, a pulse-wave arpeggio lead with a little vibrato, and noise
bursts standing in for hats and kick. Its own docstring says what it is: self-authored,
nothing sampled, nothing pulled off the internet, public domain.</p>

</section>

<section class="prose">

  <h2>The obvious question</h2>

  <p>The captain saw it and asked the question that actually mattered: if a generator times
its own output to a video and plays inside that video, why does it live next to a blog
asset instead of inside the renderer built to time things to pictures? Should it not be
a feature, in Rust?</p>

  <p>It now is. ShowReel's own commit for it calls the Python file what it was — "the
devlog's throwaway <code class="language-plaintext highlighter-rouge">chiptune.py</code>" — and ports the idiom faithfully rather than treating
it as a spec to improve on: the same pulse bass, the same arpeggio lead, the same noise
drums, none of it reinvented.</p>

</section>

<section class="prose">

  <h2>What shipped</h2>

  <p>A track's source can now be generated music instead of a file. A <code class="language-plaintext highlighter-rouge">Music</code> spec resolves
to a synthesised WAV at exactly the point a file track's <code class="language-plaintext highlighter-rouge">asset</code> resolves to a path, so
from there it is an ordinary audio input — placement, gain, fades, multi-track mixing
and the mobile cut all compose for free rather than through a second audio path built to
match the first.</p>

  <p>Authoring is one word in the film file, mirroring the shorthand the colour grade already
uses:</p>

</section>

<figure class="code">
  <pre><code>"audio": [
  { "music": "funk" },
  { "music": { "mood": "dreamy", "key": "C", "bpm": 96 }, "fade_out": 2.0 }
]</code></pre>
  <figcaption>
    A bare mood word for the reference tune, sized to the film, or an object for key, bpm and
    tempo fit — <code>examples/music_demo.film.jsonc</code> in the ShowReel repository.
  </figcaption>
</figure>

<section class="prose">

  <h2>The timing is the point</h2>

  <p>With no duration given, the tune sizes itself to exactly the film's length, so it never
needs trimming and never hard-cuts to silence. <code class="language-plaintext highlighter-rouge">fit: "film"</code> goes further: the authored
tempo is treated as a target and nudged so a whole number of bars spans the film
exactly, landing the final downbeat on the last frame. Space scene cuts a whole number
of bars apart and every cut becomes a chord change, generated to fit rather than
hand-aligned after the fact.</p>

  <p>The self-contained demo film proves it: four bar-aligned scenes at 128 BPM, and its
three hard cuts land within about 7 ms of a downbeat in the rendered audio.</p>

</section>

<figure>
  <video controls="" preload="metadata" poster="../assets/img/showreel/music-demo-poster.png" width="720" height="406">
    <source src="../assets/video/showreel/music-demo-mobile.mp4" type="video/mp4" />
  </video>
  <figcaption>
    ShowReel's own proof file, rendered end to end with no assets — every background is a
    colour, the only source is the chiptune the one <code>music</code> track synthesises. Each
    hard cut lands on the downbeat of bar 5, 9 or 13; the progress bar under the second
    scene's title is that beat grid drawn on screen.
  </figcaption>
</figure>

<section class="prose">

  <h2>Checked against the thing it replaced</h2>

  <p>The Rust port was checked honestly against the Python original rather than assumed
faithful: envelope and spectrum correlation of roughly 0.9997 and 0.9999. The one
deliberate difference is a seeded random generator in place of the Python script's
global one, which means renders now reproduce — same film, same audio, every time.
That's a real improvement on the thing it replaced, not just a port of it.</p>

</section>

<div class="pullquote">
  <p>&ldquo;It was in the wrong repo, as a throwaway script, until someone asked the obvious
  question.&rdquo;</p>
  <cite>Why this one is worth telling</cite>
</div>

<section class="prose">

  <h2>Back where it started</h2>

  <p>The feature went straight back into the video that caused it. The AsciiWorldEngine lift
film's soundtrack is arranged to its own cuts, not laid under them: sparse under the
opening terminal, a riser through the bar before the reveal, the full band dropping in
with a crash on the cut to the city, driving through the street and the ride, and a
one-bar resolve to the tonic at the end so it lands rather than getting chopped. The
ShowReel feature landed at 19:03; the recut using it landed at 19:21. Eighteen minutes.</p>

</section>]]></content><author><name></name></author><category term="ShowReel" /><summary type="html"><![CDATA[A demo video needed music with no licence to worry about, so a 114-line Python script wrote its own chiptune. The captain asked why that lived beside a blog asset instead of inside the renderer that times things to pictures for a living — and it moved.]]></summary></entry><entry><title type="html">The one clean miss</title><link href="https://alchemy86.github.io/devlog/posts/the-one-clean-miss.html" rel="alternate" type="text/html" title="The one clean miss" /><published>2026-08-28T00:00:00+00:00</published><updated>2026-08-28T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-one-clean-miss</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-one-clean-miss.html"><![CDATA[<section class="prose">

  <h2>What was actually checked</h2>

  <p>Nobody on this crew watched a full video of his with sound. The browser tooling
available has no audio pipeline, and none of his videos had captions turned on. The one
technique with real weight behind it — a specific fake-depth trick applied to flat
screenshots — came from two independent tutorials that reverse-engineer his shots frame
by frame, one of which he commented on himself, a thin but real signal that the
breakdown was accurate enough for him to endorse rather than correct.</p>

  <p>Everything else — cut rhythm, sound design, the exact grade — is genre-level inference,
not something verified against his own footage, and it's named as such below rather than
folded in as if it were checked the same way.</p>

</section>

<section class="prose">

  <h2>The technique with real evidence behind it</h2>

  <p>Cut a flat screenshot — a tweet, a channel page — into two or three depth planes, space
them apart, and push a camera across the stack. It reads as looking into the image
rather than at a picture of it. Both tutorials that clone his shots build entire videos
around exactly this move.</p>

  <p>ShowReel already had every piece the shot needs, just not assembled into one move. A
scene already holds an ordered stack of layers, each with its own camera. A still with
transparency, layered over a background and given an independent camera rate, already is
a parallax composite — nothing new to render, just nothing that composed it in a single
call.</p>

</section>

<div class="table-wrap">
  <table>
    <thead>
      <tr><th>Technique</th><th>Verdict</th></tr>
    </thead>
    <tbody>
      <tr><td>Camera pushes over stills and clips, timed to narration</td><td>already built</td></tr>
      <tr><td>Kinetic caption pop-ins, synced word by word</td><td>already built</td></tr>
      <tr><td>Callouts and pull-ups over evidence</td><td>already built</td></tr>
      <tr><td>Stinger sound effects on cuts</td><td>already built</td></tr>
      <tr><td>A driving music bed, ducked under narration</td><td>already built</td></tr>
      <tr><td>Fake-depth parallax over a flat screenshot</td><td>built the same day</td></tr>
      <tr><td>Colour grade — desaturated, contrast-pushed</td><td><strong>still missing</strong></td></tr>
    </tbody>
  </table>
</div>

<section class="prose">

  <h2>The one that got built</h2>

  <p><code class="language-plaintext highlighter-rouge">Content::Parallax</code> takes a handful of depth-plane images and one authored camera move,
and gives every plane its own fraction of that move — <code class="language-plaintext highlighter-rouge">depth: 1.0</code> follows it exactly,
<code class="language-plaintext highlighter-rouge">0.0</code> sits still, anything else scales the departure, which is what actually reads as
depth: nearer things moving more.</p>

  <p>Measured directly, single-threaded: a three-plane stack at 1920×1080 over 4000×2500
source planes costs about 45 ms a frame, against about 15 ms for one ordinary
still-plus-camera layer at the same size — close to linear in the plane count, as it
should be. The worked example — three 3200×1800 planes, threaded — averages 15.0
ms/frame across the whole clip, because most of it sits at the wide end of the push,
where every plane's own mip pyramid picks a small, cheap level.</p>

</section>

<div class="fig-pair">
  <figure>
    <p class="cap">Wide, before the push</p>
    <img src="../assets/img/showreel/parallax-wide.png" width="1152" height="648" alt="A synthetic night skyline before the camera has moved: a row of buildings of varying heights under a gradient sky with a pale moon or sun low behind them." />
    <figcaption>
      Three depth planes, one authored move, no cut applied yet.
    </figcaption>
  </figure>
  <figure>
    <p class="cap"><span class="accent">Pushed</span> · 7s later</p>
    <img src="../assets/img/showreel/parallax-pushed.png" width="1152" height="648" alt="The same skyline pushed in on the light source, with buildings visibly at different depths — nearer ones larger and further into frame than distant ones." />
    <figcaption>
      Nearer buildings have moved further across the frame than the skyline behind them —
      the whole of the depth cue.
    </figcaption>
  </figure>
</div>

<section class="prose">

  <h2>The one that didn't</h2>

  <p>Confirmed by reading the render pipeline, not assumed: there is no global grade pass
anywhere in the crate. Compositing and blurring happen; nothing tone-maps a finished
frame. That's the one technique on the list ShowReel genuinely cannot do.</p>

  <p>It's real, and it's small — a single lift/gamma/gain or contrast-and-saturation pass,
one more stage in the per-frame pipeline, touching nothing about text, camera or
transitions. It's also ranked last of everything on the list, on purpose: a correctly
graded film with no parallax evidence shots in it looks less like this genre than an
ungraded one that has them. The parallax convenience shipped the same day the study did.
The grade pass is still just a line item.</p>

</section>

<div class="pullquote">
  <p>&ldquo;The reputation is a design decision applied consistently, not a feature.&rdquo;</p>
  <cite>What separates a look from a checklist</cite>
</div>

<section class="prose">

  <h2>What actually separates the two</h2>

  <p>It isn't a missing feature. It's that his channel applies one trick — split the evidence
into depth planes, time the push to the voice — with total consistency across an entire
catalogue. Building the grade pass closes what's left of the capability gap. Nothing
closes the craft gap except someone actually cutting a film that way, every time, on
purpose.</p>

</section>]]></content><author><name></name></author><category term="ShowReel" /><summary type="html"><![CDATA[The captain asked ShowReel to study a specific YouTube editor's display style. Going technique by technique, six of the seven it needed already existed. The seventh was a colour grade, and it still isn't built.]]></summary></entry><entry><title type="html">Three bytes a cell, doing nothing</title><link href="https://alchemy86.github.io/devlog/posts/three-bytes-a-cell-doing-nothing.html" rel="alternate" type="text/html" title="Three bytes a cell, doing nothing" /><published>2026-08-28T00:00:00+00:00</published><updated>2026-08-28T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/three-bytes-a-cell-doing-nothing</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/three-bytes-a-cell-doing-nothing.html"><![CDATA[<section class="prose">

  <h2>The gap was one sentence</h2>

  <p>Every building here already carries a named hue — one of six facades, from a neon grid
green to a red terminal orange — and every vehicle and street surface picks its own
colour the same way. Walk the street and the red tower, the cyan tower and the yellow
tower are plainly three different buildings. But the hue was only ever the glyph's
colour. The cell behind each character stayed black, so at any distance a building
dissolved back into a scatter of coloured dots on black — the exact thing an ASCII
renderer is always accused of looking like.</p>

  <p>The reference engine does the opposite: the wall is a filled block of one hue with
detail drawn <em>on</em> it, the road is a colour, a taxi is a filled yellow shape with wheels.
Nothing in its frame is a bright glyph floating on black. The whole difference between
the two comes down to one sentence: it colours the cell; this one coloured the glyph.</p>

</section>

<div class="fig-pair">
  <figure>
    <p class="cap">Classic</p>
    <img src="../assets/img/asciiworldengine/view-street-classic.png" width="1400" height="750" alt="A street in AsciiWorldEngine's classic view: towers rendered as coloured characters on a black background, the road between them unlit." />
    <figcaption>Coloured glyphs on black — the look every capture on this site has shown until now.</figcaption>
  </figure>
  <figure>
    <p class="cap"><span class="accent">Blocks</span></p>
    <img src="../assets/img/asciiworldengine/view-street-blocks.png" width="1400" height="750" alt="The same street in --view blocks: each tower filled with a dark shade of its own hue behind the glyphs, and the road now a lit grey surface with visible lane markings and kerbs." />
    <figcaption>Same seed, same position, same everything else. The road stops being the largest black shape in frame.</figcaption>
  </figure>
</div>

<div class="pullquote">
  <p>&ldquo;It colours the cell; this one coloured the glyph.&rdquo;</p>
  <cite>The whole gap, in one sentence</cite>
</div>

<section class="prose">

  <h2>The plumbing was already there</h2>

  <p>The surprise is how little of this turned out to be new. The frame buffer has carried a
per-cell background plane — three bytes a cell — since registration plates needed one: a
plate is black characters on yellow, which a foreground colour alone can't say. Every
way the engine turns a frame into a picture already reads it — the terminal writer emits
the colour run, the evidence SVG draws a filled rectangle under the text, the wasm
bridge hands the whole plane to the browser's own painter. None of that is new code.
It's been wired end to end since the plate work landed, and on every frame that wasn't a
registration plate it painted nothing, because nothing ever set it.</p>

  <p>That made the fill a rendering change, not a data one. Every place the renderer already
picks a glyph's colour already has the hue in hand — <code class="language-plaintext highlighter-rouge">Grid::put</code> just also writes a flat
0.30 downscale of that same colour into the background plane and raises a flag saying
the cell is filled. One factor on all three channels keeps the hue exact and keeps the
per-cell lightness variation that gives a wall its depth: a brighter wall cell gets a
brighter, still-dark fill. The world model needed nothing new — no field on a building,
a car or a cell — because the fill is derived at the moment it's drawn, not stored
anywhere.</p>

</section>

<section class="prose">

  <h2>Why a view, not a flag</h2>

  <p>Because the background plane costs nothing while it's black, turning the fill on could
be gated behind a single per-frame check — the same check that already made the plate
work free on every frame without one. That's what let this ship as <code class="language-plaintext highlighter-rouge">--view
classic|blocks|middle</code>, read the same way <code class="language-plaintext highlighter-rouge">--weather clear|rain|downpour</code> already is,
rather than as a setting that quietly changed the default look. Classic isn't a second
code path kept alive for compatibility; it's the fill code simply not running. Every
capture and every film already on this site stays exactly the frame it was shot as.</p>

  <p>That distinction was checked, not assumed: three scripted <code class="language-plaintext highlighter-rouge">--capture</code> walks in <code class="language-plaintext highlighter-rouge">--view
classic</code> came back byte-identical to the frames the engine wrote before any of this
landed. And it's worth being straight about the reference clip here too — its whole four
minutes are the new look, with no old-view section and no side-by-side. That it can fall
back to a bare-glyph mode is a credible claim about how engines like this are usually
built, but it isn't demonstrated in the source. Proving reversibility here, rather than
assuming it, is the actual difference between a view and a repaint.</p>

</section>

<section class="prose">

  <h2>The three things that weren't free</h2>

  <p>Deriving the fill from a hue the engine already had was the easy 90%. Three things
needed real work:</p>

  <ul>
    <li><b>The ground had gaps.</b> A road cell whose glyph is a space was simply skipped, so
the street was already speckled — colour on the characters, black between, and the
road is the single largest black area in any frame. In a filled view the ground now
writes its fill even where no glyph lands. It's the single change that opens the
street up the most.</li>
    <li><b>Contrast had to be designed, not just derived.</b> A glyph on a fill of its own hue
can vanish if the two lightnesses sit too close. A flat 0.30 downscale — tried by eye
against 0.24 and 0.38 side by side — keeps the ink about 3.3× brighter than its
own field at every lightness, so it can't disappear even in the tightest case: amber
on amber, a hue with no chroma to fall back on, only luminance.</li>
    <li><b>Plates had to stay plates.</b> The legibility scorer that grades a registration
reads runs of coloured background, so once the whole city has one, a filled frame
could read as one enormous plate. It didn't need fixing — the scorer already
disambiguates a plate from its surroundings by a separate plate mask, not by the
background colour itself — but that had to be checked, not assumed, and it's now a
named test rather than a hope.</li>
  </ul>

</section>

<section class="prose">

  <h2>Indoors, the same rule holds</h2>

  <p>The reference engine's buildings read as one flat hue apiece; this engine's walls vary
lightness cell to cell for depth, and that variation was the one thing not up for
negotiation. Fill and keep the gradient, or the change is a downgrade wearing an
upgrade's clothes.</p>

</section>

<div class="fig-pair">
  <figure>
    <p class="cap">Classic</p>
    <img src="../assets/img/asciiworldengine/view-interior-classic.png" width="1400" height="750" alt="Inside a room in AsciiWorldEngine's classic view: walls and floor drawn as coloured characters on black." />
    <figcaption>A room, same as every interior shot on this site until now.</figcaption>
  </figure>
  <figure>
    <p class="cap"><span class="accent">Blocks</span></p>
    <img src="../assets/img/asciiworldengine/view-interior-blocks.png" width="1400" height="750" alt="The same room in --view blocks: walls and floor filled with a shaded colour field behind the glyphs, the per-cell lightness that shows depth still visible on the walls." />
    <figcaption>Same room, same position. The wall still reads as a wall with depth on it, not a flat panel.</figcaption>
  </figure>
</div>

<section class="prose">

  <h2>What it costs, measured here</h2>

  <p>Filling the background is new work on the hottest path in the renderer, so it was
measured rather than assumed — six interleaved pairs of 400 frames at 180×60,
<code class="language-plaintext highlighter-rouge">tools/bench-view.sh</code>, the same binary run in <code class="language-plaintext highlighter-rouge">--view classic</code> and <code class="language-plaintext highlighter-rouge">--view blocks</code>
alternately so machine load lands on both equally:</p>

  <div class="table-wrap">
  <table>
    <thead>
      <tr><th>View</th><th class="num">mean ms/frame</th><th class="num">delta</th></tr>
    </thead>
    <tbody>
      <tr><td>classic</td><td class="num">0.614</td><td class="num">—</td></tr>
      <tr><td>blocks</td><td class="num">0.732</td><td class="num">+19.2%</td></tr>
    </tbody>
  </table>
</div>

  <p class="note" style="margin-top:14px;">
  The machine wasn't fully settled while this ran — load average sat around 7–8 rather than
  the under-0.5 the project's own benchmarking notes ask for — but the method is interleaved
  pairs precisely so steady background load lands on both sides equally, and it reproduces
  the project's own +19% figure for the street almost exactly. Most of the cost is in
  <em>paint</em>, not the renderer itself: a filled frame emits a terminal colour-change
  sequence wherever the fill colour changes, where a classic frame emits none. Classic stays
  the default, and stays free — nothing pays this cost unasked.
</p>

</section>

<section class="prose">

  <h2>The render got faster along the way</h2>

  <p>Profiling render broken down by pass, to see where the fill's cost was landing, found
two passes doing work once a column that only depends on the row — sky was recomputing
an elevation angle per cell instead of once a row, and ground was re-deriving the same
world cell down a column where one screen cell spans several rows of it. Both were
hoisted out.</p>

  <p>Measured the same way, interleaved against the build immediately before this change, six
pairs of 400 frames at 180×60:</p>

  <div class="table-wrap">
  <table>
    <thead>
      <tr><th></th><th class="num">mean ms/frame</th><th class="num">delta</th></tr>
    </thead>
    <tbody>
      <tr><td>before</td><td class="num">0.644</td><td class="num">—</td></tr>
      <tr><td>after, <code>--view classic</code></td><td class="num">0.614</td><td class="num">&minus;4.7%</td></tr>
    </tbody>
  </table>
</div>

  <p class="note" style="margin-top:14px;">
  The "after" binary includes the whole view-mode change — the extra branch on every pass
  that checks whether to fill — and it's still faster than the build before it existed,
  because the two hoists pay for the new branching several times over. Under the same
  machine conditions as the table above, so read the absolute numbers with the same
  caution; the direction and the rough size of the win are the finding. <code>walls</code>,
  the single most expensive pass, was left alone — it has no redundant work to hoist out,
  and chasing a few percent there risks the look for not much.
</p>

</section>

<section class="prose">

  <h2>What shipped</h2>

  <p>Three named views, run the same way weather is and toggled live with <code class="language-plaintext highlighter-rouge">B</code> while you walk:</p>

  <div class="table-wrap">
  <table>
    <thead><tr><th>View</th><th>What it does</th></tr></thead>
    <tbody>
      <tr><td><code>--view classic</code></td><td>coloured glyphs on black — the look every capture on this site showed before this change, and still the default</td></tr>
      <tr><td><code>--view blocks</code></td><td>a filled colour field behind every surface — buildings, road and traffic alike</td></tr>
      <tr><td><code>--view middle</code></td><td>the buildings filled, the road and traffic left bare — a lit skyline over a dark street</td></tr>
    </tbody>
  </table>
</div>

  <p>All three work everywhere the engine runs — <code class="language-plaintext highlighter-rouge">--vista</code>, <code class="language-plaintext highlighter-rouge">--capture</code>, <code class="language-plaintext highlighter-rouge">--film</code>,
<code class="language-plaintext highlighter-rouge">--doorway</code>, <code class="language-plaintext highlighter-rouge">--lift</code>, the browser build — because the fill isn't a fourth picture the
renderer draws. It's the frame buffer's own background plane, finally filled.</p>

</section>]]></content><author><name></name></author><category term="AsciiWorldEngine" /><summary type="html"><![CDATA[AsciiWorldEngine's frame buffer has carried a per-cell background colour plane since the plate work landed, wired into every output path and never turned on. Filling it borrowed a reference engine's look — and because nothing about the world model had to change to do it, the result shipped as a --view mode instead of a flag.]]></summary></entry><entry><title type="html">271 colours in a four-shade picture</title><link href="https://alchemy86.github.io/devlog/posts/271-colours-in-a-four-shade-picture.html" rel="alternate" type="text/html" title="271 colours in a four-shade picture" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/271-colours-in-a-four-shade-picture</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/271-colours-in-a-four-shade-picture.html"><![CDATA[<section class="prose">

  <p>A terminal that speaks no image protocol gets the picture drawn out of the
upper-half-block glyph <code class="language-plaintext highlighter-rouge">▀</code>. The foreground colour paints the top half of a character
cell, the background paints the bottom, two vertical pixels per cell. That is crisp by
construction — a cell is two flat colours and there is nothing inside it that can be
soft.</p>

  <p>And yet on Ptyxis it was visibly blurry, and on WezTerm it was not. Same binary, same
ROM, same scale.</p>

</section>

<div class="fig-pair">
  <figure class="pixel">
    <p class="cap">Before · <code>render_path = legacy</code></p>
    <img src="../assets/img/terminalgb/halfblocks-blurry.png" width="1000" height="572" alt="The dmg-acid2 demon face drawn with half-block characters, visibly soft and smeared, with grey fringes around every edge." />
    <figcaption>
      Every cell is a weighted average of the Game Boy pixels underneath it.
    </figcaption>
  </figure>
  <figure class="pixel">
    <p class="cap"><span class="accent">After</span> · default path</p>
    <img src="../assets/img/terminalgb/halfblocks-crisp.png" width="1000" height="572" alt="The same dmg-acid2 demon face drawn with half-block characters, now with hard edges and flat blocks of tone." />
    <figcaption>
      Sampled nearest-neighbour at pixel centres, straight out of the framebuffer.
    </figcaption>
  </figure>
</div>

<p class="note" style="max-width:var(--measure);margin-top:14px;">
  Both captures in Ptyxis 50.1 at 80×24 cells, 2.8× scale, one binary, two render
  paths.
</p>

<section class="prose">

  <h2>The softness was never in the glyph</h2>

  <p>It was in the sampling, four lines down in a dependency:</p>

  <figure class="code">
  <pre><code>let img = img.resize_exact(rect.width as u32, (rect.height * 2) as u32,
                          FilterType::Triangle);</code></pre>
  <figcaption>
    <code>ratatui-image-4.2.0/src/protocol/halfblocks.rs</code>. The same crate's
    Sixel encoder does no such thing, and <code>Resize::Scale</code>'s own default
    filter is <code>FilterType::Nearest</code>. Half-blocks was the one path with a
    filter baked in — which is why WezTerm, on the Sixel path, looked right.
  </figcaption>
</figure>

  <p>Measured on dmg-acid2 at 80×24 cells: 271 distinct colours, 268 of which no Game Boy
shade maps to.</p>

  <p>The fix samples the cells directly, nearest-neighbour at pixel centres, the same rule
the video scaler already used. It also writes a uniform cell as a space rather than <code class="language-plaintext highlighter-rouge">▀</code>,
so a font with a poor block glyph cannot soften the large flat areas a Game Boy picture
is mostly made of.</p>

</section>

<section class="prose">

  <h2>Doing the work ourselves is cheaper</h2>

  <p>This is the part that surprised me. The new path pays a full sampling cost on every
frame and still wins: over roughly 1,070 frames at 146×54 cells, same ROM and window
both sides, whole-frame time went from 0.460 ms to 0.349 ms.</p>

  <p>It replaces two <code class="language-plaintext highlighter-rouge">image</code>-crate resizes — a nearest upscale to the display's pixel size,
then a Triangle downscale back to the cell grid. One pass of arithmetic beats two passes
of somebody else's.</p>

</section>

<section class="prose">

  <h2>The guard is a property, not a pixel</h2>

  <p>Blur has now been reported twice here, so the regression test asserts the thing that
matters: <strong>every colour on screen must be a colour that was in the framebuffer.</strong> No
interpolating resampler can satisfy that.</p>

  <p>One unit test runs a one-pixel checkerboard — the worst case for a smoothing filter — at
five window sizes. One end-to-end test drives the real render on dmg-acid2 at four.</p>

  <div class="callout">
  <p class="eyebrow">The same trap, one repository over</p>
  <p>
    <a href="../projects/pixelgb.html">PixelGB</a> extracts map art from a running
    cartridge and guards its output on the same rule — no colour may appear that was
    not in the source — and cites this incident as the reason.
  </p>
</div>

</section>]]></content><author><name></name></author><category term="TerminalGB" /><summary type="html"><![CDATA[A terminal half-block renderer looked blurry on GNOME terminals and crisp on WezTerm. The cause was a hard-coded smoothing filter four lines down in a dependency.]]></summary></entry><entry><title type="html">A hundred checks in one session</title><link href="https://alchemy86.github.io/devlog/posts/a-hundred-checks-in-one-session.html" rel="alternate" type="text/html" title="A hundred checks in one session" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/a-hundred-checks-in-one-session</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/a-hundred-checks-in-one-session.html"><![CDATA[<section class="prose">

  <h2>The fault</h2>

  <p>A flag the picture processor keeps about a running object transfer was cleared on one of
the two paths that can notice the transfer has ended, and not the other. The idle arm's
own comment claimed the default machine never reached it.</p>

  <p>It does reach it. The transfer's last machine cycle leaves the position counter at its
limit inside the bus tick without the flag moving, so the instruction's remainder is
where the transfer gets noticed to be over.</p>

  <p>Once the flag latched, the object scan <strong>never read object memory again for the rest of
the session</strong> — 192 of 550 frames of a real cartridge.</p>

</section>

<section class="prose">

  <h2>Why a suite of short ROMs cannot see it</h2>

  <p>Each one boots, measures one thing and stops. A latch that needs a transfer to end on an
instruction boundary and a scan to follow it never gets the chance.</p>

  <p>That is not a criticism of those suites. It is a property of their shape.</p>

  <div class="pullquote">
  <p>
    A cartridge that runs a hundred checks back to back in one session is a shape no
    test ROM has.
  </p>
</div>

</section>

<section class="prose">

  <h2>The check itself, which is a nice piece of indirection</h2>

  <p>A cartridge cannot see which objects the scan found. What it <em>can</em> see is how long mode
3 took, and ten objects on a line cost the fetcher at least sixty dots.</p>

  <p>So: leave object memory entirely empty, put the visible objects in the transfer's
source, and start the transfer so it covers the measured line's scan. A machine whose
scan reads object memory straight through the transfer finds the ten objects the
transfer has already written and is still drawing them. Hardware finds none and is
already in horizontal blank.</p>

  <p>Two details are load-bearing rather than decorative. Object memory is left empty on
purpose, so whatever slot the scan last managed to read, it read a zero — the frozen Y
puts the phantom object above the screen, and the check does not depend on where the
freeze landed. And the transfer is sourced from video memory rather than work memory,
because on a monochrome console work memory sits on the cartridge's own bus and a
transfer out of it would feed the processor its own bytes instead of instructions. That
is why every real game's transfer routine lives in high RAM.</p>

</section>

<section class="prose">

  <h2>The rule it left behind</h2>

  <p>When a check is written, run the whole cartridge and read the total, not just that
check's line. A new check that passes in isolation and moves an unrelated area's verdict
has found something.</p>

</section>]]></content><author><name></name></author><category term="GBSelfTest" /><summary type="html"><![CDATA[A new check found a real bug in TerminalGB on the day it was written — one that thousands of rows across five public suites could not see, because of the shape they have.]]></summary></entry><entry><title type="html">Every failure was the same starter</title><link href="https://alchemy86.github.io/devlog/posts/every-failure-was-the-same-starter.html" rel="alternate" type="text/html" title="Every failure was the same starter" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/every-failure-was-the-same-starter</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/every-failure-was-the-same-starter.html"><![CDATA[<section class="prose">

  <h2>The number was fine. That was the problem.</h2>

  <p>Twenty-three of twenty-six agents completed every milestone from a cold boot: out of the
bedroom, up Route 1, into the Mart, back down again, and the Pokédex in hand. The
confidence interval was reported, the failures were located to a tile, and the three
that stopped were noted as stopping in Oak's Lab and on Route 1.</p>

  <p>Everything about that report was true. Nothing about it said that one entire third of
the population failed <strong>every single time it was drawn</strong>.</p>

  <p>The reason it was caught at all is that the captain watched the film rather than reading
the table. Twenty-six agents on screen at once, each in its own cell, each playing its
own run — and the eye does something a summary statistic does not. It groups. Three
agents stuck in the same room, and all three had the same Pokémon.</p>

</section>

<figure class="scrollfig" aria-labelledby="fig-swarm-cap">
  <svg viewBox="0 0 720 296" role="img" xmlns="http://www.w3.org/2000/svg" font-family="'JetBrains Mono', monospace">
    <style>
      .c  { fill: var(--code-bg); stroke: var(--hairline); stroke-width: 1.5; }
      .c.f{ fill: none; stroke: var(--accent); stroke-width: 2; stroke-dasharray: 4 3; }
      .sp { fill: var(--ink); font-size: 15px; font-weight: 700; }
      .sp.f{ fill: var(--accent); }
      .mk { fill: var(--ink-soft); font-size: 12px; }
      .mk.f{ fill: var(--accent); font-weight: 700; }
      .hd { fill: var(--ink); font-size: 11.5px; font-weight: 700; letter-spacing: .1em; }
      .hs { fill: var(--ink-soft); font-size: 10.5px; }
    </style>
    <text x="0" y="16" class="hd">BEFORE — 23 / 26 = 88.5%</text>
    <text x="696" y="16" text-anchor="end" class="hs">BULBASAUR 21/21 · CHARMANDER 2/2 · SQUIRTLE 0/3</text>
    <rect x="0" y="28" width="48" height="48" rx="6" class="c" />
    <text x="24.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="24.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="54" y="28" width="48" height="48" rx="6" class="c" />
    <text x="78.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="78.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="108" y="28" width="48" height="48" rx="6" class="c" />
    <text x="132.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="132.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="162" y="28" width="48" height="48" rx="6" class="c" />
    <text x="186.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="186.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="216" y="28" width="48" height="48" rx="6" class="c" />
    <text x="240.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="240.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="270" y="28" width="48" height="48" rx="6" class="c" />
    <text x="294.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="294.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="324" y="28" width="48" height="48" rx="6" class="c" />
    <text x="348.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="348.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="378" y="28" width="48" height="48" rx="6" class="c" />
    <text x="402.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="402.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="432" y="28" width="48" height="48" rx="6" class="c" />
    <text x="456.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="456.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="486" y="28" width="48" height="48" rx="6" class="c" />
    <text x="510.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="510.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="540" y="28" width="48" height="48" rx="6" class="c" />
    <text x="564.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="564.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="594" y="28" width="48" height="48" rx="6" class="c" />
    <text x="618.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="618.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="648" y="28" width="48" height="48" rx="6" class="c" />
    <text x="672.0" y="50" text-anchor="middle" class="sp">B</text>
    <text x="672.0" y="66" text-anchor="middle" class="mk">✓</text>
    <rect x="0" y="82" width="48" height="48" rx="6" class="c" />
    <text x="24.0" y="104" text-anchor="middle" class="sp">B</text>
    <text x="24.0" y="120" text-anchor="middle" class="mk">✓</text>
    <rect x="54" y="82" width="48" height="48" rx="6" class="c" />
    <text x="78.0" y="104" text-anchor="middle" class="sp">B</text>
    <text x="78.0" y="120" text-anchor="middle" class="mk">✓</text>
    <rect x="108" y="82" width="48" height="48" rx="6" class="c" />
    <text x="132.0" y="104" text-anchor="middle" class="sp">B</text>
    <text x="132.0" y="120" text-anchor="middle" class="mk">✓</text>
    <rect x="162" y="82" width="48" height="48" rx="6" class="c" />
    <text x="186.0" y="104" text-anchor="middle" class="sp">B</text>
    <text x="186.0" y="120" text-anchor="middle" class="mk">✓</text>
    <rect x="216" y="82" width="48" height="48" rx="6" class="c" />
    <text x="240.0" y="104" text-anchor="middle" class="sp">B</text>
    <text x="240.0" y="120" text-anchor="middle" class="mk">✓</text>
    <rect x="270" y="82" width="48" height="48" rx="6" class="c" />
    <text x="294.0" y="104" text-anchor="middle" class="sp">B</text>
    <text x="294.0" y="120" text-anchor="middle" class="mk">✓</text>
    <rect x="324" y="82" width="48" height="48" rx="6" class="c" />
    <text x="348.0" y="104" text-anchor="middle" class="sp">B</text>
    <text x="348.0" y="120" text-anchor="middle" class="mk">✓</text>
    <rect x="378" y="82" width="48" height="48" rx="6" class="c" />
    <text x="402.0" y="104" text-anchor="middle" class="sp">B</text>
    <text x="402.0" y="120" text-anchor="middle" class="mk">✓</text>
    <rect x="432" y="82" width="48" height="48" rx="6" class="c" />
    <text x="456.0" y="104" text-anchor="middle" class="sp">C</text>
    <text x="456.0" y="120" text-anchor="middle" class="mk">✓</text>
    <rect x="486" y="82" width="48" height="48" rx="6" class="c" />
    <text x="510.0" y="104" text-anchor="middle" class="sp">C</text>
    <text x="510.0" y="120" text-anchor="middle" class="mk">✓</text>
    <rect x="540" y="82" width="48" height="48" rx="6" class="c f" />
    <text x="564.0" y="104" text-anchor="middle" class="sp f">S</text>
    <text x="564.0" y="120" text-anchor="middle" class="mk f">✗</text>
    <rect x="594" y="82" width="48" height="48" rx="6" class="c f" />
    <text x="618.0" y="104" text-anchor="middle" class="sp f">S</text>
    <text x="618.0" y="120" text-anchor="middle" class="mk f">✗</text>
    <rect x="648" y="82" width="48" height="48" rx="6" class="c f" />
    <text x="672.0" y="104" text-anchor="middle" class="sp f">S</text>
    <text x="672.0" y="120" text-anchor="middle" class="mk f">✗</text>
    <text x="0" y="174" class="hd">AFTER THREE FIXES — 26 / 26 = 100.0%</text>
    <text x="696" y="174" text-anchor="end" class="hs">BULBASAUR 21/21 · CHARMANDER 2/2 · SQUIRTLE 3/3</text>
    <rect x="0" y="186" width="48" height="48" rx="6" class="c" />
    <text x="24.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="24.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="54" y="186" width="48" height="48" rx="6" class="c" />
    <text x="78.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="78.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="108" y="186" width="48" height="48" rx="6" class="c" />
    <text x="132.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="132.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="162" y="186" width="48" height="48" rx="6" class="c" />
    <text x="186.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="186.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="216" y="186" width="48" height="48" rx="6" class="c" />
    <text x="240.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="240.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="270" y="186" width="48" height="48" rx="6" class="c" />
    <text x="294.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="294.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="324" y="186" width="48" height="48" rx="6" class="c" />
    <text x="348.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="348.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="378" y="186" width="48" height="48" rx="6" class="c" />
    <text x="402.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="402.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="432" y="186" width="48" height="48" rx="6" class="c" />
    <text x="456.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="456.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="486" y="186" width="48" height="48" rx="6" class="c" />
    <text x="510.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="510.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="540" y="186" width="48" height="48" rx="6" class="c" />
    <text x="564.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="564.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="594" y="186" width="48" height="48" rx="6" class="c" />
    <text x="618.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="618.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="648" y="186" width="48" height="48" rx="6" class="c" />
    <text x="672.0" y="208" text-anchor="middle" class="sp">B</text>
    <text x="672.0" y="224" text-anchor="middle" class="mk">✓</text>
    <rect x="0" y="240" width="48" height="48" rx="6" class="c" />
    <text x="24.0" y="262" text-anchor="middle" class="sp">B</text>
    <text x="24.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="54" y="240" width="48" height="48" rx="6" class="c" />
    <text x="78.0" y="262" text-anchor="middle" class="sp">B</text>
    <text x="78.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="108" y="240" width="48" height="48" rx="6" class="c" />
    <text x="132.0" y="262" text-anchor="middle" class="sp">B</text>
    <text x="132.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="162" y="240" width="48" height="48" rx="6" class="c" />
    <text x="186.0" y="262" text-anchor="middle" class="sp">B</text>
    <text x="186.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="216" y="240" width="48" height="48" rx="6" class="c" />
    <text x="240.0" y="262" text-anchor="middle" class="sp">B</text>
    <text x="240.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="270" y="240" width="48" height="48" rx="6" class="c" />
    <text x="294.0" y="262" text-anchor="middle" class="sp">B</text>
    <text x="294.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="324" y="240" width="48" height="48" rx="6" class="c" />
    <text x="348.0" y="262" text-anchor="middle" class="sp">B</text>
    <text x="348.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="378" y="240" width="48" height="48" rx="6" class="c" />
    <text x="402.0" y="262" text-anchor="middle" class="sp">B</text>
    <text x="402.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="432" y="240" width="48" height="48" rx="6" class="c" />
    <text x="456.0" y="262" text-anchor="middle" class="sp">C</text>
    <text x="456.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="486" y="240" width="48" height="48" rx="6" class="c" />
    <text x="510.0" y="262" text-anchor="middle" class="sp">C</text>
    <text x="510.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="540" y="240" width="48" height="48" rx="6" class="c" />
    <text x="564.0" y="262" text-anchor="middle" class="sp">S</text>
    <text x="564.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="594" y="240" width="48" height="48" rx="6" class="c" />
    <text x="618.0" y="262" text-anchor="middle" class="sp">S</text>
    <text x="618.0" y="278" text-anchor="middle" class="mk">✓</text>
    <rect x="648" y="240" width="48" height="48" rx="6" class="c" />
    <text x="672.0" y="262" text-anchor="middle" class="sp">S</text>
    <text x="672.0" y="278" text-anchor="middle" class="mk">✓</text>
  </svg>        <figcaption id="fig-swarm-cap">
    <strong>The same twenty-six runs, before and after.</strong> B, C and S are the
    starter each agent chose for itself — the harness reads it back off the cartridge,
    and the network never sees it. Every dashed cell is a run that did not finish. The
    aggregate moved from 88.5% to 100.0%, but the aggregate is not the finding. The
    <em>shape</em> is.
  </figcaption>
</figure>

<section class="prose">

  <h2>One in two thousand six hundred</h2>

  <p>"All three failures were the same thing" is exactly the sort of pattern a human finds in
noise, so: with 21 Bulbasaur, 2 Charmander and 3 Squirtle runs and no species effect at
all, the probability that the three failures land on the three Squirtle runs is about
<strong>1 in 2,600</strong>. p ≈ 0.00038.</p>

  <p>That is not a coincidence you shrug at. And it was the <em>second</em> time this exact shape of
bug had appeared in this project, which is what turned it from an incident into a rule.</p>

  <div class="callout">
  <p class="eyebrow">The standing order that came out of it</p>
  <p>
    <strong>Report per starter on every certification and every swarm.</strong> Not
    when it looks relevant. Every time. An aggregate over a population with a
    structural split in it is a number that is simultaneously correct and useless.
  </p>
</div>

</section>

<section class="prose">

  <h2>Why the film sees what the table cannot</h2>

  <p>Not because a film is nicer to look at. Because of what each representation can carry.</p>

  <p>A completion table has one row per milestone and one rate per row. It is a <em>marginal</em> —
it has already summed over the population, and a correlation between a run's hidden
attribute and its outcome is exactly the information summing destroys. You cannot
recover it from the table no matter how carefully you read, because it is not in the
table.</p>

  <p>The film has not summed over anything yet. Every agent is still a separate object on
screen, carrying everything about itself, and the failures are visible <em>as a group</em> —
three cells stuck in the same room at the same time is a thing you see rather than
compute.</p>

  <div class="pullquote">
  <p>
    The swarm film is not a nicer way of showing the number. It is the only artefact
    in the pipeline where the population has not been collapsed yet.
  </p>
  <cite>What this cost, and what it bought</cite>
</div>

  <p>It is not free. The film that showed this takes roughly twenty-four screenshots per
decision per agent — gigabytes — where the table costs nothing but the emulation. It is
the expensive artefact, and this is what it is for.</p>

</section>

<section class="prose">

  <h2>Three separate causes, none of them the one first suspected</h2>

  <p>The obvious hypothesis was that the training corpus was biased toward one starter, so
the student was simply worse with the others. That was checked, and it is not what was
happening.</p>

  <p>The starter <em>choice</em> is genuinely balanced. At the chain's own entry state the first
decision carries 0.992 bits of entropy, and over 500 sampled attempts the committed
student takes Bulbasaur 254 times, Squirtle 127 and Charmander 118, reaching the goal in
499 of 500. That is a real three-way split, not a collapse.</p>

  <p>What <em>was</em> true, and actionable, is narrower and less obvious: two of the links on the
return leg have exactly <strong>one</strong> recorded start state each, so their corpora contained
<strong>zero species variation</strong> — regardless of how balanced the entry choice was. The
student had simply never been shown those rooms with a Squirtle in the party.</p>

  <p>Pulling the three failures apart gave three unrelated causes:</p>

</section>

<div class="cards">
  <div class="card">
    <div class="card-hd">
      <p class="kicker">Two agents</p>
      <h3>Reached Oak and never spoke to him</h3>
    </div>
    <div class="card-bd">
      <p>
        They walked into him, forever. At the four interaction tiles, facing correctly,
        the base policy's own prediction is the right button only <strong>4.55%</strong>
        of the time — the corpus mostly demonstrates the <em>dialogue</em> that follows
        the first press, not the press that starts it.
      </p>
      <p>
        Fixed with a dedicated goal whose positives were harvested structurally from the
        link's own existing corpora at the four declared tile-and-facing spots.
      </p>
    </div>
  </div>
  <div class="card">
    <div class="card-hd">
      <p class="kicker">One agent</p>
      <h3>Deadlocked in a battle menu for 3,600 decisions</h3>
    </div>
    <div class="card-bd">
      <p>
        Traced directly on a real seed: a wild Rattata at 8/23 HP, and the agent pressed
        <code>right</code> forever with the HP never once changing. The return crossing
        of Route 1 was still set to flee-only — the exact state the <em>outbound</em>
        crossing had been in before its own upgrade.
      </p>
      <p>
        It was also explicitly ruled out that the trainer-battle goal was false-firing
        here: its own history had stopped growing well before the deadlock began.
      </p>
    </div>
  </div>
  <div class="card">
    <div class="card-hd">
      <p class="kicker">One agent</p>
      <h3>Whited out and never recovered</h3>
    </div>
    <div class="card-bd">
      <p>
        The rule that a whiteout must not end a run was already active. The real gap was
        that nothing had ever taught the student what to do once it wakes up at home.
      </p>
      <p>
        Fixed for free: a whiteout this early teleports the party to the player's own
        house, and the link's goal is simply "be outdoors in Pallet Town" — so walking
        out of the house satisfies it, and two existing links already do exactly that at
        100%. No new data was collected at all.
      </p>
    </div>
  </div>
</div>

<section class="prose">

  <h2>The fix that broke everything, and what it taught</h2>

  <p>The first wiring of the Oak-interaction goal took the whole chain from 88.5% to <strong>0 of
150</strong>.</p>

  <p>The trigger tile it had been trained on — one specific tile, facing one specific
direction — is, independently, the chain's own <em>entry</em> tile for taking a starter six
links earlier. Same physical room, visited three times by this chain for three different
reasons. An ungated goal that fires whenever it recognises its screen fired on the very
first approach to the Poké Balls and broke every single run.</p>

  <p>The fix was structural rather than a heuristic: the late-chain goals are now only ever
consulted once a return-leg stage has <em>already</em> latched. Not by convention — by
construction. A screen that a return-leg goal happens to recognise can no longer hijack
the outward leg, however visually identical it is to something further down the chain.</p>

  <p>There is a general shape here worth naming. In a route that revisits the same rooms, <strong>a
recogniser's precision is not a property of the recogniser</strong>. It is a property of the
recogniser <em>and</em> the window of the episode it is allowed to look in. Ours was excellent
at its job and catastrophic without a gate.</p>

</section>

<section class="prose">

  <h2>Where it landed</h2>

  <p>After the three fixes, an eight-agent smoke test first — 8/8 — before committing to the
long run. Then a cold-boot certification at N=150:</p>

  <div class="table-wrap">
  <table>
    <thead>
      <tr>
        <th>Starter</th>
        <th class="num">Completed the whole chain</th>
        <th class="num">Rate</th>
        <th class="num">95% interval</th>
      </tr>
    </thead>
    <tbody>
      <tr><td>BULBASAUR</td><td class="num">87 / 90</td><td class="num">96.7%</td><td class="num">90.7–98.9%</td></tr>
      <tr><td>CHARMANDER</td><td class="num">13 / 13</td><td class="num">100.0%</td><td class="num">77.2–100.0%</td></tr>
      <tr><td>SQUIRTLE</td><td class="num">45 / 47</td><td class="num">95.7%</td><td class="num">85.8–98.8%</td></tr>
      <tr><td><strong>All sixteen links</strong></td><td class="num"><strong>145 / 150</strong></td><td class="num"><strong>96.67%</strong></td><td class="num"><strong>92.4–98.6%</strong></td></tr>
    </tbody>
  </table>
</div>

  <p>No starter anywhere near zero, all three intervals overlapping heavily. The filmed swarm
at N=26 came back 26/26 — the first time this chain's film has shown every starter
completing at 100%, where the previous one showed a third of them at zero.</p>

  <p>N=150 is not the project's 3,000-attempt convention, and bounds failure near 2%, not
0.1%. And Charmander's 13/13 has a lower interval bound of 77.2%: thirteen runs is
evidence of not-obviously-broken, not of perfection.</p>

</section>

<section class="prose">

  <h2>What this actually generalises to</h2>

  <p>Very little of the above is about Pokémon.</p>

  <p>An agent population with a self-selected hidden attribute — which tool it picked, which
branch it took, which variant it was served — will produce an aggregate success rate
that is arithmetically correct and behaviourally meaningless the moment that attribute
correlates with outcome. The failure mode is not a wrong number. It is a right number
answering a question nobody asked.</p>

  <p>Two cheap defences came out of this. Break every rate down by the attribute, whether or
not you expect it to matter. And keep one artefact in the pipeline where the population
has not been collapsed yet — expensive, rarely looked at, and the only place a
structural split is visible before it becomes a mystery.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[A 26-agent swarm scored 88.5% and looked healthy. Watching the film instead of reading the table showed all three failures were Squirtle runs — one chance in 2,600. What the number could not show, and the reporting rule that came out of it.]]></summary></entry><entry><title type="html">Four bugs, one line each</title><link href="https://alchemy86.github.io/devlog/posts/four-bugs-one-line-each.html" rel="alternate" type="text/html" title="Four bugs, one line each" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/four-bugs-one-line-each</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/four-bugs-one-line-each.html"><![CDATA[<section class="prose">

  <h2>"The Pokémon images don't work"</h2>

  <p>The emulator was sending an offer card — sprite, types, determinant total, a shiny
prediction — right alongside the Pokémon spec. The offer endpoint read only the spec and
the save identity beside it. The card object was arriving on every request and being
dropped on the floor, so every offer showed the no-picture placeholder.</p>

  <p>The fix came with two rules more interesting than the bug.</p>

  <p>The card's sprite is a base64 PNG, not the collection's raw pixel array, so it is
decoded on the way in — indexed or RGB8, non-interlaced — and <strong>the PNG's own header is
the authority on geometry</strong>. A declared width that disagrees is a refusal, and
decompression runs with an exact output limit so a compression bomb cannot spend memory.</p>

  <p>And a malformed card is dropped field by field, never a 400. An optional decoration from
the other side of a shared contract must not turn a valid offer into an error — but the
reasons ride back on the reply, precisely because the silent version of that is the bug
above.</p>

</section>

<section class="prose">

  <h2>Every poll looked too fast</h2>

  <p>The device-code login flow enforces a minimum polling interval, and the endpoint has to
both read the last poll time and stamp a new one.</p>

  <p>Written the obvious way — one <code class="language-plaintext highlighter-rouge">UPDATE … RETURNING</code> — it silently never works, because
<code class="language-plaintext highlighter-rouge">RETURNING</code> sees post-update values. The endpoint reads its own new timestamp, computes
an interval of zero, and interval enforcement quietly does nothing.</p>

  <p>The shape that works reads the previous value under a row lock first and updates in a
second common table expression. It was caught before commit by reading the Postgres
documentation rather than by a test — and there is now a test, so a "tidy-up" back to a
single statement turns red.</p>

  <p>The same snapshot rule bites twice more in this codebase: a data-modifying CTE is
invisible to the same statement's <code class="language-plaintext highlighter-rouge">SELECT</code>, so the lapsed-sprite purge must run as a
separate <code class="language-plaintext highlighter-rouge">UPDATE</code> before the <code class="language-plaintext highlighter-rouge">SELECT</code> that reads the collection, and the rate limiter
counts only prior attempts — which is why "max" here means allow exactly max, deny the
max-plus-first.</p>

</section>

<section class="prose">

  <h2>A row id that was a string</h2>

  <p><code class="language-plaintext highlighter-rouge">pg</code> returns 64-bit integers as JavaScript strings by default, because they do not fit
safely in a double. Every id in this schema is a <code class="language-plaintext highlighter-rouge">bigint</code>, so <code class="language-plaintext highlighter-rouge">Number.isInteger(id)</code>
guards silently stopped guarding and ids in JSON quietly became strings. Measured: a
friendship id came back as <code class="language-plaintext highlighter-rouge">"1"</code>.</p>

  <p>One type-parser registration at the database module fixes it globally.</p>

</section>

<section class="prose">

  <h2>A grid that could not shrink</h2>

  <p>The Pokémon cards live in a CSS grid. Written as <code class="language-plaintext highlighter-rouge">repeat(auto-fill, minmax(16rem,
1fr))</code>, that grid physically cannot get narrower than 16rem — so below about 350 pixels
it made the whole document wider than the viewport and every panel on the page got
clipped mid-word at the right edge. The <code class="language-plaintext highlighter-rouge">min(100%, 16rem)</code> spelling is load-bearing, not
stylistic.</p>

  <p>The same class of fault lived in two other places: the site header, where the brand plus
the navigation come to about 350 pixels with no wrapping allowed; and a form label
inside a card, where a flex item's automatic minimum size is its content width, so a
16rem minimum on one label spilled the card at every width.</p>

  <p>All three are one mistake — a rule that sets a floor on the page width — and one
measurement catches all of them:</p>

  <figure class="code">
  <pre><code>for (const el of document.querySelectorAll("body *")) {
  const r = el.getBoundingClientRect(),
        p = el.parentElement.getBoundingClientRect();
  if (r.right &gt; p.right + 1) { /* this element overflows its parent */ }
}
document.documentElement.scrollWidth &gt; document.documentElement.clientWidth</code></pre>
  <figcaption>
    Run at 1440, 1024, 800, 600, 420, 360, 320 and 280 pixels, with the in-card
    accept form both closed and open. It finds the element that overflows, not just
    the fact that something does.
  </figcaption>
</figure>

</section>

<section class="prose">

  <h2>And one that belongs in no category</h2>

  <p><code class="language-plaintext highlighter-rouge">pgrep -f</code> and <code class="language-plaintext highlighter-rouge">pkill -f</code> match the process doing the matching. An automated run on this
repository killed itself with a pattern sweep for a dev server, because its own command
line contained the pattern. Kill by the process id you captured when you started the
process.</p>

</section>]]></content><author><name></name></author><category term="Portal" /><summary type="html"><![CDATA[A dropped field, a RETURNING clause that returns the wrong thing, a bigint that arrived as a string, and a CSS grid that could not get narrower than 16rem.]]></summary></entry><entry><title type="html">Let go of the D-pad</title><link href="https://alchemy86.github.io/devlog/posts/let-go-of-the-d-pad.html" rel="alternate" type="text/html" title="Let go of the D-pad" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/let-go-of-the-d-pad</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/let-go-of-the-d-pad.html"><![CDATA[<section class="prose">

  <h2>Getting in at all</h2>

  <p>Writing a map number into <code class="language-plaintext highlighter-rouge">wCurMap</code> crashes the game. The whole map header sits
underneath it, so the engine ends up reading the new map's blocks through the old map's
pointers.</p>

  <p>The safe route is <code class="language-plaintext highlighter-rouge">wWarpEntries</code>: redirect one of the current room's own warps at the
target map and walk the player into it. The engine loads the destination header itself —
<code class="language-plaintext highlighter-rouge">LoadMapHeader</code>, the tileset copy into video RAM, the palette, the objects, the screen.
Everything after the walk is the game's own.</p>

  <p>So PixelGB plays the opening, saves the machine's state the moment it stands on a real
map, and for each of the 226 maps rewinds to that moment, points one warp at the target
and walks in.</p>

</section>

<section class="prose">

  <h2>And then one line was worth a hundred maps</h2>

  <div class="pullquote">
  <p>
    Let go of the D-pad the instant the map changes.
  </p>
  <cite>The single most important line in <code>warp_to</code></cite>
</div>

  <p>A door is a warp on both sides. You arrive on the doormat, and a player still holding
the direction takes one more step and walks straight back out.</p>

  <p>Holding: 113 maps photographed. Releasing on arrival: 216.</p>

</section>

<section class="prose">

  <h2>The one that was stranger: shop clerks talk</h2>

  <p>Several maps put a text box up the moment you arrive, and Generation 1 does that by
parking the window layer at <code class="language-plaintext highlighter-rouge">WY = 0</code>. Which means the window covers the entire screen
and the map is drawn through it.</p>

  <p>An earlier version of the comparison excluded "wherever the window is", the way it
excludes sprites — and so masked the whole picture. 31 maps went from exact to
inconclusive and two to no-alignment, with nothing in the report to say why.</p>

  <p>The right answer was not to subtract the text box but to dismiss it, the way a player
does: press B until the window is off-screen again, requiring several consecutive clear
checks, because the window parks off-screen for a few frames between two boxes of one
conversation.</p>

  <p>It looked like a tolerance problem and it was a modelling one.</p>

</section>

<section class="prose">

  <h2>What it did not rescue</h2>

  <p>The Hall of Fame. Walk in and Oak starts talking, and his script is not one you can
press B through — it inducts the party. PixelGB presses B up to eighty times and
requires four consecutive clear checks before photographing; this one still has a text
box up when the budget runs out, so it is counted on its own.</p>

  <p>Ten more maps have nowhere to arrive: a warp names a destination warp on the far side
and these have none, so the camera comes to rest outside the map. Those and the Hall of
Fame are the eleven named on the <a href="../projects/pixelgb.html">project page</a>.</p>

</section>]]></content><author><name></name></author><category term="PixelGB" /><summary type="html"><![CDATA[One line took a cartridge map capture from 113 maps to 216. A door is a warp on both sides, and a player still holding the direction walks straight back out.]]></summary></entry><entry><title type="html">Reading the screen without reading the letters</title><link href="https://alchemy86.github.io/devlog/posts/reading-the-screen-without-the-letters.html" rel="alternate" type="text/html" title="Reading the screen without reading the letters" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/reading-the-screen-without-the-letters</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/reading-the-screen-without-the-letters.html"><![CDATA[<section class="prose">

  <h2>Two wrong answers first</h2>

  <p>Attempt one tested two message pairs at N=5 and got balanced accuracy 0.50: chance.
Attempt two sharpened the question and got 0.33: still chance.</p>

  <p>Both were undermined by the same thing. Three to five examples per class lets a probe
predict one class always and look exactly like chance.</p>

</section>

<section class="prose">

  <h2>The answer with real N</h2>

  <p>Dozens of genuine frames per message, taken from the typing animation itself, over six
distinct in-game messages. Nearest-centroid across all six identities, with a temporal
split — train on each message's first 65% of typing, test on the last 35% it has never
seen.</p>

  <p><strong>97.2% accuracy against 20.0% chance</strong>, N=36 held out, permutation p &lt; 0.005. The
confusion matrix is essentially the identity matrix.</p>

  <p>What survives is shape, not letters: line length, word-wrap position, letter density per
region.</p>

  <p>One of the six messages had to be captured from a separate session, and its background
differs from the other five by 14 of 1,440 cells, all inside one small icon. Removing
that message entirely, or masking those 14 cells, moves the headline by about one point.</p>

</section>

<section class="prose">

  <h2>The version that mattered in practice</h2>

  <p>Compare the bag opened with an empty item pocket against the same bag holding Oak's
parcel. <strong>57 cells of the 40×36 observation separate the two states perfectly</strong>, and not
one of them requires reading a letter.</p>

  <p>A goal marker does not have to be legible text. A reliable change in layout is enough.</p>

</section>

<figure class="pixel">
  <img src="../assets/img/agentgb/bag-parcel-structural.png" width="872" height="1284" alt="Three stacked panels: the real 160x144 bag screen, the same screen as a coarse 40x36 observation, and a difference map highlighting the cells that separate an empty item pocket from one holding the parcel." />
  <figcaption>
    The real screen, the actual observation, and a difference map, for the bag with an
    empty item pocket against the same bag holding the parcel. The separating cells are
    a change in layout, not a legible word.
  </figcaption>
</figure>

<section class="prose">

  <p>These are fresh linear probes and nearest-centroid classifiers fit on frozen
observations, not a trained student network, so there is no weights file to name against
them.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[At 40 by 36 no letterform survives. Six in-game messages are still 97.2% separable — and the first two experiments that said otherwise were both too small.]]></summary></entry><entry><title type="html">TACKLE is 95 per cent and we said 94</title><link href="https://alchemy86.github.io/devlog/posts/tackle-is-95-per-cent.html" rel="alternate" type="text/html" title="TACKLE is 95 per cent and we said 94" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/tackle-is-95-per-cent</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/tackle-is-95-per-cent.html"><![CDATA[<section class="prose">

  <p>No run was affected — the code was reading the cartridge all along. Only the prose was
wrong.</p>

  <p>It was found by re-reading the move table while writing a page about discoveries, on the
grounds that a page about discoveries which repeats a number without checking it is not
worth having.</p>

</section>

<section class="prose">

  <h2>Read the table, and check the rows nobody disputes first</h2>

  <p>Six bytes per move, straight out of the cartridge:</p>

  <div class="table-wrap">
  <table>
    <thead><tr><th>Move</th><th class="num">Id</th><th class="num">Power</th><th class="num">Accuracy byte</th><th class="num">As a percentage</th></tr></thead>
    <tbody>
      <tr><td>POUND</td><td class="num">1</td><td class="num">40</td><td class="num">255</td><td class="num">100%</td></tr>
      <tr><td>SCRATCH</td><td class="num">10</td><td class="num">40</td><td class="num">255</td><td class="num">100%</td></tr>
      <tr><td>VINE WHIP</td><td class="num">22</td><td class="num">35</td><td class="num">255</td><td class="num">100%</td></tr>
      <tr><td><strong>TACKLE</strong></td><td class="num">33</td><td class="num">35</td><td class="num"><strong>242</strong></td><td class="num"><strong>94.9%</strong></td></tr>
      <tr><td>HYPER BEAM</td><td class="num">63</td><td class="num">150</td><td class="num">229</td><td class="num">89.8%</td></tr>
      <tr><td>HYDRO PUMP</td><td class="num">56</td><td class="num">120</td><td class="num">204</td><td class="num">80.0%</td></tr>
    </tbody>
  </table>
</div>

  <p>Every row but the one under investigation matches the values Generation 1 is known for.
That is what says the table is being read at the right offset with the right stride.</p>

  <figure class="code">
  <pre><code>python3 - "$ROM" &lt;&lt;'PY'
import sys
rom = open(sys.argv[1], 'rb').read()
e = 0x0E * 0x4000 + (33 - 1) * 6   # Moves, 0E:4000, TACKLE is move 33
print("power %d accuracy %d (%.1f%%)" % (rom[e+2], rom[e+4], rom[e+4] * 100 / 255))
PY
# power 35 accuracy 242 (94.9%)</code></pre>
  <figcaption>
    The whole correction, against your own cartridge. No ROM is distributed by the
    project; you point <code>$ROM</code> at yours.
  </figcaption>
</figure>

</section>

<section class="prose">

  <h2>Where 240 came from</h2>

  <p>94% of 255 is 239.7, which rounds to 240. It is the byte you get by computing it from a
percentage you already believed, instead of reading it.</p>

  <div class="pullquote">
  <p>
    When you write down a number the cartridge holds, write down the command that
    reads it.
  </p>
</div>

</section>

<section class="prose">

  <h2>And the accuracy byte is not the accuracy anyway</h2>

  <p>The hit calculation scales a move's accuracy byte by the attacker's accuracy stage and
the inverse of the defender's evasion stage. A policy reading the ROM value is reading a
number that has not been true since the first SAND-ATTACK landed.</p>

  <p>A Pidgey's SAND-ATTACK takes a 95% TACKLE to <strong>62%</strong> and then <strong>47%</strong>. Both ratios come
out of a stage-modifier table read from the cartridge, located by searching for its own
opening bytes — a signature that occurs exactly once — and re-checked on every read
against two properties: the first pair must be 0.25, and the middle pair must be exactly
1:1, because a table read from the wrong place almost never has a neutral stage in its
middle.</p>

  <div class="callout">
  <p class="eyebrow">Found only at N = 3,000</p>
  <p>
    Three of 3,000 attempts at one scenario died in the same shape: at 2 HP against a
    Pokémon on 1 HP, the driver swung rather than escaped, because a good roll would
    end it first. Two separate 600-attempt runs were clean. The bug was not rare in
    the mechanic, it was rare in the route — it needs a Pidgey that has had time to
    use SAND-ATTACK twice.
  </p>
</div>

  <p>There is a second trap underneath it, and the atlas carries its own warning about it: in
the stat-stage block, <strong>7 is neutral, not 0</strong>, and the block is zeroed work RAM until
the battle-init routine writes the sevens. A sample taken on the first frames of a
battle reads 0 everywhere and looks like every stat six stages down.</p>

</section>]]></content><author><name></name></author><category term="AtlasGB" /><summary type="html"><![CDATA[Nothing failed. A number had been computed from a belief rather than read from the cartridge, and it sat in four files for as long as they existed.]]></summary></entry><entry><title type="html">The day that added nothing</title><link href="https://alchemy86.github.io/devlog/posts/the-day-that-added-nothing.html" rel="alternate" type="text/html" title="The day that added nothing" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-day-that-added-nothing</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-day-that-added-nothing.html"><![CDATA[<section class="prose">

  <h2>The training run is the largest term, and it had no control</h2>

  <p>Five runs of the identical configuration on one link scored:</p>

  <div class="table-wrap">
  <table>
    <thead><tr><th>Run of an identical configuration</th><th class="num">1</th><th class="num">2</th><th class="num">3</th><th class="num">4</th><th class="num">5</th></tr></thead>
    <tbody>
      <tr><td>Cold success rate</td><td class="num">100.00%</td><td class="num">92.33%</td><td class="num">92.33%</td><td class="num">84.63%</td><td class="num">77.00%</td></tr>
    </tbody>
  </table>
</div>

  <p>A 23-point band, most of it the seed and the rest the BLAS thread count. Every ablation
published before that day lacked the control, which means every one of them could have
been measuring the draw rather than the change.</p>

  <p>A fit does not reproduce. Same seed, same recipe, a differently-loaded machine, and you
get different weights.</p>

</section>

<section class="prose">

  <h2>Cold evaluation over a state pool is optimistic by about a factor of two</h2>

  <p>Every state in a start-state pool is frozen from one traversal, so the map's own people
barely move between them. Same policy, same tile, 150 attempts: 100% under a 0–96 frame
start offset, <strong>52.7% under 0–3,000</strong>.</p>

  <p>Six distinct people-layouts against twenty-two. That is the whole difference.</p>

  <p>Every rate published since carries both spreads. The gap between them is the measure of
how brittle a student is.</p>

  <div class="pullquote">
  <p>
    A person standing in the way is drawn on the screen. The screen-only student pays
    nothing measurable for the wider spread; the feature-vector student it replaced
    halved.
  </p>
</div>

</section>

<section class="prose">

  <h2>Then the naming rule</h2>

  <p>On 21 August, in one evening, this project reported a rate to the captain as belonging
to the committed network when it did not — twice. Once because a results page never
named which weights produced its headline. Once because a sampling result measured on
one student was carried onto a different one.</p>

  <p>Both reports were plausible, both were wrong, and nothing in the repository could have
caught either.</p>

  <p>The fix is a check that fails a build the same way it failed the captain. A page opts in
with a marker in its first forty lines. Every table on an opted-in page that contains a
percentage-looking cell must carry one of: a per-row attribution column, a weights
filename in its rows, or the literal word <code class="language-plaintext highlighter-rouge">unattributed</code>.</p>

  <p>The opt-in matters. Most tables in the repository are about the teacher — a scripted
program, never versioned by weights — or about cartridge mechanics. Neither has this
ambiguity.</p>

</section>

<section class="prose">

  <h2>And the checker passed for the wrong reason</h2>

  <p>An early version carried a sticky "have we seen a model name anywhere in this file yet"
flag. A single mention in a page's opening paragraph made every table below it
unfailable, no matter how far below and no matter what it was about.</p>

  <p>It was caught by deleting a real table's attribution column and watching the check still
pass. Each table is now judged on its own.</p>

  <div class="pullquote">
  <p>
    A rate with no model name attached is not a verified rate.
  </p>
  <cite>The rule this repository keeps</cite>
</div>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[Five identical training runs, a 23-point spread, and two published numbers that turned out not to be readable.]]></summary></entry><entry><title type="html">The door rule</title><link href="https://alchemy86.github.io/devlog/posts/the-door-rule.html" rel="alternate" type="text/html" title="The door rule" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-door-rule</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-door-rule.html"><![CDATA[<section class="prose">

  <h2>Outdoors is the cartridge's</h2>

  <p>The 36 connected maps go where the connection records put them. Exact arithmetic, no
search: a connection record names one block twice — once in the neighbour's block data,
once where the engine copies it — and one block in two frames is an exact offset.</p>

  <p>Every chain of connections that arrives at a map puts it in the same place. Zero
placement disagreements.</p>

</section>

<section class="prose">

  <h2>Indoors is a decision, but the door is not</h2>

  <p>An interior has no connections at all. The cartridge says which door leads to it and
nothing about where the room is. So the rule is one sentence: a building is drawn
adjacent to the map its door is in, on the side that door is on, in line with it.</p>

  <p>What follows from that took several rounds, and the way to compare them is the
room-to-door distance over the rooms every layout can measure:</p>

  <div class="table-wrap">
  <table>
    <thead>
      <tr><th>Layout revision</th><th class="num">Door distance mean / median</th><th class="num">Nearer another region's door</th><th class="num">Out of door order</th></tr>
    </thead>
    <tbody>
      <tr><td>Before the whole-map review</td><td class="num">715 / 456 px</td><td class="num">92 of 169</td><td class="num">16 of 71</td></tr>
      <tr><td>After it</td><td class="num">599 / 383 px</td><td class="num">85 of 184</td><td class="num">—</td></tr>
      <tr><td>After three reported faults</td><td class="num">503 / 350 px</td><td class="num">79 of 184</td><td class="num">—</td></tr>
      <tr><td>Doors' order as the primary key</td><td class="num"><strong>494 / 305 px</strong></td><td class="num"><strong>72 of 184</strong></td><td class="num"><strong>0 of 71</strong></td></tr>
    </tbody>
  </table>
</div>

</section>

<section class="prose">

  <h2>Ordering has to outrank nearness, and the weight was measured</h2>

  <p>Two buildings whose street doors are on the same side of the same town must be drawn in
the doors' own order along that side. Added as an ordinary term in the distance cost,
that loses every time: an inversion the eye reads at a glance is worth only a few pixels
of nearness, and a building will buy the pixels. Which is how Cinnabar's gym came to be
drawn below the two shops whose doors are eight tiles below its own.</p>

  <p>At weight 4 the cartridge still has five pairs the wrong way round. At 8 it has none.
From 8 to 16 the layout does not move at all.</p>

  <p>And "not the wrong way round" turned out to be a loophole the search will find. With
equality free, seven pairs came to rest exactly level — Saffron's gym and mart among
them, the very pair that was complained about. Making the ordering strict cost nothing:
same mean, same median, same sheet.</p>

</section>

<figure class="pixel narrow" style="margin-inline:auto;">
  <img src="../assets/img/pixelgb/pallet-town-2x.png" width="640" height="576" alt="Pallet Town rendered at 2x scale: a small Game Boy town with two houses, a laboratory, water to the south and a path north." />
  <figcaption>
    Pallet Town, the worked example. Red's house at tile (5,5) files left; Blue's at
    (13,5) and Oak's lab at (12,11) file right, with Blue's house above the lab because
    its door is six tiles above the lab's. The community's hand-authored atlas puts
    those four panels on those same two sides in that same order, which is independent
    confirmation of the rule and not its source.
  </figcaption>
</figure>

<section class="prose">

  <h2>One warp in the cartridge is not a door</h2>

  <p>Celadon City's ninth warp, tile (39,19), drops you into map 136 — the department store's
fifth floor. That floor's own three warps lead to the roof, the fourth floor and the
lift; none of them leads back to the street. Read as a street door, it filed one floor
of the store 254 tiles from the other six, on the opposite side of the city.</p>

  <p>So an outdoor warp counts as the way in only when the room it reaches has a warp back to
the map you came from. Map 136 is the only map in the cartridge that fails that test,
and with the rule in place the store lands as one column of seven.</p>

  <p>Victory Road looks like the same bug and is not. Route 23 has two street doors of its
own, into 1F and into 2F, and both rooms walk back out. The route is 20 tiles wide, so
those doors are genuinely on opposite sides of it and the two halves file either side.
The community's hand-authored atlas bundles them; the cartridge does not, and the
cartridge wins.</p>

</section>

<section class="prose">

  <h2>A building that will not fit comes apart at one of its own doors</h2>

  <p>The S.S. Anne is entered through Vermilion's dock rather than off the street, so the
dock, the ship and its eleven rooms are one building. A twelve-room block wants about a
million square pixels of one rectangle, and that does not exist within two screens of
Vermilion. Every arrangement of it as one block put it beyond Route 12, further from its
own town than anything else in the cartridge.</p>

  <p>It was not mis-assigned and the search was not failing. Measured against the finished
sheet, where it stood was the nearest place a single rectangle of that size would go.</p>

  <p>So a building may be cut at one of its rooms, and the rooms behind that door become a
wing, placed by the same rule with "the map" now meaning a room of its own building.</p>

  <p>What is not negotiable through any of that: a panel never covers walkable world or
another panel. The finished rectangles are checked and an intersection fails the render.
That assertion is worth more than the rule it guards, because it is what makes the next
change to the layout safe.</p>

</section>]]></content><author><name></name></author><category term="PixelGB" /><summary type="html"><![CDATA[The cartridge says where outdoor maps go and says nothing at all about interiors. Placing 190 rooms is a decision, and it was settled by measurement.]]></summary></entry><entry><title type="html">The wall you hit walking home</title><link href="https://alchemy86.github.io/devlog/posts/the-doubling-back-wall.html" rel="alternate" type="text/html" title="The wall you hit walking home" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-doubling-back-wall</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-doubling-back-wall.html"><![CDATA[<section class="prose">

  <h2>Two numbers that sum to a hundred</h2>

  <p>The route this agent is learning goes out and comes back. Pallet Town, up Route 1, into
Viridian City, into the Mart to collect Oak's parcel — and then the same ground again in
reverse, all the way home to Oak's lab. Viridian City and Route 1 are each crossed
twice, and from almost every tile in them the two crossings want <em>opposite</em> directions.</p>

  <p>A single student trained over both directions of Viridian City scores <strong>33.53%</strong> on one
task and <strong>66.77%</strong> on the other. Those two numbers are not a pair of disappointing
results. They sum to a hundred, and that is the signature of a partition: from any given
start the shared student walks one way or the other, and whichever it picks, exactly one
of the two tasks is satisfied. There is no third outcome and no middle ground.</p>

  <div class="callout">
  <p class="eyebrow">Why that number shape matters</p>
  <p>
    A policy scoring 40% and 45% is doing something imperfectly. A policy scoring
    33.53% and 66.77% is doing something <em>perfectly</em> — it is just answering a
    different question than the one being asked. When two rates in a supposedly
    independent pair add to one, stop tuning and go and find out what the model
    cannot see.
  </p>
</div>

</section>

<figure class="scrollfig" aria-labelledby="fig-route-cap">
  <svg viewBox="0 0 720 286" role="img" xmlns="http://www.w3.org/2000/svg" font-family="'JetBrains Mono', monospace">
    <style>
      .nd  { fill: var(--code-bg); stroke: var(--hairline); stroke-width: 1.5; }
      .nd.a{ fill: var(--card); stroke: var(--accent); stroke-width: 2; }
      .nt  { fill: var(--ink); font-size: 13px; font-weight: 700; }
      .ns  { fill: var(--ink-soft); font-size: 10px; }
      .ns.a{ fill: var(--accent); font-weight: 700; }
      .lnk { stroke: var(--hairline); stroke-width: 2; }
      .out { stroke: var(--accent); stroke-width: 2; fill: none; }
      .ret { stroke: var(--ink-soft); stroke-width: 2; fill: none; }
      .lo  { fill: var(--accent); font-size: 10.5px; font-weight: 700; letter-spacing: .1em; }
      .lr  { fill: var(--ink-soft); font-size: 10.5px; font-weight: 700; letter-spacing: .1em; }
      .amb { fill: var(--accent); font-size: 10.5px; font-weight: 700; }
    </style>
    <defs>
      <marker id="ao" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto">
        <path d="M0,0 L9,4.5 L0,9 z" fill="var(--accent)" />
      </marker>
      <marker id="ar" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto">
        <path d="M0,0 L9,4.5 L0,9 z" fill="var(--ink-soft)" />
      </marker>
    </defs>
    <line x1="157.0" y1="143.0" x2="189.7" y2="143.0" class="lnk" />
    <line x1="343.7" y1="143.0" x2="376.3" y2="143.0" class="lnk" />
    <line x1="530.3" y1="143.0" x2="563.0" y2="143.0" class="lnk" />
    <rect x="3.0" y="110" width="154" height="66" rx="8" class="nd" />
    <text x="80.0" y="138" text-anchor="middle" class="nt">Oak's Lab</text>
    <text x="80.0" y="156" text-anchor="middle" class="ns">Pallet Town</text>
    <rect x="189.7" y="110" width="154" height="66" rx="8" class="nd a" />
    <text x="266.7" y="138" text-anchor="middle" class="nt">Route 1</text>
    <text x="266.7" y="156" text-anchor="middle" class="ns a">crossed twice</text>
    <rect x="376.3" y="110" width="154" height="66" rx="8" class="nd a" />
    <text x="453.3" y="138" text-anchor="middle" class="nt">Viridian City</text>
    <text x="453.3" y="156" text-anchor="middle" class="ns a">crossed twice</text>
    <rect x="563.0" y="110" width="154" height="66" rx="8" class="nd" />
    <text x="640.0" y="138" text-anchor="middle" class="nt">Viridian Mart</text>
    <text x="640.0" y="156" text-anchor="middle" class="ns">the parcel</text>
    <text x="3" y="20" class="lo">OUTBOUND — GO AND FETCH OAK&#8217;S PARCEL</text>
    <path d="M80.0,102 L80.0,66 L640.0,66 L640.0,99" class="out" marker-end="url(#ao)" />
    <text x="3" y="280" class="lr">RETURN — CARRY IT BACK TO OAK, OVER THE SAME GROUND</text>
    <path d="M640.0,184 L640.0,220 L80.0,220 L80.0,187" class="ret" marker-end="url(#ar)" />
    <text x="360.0" y="96" text-anchor="middle" class="amb">same tiles, opposite answer</text>
  </svg>        <figcaption id="fig-route-cap">
    The two rooms in amber are walked twice, in opposite directions. A policy that
    sees only the overworld screen meets the identical picture on both passes and has
    no basis on which to answer differently — the thing that distinguishes them is a
    parcel in the bag, and the bag is not drawn on the overworld.
  </figcaption>
</figure>

<section class="prose">

  <h2>Why no amount of training fixes it</h2>

  <p>What tells the two crossings apart is <code class="language-plaintext highlighter-rouge">OAK's PARCEL</code> sitting in the inventory. The
inventory is not drawn on the overworld screen. This agent reads a 4 × 36 × 40 picture
and nothing else — no coordinates, no map register, no memory read of any kind — so the
input on the way out and the input on the way back are, for practical purposes, the same
input.</p>

  <p>A function cannot return two different values for the same argument. This is not a
capacity problem that a wider network or another thousand epochs reaches; it is a
statement about what is representable. The 33.53/66.77 split is that statement,
measured.</p>

</section>

<section class="prose">

  <h2>The first fix worked, and was abandoned anyway</h2>

  <p>The obvious answer is to go and look. Gen 1 <em>does</em> draw the bag's contents, and the two
states are visibly different — <code class="language-plaintext highlighter-rouge">▶OAK's PARCEL / CANCEL</code> with it, <code class="language-plaintext highlighter-rouge">▶CANCEL</code> alone
without. So the first design gave the agent a seventh button, taught it to open the bag,
read it, close it, and only then decide which way to walk.</p>

  <p>It was built, trained, and smoke-tested successfully. A real certification run put the
harder of the two rooms at 5/5 and then 299/300. It genuinely worked.</p>

</section>

<figure class="pixel" aria-labelledby="fig-bag-cap">
  <img src="../assets/img/agentgb/bag-parcel-structural.png" alt="Four panels. Top row: two real 160 by 144 Game Boy screens of the item bag, one reading CANCEL alone and one reading OAK's PARCEL above CANCEL. Middle row: the same two screens as the coarse 40 by 36 arrays the network actually receives. Bottom: a difference map of those two arrays, mostly black with a small red band where the extra menu row sits." />
  <figcaption id="fig-bag-cap">
    <strong>The abandoned approach, and why it was tempting.</strong> The two bag states
    really are distinguishable in what the network receives — 57 of 1,440 cells differ,
    4.0% of the observation. The middle row is the literal array handed to the policy,
    not a re-render of the screen above it.
  </figcaption>
</figure>

<section class="prose">

  <p>It was thrown away on the captain's correction, and the reason is the interesting part.
The game <em>already announces</em> that the parcel has been collected, in plain unmissable
text, at the exact frame the milestone fires:</p>

  <div class="pullquote">
  <p>“BLUE got OAK's PARCEL!”</p>
  <cite>The cartridge, saying the quiet part out loud</cite>
</div>

  <p>Building a seventh button, a bag-opening sequence, and an
on-the-way-in-versus-on-the-way-out disambiguation at an identical menu picture was
solving a problem the cartridge had already solved — one layer further from the screen
than necessary. The bag check was not wrong in the sense of not working. It was wrong in
the sense of reaching <em>past</em> an answer already sitting in plain text to go digging for a
second, harder copy of the same fact.</p>

</section>

<figure class="pixel narrow" aria-labelledby="fig-parcel-cap">
  <img src="../assets/img/agentgb/mgba-collect-oaks-parcel.png" alt="A Game Boy screenshot of the Viridian Mart interior with the player standing at the counter and a dialogue box drawing at the bottom of the screen, captioned as the collect-oaks-parcel milestone at decision 578 on the mGBA libretro core, seed 0." />
  <figcaption id="fig-parcel-cap">
    The frame the cartridge set the <code>collect-oaks-parcel</code> milestone byte, on
    a genuine cold power-on — captured on mGBA's libretro core rather than our own
    emulator, seed 0, decision 578. The dialogue box is mid-draw here; the announcement
    text lands in the frames immediately after.
  </figcaption>
</figure>

<section class="prose">

  <h2>The pattern that came out of it</h2>

  <p>Once you look for it, the shape is everywhere in this route: <strong>every goal in the chain
ends with the cartridge announcing, in unique on-screen text, that it is done — and that
announcement is the trigger for the next goal.</strong> A recogniser per announcement, a
trained behaviour per goal, chained in order.</p>

  <p>Nothing is deduced, nothing is inspected, nothing is hard-coded. The driver that
implements this knows nothing about parcels, Oak, or any specific room; declaring a new
stage is a longer list in a data file, never a change to the code.</p>

  <p>Two design details are worth pulling out, because they are the whole difference between
this and the interrupt mechanism the project already had:</p>

</section>

<div class="cards">
  <div class="card">
    <div class="card-hd">
      <p class="kicker">A latch, not a check</p>
      <h3>The trigger screen is gone within a handful of frames</h3>
    </div>
    <div class="card-bd">
      <p>
        A per-decision recogniser — the kind that hands control back the instant its
        screen disappears — would see the announcement once and forget it on the very
        next decision. That is useless for a fact which, once true, stays true for the
        rest of the episode.
      </p>
      <p>
        So the stage is <em>latched</em>: a single integer held across the episode,
        advanced once, never re-checked. Once a stage fires, every earlier recogniser
        is never consulted again, and the route itself has changed — this is not a
        momentary interruption of some other goal.
      </p>
    </div>
  </div>
  <div class="card">
    <div class="card-hd">
      <p class="kicker">A nudge, not a retrain</p>
      <h3>The base network is proven byte-identical before and after</h3>
    </div>
    <div class="card-bd">
      <p>
        The behaviour each stage latches is a small separate head nudging a
        <em>frozen</em> base policy, at its original six-action width — no seventh
        button, no padding. Five links share one pooled adapter rather than five
        separate ones, which keeps peak memory during training well under 5 GB against
        a 20 GB ceiling that had cancelled a full retrain.
      </p>
      <p>
        The base weights are hashed before training and after, and the hash is required
        to match. A fix that quietly moved the thing it was meant to leave alone would
        not be a fix.
      </p>
    </div>
  </div>
</div>

<section class="prose">

  <h2>The trigger did not need a crop, and that was checked first</h2>

  <p>Another recogniser in this project keys on a nickname prompt and needs a deliberate
crop, to hide a species name a wider view would let it cheat on. This one takes the full
observation, because the parcel announcement is not a screen that could plausibly be
confused with anything else in the game.</p>

  <p>That was verified before a recogniser was trained rather than assumed after. Playing the
scripted teacher through the collection and recording the on-screen text at every
decision found the exact string and the exact decision it appears on. Then the last
recorded frame of successful episodes — from a 1,500-episode corpus whose episode
lengths range from 13 to 34 decisions — was compared against a fresh live capture of
that screen: <strong>byte-for-byte identical, zero difference, on every one of five
spot-checked episodes of very different lengths</strong>.</p>

  <p>The reason it is identical is structural rather than lucky. The dialogue box is opaque,
and the success flag fires at a fixed point in the game's own script no matter which
button got the player there. That is what makes "the last frame of a successful episode"
a correct way to harvest training positives without any per-frame text matching at all.</p>

  <p>The shipped recogniser scores <strong>99.93%</strong> held-out accuracy and 99.96% balanced accuracy,
trained on 1,500 positives against 6,000 negatives sampled from six other links' own
corpora.</p>

</section>

<section class="prose">

  <h2>What it measured</h2>

  <p>Each of the five return-leg links certified on its own, with the adapter applied
unconditionally, sampled at temperature 1.0, N=300 — alongside two floors, because a
rate with nothing under it is not evidence:</p>

  <div class="table-wrap">
  <table>
    <thead>
      <tr>
        <th>Link</th>
        <th class="num">Success</th>
        <th class="num">Random floor</th>
        <th class="num">Press-A floor</th>
      </tr>
    </thead>
    <tbody>
      <tr><td class="mono">out-of-the-mart</td><td class="num">300 / 300 = 100.00%</td><td class="num">34.80%</td><td class="num">0.00%</td></tr>
      <tr><td class="mono"><strong>south-out-of-viridian</strong></td><td class="num"><strong>300 / 300 = 100.00%</strong></td><td class="num">3.40%</td><td class="num">0.00%</td></tr>
      <tr><td class="mono"><strong>back-down-route-1</strong></td><td class="num"><strong>300 / 300 = 100.00%</strong></td><td class="num">1.20%</td><td class="num">0.00%</td></tr>
      <tr><td class="mono">into-oaks-lab</td><td class="num">294 / 300 = 98.00%</td><td class="num">4.00%</td><td class="num">0.00%</td></tr>
      <tr><td class="mono">receive-the-pokedex</td><td class="num">221 / 300 = 73.67%</td><td class="num">6.40%</td><td class="num">0.00%</td></tr>
    </tbody>
  </table>
</div>

  <p>The two rows in bold are the doubling-back rooms — the entire reason the work exists.
Both certify at 100.00%, against random floors of 3.40% and 1.20%. The mechanism
resolves an ambiguity that a memoryless, unconditioned student <em>provably</em> could not.</p>

  <p>N=300, not this project's usual 3,000. Three hundred clean attempts bound a failure rate
near 1% by the rule of three.</p>

</section>

<section class="prose">

  <h2>And then the honest part</h2>

  <p>The first full sixteen-link cold-boot swarm, with everything wired in, reached the last
link before the parcel is delivered — and scored <strong>0 of 26</strong> on it.</p>

  <p>Not 60%. Zero. Fifteen of sixteen milestones at 100%, both hard rooms perfect, and the
last one a complete failure.</p>

  <p>It was worth having, because it was specific. The 73.67% above was the same weakness
measured in isolation, and the chain's own actual entry tile evidently sits on the
unlucky side of it consistently enough that no draw of 26 clears it. Isolated replay
showed the failure precisely: the adapter reproduces the teacher's first eight moves
exactly — <code class="language-plaintext highlighter-rouge">left</code>, then seven <code class="language-plaintext highlighter-rouge">up</code>s — and then, at the one decision the teacher turns
<code class="language-plaintext highlighter-rouge">right</code> toward Oak, presses <code class="language-plaintext highlighter-rouge">down</code> and oscillates <code class="language-plaintext highlighter-rouge">up</code>/<code class="language-plaintext highlighter-rouge">down</code> at that junction for the
rest of its budget.</p>

  <p>A second training draw with more epochs and a larger sampled corpus produced the
<em>identical</em> failure at the <em>identical</em> junction. Not seed noise — stable across
independent draws, which is its own finding: this project has now found three times that
"more epochs" is not an answer to a genuinely blind tile.</p>

</section>

<section class="prose">

  <h2>The mechanism suspected, half confirmed and half refuted</h2>

  <p>The plausible story was that the frozen base holds a confident, actively <em>wrong</em> prior
at that tile — it shares its room with several links the base <em>was</em> trained on — so the
adapter has to overturn a strong opinion rather than fill an empty one.</p>

  <p>Measured directly at the stuck tile, that half is true: the base reads <strong>96.3–97.7%
<code class="language-plaintext highlighter-rouge">down</code></strong> — the wrong answer — under every trained goal id.</p>

  <p>The proposed <em>fix</em>, though, was refuted rather than left untested. Feeding the untrained
neutral conditioning row instead of the usual one gives 96.3% <code class="language-plaintext highlighter-rouge">down</code>, statistically
indistinguishable from what it replaced. The identity transform passes features through
unmodified, but the action head was never trained on unconditioned features in isolation
— so an "untrained" row is not the same thing as an "unbiased" one. That is a
distinction worth carrying out of here.</p>

  <p>One id <em>is</em> measurably different, reading 61.3% <code class="language-plaintext highlighter-rouge">right</code> / 38.7% <code class="language-plaintext highlighter-rouge">down</code> — not confidently
correct, but the only one whose prior does not actively fight the right answer.
Retraining against it is the next thing worth trying, and it was deliberately not tried
here: that id's semantics for this checkpoint are not established, and changing it risks
four links in the same stage that already work.</p>

</section>

<section class="prose">

  <h2>Where it ended up</h2>

  <p>The last link was closed in a later round — a fresh corpus for the conversation half of
it, a dedicated goal for the press that <em>starts</em> the conversation (the base gets that
right only 4.55% of the time unaided), a battle policy on the return crossing of Route 1
changed from flee to fight-or-flee after a real seed was traced into an infinite
battle-menu deadlock, and two purpose-built start states so the corpora were not all the
same starter.</p>

  <p>Cold boot, N=150, sampled, the whole sixteen-link chain: <strong>145/150 = 96.67%</strong>
[92.4–98.6%]. Per starter — the standing rule now — <strong>Bulbasaur 87/90, Charmander 13/13,
Squirtle 45/47</strong>, no starter anywhere near zero and all three intervals overlapping
heavily.</p>

  <p>One more result belongs here because it is the most useful kind. Running the same
command <em>without</em> the already-shipped interrupt adapters gives a much worse 13/26 — and
every one of those twelve extra failures is a known trap six links earlier, nothing to
do with the return leg at all. A chain-level number only means what it claims once every
already-fixed piece is actually wired in.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[An agent that learns the way to the shop perfectly cannot learn the way back, because the tiles are identical and the answer is opposite. Two numbers that sum to a hundred prove it is impossible, not merely hard — and the fix was to stop looking in the bag and read what the game already says out loud.]]></summary></entry><entry><title type="html">293 runs stopped on the same text box</title><link href="https://alchemy86.github.io/devlog/posts/the-label-bug-in-oaks-lab.html" rel="alternate" type="text/html" title="293 runs stopped on the same text box" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-label-bug-in-oaks-lab</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-label-bug-in-oaks-lab.html"><![CDATA[<section class="prose">

  <p>Oak's Lab, tile (5,3), the message <em>"Gramps! What about me?"</em> — the student pressing a
direction at an open text box for 400 decisions out of 400 without moving a single tile.</p>

  <p>That specificity is the useful part. A perception failure scatters. A student that fails
293 times in one room, on one tile, on one message, is not confused about what it is
seeing.</p>

</section>

<section class="prose">

  <h2>The bug was in the teacher</h2>

  <p>Every teacher in the repository answers an open text box with <code class="language-plaintext highlighter-rouge">B</code>. Except the navigation
teacher, which gated that rule on a mode flag — and that flag reads "overworld" while a
box is on the screen.</p>

  <p>For five links nothing noticed, because no navigation link had ever been in a room with
a conversation in it. The link that leaves the lab is the first. It put thousands of
frames of an open text box into the corpus <em>labelled with a direction</em>.</p>

  <p>A direction press re-triggers the box. The student learned that faithfully and did it
four hundred times.</p>

  <div class="pullquote">
  <p>
    The picture was fine. The student did exactly what its supervisor said. It was a
    label bug, not a perception bug.
  </p>
</div>

</section>

<section class="prose">

  <h2>Measuring the blast radius</h2>

  <p>The fix is one shared helper. The interesting question was how much else the bug had
poisoned: 12.84% label drift on the link that leaves the lab, and 1.43–3.63% on the four
other navigation links.</p>

  <p>Both the before and the after are published, because the after is not legible without
the before.</p>

</section>

<section class="prose">

  <h2>Which weights these numbers belong to</h2>

  <p>The 7-of-300 and 300-of-300 figures belong to an earlier, uncommitted arm of the student
which always took Charmander. They are a before-and-after on one training arm, which is
what they are, and not a score for the file that ships. The shipped student's own rates
are on the <a href="../projects/agentgb.html">project page</a>.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[A nine-link chain collapsed from 300 of 300 to 7. The student was doing exactly what its supervisor had told it to do.]]></summary></entry><entry><title type="html">The ledge in Viridian City</title><link href="https://alchemy86.github.io/devlog/posts/the-ledge-in-viridian-city.html" rel="alternate" type="text/html" title="The ledge in Viridian City" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-ledge-in-viridian-city</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-ledge-in-viridian-city.html"><![CDATA[<section class="prose">

  <p>A flood fill says (29,26) is fine. There is a 13-step path back to it crossing no warp
tile. It reports nothing.</p>

  <p>Two things had to be modelled exactly before any check could catch it.</p>

  <p><strong>The ledge.</strong> Stepping down off row 26 does not land on row 27. It jumps to row 28. And
the one-wayness is in the cartridge's own data — eight ledge entries, for facing down,
left and right, and none for up.</p>

  <p><strong>The walker.</strong> A greedy waypoint walker is not a path-finder. Below a one-way ledge it
slides along the wall, which is precisely what the 17 failing runs did.</p>

  <figure class="code">
  <pre><code>          0123456789012345678901234567890123456789
  y=25    ####....######........#.##..........####
  y=26    ####....######......................####   &lt;- the waypoint was (29,26)
  y=27    ###############.###.####################   &lt;- ledge; gaps only at x=15, 19
  y=28    ...#................................####   &lt;- where 17 runs slid, x=26..32
  y=29    ...#.................#..............####
  y=30    ...#................................####

(29,26) walkable=True   step down -&gt; (29,28)     a two-tile jump over row 27
(29,28) walkable=True   step up   -&gt; None        nothing comes back
LedgeTiles: 8 entries, facings down/left/right, none for up

GoTo simulated below the ledge, target (29,26):
  from (29,28) -&gt; stops at (32,28)
  from (30,28) -&gt; stops at (27,28)
  from (26,28) -&gt; stops at (31,28)</code></pre>
  <figcaption>
    Viridian City rows 25–30, read off the retail cartridge with no emulator run at
    all — and the failure reproduced from it in three lines. Where the walker stops
    is exactly the x=26…32 slide the 600-attempt corpus reported.
  </figcaption>
</figure>

</section>

<section class="prose">

  <h2>The repair is not a bigger budget</h2>

  <p>The row the mart door opens onto crosses the city cleanly, sits six rows above the
ledge, and the rest of the chain finishes even from below the ledge. So the leg turns
west first, and an overshoot costs nothing.</p>

  <p>This shape has now appeared three times, which is what makes it a rule rather than three
anecdotes:</p>

  <div class="pullquote">
  <p>
    A waypoint whose overshoot is unrecoverable is a waypoint pointing at a trap.
    Prefer the turning point with slack around it.
  </p>
</div>

</section>

<section class="prose">

  <h2>The tool that checks it over-reports, on purpose</h2>

  <p>It cannot know whether an overshoot is reachable — that needs the map's people, and a
person in the way is what pushed the walker over the ledge in the first place — and it
has no model of the driver's stuck-sidestep. So it is a design aid, not a gate, and it
says so.</p>

</section>]]></content><author><name></name></author><category term="AtlasGB" /><summary type="html"><![CDATA[Seventeen runs of six hundred died on the same row of the same town. A flood fill said the tile was reachable, and the flood fill was right.]]></summary></entry><entry><title type="html">The number that could have sunk it</title><link href="https://alchemy86.github.io/devlog/posts/the-number-that-could-have-sunk-it.html" rel="alternate" type="text/html" title="The number that could have sunk it" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-number-that-could-have-sunk-it</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-number-that-could-have-sunk-it.html"><![CDATA[<section class="prose">

  <h2>Two of the three already existed</h2>

  <p>PixelGB already renders every one of Pokémon Blue's 226 maps into one picture,
6,832 × 7,024, 48.0 megapixels, with the seams between towns and routes measured
rather than eyeballed. AgentGB already turns a swarm of parallel cold boots into real
Game Boy footage — the same runs this blog already writes about, not a re-enactment of
them. And the narrow phone cut this needed was already policy rather than a new problem:
Telegram's <code class="language-plaintext highlighter-rouge">sendVideo</code> refuses 60fps outright, at any file size, so a 720p/30fps pass
has been the standard delivery output for a while.</p>

  <p>Two-thirds of the job was already sitting on disk, done, and nobody had noticed.</p>

</section>

<section class="prose">

  <h2>The piece that didn't exist</h2>

  <p>Nothing in the house could zoom or pan across a still that large, or choreograph several
independently-timed clips appearing at chosen positions on cue. That's a camera and a
scheduler, not a renderer — and it's exactly what a tool like Remotion is for.</p>

  <p>Remotion's own documentation doesn't put a number on the one thing that actually
mattered here: whether a smooth pull-back over an image that size performs at all. Not
"renders eventually" — holds something close to real time, for a shot that needs nine
continuous seconds of it. That was the one genuine unknown, and it was the kind of thing
that could have ended the idea before it started.</p>

</section>

<section class="prose">

  <h2>Building our own instead of adopting theirs</h2>

  <p>The licence was never actually the obstacle. Remotion is free for one person working
alone, personal or commercial use either way, so the open-source-versus-paid-seat
question people expect never came up. What decided it was smaller than that: the piece
Remotion would actually contribute — a camera and clip choreography over footage we
already generate — is a well-understood technique, and building it cost about the same
as learning somebody else's framework well enough to trust it. Minus the ~300MB Chromium
download and a Node toolchain nothing else here uses.</p>

  <p>So: ShowReel. A Rust crate — eighteen modules, a little over 8,600 lines — that takes a
description of a film and renders it, deterministically, to an mp4. <code class="language-plaintext highlighter-rouge">tiny-skia</code> and
<code class="language-plaintext highlighter-rouge">rustybuzz</code> draw the pixels and the glyphs; <code class="language-plaintext highlighter-rouge">ffmpeg</code> encodes. No browser, no Node, no
headless Chromium. From the point the Remotion question was answered to a rendered film
was under two hours.</p>

  <p>The film itself is 393 lines written against nothing but that public API — a title
screen, a camera move, five bursts of footage, a pull-up naming one of them. Nothing in
the crate underneath it knows what a map or a Pokémon is; every map rectangle and
clip timestamp lives in that one file, as input.</p>

</section>

<figure aria-labelledby="fig-title-cap">
  <img src="../assets/img/showreel/title-card.png" alt="The opening title card of the rendered film: Pok&eacute;mon Blue's own title screen, framed and bordered on a dark background, with the caption AgentGB - Plays Pokemon! and the line 600 cold boots, one retail cartridge, no save states beneath it." />
  <figcaption id="fig-title-cap">
    The opening frame. The cartridge's own title screen, captured headlessly and framed by
    ShowReel — not a picture of one.
  </figcaption>
</figure>

<section class="prose">

  <h2>The number that mattered</h2>

  <p>The test that answered the risky question: a 120-frame pull-back over the actual 6,832
× 7,024 atlas, rendered down to 1920 × 1080.</p>

  <div class="table-wrap">
  <table>
    <thead>
      <tr>
        <th>Approach</th>
        <th class="num">ms / frame</th>
        <th class="num">120 frames</th>
      </tr>
    </thead>
    <tbody>
      <tr><td>Crop and resample from full resolution, one thread</td><td class="num">142.1</td><td class="num">17.05 s</td></tr>
      <tr><td>Mip pyramid, one thread</td><td class="num">42.2</td><td class="num">5.06 s</td></tr>
      <tr><td><strong>Mip pyramid, 20 threads</strong></td><td class="num"><strong>4.3</strong></td><td class="num"><strong>0.52 s</strong></td></tr>
    </tbody>
  </table>
</div>

  <p>33× faster than the obvious version, and around 8× faster than real time for
the actual shot.</p>

  <p>The reason is choosing an already-shrunk copy of the image before resampling, instead of
reading the full 48 megapixels on every single frame regardless of how much of it ends
up on screen. Cost becomes proportional to the output size rather than the source size —
close to constant, whatever the zoom. Building that pyramid once, at load, costs 523
milliseconds and holds 192MB resident afterward. When the camera pushes in rather than
pulls back — a Game Boy screen blown up to fill the frame — the same code switches to
nearest-neighbour resampling instead, so the pixel art stays sharp rather than turning
to mush.</p>

</section>

<figure aria-labelledby="fig-pullback-cap">
  <img src="../assets/img/showreel/pull-back-mid.png" alt="A frame partway through the pull-back: several Kanto towns and routes are visible at once, with a Kanto, entire lower-third caption and a counter reading 226 maps in this picture in the top right corner, over a still-zoomed-in section of the full atlas." />
  <figcaption id="fig-pullback-cap">
    Partway through the nine-second move — the camera is well off its 1:1 opening on
    Pallet Town, still short of the whole atlas.
  </figcaption>
</figure>

<section class="prose">

  <h2>Where it actually happened</h2>

  <p>The whole opening chain — leaving the bedroom, the rival battle, out of the lab, north
up Route 1 — happens within a few hundred pixels of Pallet Town. On a picture of the
whole region, that is a few dozen screen pixels. Every burst in the finished film
clusters there, because that is where the footage covers, not because the film is
choosing to.</p>

  <p>So each clip sits at the edge of the frame instead, and a line is drawn back to the map
coordinate it actually happened at.</p>

  <div class="pullquote">
  <p>&ldquo;The line is the claim, not the position.&rdquo;</p>
  <cite>What the footage placements are allowed to say</cite>
</div>

</section>

<figure aria-labelledby="fig-bursts-cap">
  <img src="../assets/img/showreel/bursts.png" alt="The whole Kanto map dimmed, with three video insets bordered in white showing real cold-boot footage, each connected by a thin line to a labelled point on the map: Pallet Town, Oak's Lab with a Charmander battle mid-turn, and a third clip near a route entrance. A counter in the top right reads 584 runs finished." />
  <figcaption id="fig-bursts-cap">
    Three clips live at once, each on its own line back to the map. The Oak's Lab inset is
    mid-battle: Charmander using Scratch, a frame out of a real cold boot on the retail
    cartridge.
  </figcaption>
</figure>

<section class="prose">

  <h2>What it doesn't do</h2>

  <p>There's no audio — every encode ShowReel produces drops the input track outright. Memory
is spent on footage, not on the picture: the finished film holds over a gigabyte of
decoded video clips in memory at once, about six times what the 48-megapixel atlas
itself costs once it's been reduced to its pyramid.</p>

  <p>There's no scrubbable preview player either, the thing Remotion's own docs lean on
hardest. In its place: a single frame in about a tenth of a second, a whole labelled
contact sheet of the film in three seconds, and a genuinely scaled-down preview pass in
under seven — all well short of the 25 seconds a full render takes. That loop is what
caught a caption running off the edge of the frame before a single second of video was
ever encoded.</p>

</section>]]></content><author><name></name></author><category term="ShowReel" /><summary type="html"><![CDATA[Could Remotion open on Pok&eacute;mon Blue's title screen, pull back to the whole world map, and burst out real battle footage from where it happened? Two of the three ingredients already existed. The one real unknown came back at 4.3 milliseconds a frame.]]></summary></entry><entry><title type="html">The one failure in twelve thousand</title><link href="https://alchemy86.github.io/devlog/posts/the-one-failure-in-twelve-thousand.html" rel="alternate" type="text/html" title="The one failure in twelve thousand" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-one-failure-in-twelve-thousand</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-one-failure-in-twelve-thousand.html"><![CDATA[<section class="prose">

  <p>It reproduces exactly. A certification's start states are a pure function of the link,
the held-out split, the seed and the spread, so no rollout has to be kept for an attempt
to be run again: attempt #705 of 3,000, held-out start (31,26), start offset 1,772
frames. Re-run, it fails the same way. The fifteen other attempts from that same tile
all reach the goal in 30 to 67 decisions.</p>

</section>

<figure class="pixel">
  <img src="../assets/img/agentgb/bagcheck-the-one-failure.png" width="760" height="528" alt="A Game Boy screen showing the player character standing in a street column, facing up, with another character directly above blocking the way and a shop entrance visible at the top of the screen." />
  <figcaption>
    The last frame of the run. The Mart is at the top of the screen and the student
    never reaches it: it walks up column 31, meets somebody standing on (31,20) — the
    corner tile, the one place its route has to turn — and presses up into that person.
    The shop is in view, the person is in the way, the budget is gone.
  </figcaption>
</figure>

<section class="prose">

  <h2>The anatomy</h2>

  <div class="table-wrap">
  <table>
    <tbody>
      <tr><td>Where it stood</td><td>(31,21) for 533 of 600 decisions</td></tr>
      <tr><td>What it pressed there</td><td><code>up</code> 177, <code>start</code> 89, and 267 more with a menu on screen</td></tr>
      <tr><td>What the teacher would have pressed</td><td><code>left</code> 177 — a clean 177-for-177 divergence</td></tr>
      <tr><td>Its own probabilities on those 177</td><td>p(<code>up</code>) median 0.926; p(<code>left</code>) median 0.020, max 0.107</td></tr>
      <tr><td>One press of <code>left</code>, at the moment it is stuck</td><td>reaches the goal in 79 decisions</td></tr>
    </tbody>
  </table>
</div>

</section>

<section class="prose">

  <h2>Three explanations, all closed by measurement</h2>

  <p><strong>Not the route.</strong> The teacher, driving from the same tile with the same offset, reaches
the Mart in 30 decisions. There is a way round and the privileged player finds it.</p>

  <p><strong>Not a wanderer who would eventually move.</strong> The person steps twice, at decisions 13
and 61, reaches (31,20), and then does not move for the remaining 533 decisions — about
17,000 frames, or 4.7 minutes of game time. Checked under four different player
behaviours from the stuck moment, including backing off a tile and standing still, and
pacing 266 tiles up and down. It stays parked in all four.</p>

  <p><strong>Not the bag check.</strong> The run spends 301 of its 600 decisions with a menu on screen,
but the menus are not what pins the person: an arm that presses only <code class="language-plaintext highlighter-rouge">b</code> runs the
overworld continuously for 533 decisions and the person does not move either.</p>

</section>

<section class="prose">

  <h2>Which weights these rows belong to</h2>

  <p>Every rate for this link predates a corpus re-collection on 23 Aug 2026. The round-0
corpus it was trained from carried 105 leaked frames; a fresh corpus collected under the
fix audits clean, and no student has been retrained or re-certified on it. The
certification file does not record which weights it ran, so these rows are marked
unattributed rather than filled in from a directory convention.</p>

  <p>This is a measurement of one reproducible attempt. It has not been re-measured since.</p>

</section>]]></content><author><name></name></author><category term="AgentGB" /><summary type="html"><![CDATA[Four certification cells of 3,000 attempts each produced exactly one failure. Somebody was standing on the corner tile.]]></summary></entry><entry><title type="html">The red we refused to fix</title><link href="https://alchemy86.github.io/devlog/posts/the-red-we-refused-to-fix.html" rel="alternate" type="text/html" title="The red we refused to fix" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-red-we-refused-to-fix</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-red-we-refused-to-fix.html"><![CDATA[<section class="prose">

  <h2>Why sweeping would have been wrong</h2>

  <p>Bully waits for <code class="language-plaintext highlighter-rouge">LY &gt;= $90</code> before reading the divider. So what it pins is not the
counter. It is the <em>phase between the system counter and the picture processor</em> at boot
hand-off — two unknowns multiplied together.</p>

  <p>And no published ROM measures where a Color boot ROM leaves the PPU for a Color
cartridge. Fitting one unknown to absorb another would have moved every Color
cartridge's divider, and with it its audio phase and its random number generator, on the
evidence of one row.</p>

  <div class="pullquote">
  <p>
    A test going green is not evidence when the change that made it green touched
    two things and the test only measures their product.
  </p>
</div>

</section>

<section class="prose">

  <h2>The counter did change, later, for a different reason</h2>

  <p>A second independent source arrived. Gambatte's <code class="language-plaintext highlighter-rouge">div/start_inc_{1,2}_cgb04c</code> take the
genuine Color path and pin both the divider's value <em>and</em> the exact machine cycle its
low byte wraps on, at a fixed distance with no <code class="language-plaintext highlighter-rouge">LY</code> wait.</p>

  <p>So they measure the counter alone — precisely the confound the original objection named.
The constant moved on their evidence.</p>

  <p>Bully's row is still red, byte-identical in both picture modes, because the PPU half is
untouched. Two crews' work settled half a question; the other half is still open, and
the red row is what says so.</p>

</section>

<section class="prose">

  <h2>The other red that stays</h2>

  <p>Blargg's <code class="language-plaintext highlighter-rouge">oam_bug</code> fails on a Color console here. All eight sub-tests pass on DMG, where
the object-memory corruption defect is modelled faithfully. Color silicon has no such
defect, so real hardware fails that ROM too. Passing it would be the bug.</p>

  <p>Both rows are kept on the board rather than excluded, because a suppressed red is
indistinguishable from a fixed one six months later.</p>

</section>]]></content><author><name></name></author><category term="TerminalGB" /><summary type="html"><![CDATA[One sweep would have turned a failing test green. Taking it would have moved every Color cartridge's divider on the evidence of a single row.]]></summary></entry><entry><title type="html">The transfer controller owns the bus</title><link href="https://alchemy86.github.io/devlog/posts/the-transfer-controller-owns-the-bus.html" rel="alternate" type="text/html" title="The transfer controller owns the bus" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/the-transfer-controller-owns-the-bus</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/the-transfer-controller-owns-the-bus.html"><![CDATA[<section class="prose">

  <p>The transfer controller has no address bus of its own. While a transfer runs it drives
the object-memory address lines — and the picture processor's object scan is on the
other end of them.</p>

  <p>So for the eighty dots of the scan the PPU cannot read a single entry. Its Y/X latch
keeps whatever it last held, and all forty object slots are judged against that one
stale pair.</p>

  <p>Modelling that took the Shootout from 238 to 239 of 264 and Gambatte from 3,613 to 3,617
scored rows, with zero losses and everything else byte-identical on both picture
engines. One row — but it was <code class="language-plaintext highlighter-rouge">ashiepaws/strikethrough.gb</code>, the first row that suite has
ever scored here.</p>

</section>

<section class="prose">

  <h2>The constant was derived, not chosen</h2>

  <p>The controller takes the bus a fixed number of dots after the write to <code class="language-plaintext highlighter-rouge">$FF46</code>, and that
number is the one free parameter in the whole model. It came out of eight ROMs.</p>

  <p>Gambatte's <code class="language-plaintext highlighter-rouge">late_sp{00,01,02,39}x_{1,2}</code> each put the write one machine cycle either
side of one scan slot. <code class="language-plaintext highlighter-rouge">sp00x_2</code> finds object 0 while <code class="language-plaintext highlighter-rouge">sp01x_1</code> misses object 1, which
brackets the arm. The <code class="language-plaintext highlighter-rouge">sp02x</code>/<code class="language-plaintext highlighter-rouge">sp39x</code> pair — same DMA phase, different slot — rules out
every uniform shift.</p>

  <p>Then it was swept across the whole 811-row <code class="language-plaintext highlighter-rouge">oamdma/</code> group:</p>

  <div class="table-wrap">
  <table>
    <thead>
      <tr><th>Arm delay (dots)</th><th class="num">0</th><th class="num">1</th><th class="num">2</th><th class="num">3</th><th class="num">4</th></tr>
    </thead>
    <tbody>
      <tr><td>Gambatte <span class="mono">oamdma/</span> rows passing, of 811</td><td class="num">742</td><td class="num"><strong>744</strong></td><td class="num"><strong>744</strong></td><td class="num">738</td><td class="num">738</td></tr>
    </tbody>
  </table>
</div>

  <p>The scan reads on even dots, so 1 and 2 are the same machine. A curve with a flat top
two dots wide, and a physical reason for the width — that is what a derived constant
looks like, as opposed to the one value that made a favourite test go green.</p>

</section>

<section class="prose">

  <h2>The data half came first, and was worth four hundred rows</h2>

  <p>Three days earlier the same physical rule was modelled from the other side: while the
transfer runs, the CPU and the controller share one bus, so the CPU's reads of most of
the map come back as whatever the transfer is putting on it.</p>

  <p>Gambatte went from 2,682 to 3,083 of the rows scored at the time. Four hundred rows,
from modelling a bus conflict instead of a copy.</p>

  <div class="pullquote">
  <p>
    A peripheral that moves bytes is doing two things at once, and only one of them
    is the copy.
  </p>
</div>

</section>

<section class="prose">

  <h2>And then a sibling project found the flag</h2>

  <p><a href="../projects/gbselftest.html">GBSelfTest</a>'s object-DMA check found a real bug in this
model on the day it was written: the flag the PPU keeps about a running transfer was
cleared on one of the two paths that can notice the transfer has ended, and not the
other. Once it latched, the object scan never read object memory again for the rest of
the session.</p>

  <p>Thousands of rows across five public suites were byte-identical either way. <a href="a-hundred-checks-in-one-session.html">Why they
could not see it →</a></p>

</section>]]></content><author><name></name></author><category term="TerminalGB" /><summary type="html"><![CDATA[Object DMA is usually modelled as a copy that blocks the CPU. It is also an address-bus conflict, and the sprite scan is on the other end of those lines.]]></summary></entry><entry><title type="html">Three ways this cartridge was wrong about itself</title><link href="https://alchemy86.github.io/devlog/posts/three-ways-this-cartridge-was-wrong.html" rel="alternate" type="text/html" title="Three ways this cartridge was wrong about itself" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/three-ways-this-cartridge-was-wrong</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/three-ways-this-cartridge-was-wrong.html"><![CDATA[<section class="prose">

  <h2>The run decided the verdict, not the machine</h2>

  <p><code class="language-plaintext highlighter-rouge">GB-CYC-07</code> shipped once in a form that zeroed the timer counter and then restarted the
divider. Three cycles separate those two writes, and whether the tapped bit falls inside
them depends on the counter phase the check was entered in — which is set by how much
code ran above it.</p>

  <p>So the verdict was decided by which checks failed earlier. It passed on one emulator and
failed on SameBoy, and swapping the earlier failures swapped the answer.</p>

  <p>Restart the counter first and zero the timer after it, and sweep more than one delay
rather than sampling once. Anything that reads a timer edge and does not do both is
measuring the rest of the run.</p>

</section>

<section class="prose">

  <h2>A subroutine that quietly ate the measurement</h2>

  <p><code class="language-plaintext highlighter-rouge">FlipSpeed</code> runs the documented STOP-based speed-switch sequence and, like every
subroutine in that file, makes no promise about which registers survive it. It loads <code class="language-plaintext highlighter-rouge">A</code>
repeatedly on the way through.</p>

  <p>A check that measured something into <code class="language-plaintext highlighter-rouge">A</code>, called <code class="language-plaintext highlighter-rouge">FlipSpeed</code> to return to the starting
speed, then compared against <code class="language-plaintext highlighter-rouge">A</code>, was comparing <code class="language-plaintext highlighter-rouge">FlipSpeed</code>'s own leftover value.</p>

  <p>It does so silently, producing a plausible and constant wrong number rather than a
crash. It shipped in <code class="language-plaintext highlighter-rouge">GB-PPU-07</code>'s double-speed half and was caught only because the
wrong number (<code class="language-plaintext highlighter-rouge">$1</code>) disagreed with an independent measurement on two unrelated emulators
that had already established the right one (<code class="language-plaintext highlighter-rouge">$2</code>).</p>

</section>

<section class="prose">

  <h2>Two checks that skipped on every console, forever</h2>

  <p>This is the best of the three, because nothing about it looks wrong.</p>

  <p>The build did not pass <code class="language-plaintext highlighter-rouge">rgbfix -c</code>, so the ROM's header byte at <code class="language-plaintext highlighter-rouge">$0143</code> was <code class="language-plaintext highlighter-rouge">$00</code> — and
a real Color console boots a cartridge with no Color flag into DMG compatibility mode,
where <code class="language-plaintext highlighter-rouge">KEY1</code> and double speed do not exist at all.</p>

  <p>Meanwhile the cartridge's own console detection still correctly reported CGB, because it
reads the boot handover registers, which do not care about the cartridge's header.</p>

  <p>So a double-speed check written the ordinary way built, linked, and skipped on every
run, on every console, forever. Nothing looked wrong until somebody specifically asked
whether it had ever run. Confirmed by building both ways and watching <code class="language-plaintext highlighter-rouge">GB-CYC-09</code> and
<code class="language-plaintext highlighter-rouge">GB-PPU-07</code> go from permanently-skip to actually-run, with no change to any other
check's verdict on any console.</p>

  <div class="callout">
  <p class="eyebrow">Which is why a skip is counted separately</p>
  <p>
    A skip is not a pass, and it is not a failure either. Each one prints its own
    reason in the run, so a skipped row is never silent — and two of the 102 do not
    judge at all: they report, printing the number they saw and leaving the verdict
    to a reader who has real hardware, because no published reference settles what
    the right answer is.
  </p>
</div>

</section>]]></content><author><name></name></author><category term="GBSelfTest" /><summary type="html"><![CDATA[A verdict decided by which checks failed above it, a subroutine that ate a measurement, and two checks that skipped on every console, forever.]]></summary></entry><entry><title type="html">The double-speed bug that cost 2,304 pixels</title><link href="https://alchemy86.github.io/devlog/posts/terminalgb-double-speed-accuracy.html" rel="alternate" type="text/html" title="The double-speed bug that cost 2,304 pixels" /><published>2026-08-26T00:00:00+00:00</published><updated>2026-08-26T00:00:00+00:00</updated><id>https://alchemy86.github.io/devlog/posts/terminalgb-double-speed-accuracy</id><content type="html" xml:base="https://alchemy86.github.io/devlog/posts/terminalgb-double-speed-accuracy.html"><![CDATA[<section class="prose">

  <h2>The symptom</h2>

  <p>A Game Boy Color game that flips <code class="language-plaintext highlighter-rouge">LCDC</code> bits partway through a scanline — the register
that controls which tile sources the picture processor is reading from — came out with a
visibly wrong stripe pattern, but <em>only</em> when the console was running in double-speed
mode. The same game in normal speed was pixel-exact.</p>

  <p>That "only in double speed" is the whole tell. When one variant of a scene is perfect
and its faster-clocked twin is wrong, the bug is not in the drawing logic — the drawing
logic is provably correct at single speed. The bug is in how time is being counted.</p>

</section>

<section class="prose">

  <h2>Two clocks, one of which doubles</h2>

  <p>The Game Boy Color can switch its CPU into a double-speed mode. The important,
easily-forgotten detail is that this does <em>not</em> speed up the screen. The picture
processor's dot clock is fixed; only the CPU's own clock doubles. So the unit that a lot
of internal timing is measured in — the CPU machine cycle, or "M-cycle" — changes width
depending on the mode:</p>

</section>

<figure aria-labelledby="fig1-cap">
  <svg viewBox="0 0 720 300" role="img" xmlns="http://www.w3.org/2000/svg" font-family="'JetBrains Mono', monospace">
    <style>
      .cell   { fill: var(--code-bg); stroke: var(--hairline); stroke-width: 1.5; }
      .stag   { fill: var(--accent);  opacity: .18; }
      .stag-b { fill: none; stroke: var(--accent); stroke-width: 1.5; }
      .lbl    { fill: var(--ink-soft); font-size: 12px; letter-spacing: .04em; }
      .ttl    { fill: var(--ink); font-size: 12px; font-weight: 700; letter-spacing: .06em; }
      .ok     { fill: var(--ink); font-size: 12px; }
      .bad    { fill: var(--accent); font-size: 12px; font-weight: 700; }
      .dot    { fill: var(--ink-soft); font-size: 11px; }
    </style>

    <!-- SINGLE SPEED -->
    <text x="0" y="30" class="ttl">SINGLE SPEED — 4 DOTS PER M-CYCLE</text>
    <!-- 4 cells, 120px each -->
    <rect x="0" y="46" width="120" height="56" class="cell" />
    <rect x="120" y="46" width="120" height="56" class="cell" />
    <rect x="240" y="46" width="120" height="56" class="cell" />
    <rect x="360" y="46" width="120" height="56" class="cell" />
    <text x="60" y="80" text-anchor="middle" class="dot">dot 1</text>
    <text x="180" y="80" text-anchor="middle" class="dot">dot 2</text>
    <text x="300" y="80" text-anchor="middle" class="dot">dot 3</text>
    <text x="420" y="80" text-anchor="middle" class="dot">dot 4</text>
    <!-- 1-dot stagger occupies dot 1 -->
    <rect x="0" y="46" width="120" height="56" class="stag" />
    <rect x="0" y="46" width="120" height="56" class="stag-b" />
    <text x="60" y="122" text-anchor="middle" class="lbl">stagger</text>
    <text x="300" y="122" text-anchor="middle" class="ok">value applied ✓ (3 dots spare)</text>

    <!-- DOUBLE SPEED -->
    <text x="0" y="196" class="ttl">DOUBLE SPEED — 2 DOTS PER M-CYCLE</text>
    <rect x="0" y="212" width="120" height="56" class="cell" />
    <rect x="120" y="212" width="120" height="56" class="cell" />
    <text x="60" y="246" text-anchor="middle" class="dot">dot 1</text>
    <text x="180" y="246" text-anchor="middle" class="dot">dot 2</text>
    <!-- 1-dot stagger consumes the whole window (both dots) -->
    <rect x="0" y="212" width="240" height="56" class="stag" />
    <rect x="0" y="212" width="240" height="56" class="stag-b" />
    <text x="120" y="288" text-anchor="middle" class="lbl">stagger consumes the entire M-cycle</text>
    <!-- commit falls off the edge -->
    <text x="360" y="246" class="bad">✗ value never applied</text>
    <line x1="240" y1="240" x2="345" y2="240" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="4 4" />
  </svg>
  <figcaption id="fig1-cap">
    A one-dot register-commit stagger fits comfortably inside a four-dot M-cycle.
    Halve the M-cycle and the same one dot consumes the whole thing — leaving no
    room for the deferred value to land.
  </figcaption>
</figure>

<section class="prose">

  <h2>The one-dot stagger</h2>

  <p>Real Color hardware doesn't make an <code class="language-plaintext highlighter-rouge">LCDC</code> write visible at the exact instant the CPU
performs it — a couple of the bits become visible to the picture processor one dot later
than the rest. TerminalGB modelled that faithfully with a one-dot "early" stagger on the
memory unit's <code class="language-plaintext highlighter-rouge">LCDC</code> write-commit: hold the change back by a dot, then apply it.</p>

  <p>The trouble is where that one dot came from. The stagger was calibrated exclusively
against a suite of single-speed reference ROMs, where an M-cycle is four dots wide. A
one-dot delay inside a four-dot budget is comfortable. But in double speed the M-cycle
is only two dots wide, and the deferred value's slot fell off the end of the cycle. The
commit was <em>silently never applied</em> — no error, no assertion, just a value that quietly
evaporated and a stripe drawn from the wrong tile source for one dot too long.</p>

  <div class="callout">
  <p class="eyebrow">The general trap</p>
  <p>
    A timing offset measured in absolute dots is only correct for the clock it was
    measured on. The moment the surrounding cycle changes width, an offset that
    "fit comfortably" can consume the entire window — and because nothing overflows,
    nothing complains.
  </p>
</div>

</section>

<section class="prose">

  <h2>Finding it</h2>

  <p>It surfaced as a hard reference failure, not a bug report. The accuracy harness runs the
AGE test-ROM <code class="language-plaintext highlighter-rouge">m3-bg-lcdc-ds@cgbBCE</code> — a Color, double-speed ROM built specifically to
stress mid-scanline <code class="language-plaintext highlighter-rouge">LCDC</code> writes — and compares the rendered frame against a
hardware-captured reference image. It read <strong>2,304 of 23,040 pixels wrong</strong>. Its
single-speed sibling, <code class="language-plaintext highlighter-rouge">m3-bg-lcdc@cgbBCE</code>, was already pixel-exact. One passing, one
failing, identical but for the speed switch: that pair is what pointed straight at the
clock, not the renderer.</p>

</section>

<section class="prose">

  <h2>The fix</h2>

  <p>The commit routine now knows what speed it is running at. Where it used to apply a fixed
one-dot stagger, it takes the running <code class="language-plaintext highlighter-rouge">GbSpeed</code> and drops the stagger to zero when there
is no room for it:</p>

  <pre><code>// ppu_write_commit — CGB LCDC arm
let early = match speed {
    GbSpeed::Single =&gt; 1,   // 4-dot M-cycle: a spare dot exists, model the hardware delay
    GbSpeed::Double =&gt; 0,   // 2-dot M-cycle: no spare dot — apply immediately instead
};</code></pre>

  <p>Removing the stagger outright in double speed beats leaving it in to be silently
truncated. The single-speed path is untouched, so nothing that already passed regresses.</p>

</section>

<section class="prose">

  <h2>What actually happened to the numbers</h2>

  <p><code class="language-plaintext highlighter-rouge">m3-bg-lcdc-ds@cgbBCE</code> went from a 2,304-pixel failure to a pixel-exact pass, moving the
AGE suite from 10 to 11 of 59, and the Gambatte double-speed rows improved on the
accurate engine. Five more rows fell sharply without reaching zero:</p>

  <div class="table-wrap">
  <table>
    <thead>
      <tr>
        <th>Reference row (double speed)</th>
        <th class="num">Before</th>
        <th class="num">After</th>
      </tr>
    </thead>
    <tbody>
      <tr><td class="mono">m3-bg-lcdc-ds@cgbBCE</td><td class="num">2,304</td><td class="num">PASS</td></tr>
      <tr><td class="mono">bgtiledata_spx08_ds_3</td><td class="num">1,032</td><td class="num">8</td></tr>
      <tr><td class="mono">bgtiledata_spx08_ds_4</td><td class="num">2,064</td><td class="num">16</td></tr>
      <tr><td class="mono">bgtiledata_spx09_ds_1</td><td class="num">1,144</td><td class="num">8</td></tr>
      <tr><td class="mono">bgtiledata_spx09_ds_3</td><td class="num">1,144</td><td class="num">8</td></tr>
      <tr><td class="mono">bgtiledata_spx09_ds_4</td><td class="num">2,048</td><td class="num">16</td></tr>
    </tbody>
  </table>
</div>

  <p>Those remainders are a different, smaller timing discrepancy that the stagger bug was
masking. Eight wrong pixels are reported as eight, so the next person to open that
scoreboard sees exactly how much is left to chase.</p>

  <p>The regression is locked in with a test that has teeth: reverting the stagger to a fixed
<code class="language-plaintext highlighter-rouge">1</code> unconditionally reproduces the original 2,304-pixel failure, so the mistake cannot
quietly come back.</p>

</section>

<section class="prose">

  <h2>A note on counting pixels</h2>

  <p>Not every "wrong pixels" number on the scoreboard is a raw count of differing pixels.
The Mealybug Tearoom comparisons use a shade-rank distance, which is deliberately
unforgiving: a frame that introduces one extra distinct shade can report on the order of
23,040 "wrong" no matter how much of the rest is perfect, and a four-pixel error can
move the total by tens of thousands. Four genuinely wrong pixels there scores 22,070.</p>

  <p>So these figures are never converted to a percentage. The 2,304 and the residual counts
above <em>are</em> direct pixel counts from the AGE and Gambatte comparisons; a shade-rank
distance is treated as an ordering signal, not a pixel tally.</p>

</section>

<section class="prose">

  <h2>Why this needed the accurate engine at all</h2>

  <p>A one-dot, mid-scanline error is invisible to a fast emulator by construction — which is
exactly why TerminalGB carries two picture engines rather than one.</p>

  <p>The <strong><code class="language-plaintext highlighter-rouge">standard</code></strong> engine draws a whole scanline in a single step at the end of mode 3,
from whatever the registers hold at that instant. It is fast and it is correct for the
overwhelming majority of software, but it physically cannot represent "the wrong tile
source for one dot in the middle of a line" — it never looks at the registers mid-line.
The <strong><code class="language-plaintext highlighter-rouge">identical</code></strong> engine walks the real hardware pixel pipeline dot by dot: the
background fetcher, the eight-pixel FIFO, sprite fetches, the window restart, the <code class="language-plaintext highlighter-rouge">SCX &amp;
7</code> discard. A mid-scanline write lands exactly where a Game Boy puts it — which means a
one-dot timing bug lands exactly there too, and gets caught.</p>

  <p>The two are not a quality or an appearance setting. On ordinary games they produce
byte-identical framebuffers — verified across 600 frames of dmg-acid2 plus Blargg's
<code class="language-plaintext highlighter-rouge">cpu_instrs</code>. The only axis between them is exactness against speed:</p>

  <div class="table-wrap">
  <table>
    <thead>
      <tr>
        <th>Test suite</th>
        <th class="num"><code>standard</code></th>
        <th class="num"><code>identical</code></th>
      </tr>
    </thead>
    <tbody>
      <tr><td>Mooneye GB acceptance</td><td class="num">67 / 75</td><td class="num">75 / 75</td></tr>
      <tr><td>Mealybug Tearoom (pixel-exact rows)</td><td class="num">3 / 79</td><td class="num">29 / 79</td></tr>
      <tr><td>Gambatte (5,225 scored rows)</td><td class="num">3,176</td><td class="num">3,622</td></tr>
      <tr><td>GBMicrotest</td><td class="num">252 / 513</td><td class="num">339 / 513</td></tr>
      <tr><td>GB Emulator Shootout (264 rows)</td><td class="num">223</td><td class="num">243</td></tr>
      <tr><td>Frame cost, Pokémon Blue (one P-core)</td><td class="num">0.094 ms</td><td class="num">0.373 ms</td></tr>
    </tbody>
  </table>
</div>

  <p>The accurate engine costs roughly four times as much per frame — 0.373 ms against 0.094
ms, still only a couple of percent of a frame's budget on a desktop core. That is cheap
enough to be the default, and it is: <code class="language-plaintext highlighter-rouge">identical</code> has been the shipping default since it
stopped losing accuracy rows the fast engine kept. But four times the cost is not free
everywhere. On the PSP frontend, where the CPU budget is tight, the emulator
deliberately pins <code class="language-plaintext highlighter-rouge">standard</code> — an unplayable-but-exact picture helps no one.</p>

  <p>That is the whole reason the split exists. The fast engine is what makes the emulator
usable on modest hardware; the accurate engine is what makes a bug like this one
<em>findable</em>. Ship only the fast one and the double-speed stripe would still be on screen,
unmeasured and unfixed, because nothing in the pipeline would ever have looked closely
enough to see it.</p>

</section>]]></content><author><name></name></author><category term="TerminalGB" /><summary type="html"><![CDATA[A one-dot register-commit stagger, calibrated on single-speed test ROMs, silently vanished in double speed — drawing a wrong stripe across the screen. Fixing it took an AGE reference ROM from 2,304 wrong pixels to a clean pass, and explains why TerminalGB ships two picture engines.]]></summary></entry></feed>