<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>spf13</title><link>https://spf13.com/tags/emulation/</link><description>Recent content on spf13</description><generator>Hugo</generator><language>en-us</language><copyright>Copyright (c) 2008 - 2026, Steve Francia; all rights reserved.</copyright><atom:link href="https://spf13.com/tags/emulation/index.xml" rel="self" type="application/rss+xml"/><lastBuildDate>Tue, 27 Oct 2026 10:00:00 -0400</lastBuildDate><item><title>Game Boy Color on My Conference Badge</title><link>https://spf13.com/p/game-boy-color-on-my-conference-badge/</link><pubDate>Tue, 27 Oct 2026 10:00:00 -0400</pubDate><guid>https://spf13.com/p/game-boy-color-on-my-conference-badge/</guid><media:content url="https://spf13.com/p/game-boy-color-on-my-conference-badge/gbc-verification-sheet.png" medium="image"/><media:thumbnail url="https://spf13.com/p/game-boy-color-on-my-conference-badge/gbc-verification-sheet.png"/><description>A Game Boy Color emulator in MicroPython Viper on the GitHub Universe badge.</description><content:encoded>&lt;![CDATA[<p><img src="https://spf13.com/p/game-boy-color-on-my-conference-badge/gbc-verification-sheet.png" alt="Game Boy Color on My Conference Badge"/><p>After finishing the<a href="/p/zelda-on-my-conference-badge/">NES emulator</a>, every NES game I tried played fine. I even beat the first few dungeons in Zelda on the badge. This all got me thinking that while I wanted to play Zelda on this, its form factor really looked a lot like a Game Boy. A lot more like a Game Boy than an NES. And there&rsquo;s Zelda and Pokémon games on the Game Boy. I wonder how hard this will be as I don&rsquo;t know really anything about Game Boy hardware. Turns out it was quite hard.</p><p><img src="gbc-verification-sheet.png" alt="Game Boy Color Verification Sheet on the GitHub Universe 2026 Badge"/><p>So I teamed up with Gemini again to see if the same<a href="https://badger.github.io/about-badge/">Badger</a> board and<a href="https://pip.raspberrypi.com/documents/RP-008373-DS-2-rp2350-datasheet.pdf">RP2350</a> could run Game Boy and Game Boy Color. We had the same MicroPython Viper emitter and the same 8 MB of QSPI PSRAM, just a very different puzzle on the CPU and cartridge side.</p><p>You don&rsquo;t need an emulator background for this; I&rsquo;ll explain each term as it comes up.</p><p><img src="gbc-showcase-badge.gif" alt="Game Boy Color Emulator running on the GitHub Universe 2026 Badge"/><h2 id="i-thought-the-sm83-would-be-simpler-it-was-not">I thought the SM83 would be simpler. It was not.</h2><p>On the NES, the CPU is a Ricoh 2A03, a trimmed MOS 6502 with three 8-bit registers (<code>A</code>,<code>X</code>,<code>Y</code>), one stack pointer, and 56 instructions. The whole core fit in local variables inside one<code>@micropython.viper</code> loop over a fixed, unbanked memory map.</p><p>The Game Boy has the Sharp SM83 (<code>LR35902</code>), an 8080/Z80 hybrid with eight 8-bit registers (<code>A, F, B, C, D, E, H, L</code>, paired as<code>AF, BC, DE, HL</code>), a stack pointer, a program counter, and 512 instructions: 256 base opcodes plus 256 behind the<code>0xCB</code> prefix, nine times the 6502&rsquo;s count.</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span> MOS 6502 (NES) Sharp SM83 (Game Boy Color)</span></span><span style="display:flex;"><span>┌───────────────────────────┐ ┌───────────────────────────────────┐</span></span><span style="display:flex;"><span>│ Registers: A, X, Y │ │ Registers: A, F, B, C, D, E, H, L │</span></span><span style="display:flex;"><span>│ Opcodes: 56 base │ vs │ Opcodes: 512 (256 base + 256 CB)│</span></span><span style="display:flex;"><span>│ Banking: Cartridge discrete │ Banking: ROM, VRAM, WRAM, SRAM │</span></span><span style="display:flex;"><span>│ Speed: 1.79 MHz flat │ │ Speed: 4.19 MHz / 8.38 MHz GBC│</span></span><span style="display:flex;"><span>└───────────────────────────┘ └───────────────────────────────────┘</span></span></code></pre></div><p>While the CPU is more complicated, the display is simpler:</p><table><thead><tr><th>Metric</th><th>GBC (<code>apps/gbc/emu.py</code>)</th><th>NES (<code>apps/nes/emu.py</code>)</th></tr></thead><tbody><tr><td>Frame pixels</td><td>23,040</td><td>61,440</td></tr><tr><td>Scanlines / frame</td><td>154</td><td>262</td></tr><tr><td>CPU cycles / frame</td><td>17,556</td><td>29,606</td></tr><tr><td>Max sprites</td><td>40</td><td>64</td></tr><tr><td>CHR bank-switch copy (bytes)</td><td>0</td><td>8,192</td></tr></tbody></table><p>2.67× fewer pixels, 1.7× fewer scanlines.</p><p>To get this to work within the constraints of MicroPython I had to implement five additional Viper tricks the original NES build didn&rsquo;t have.</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ViperTypeError: viper:8004</span></span></code></pre></div><p>On the NES that approach worked. The 6502 has three registers —<code>A</code>,<code>X</code>,<code>Y</code> — so<code>PC</code>,<code>SP</code>,<code>P</code>, and a few pointers still fit in Viper&rsquo;s four fast locals (<code>r0</code>–<code>r3</code>). Its 56 opcodes started as a 55-deep<code>if/elif</code> chain that collapsed into a five-level binary search to halve dispatch cost. One Viper loop, fixed memory map, compiled clean.</p><p>The SM83 doesn&rsquo;t fit. Eight registers (<code>A</code>,<code>F</code>,<code>B</code>,<code>C</code>,<code>D</code>,<code>E</code>,<code>H</code>,<code>L</code>) plus<code>PC</code>,<code>SP</code>, and cycle counters overflow those four locals — the Cortex-M33&rsquo;s other registers are reserved for stack, exception frame, and runtime. That&rsquo;s<code>viper:8004</code>: the register file won&rsquo;t fit in locals.</p><p>The decoder doesn&rsquo;t scale either. A tree that worked well for 56 opcodes needs ~500 branches for 512 (256 base + 256<code>CB</code>-prefixed). What made the NES fast won&rsquo;t even build for the Game Boy.</p><p>That leaves two problems: where to keep eight registers if not in locals, and how to decode 512 instructions without 512 branches?</p><h2 id="eight-registers-wont-fit-in-four-locals">Eight registers won&rsquo;t fit in four locals</h2><p>Reading each opcode as three small numbers instead of one collapses 512 instructions into a handful of blocks. For it to index registers directly, the 8-bit register file had to move out of Viper locals and into flat memory: eight bytes at<code>0x14500</code> inside one<code>bytearray</code> (<code>self.mem = bytearray(0x14A00)</code>):</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># CPU 8-bit Register File in flat arena (0x14500..0x14507)</span></span></span><span style="display:flex;"><span><span style="color:#75715e"># Index: 0=B, 1=C, 2=D, 3=E, 4=H, 5=L, 6=(HL) indirect pointer, 7=A</span></span></span></code></pre></div><p>Index<code>6</code> is the exception: on the SM83,<code>6</code> in the bitfield means indirect memory through<code>(HL)</code> instead of a register, so the real registers sit at<code>0, 1, 2, 3, 4, 5, 7</code>.</p><p>The Z80/SM83 designers worked on paper in<em>octal</em>. Read any opcode byte in base 8 and it splits into three octal digits (<code>x</code>,<code>y</code>,<code>z</code>), which are the instruction set&rsquo;s bitfields:</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>x: int<span style="color:#f92672">=</span> op<span style="color:#f92672">>></span><span style="color:#ae81ff">6</span><span style="color:#75715e"># Bits 7-6: Opcode category (0..3)</span></span></span><span style="display:flex;"><span>y: int<span style="color:#f92672">=</span> (op<span style="color:#f92672">>></span><span style="color:#ae81ff">3</span>)<span style="color:#f92672">&amp;</span><span style="color:#ae81ff">7</span><span style="color:#75715e"># Bits 5-3: Destination register or ALU operation (0..7)</span></span></span><span style="display:flex;"><span>z: int<span style="color:#f92672">=</span> op<span style="color:#f92672">&amp;</span><span style="color:#ae81ff">7</span><span style="color:#75715e"># Bits 2-0: Source register or condition code (0..7)</span></span></span></code></pre></div><p>All 64<code>LD r, r'</code> instructions (<code>0x40..0x7F</code>) share<code>x == 1</code>, so they collapse into five lines of Viper:</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> x<span style="color:#f92672">==</span><span style="color:#ae81ff">1</span>:</span></span><span style="display:flex;"><span><span style="color:#75715e"># 0x40..0x7F: LD r[y], r[z] or HALT (0x76)</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> op<span style="color:#f92672">==</span><span style="color:#ae81ff">0x76</span>:</span></span><span style="display:flex;"><span> halted<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>:</span></span><span style="display:flex;"><span> vr1: int<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> z<span style="color:#f92672">==</span><span style="color:#ae81ff">6</span>:</span></span><span style="display:flex;"><span> hl1: int<span style="color:#f92672">=</span> (int(mem[<span style="color:#ae81ff">0x14504</span>])<span style="color:#f92672">&lt;&lt;</span><span style="color:#ae81ff">8</span>)<span style="color:#f92672">|</span> int(mem[<span style="color:#ae81ff">0x14505</span>])</span></span><span style="display:flex;"><span> vr1<span style="color:#f92672">=</span> read_memory(hl1)</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>:</span></span><span style="display:flex;"><span> vr1<span style="color:#f92672">=</span> int(mem[<span style="color:#ae81ff">0x14500</span><span style="color:#f92672">+</span> z])</span></span><span style="display:flex;"><span/></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> y<span style="color:#f92672">==</span><span style="color:#ae81ff">6</span>:</span></span><span style="display:flex;"><span> write_memory((int(mem[<span style="color:#ae81ff">0x14504</span>])<span style="color:#f92672">&lt;&lt;</span><span style="color:#ae81ff">8</span>)<span style="color:#f92672">|</span> int(mem[<span style="color:#ae81ff">0x14505</span>]), vr1)</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>:</span></span><span style="display:flex;"><span> mem[<span style="color:#ae81ff">0x14500</span><span style="color:#f92672">+</span> y]<span style="color:#f92672">=</span> vr1</span></span></code></pre></div><p>That single check covers sixty-four opcodes, with<code>y</code> and<code>z</code> picking the destination and source registers.</p><p>When<code>x == 2</code>, those same bitfields map to all eight ALU ops on<code>A</code> from<code>r[z]</code> (<code>ADD</code>,<code>ADC</code>,<code>SUB</code>,<code>SBC</code>,<code>AND</code>,<code>XOR</code>,<code>OR</code>,<code>CP</code>), handling another sixty-four arithmetic instructions in one block.</p><p>The 256<code>0xCB</code> bit ops split the same way:<code>cb_x == 1</code> is<code>BIT</code>,<code>2</code> is<code>RES</code>,<code>3</code> is<code>SET</code>, and<code>0</code> shifts, rotates, or swaps<code>y</code> on<code>r[z]</code>. Fifty lines of bit math replaced hundreds of branches.</p><h2 id="four-lines-of-pointer-math-no-more-address-branches">Four lines of pointer math, no more address branches</h2><p>On the NES I could cheat. RAM, PPU, APU, PRG-ROM, everything sits where it was wired, so a read is just an address check and you&rsquo;re done.</p><p>The Game Boy doesn&rsquo;t do this. The CPU still sees 64 KB, but the cart is constantly swapping what lives behind that window:</p><ul><li><code>$0000..$3FFF</code>: 16 KB fixed ROM Bank 0</li><li><code>$4000..$7FFF</code>: 16 KB switchable ROM Bank (up to 125/128/512 banks under<code>MBC1</code>/<code>MBC3</code>/<code>MBC5</code>)</li><li><code>$A000..$BFFF</code>: 8 KB External SRAM (4 banks)</li><li><code>$C000..$CFFF</code>: 4 KB fixed Work RAM Bank 0</li><li><code>$D000..$DFFF</code>: 4 KB switchable WRAM (Banks 1–7 via<code>SVBK</code> at<code>$FF70</code>)</li><li><code>$E000..$FDFF</code>: Echo RAM (mirror of<code>$C000..$DDFF</code>)</li></ul><p>My first version checked the address on every access, if it&rsquo;s in this range do this, else if it&rsquo;s in that range do that:</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> addr<span style="color:#f92672">&lt;</span><span style="color:#ae81ff">0x8000</span>:<span style="color:#f92672">...</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">elif</span> addr<span style="color:#f92672">&lt;</span><span style="color:#ae81ff">0xA000</span>:<span style="color:#f92672">...</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">elif</span> addr<span style="color:#f92672">&lt;</span><span style="color:#ae81ff">0xC000</span>:<span style="color:#f92672">...</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">elif</span> addr<span style="color:#f92672">&lt;</span><span style="color:#ae81ff">0xD000</span>:<span style="color:#f92672">...</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">elif</span> addr<span style="color:#f92672">&lt;</span><span style="color:#ae81ff">0xE000</span>:<span style="color:#f92672">...</span></span></span></code></pre></div><p>That&rsquo;s readable, and it matches how the memory map is drawn in every doc. But the Game Boy runs at 4.19 MHz — double that in GBC double-speed, so that check runs millions of times a second. Again, running into the constraints of this limited hardware. Every<code>if</code> is a branch the Cortex-M33 has to predict, and a miss stalls the pipeline.</p><p>The trick is that every Game Boy address is 16 bits — four hex digits like<code>$D123</code>. In binary that&rsquo;s<code>1101 0001 0010 0011</code>, where the left four bits (15–12) are the top nibble and the right twelve bits (11–0) are the position inside a 4 KB page.</p><p>A page is 4 KB = 4,096 = 2¹² bytes, so shifting right by 12 (<code>addr >> 12</code>) drops the low twelve and leaves just that top nibble: 0 to 15. Sixteen values, sixteen pages, exactly covering 64 KB. Masking the other way (<code>addr &amp; 0xFFF</code>) keeps the low twelve as the offset 0–4,095 inside the page.</p><p><code>$D123</code> →<code>0xD</code> (page 13) +<code>0x123</code> (offset 291). That top nibble already tells me which bank to use, no<code>if</code> needed. I keep a 16-entry page table in<code>self.st</code> (slots 32–47 of the<code>array('i', [0]*64)</code>) with precalculated offsets into<code>self.mem</code>, indexed directly by that nibble:</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Address Range Page Slot Mapped Arena Region</span></span><span style="display:flex;"><span>$8000..$8FFF st[40] ──► 0x0000 (VRAM Bank 0 or 1, low)</span></span><span style="display:flex;"><span>$9000..$9FFF st[41] ──► 0x1000 (VRAM Bank 0 or 1, high)</span></span><span style="display:flex;"><span>$A000..$AFFF st[42] ──► 0xC000 + (sram_bank &lt;&lt; 13)</span></span><span style="display:flex;"><span>$B000..$BFFF st[43] ──► 0xD000 + (sram_bank &lt;&lt; 13)</span></span><span style="display:flex;"><span>$C000..$CFFF st[44] ──► 0x4000 (WRAM Bank 0)</span></span><span style="display:flex;"><span>$D000..$DFFF st[45] ──► 0x4000 + (wram_bank &lt;&lt; 12)</span></span><span style="display:flex;"><span>$E000..$EFFF st[46] ──► 0x4000 (Echo WRAM0)</span></span></code></pre></div><p>Switching banks is one store, no copy:</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Switching GBC WRAM bank (SVBK at $FF70)</span></span></span><span style="display:flex;"><span>wb: int<span style="color:#f92672">=</span> vw<span style="color:#f92672">&amp;</span><span style="color:#ae81ff">7</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> wb<span style="color:#f92672">==</span><span style="color:#ae81ff">0</span>: wb<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span></span></span><span style="display:flex;"><span>st[REG_WRAM_BANK]<span style="color:#f92672">=</span> wb</span></span><span style="display:flex;"><span>st[<span style="color:#ae81ff">45</span>]<span style="color:#f92672">=</span><span style="color:#ae81ff">0x4000</span><span style="color:#f92672">+</span> (wb<span style="color:#f92672">&lt;&lt;</span><span style="color:#ae81ff">12</span>)<span style="color:#75715e"># Slot 45 points to the active 4KB WRAM bank</span></span></span></code></pre></div><p>And a read is four lines with no branches:</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># 4KB Page Table lookup in Viper</span></span></span><span style="display:flex;"><span>page_offset: int<span style="color:#f92672">=</span> st[<span style="color:#ae81ff">32</span><span style="color:#f92672">+</span> (addr<span style="color:#f92672">>></span><span style="color:#ae81ff">12</span>)]</span></span><span style="display:flex;"><span>val: int<span style="color:#f92672">=</span> int(mem[page_offset<span style="color:#f92672">+</span> (addr<span style="color:#f92672">&amp;</span><span style="color:#ae81ff">0xFFF</span>)])</span></span></code></pre></div><p>Lookup, mask, add, load. Banking stopped showing up in profiles after that.</p><h2 id="lazy-loading-2-mb-cartridges-across-the-115-mhz-qspi-bus">Lazy-loading 2 MB cartridges across the 115 MHz QSPI bus</h2><p>NES cartridges in our library were 40 KB to 256 KB, small enough to read into RAM in one shot at boot. Game Boy Color cartridges are much larger:<em>Link&rsquo;s Awakening DX</em> is 1 MB (64 banks of 16 KB) and<em>Pokémon Crystal</em> is 2 MB (128 banks).</p><p>Reading 2 MB off QSPI Flash at launch freezes the badge for several seconds. When we pushed the RP2350 to<code>machine.freq(300_000_000)</code> to get extra frame headroom, the badge hard-locked on bank switches. USB serial died with it; only a power cycle brought MonaOS back.</p><p>The bottleneck was the shared QMI bus:</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span> RP2350 Microcontroller</span></span><span style="display:flex;"><span> ┌──────────────────────┐</span></span><span style="display:flex;"><span> │ Cortex-M33 Cores │</span></span><span style="display:flex;"><span> │ 520 KB SRAM │</span></span><span style="display:flex;"><span> └──────────┬───────────┘</span></span><span style="display:flex;"><span> │ QMI Bus (clk_sys / 2)</span></span><span style="display:flex;"><span> ┌───────────────┴───────────────┐</span></span><span style="display:flex;"><span> │ │</span></span><span style="display:flex;"><span> XIP_CS0 (Flash) XIP_CS1 (PSRAM)</span></span><span style="display:flex;"><span> ┌──────────────────────┐ ┌──────────────────────┐</span></span><span style="display:flex;"><span> │ 16 MB QSPI Flash │ │ 8 MB QSPI PSRAM │</span></span><span style="display:flex;"><span> │ (Firmware &amp; ROMs) │ │ (Heap &amp; Buffers) │</span></span><span style="display:flex;"><span> └──────────────────────┘ └──────────────────────┘</span></span></code></pre></div><p>The RP2350 is rated at 150 MHz, and this badge&rsquo;s firmware runs at 230 MHz (a 53 percent overclock). Per the<a href="https://github.com/badger/home/blob/main/hardware/README.md">badge hardware schematic</a> and Pimoroni&rsquo;s<a href="https://github.com/pimoroni/tufty2350/blob/main/board/pimoroni_tufty2350.h"><code>tufty2350</code> board config</a>, the 16 MB Winbond<code>W25Q128JVPIQ</code> Flash and 8 MB AP Memory<code>APS6404L-3SQR-ZR</code> PSRAM (<code>PSRAM_CS</code> on<code>GPIO8</code>) share<code>QSPI_SD0..3</code> and<code>QSPI_SCLK</code> with<code>PICO_FLASH_SPI_CLKDIV = 2</code> (<code>clk_sys / 2</code>). At 230 MHz, QSPI runs at:</p><p>$$\frac{230\text{ MHz}}{2} = 115\text{ MHz}$$</p><p>Both memory chips are rated for 133 MHz max (<code>pimoroni_tufty2350.h</code> notes<code>266 MHz</code> as the<em>&ldquo;Absolute maximum before a drop in PSRAM freq&rdquo;</em>), so 115 MHz gives comfortable timing margin. At 300 MHz, QSPI hit 150 MHz — past the 133 MHz spec on both parts. The Cortex-M33 core survived, but burst-copying 16 KB banks from Flash (<code>XIP_CS0</code>) into PSRAM (<code>XIP_CS1</code>) over the shared QMI bus locked the hardware.</p><p>Because the badge has 8 MB of PSRAM on the MicroPython heap (<code>gc.mem_free()</code> reports<code>8,189,632</code> bytes free), we didn&rsquo;t need to evict banks or overclock the bus:</p><ol><li>Stay at the badge&rsquo;s stock<strong>230 MHz</strong> so the QMI bus stays at 115 MHz.</li><li>Allocate the full cartridge arena once in PSRAM (<code>self.rom = bytearray(self.num_banks * 16384)</code>) with<code>direct_rom = True</code>, falling back to the 20-slot ring buffer only if<code>MemoryError</code> triggers.</li><li>Preload only Bank 0 and Bank 1 at launch so games boot immediately, then fault in each 16 KB bank on first visit (<code>self.mem[0x14300 + bank] = slot</code>) and keep it pinned in PSRAM for the rest of the session.</li></ol><p>That eliminated both the multi-second boot stall and mid-game Flash thrashing.</p><h2 id="screensize-15x">Screensize 1.5x</h2><p>The Game Boy LCD is 160×144 and the badge screen is 320×240. Not enough to double the size, but it can do 1.5× (240×216, repeating every other scanline<code>2:1:2:1</code>), which seemed like it was a nice compromise.</p><p>On the screen it&rsquo;s ok. It results in doubling up the width of some things and keeping others normal thickness. It has the advantage of taking up the entire height of the screen, but with the visual compromise of not actually what the game should look like.</p><p>I kept in the scaler but made the default the native<strong>1:1</strong>: 160×144 centered on 320×240, one LCD pixel per badge pixel and just have a larger gutter around it.</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span> 320 px (Badge Screen Width)</span></span><span style="display:flex;"><span>┌──────────────────────────────────────────────┐</span></span><span style="display:flex;"><span>│ GUTTER (80 px) 160x144 LCD GUTTER (80px)│</span></span><span style="display:flex;"><span>│ ┌──────────────┬──────────────┬────────────┐ │ 48 px Top</span></span><span style="display:flex;"><span>│ │ spf13 labs │ │ │ │</span></span><span style="display:flex;"><span>│ │ 60 fps │ 1:1 NATIVE │ [SAV] │ │</span></span><span style="display:flex;"><span>│ │ │ PIXEL │ │ │ 144 px LCD</span></span><span style="display:flex;"><span>│ │ [D-PAD HUD] │ │ │ │</span></span><span style="display:flex;"><span>│ │ (Active) │ │ │ │</span></span><span style="display:flex;"><span>│ └──────────────┴──────────────┴────────────┘ │ 48 px Bottom</span></span><span style="display:flex;"><span>│ GAME BOY COLOR │</span></span><span style="display:flex;"><span>└──────────────────────────────────────────────┘</span></span></code></pre></div><p>At 1:1 the pixel art replicates the original intent of the artists, but you might need glasses to actually read it. Reminds me of the old Game Boy screen magnifier which was just a magnifying glass that mounted to the Game Boy screen. Maybe someone can make one for the badge :)</p><p><img src="handy-boy-magnifier.jpg" alt="The Joyplus Handy Boy magnifier for the original Game Boy — one of several clip-on lenses sold in the 90s"><em>The Joyplus Handy Boy with its magnifier and stereo speakers. Photo:<a href="https://commons.wikimedia.org/wiki/User:ComputerHotline">Thomas Bresson</a>,<a href="https://creativecommons.org/licenses/by/3.0">CC BY 3.0</a></em></p><p>Centering a 160×144 viewport leaves 80 px gutters on the sides and 48 px on the top and bottom. Empty space beside a running game reads as a mistake, and a real Game Boy has plastic around its screen anyway, so I drew a bezel in twilight slate with gold pinstriping and cream<code>GAME BOY COLOR</code> lettering along the bottom (switching to<code>GAME BOY POCKET</code> for monochrome<code>.gb</code> cartridges). The left gutter holds a small HUD for battery, frame rate, and a live capacitive D-pad and button visualizer; the right gutter shows<code>SAV</code> and<code>RES</code> save-state indicators.</p><table><thead><tr><th style="text-align: center"><em>Link&rsquo;s Awakening DX</em></th><th style="text-align: center"><em>Pokémon Crystal</em></th></tr></thead><tbody><tr><td style="text-align: center"><img src="gbc-zelda-ladx.png" alt="Link&rsquo;s Awakening DX running 1:1 on the GitHub Universe 2026 Badge"/><td style="text-align: center"><img src="gbc-pokemon-crystal.png" alt="Pokémon Crystal running 1:1 on the GitHub Universe 2026 Badge"/></tr></tbody></table><table><thead><tr><th style="text-align: center"><em>Super Mario Land</em></th><th style="text-align: center"><em>Tetris DX</em></th></tr></thead><tbody><tr><td style="text-align: center"><img src="gbc-mario-land.png" alt="Super Mario Land running 1:1 on the GitHub Universe 2026 Badge"/><td style="text-align: center"><img src="gbc-tetris-dx.png" alt="Tetris DX running 1:1 on the GitHub Universe 2026 Badge"/></tr></tbody></table><h2 id="supporting-gbc-roms-is-extra-tricky">Supporting GBC ROMs is extra tricky.</h2><p>The underlying hardware had a very long run — the original monochrome Game Boy launched in Japan on April 21, 1989 and the Color revision that doubled the SM83 to 8.4 MHz didn&rsquo;t arrive until October 21, 1998, nine years later on the same 160×144 PPU. Nintendo kept the combined GB/GBC family in production until March 2003. By that tail end there were no untried registers left, so late-era GBC games lean on every trick the hardware allows, from mid-frame window layers to animated fonts streamed out of ROM.</p><h3 id="window-splits-need-per-scanline-latching">Window splits need per-scanline latching</h3><p>In<em>Oracle of Ages</em>, walking up to an NPC brought up the bottom dialogue box, and the overworld sprites bled right through it, drawing over the text.</p><p>To draw a bottom dialogue box (<code>LY = 80..143</code>), the game turns on the Window layer (<code>LCDC</code> bit 5) via<code>LYC == LY</code> at scanline 80 and clears it again at VBlank. My first renderer only checked a single frame-level<code>WY</code> after scanline 143 had finished, judging all 144 scanlines by whatever state the registers held at the end of the frame. Movable rocks on scanlines<code>0..79</code> acted as if the Window covered them, while sprites on<code>80..143</code> drew over the text because VBlank had already cleared<code>LCDC</code> bit 5.</p><p>Latching<code>LCDC</code>,<code>SCY</code>,<code>SCX</code>,<code>WX</code>, and<code>WY</code> into<code>0x14600</code> and<code>0x14840</code> on every scanline — and tracking the active Window line counter in<code>0x14900</code> during rendering — gives the renderer what the PPU actually saw on that line:</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Snapshot per-scanline LCDC, SCY, SCX, WX, WY for visible lines 0..143</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> sl<span style="color:#f92672">&lt;</span><span style="color:#ae81ff">144</span>:</span></span><span style="display:flex;"><span> sl_off: int<span style="color:#f92672">=</span><span style="color:#ae81ff">0x14600</span><span style="color:#f92672">+</span> (sl<span style="color:#f92672">&lt;&lt;</span><span style="color:#ae81ff">2</span>)</span></span><span style="display:flex;"><span> mem[sl_off]<span style="color:#f92672">=</span> mem[<span style="color:#ae81ff">0x14140</span>]<span style="color:#75715e"># LCDC</span></span></span><span style="display:flex;"><span> mem[sl_off<span style="color:#f92672">+</span><span style="color:#ae81ff">1</span>]<span style="color:#f92672">=</span> mem[<span style="color:#ae81ff">0x14142</span>]<span style="color:#75715e"># SCY</span></span></span><span style="display:flex;"><span> mem[sl_off<span style="color:#f92672">+</span><span style="color:#ae81ff">2</span>]<span style="color:#f92672">=</span> mem[<span style="color:#ae81ff">0x14143</span>]<span style="color:#75715e"># SCX</span></span></span><span style="display:flex;"><span> mem[sl_off<span style="color:#f92672">+</span><span style="color:#ae81ff">3</span>]<span style="color:#f92672">=</span> mem[<span style="color:#ae81ff">0x1414B</span>]<span style="color:#75715e"># WX</span></span></span><span style="display:flex;"><span> mem[<span style="color:#ae81ff">0x14840</span><span style="color:#f92672">+</span> sl]<span style="color:#f92672">=</span> mem[<span style="color:#ae81ff">0x1414A</span>]<span style="color:#75715e"># WY</span></span></span></code></pre></div><h3 id="double-speed-halves-your-scanline-budget">Double-speed halves your scanline budget</h3><p><em>Link&rsquo;s Awakening DX</em> had a weirder glitch: Link&rsquo;s two-tile walk animation tore inside-out for a single frame on every step, but only when walking down, never left or right.</p><p>Walking left or right keeps Link&rsquo;s two 8×16 sprites in place. Walking down swaps their VRAM tiles (<code>$8000</code>/<code>$8020</code>) and toggles<code>hflip</code> (<code>0x20</code>) on both OAM entries (<code>$FE0B</code>/<code>$FE0F</code>) every 8 frames.</p><p>Because<em>Link&rsquo;s Awakening DX</em> runs in GBC double-speed mode (<code>KEY1</code> bit 7), the CPU gets<strong>228 M-cycles/scanline</strong>, not 114. Budgeting 114 starved the scanline midway between the VRAM tile write and the OAM flip, leaving Link half-turned for a frame:</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>target_cyc: int<span style="color:#f92672">=</span><span style="color:#ae81ff">228</span><span style="color:#66d9ef">if</span> st[REG_DOUBLE_SPD]<span style="color:#66d9ef">else</span><span style="color:#ae81ff">114</span></span></span></code></pre></div><p>Giving double-speed mode its full 228-cycle scanline budget fixed the tear.</p><h3 id="stack-pointers-can-aim-at-rom">Stack pointers can aim at ROM</h3><p>When I booted<em>Pokémon Crystal</em>, Professor Oak&rsquo;s intro and every menu border came up as empty rectangles.</p><p>I did not know the hardware allowed what the game was doing here. To decompress 1bpp font and border tiles into 2bpp WRAM,<em>Pokémon Crystal</em> calls<code>FarCopyBytesDouble_DoubleBankSwitch</code> (<code>$0DEF</code>), which points<strong>SP into banked ROM (<code>$4000..$7FFF</code>)</strong>, loops<code>POP DE</code> to pull two bytes every 3 M-cycles, streams the result over HBlank HDMA (<code>$4209</code>), and stops the transfer with<code>RES 7, (HL)</code> on<code>$FF55</code> (<code>424A: cb be</code>).</p><p>I had assumed<code>SP</code> always lived in RAM and<code>RES 7</code> was an ordinary bit op, which broke both steps. Routing<code>POP rr</code> through the ROM window when<code>SP &lt; 0x8000</code> and treating bit-7 clears on<code>$FF55</code> as an HDMA cancel (not a 128-tile GP-DMA<code>0x7F</code>) brought Oak and the borders back.</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#a6e22e">@micropython.viper</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span><span style="color:#a6e22e">update_palettes_cgb_16</span>(st: ptr32, mem: ptr8, fpal: ptr16):</span></span><span style="display:flex;"><span> st[REG_PAL_DIRTY]<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> i<span style="color:#f92672">in</span> range(<span style="color:#ae81ff">32</span>):</span></span><span style="display:flex;"><span> i2: int<span style="color:#f92672">=</span> i<span style="color:#f92672">&lt;&lt;</span><span style="color:#ae81ff">1</span></span></span><span style="display:flex;"><span> bgr: int<span style="color:#f92672">=</span> int(mem[<span style="color:#ae81ff">0x14200</span><span style="color:#f92672">+</span> i2])<span style="color:#f92672">|</span> (int(mem[<span style="color:#ae81ff">0x14201</span><span style="color:#f92672">+</span> i2])<span style="color:#f92672">&lt;&lt;</span><span style="color:#ae81ff">8</span>)</span></span><span style="display:flex;"><span> r5: int<span style="color:#f92672">=</span> bgr<span style="color:#f92672">&amp;</span><span style="color:#ae81ff">0x1F</span></span></span><span style="display:flex;"><span> g5: int<span style="color:#f92672">=</span> (bgr<span style="color:#f92672">>></span><span style="color:#ae81ff">5</span>)<span style="color:#f92672">&amp;</span><span style="color:#ae81ff">0x1F</span></span></span><span style="display:flex;"><span> b5: int<span style="color:#f92672">=</span> (bgr<span style="color:#f92672">>></span><span style="color:#ae81ff">10</span>)<span style="color:#f92672">&amp;</span><span style="color:#ae81ff">0x1F</span></span></span><span style="display:flex;"><span> col_bg: int<span style="color:#f92672">=</span> (r5<span style="color:#f92672">&lt;&lt;</span><span style="color:#ae81ff">11</span>)<span style="color:#f92672">|</span> (g5<span style="color:#f92672">&lt;&lt;</span><span style="color:#ae81ff">6</span>)<span style="color:#f92672">|</span> b5</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> i<span style="color:#f92672">&amp;</span><span style="color:#ae81ff">3</span>:</span></span><span style="display:flex;"><span> col_bg<span style="color:#f92672">=</span> col_bg<span style="color:#f92672">|</span><span style="color:#ae81ff">0x0020</span></span></span><span style="display:flex;"><span> fpal[i]<span style="color:#f92672">=</span> col_bg</span></span></code></pre></div><p>Paired with a background renderer that skips empty tiles, render time in our serial timings sits around 1.5 to 2.8 ms per frame.</p><h2 id="hallway-rpgs-on-a-lanyard">Hallway RPGs on a lanyard</h2><p>From what we measured on the badge, CPU execution stays under 12 ms and rendering under 3 ms, keeping the loop inside the 16.6 ms budget for 60 Hz gameplay.</p><p>Saving has to work too because it&rsquo;s a conference badge.</p><ul><li>Holding<code>A + B</code> or<code>START + SELECT</code> for 650 ms (<code>now - self.menu_hold_start >= 650</code> in<code>apps/gbc/__init__.py</code>, alongside a 60-second<code>AUTO_SAVE_IDLE_MS</code> timer when no buttons are pressed) snapshots state and opens the pause menu for frame skip, scanline filters, scaling, and exit.</li><li>Touching the rear capacitive HOME pad fires<code>machine.Pin.board.BUTTON_HOME.irq</code>, calling<code>save_state(sync_sram=True)</code> to write the 84 KB arena and register state to flash before returning to MonaOS.</li></ul><p>The code is open source at<a href="https://github.com/spf13/universe-2026-badge-hacks">github.com/spf13/universe-2026-badge-hacks</a>. To put it on a badge:</p><ol><li><p>Connect the badge over USB, open<strong>USB Disk</strong> in MonaOS, and mount the drive.</p></li><li><p>Clone the repo and copy the app over:</p><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span>git clone https<span style="color:#960050;background-color:#1e0010">:</span>//github.com/spf13/universe-<span style="color:#ae81ff">2026</span>-badge-hacks.git</span></span><span style="display:flex;"><span>cd universe-<span style="color:#ae81ff">2026</span>-badge-hacks</span></span><span style="display:flex;"><span>Copy-Item -Recurse -Force .\apps\gbc F:\apps\</span></span></code></pre></div></li><li><p>Copy your own legally dumped<code>.gb</code>/<code>.gbc</code> files into<code>F:\apps\gbc\roms\</code> (no copyrighted ROMs are distributed):</p><ul><li><code>zelda_ladx.gbc</code> (<em>Link&rsquo;s Awakening DX</em>)</li><li><code>pokemon_crystal.gbc</code> (<em>Pokémon Crystal</em>)</li><li><code>dw_1_2.gbc</code> (<em>Dragon Warrior I &amp; II</em>)</li><li><code>tetris_dx.gbc</code> (<em>Tetris DX</em>)</li><li><code>zelda_ages.gbc</code> (<em>Oracle of Ages</em>)</li><li><code>mario_land.gb</code> (<em>Super Mario Land</em>)</li><li><code>ff_legend.gb</code> (<em>The Final Fantasy Legend</em>)</li></ul></li><li><p>Safely eject the drive, open the GBC launcher, flip your badge upside-down, and play.</p></li></ol><p>Building the Game Boy Color emulator ended up making the<a href="/p/zelda-on-my-conference-badge/">NES emulator</a> faster too. Once those five Viper tricks worked on the SM83, I ported them back to<code>apps/nes/emu.py</code>: pointer-based CHR bank switching (<code>REG_CHR_B0_OFF</code> /<code>REG_CHR_B1_OFF</code> into<code>self.mem</code> instead of copying 8,192 bytes into Python on every switch), keeping all 262 scanlines inside one Viper call instead of breaking out on<code>REG_CHR_DIRTY</code>, dirty-flagged Viper palette updates (<code>REG_PAL_DIRTY</code>), precomputed nametable row pointers with unrolled 8-pixel writes in<code>render_bg_32</code>, and tighter operand and wait-loop fast paths.</p><p>Now I have a Game Boy Color hanging from my neck, running<em>Link&rsquo;s Awakening DX</em> and<em>Pokémon Crystal</em> at 1:1. If you&rsquo;re at Universe in San Francisco, come say hi.</p>
]]></content:encoded></item></channel></rss>