A Fable of Python on the Super Nintendo

· Fabian Kübler ·

5 min read Original article ↗

Python on the SNES has been a dream of mine for a long time. My brother and I shared a SNES as kids, and Python was my professional home for a big part of my career. These days I barely touch code anymore, so the dream has somehow lost its relevance. Then Claude Fable 5 came out, and I thought: this would make a great benchmark for the model. Porting MicroPython to the SNES: a 3.58 MHz CPU, 128 KB of RAM, 16-bit int, and a niche C compiler. This might just barely be possible.

Press start to run it via EmulatorJS. Keyboard keys match the SNES buttons: A, B, X, Y do what the screen says; arrows = D-pad, Enter = run.

So on the very first day of Fable’s release, I started. By the evening, it had built a hello-world ROM, a compiler self-test that had already caught two real compiler bugs, and all of MicroPython’s core compiling and booting on the SNES.

I woke up the next morning looking forward to continuing, only to find Fable gone from the model picker… export ban. Yikes. I continued with Opus 4.8, but to no avail: any nontrivial Python died with garbage errors, the failures moved around with every build, and after a day of careful work Opus concluded that the project was stuck. It sat dead for three weeks.

Now Fable 5 is available again. And I gave it five words — “Please make shit work now!” — and it did. Ninety minutes to the actual bug; by dinner the same day, a controller was typing 1+2 into a Python REPL, and the SNES answered 3.

I won’t walk through every technical detail. Fable solved problems here in a few hours that would have taken me months, if I managed them at all. In short: along the way it tracked down 23 bugs in the C compiler and 4 in MicroPython itself, each root-caused with a minimal reproducer and reported upstream. The whole port is on GitHub.

Looking back, two things stuck with me.

The first: before porting anything, the model built itself a headless emulator harness: a mailbox buffer in the SNES’s RAM, drained into logs and checked byte for byte by pytest. Nothing counted as working until it had run there, and that harness let it iterate hundreds of times without me in the loop.

The second: Fable’s skepticism. Opus had written down a diagnosis: the compiler failing to preserve caller-saved registers across the VM’s enormous interpreter loop. It was plausible, matched the docs, matched the disassembly… and wrong. But Opus read every experiment afterwards through it. Fable inherited the theory and tested it instead: when the first print still died, an opcode trace showed the bytecode was fine, the link map showed mp_builtin_print_obj sitting at an odd address, and a three-line compiler experiment nailed the real culprit. aligned() on struct members silently does nothing. Fable left no theory, including its own, unquestioned for long.

Four favorite bugs, for the curious

The alignment bug. The one the project sat stuck on for those three weeks. MicroPython’s pointer tagging needs every object at an even address; the compiler silently ignores __attribute__((aligned)) on struct members, so every object in ROM had coin-flip address parity, and odd ones were misread as integers. That’s why the failures moved with every build: many hours of workarounds had just been re-rolling the dice. Fix: inject the attribute at variable position (the only place it’s honored), plus a build step that parses the link map and fails the link if any object lands odd. (Filed upstream.)

The vanishing-variables bug. This one is MicroPython’s own and affects any 16-bit platform. The garbage collector’s root scan does its pointer arithmetic in size_t and assumes pointer-aligned strides; with 16-bit size_t and packed structs, it skipped half the root pointers, so it freed objects that were still in use. gc.collect() could erase your variables. Fix: scan the root section byte-accurately; reported upstream with the patch.

The negative-Y bug. p[-1] on a far pointer can compile to Y = 0xFFFF indexing. The 65816 adds Y as an unsigned 16-bit value. On this CPU, array[-1] reads memory 65,535 bytes forward. Eighteen instances in the VM alone. Fix: an index helper that never goes negative, plus a checker that scans the generated assembly. (Filed upstream.)

The bank-loss bug. A call’s third pointer argument can lose its top address byte, so the heap allocator was told the heap ends at $00E000 instead of $7FE000, and handed out pointers into the CPU’s own stack page. bytearray(4000) happily zero-filled the C stack. Fix: route the fragile calls through volatile function pointers, the only fence this compiler respects. (Filed upstream.)

Today the port passes 91.9% of MicroPython’s core tests/basics suite — 430 of 468 tests — on emulated hardware. And it plays: the demo below uses an adapted version of Stage, a small Python game engine built for DIY handhelds. So what you’re watching is Python game logic driving the SNES’s PPU, the original 1990 sprite hardware.

Boot takes ~20 seconds: importing and setting up Python modules at 3.58 MHz is the slow part. After that: six bouncing sprites at ~0.8 frames per second of pure Python game logic. ❤️

The ROMs are in the repo. If you run one on real hardware — and maybe a CRT ;) — please send me a picture.