---
title: serna64
---

The lab you were just walking around in is a real Nintendo 64 ROM. I built it with libdragon and Pyrite64, and it runs in a MIPS emulator compiled to WebAssembly, right there on the page. The sidebar highlighting a machine as you approach it, the page you're reading if you pressed B, all of that is the game talking to the browser through emulated console RAM.

Here's a look at it running:

<div class="article-video-embed">
  <iframe src="https://www.youtube-nocookie.com/embed/jQ5tw0djE_w" title="serna64" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div>

## Why the browser fights you

Getting the emulator on the browser wasn't supposed to be the hard part. 
N64 emulators compiled to WebAssembly already exist, and they run Mario 64 in 
a tab just fine. The plan was to build my ROM with libdragon, hand it to one 
of those cores, and spend all my time on the game itself. 
Instead I was forced to understand a cool shortcut every N64 emulator takes.

The N64 has no fixed graphics pipeline. Each game uploads its own microcode to
the RSP coprocessor and issues drawing commands through it. Retail games are
the easy case: they all use Nintendo's handful of official microcodes, so
emulators recognize those and skip straight to a fast high-level GL renderer.
libdragon homebrew ships its own microcode, which those renderers have never
heard of, so they drop every display list and show black. The only path that
draws this cartridge is full low-level emulation, with cxd4 interpreting RSP
microcode instruction by instruction and angrylion rasterizing every pixel in
software on the main thread.

I checked the alternatives properly by disassembling the shipped `.wasm` with
wabt and binary-patching the plugin selection. Glide64 and Rice composite
black, gln64 traps on a null function, and parallel-RDP wants Vulkan, which a
browser will not give you. There is no faster option to find. Software
rasterization is the ceiling, so everything else had to be tuned around it.

## Fixing the emulator

Getting the ROM to boot at all meant fixing the emulator itself. Twenty years
of N64 emulation is shaped by what retail games exercised, and libdragon is
written like systems code for the actual silicon, so it walks straight into the
untested corners. The core was missing XKPHYS 64-bit addressing and the
LLD/SCD atomic pair. Its exception paths overwrote the CAUSE register
wholesale, erasing pending interrupt bits and breaking both lazy FPU thread
switching and the kernel scheduler. libdragon's memset turned out to use MI
repeat mode, a real hardware feature no retail game ever shipped, so no
emulator had bothered to implement it. My favorite bug looked like rows of tiny
glyphs striped across a crate texture. The RSP DMA was masking addresses
across the 4KB DMEM/IMEM bank boundary instead of wrapping within it, so the
emulator was blitting microcode into the Z-buffer, and the glyphs were literally
code bytes interpreted as depth values. The highest-leverage fix was also the
smallest, about forty lines implementing ISViewer, the debug channel libdragon
asserts through. That turned a mute black screen into a stack of readable
error messages, and nearly every later fix started from one of them.

## The mailbox (how the game communicates with the website)

The cartridge stamps a small mailbox struct into RDRAM with a magic signature.
RDRAM lives inside the emulator's WebAssembly linear memory, so the site scans
`Module.HEAPU8` for that signature once, then reads game state out of it every
frame. Events flow the other way too, which is how pressing B on the Dexter
console opens this article. One catch is that the heap stores RDRAM in
host-endian words, so the magic can appear byte-reversed within each 32-bit
word, and the scan has to handle both orders. The same channel drives testing.
The ROM writes player position and camera yaw into a few reserved mailbox
bytes, and a Playwright harness reads them from the heap each tick and steers
the player toward waypoints with actual feedback control, since the follow
camera makes blind input scripts land somewhere different every run.

The Emscripten build has its own personality. It is a non-modularized
sloppy-mode build, so every top-level `var` lands on `window`. Swapping
cartridges re-injects the core script, and every initialized global heals
itself, but `var calledRun;` has no initializer, so a redeclaration keeps the
previous boot's `true` and the second core silently refuses to run. Teardown
now clears it by hand, along with the rest of the module surface.

## Making it look like an N64

An angrylion frame costs 35 to 40 milliseconds of main thread, which breaks
requestAnimationFrame's contract of finishing within a vsync period, and the
resulting beat-frequency pacing read as flicker. Driving the loop off a timer
instead nearly doubled the presented frame rate. The single biggest win was
turning off the VI resample filter, which faithfully emulates the blur real
hardware applies and cost 22 fps in software. The final configuration renders 
640x240 progressive, the highest mode the N64 offers without interlacing, and 
lets the browser's bilinear scaling handle the vertical stretch back to 4:3.

Retail cartridges still work. The loader reads the homebrew cart ID and
libdragon's IPL3 banner out of the ROM header and routes each cartridge to the
right core path, low-level for homebrew, fast high-level for everything else.
So if you have a copy of Mario 64 lying around, the lab will happily step aside
and boot it.