The making of gaseA Sega Mega Drive / Genesis emulator in RustOctober 2026

A Mega Drive you can read.

How an emulator was built to be understood: one crate per chip, every CPU checked against millions of recorded test cases, and a rule that every fast path keeps the readable version beside it.

- Crates: one per chip, then app, ZIP and shells 12

- External dependencies of the core 0

- Platforms: desktop, browser, Android, iOS 4

- Z80 test cases passed 1,604,000

- 68000 test cases run 1,310,060

- Pull requests to get here 33

- Speed-up from profiling 1.5–1.9×

- unsafeblocks, all in the phone entry point 2

The answer was yes, for reasons that shaped everything that followed. The Mega Drive is one of the best-documented machines of its era: mature open emulators such as Genesis Plus GX, BlastEm, Exodus and clownmdemu exist, and between them almost every behaviour of the hardware has been measured and written down. Nothing had to be guessed; it had to be understood and written clearly.

The goals were ranked, and the ranking mattered. Pedagogy first: someone should be able to open the source and learn how the console works. Then performance, compatibility, quality of life, and finally best practices with few dependencies. When two goals pulled in different directions, the higher one won.

Rust suited those goals. Hardware arithmetic is explicit (wrapping_add, u16::from_be_bytes), so the code shows what the chip does to every bit. Generics with static dispatch let a CPU core talk to the memory map at no cost while staying a standalone crate. And #![forbid(unsafe_code)] turned out to be realistic: nothing here needs it.

Performance was not the risk

A 7.67 MHz 68000 executes one to two million instructions a second. A plain interpreter in Rust handles that in a few percent of one modern core. The study concluded that speed could be spent on accuracy and on comforts such as rewind. The real risks were elsewhere: the exactness of the 68000, the VDP’s timing tricks, the YM2612’s sound, odd cartridge hardware, and keeping audio and video in sync without crackles. Each one later got its own chapter.

The 68000 runs the game. It sees the cartridge, 64 KiB of work RAM, the controller ports and the video chip’s two ports in one 24-bit address space. The Z80, a separate 8-bit computer with 8 KiB of its own RAM, drives the sound chips; the 68000 can stop it, load a program into its RAM and restart it, and the Z80 can look into the 68000’s memory through a 32 KiB window.

The VDP is never addressed like memory. Its 64 KiB of VRAM, its palette (CRAM) and its vertical scroll table (VSRAM) are only reachable through a control port and a data port, which is why so much of a game’s code is about feeding those two ports at the right moment.

- Master clock gase-core · system.rs- 53.693175 MHz on NTSC consoles (53.203424 MHz on PAL). Every chip runs at a fraction of it, so gase keeps time in master clocks.

- Motorola 68000 gase-m68k- The main CPU at master ÷ 7 ≈ 7.67 MHz. 32-bit registers, a 16-bit data bus, 24 address lines. Runs the game.

- Cartridge gase-core · cartridge.rs- ROM at - 000000, plus battery SRAM or a serial EEPROM for saves, and the SSF2 mapper for ROMs over 4 MiB.

- Work RAM gase-core · bus.rs- 64 KiB at - E00000–- FFFFFF, mirrored. The stack grows down from the top of it.

- I/O gase-core · io.rs- The version register (region, PAL/NTSC) and the controller ports, including the 6-button pad’s timed protocol.

- Bus arbiter gase-core · bus.rs- Lets the 68000 take the Z80’s bus ( - A11100) or hold it in reset (- A11200), and gives the Z80 a banked window into 68000 space.

- VDP, Sega 315-5313 gase-vdp- Two scrolling tile planes, a window and 80 sprites, 64 colours on screen out of 512. One scanline every 3,420 master clocks.

- VRAM, CRAM, VSRAM gase-vdp- 64 KiB of tiles and tables, 64 nine-bit colours, 40 vertical scroll values. Only the VDP touches them directly.

- Zilog Z80 gase-z80- The sound CPU at master ÷ 15 ≈ 3.58 MHz, with 8 KiB of private RAM. Streams music data and drum samples to the sound chips.

- Sound RAM gase-core · bus.rs- 8 KiB at Z80 address - 0000, mirrored at- 2000. Where the sound driver lives.

- Yamaha YM2612 gase-sound- Six FM channels of four operators each, one stereo sample every 144 of its clocks (≈ 53,267 Hz). Channel 6 can play 8-bit samples.

- PSG, SN76489 gase-sound- Built into the VDP: three square waves and a noise generator, at master ÷ 15.

Each chip lives in its own crate, with module documentation written as a short course on that chip: the 68000’s registers and addressing modes, the Z80’s undocumented flags, the VDP’s tile formats, FM synthesis as the YM2612 actually computes it. The crates were built in parallel, on separate branches, before anything connected them.

What lets them stay apart is a small trait. Each CPU says what it needs from the outside world and nothing more:

// gase-m68k: everything the 68000 needs to know about the console.

pub trait Bus {

fn read_byte(&mut self, addr: u32) -> u8;

fn read_word(&mut self, addr: u32) -> u16;

fn write_byte(&mut self, addr: u32, value: u8);

fn write_word(&mut self, addr: u32, value: u16);

// interrupt acknowledge, RESET, TAS ... (with defaults)

}

impl M68k {

/// Runs one instruction; returns the 68000 clock cycles it took.

pub fn step<B: Bus>(&mut self, bus: &mut B) -> u32 { … }

}Because step is generic, the compiler produces a copy specialised for the console’s real memory map, with memory accesses as direct and often inlined calls. Tests hand the very same CPU a flat array of RAM instead.

One design choice comes straight from Rust’s borrow rules. While the 68000 runs it holds a mutable borrow of the hardware, so it cannot also live inside the hardware. The CPUs therefore sit beside Hardware in the Genesis struct, and the Z80 reaches the same hardware through a thin wrapper that applies the Z80’s own memory map. The constraint ended up documenting the machine: two processors, one set of shared devices.

A test vector is one instruction, frozen. It records every register and the relevant bytes of memory before the instruction, the same after it, and the bus activity in between. Run the instruction from the “before” state; if a single bit or cycle differs from “after”, the test fails.

Here is a real one, the first of the 1,000 cases for Z80 opcode 80, ADD A,B, shortened:

// target/test-vectors/z80/80.json, case "80 0000" (SingleStepTests)

initial: pc=51399 a=81 b=92 f=167 r=112 q=0 ram[51399]=128 (the opcode)

final: pc=51400 a=173 b=92 f=172 r=113 q=172

cycles: 4 // one opcode fetch: read at 51399, then refreshThe Z80 core passes all 1,604,000 SingleStepTests cases, comparing every register including the hidden ones, RAM, port I/O and cycle counts. It also passes ZEXDOC and ZEXALL, the classic exercisers that run on the CPU itself and checksum the results of millions of operations.

The 68000 is harder: dozens of addressing modes, exact cycle counts, a two-word prefetch queue, and exceptions that abort an instruction halfway. gase models the prefetch queue exactly and counts time the way the chip spends it, four cycles per bus access plus internal work, so the counts match Motorola’s tables by construction rather than by lookup.

When two references disagree

The two 68000 suites contradict each other in places, so the core had to pick a side. It follows the suite generated from MAME’s microcode-level 68000, which also agrees with Motorola’s manual, and passes all 310,000 of its cases. Tom Harte’s cases are excused only when they fall into a known, documented category and fail: the stacked program counter and cycle counts of address errors (about 178,000 cases), the flags of shifts by more than the operand size, the timing of ADDQ.L to an address register, a few division edge cases, LINK A7, and two corrupt vectors. Before switching to the microcode model of address errors, the core passed about 99.5% of Tom Harte’s suite.

That decision about address errors, a corner that commercial games never reach, turned out to matter sooner than expected. See chapter 08.

The scheduler in gase-core runs the console one scanline at a time. At the start of each line the VDP draws it. Then the 68000 executes instructions up to the next event on that line (the vertical interrupt, horizontal blanking, the end of the line), and after every 68000 instruction the Z80 catches up to the same master-clock time. The two CPUs never drift more than one instruction apart.

The sound chips are not stepped in that loop at all. They are brought up to date lazily: only when a CPU writes to them, so that the write lands at exactly the right sample, and at the end of the frame. When nobody is listening, catching up costs nothing.

The first real programs were five freely licensed test ROMs, chosen to cover different parts of the machine. Each became a regression test: run for a fixed number of frames, with scripted button presses, then hash the picture. A hash was only recorded after the frame had been checked by eye.

crates/core/tests/test_roms.rs

Everything on screen is made of tiles: 8 × 8 pixels, four bits per pixel, 32 bytes each. A pixel’s four bits pick one of 16 colours in one of four palettes; colour 0 is transparent. Two planes, A and B, are scrollable maps of tiles in which each entry chooses a tile, a palette, horizontal and vertical flips and a priority bit. A window can replace plane A in a fixed rectangle, for status bars that must not scroll. On top come up to 80 sprites, kept in a linked list.

Below is one real frame of Right 2 Repair taken apart. Each layer was drawn by gase’s own renderer with the other layers hidden, so this is exactly what the VDP combines.

Where the pixels come from

All of it lives in the VDP’s 64 KiB of VRAM: the tiles, the two name tables, the window’s map, the sprite table and the horizontal scroll table, at addresses the game chooses through registers. Below are all 2,048 tile slots of VRAM at that frame, each in the palette the planes or sprites use it with, and the 64 colours of CRAM.

Look closely and the jigsaw shows: the skyline and the rubble cut into tiles, the dithered sky, the letters of the font, the fighters’ animation frames. The striped rows near the bottom are not pictures at all. They are the name tables and sprite table, read as if they were tiles, a reminder that VRAM is one shared memory and the registers decide what each part means.

Line by line

gase renders one scanline at a time, as the hardware does. For each line it draws plane B, then plane A or the window, into buffers of one byte per pixel (priority, palette, colour), evaluates which sprites cross the line (at most 20 per line and 320 sprite pixels in the 320-pixel mode; beyond that the hardware drops them), and composites the three buffers by priority. Register writes a game makes during horizontal blanking show up on the next line, which is what raster effects like water lines and per-line palette changes rely on.

An operator is a sine oscillator with a volume envelope. On its own it makes a pure, dull tone. The trick, found by John Chowning in 1973 and licensed to Yamaha, is to add one operator’s output to another’s phase. The modulator bends the carrier back and forth and creates sidebands: when the two frequencies are in a simple ratio the sidebands are harmonics and the result is a rich, pitched timbre; odd ratios give bells and metal. The modulator’s volume, the modulation index, sets the brightness.

Try it. This is the formula, drawn live: out = sin(ωc·t + I · sin(ωm·t)).

Sine without multiplying

The YM2612 has no multiplier. It stores −log₂(sin) for a quarter of a wave in a 256-entry table, adds the envelope’s attenuation (already in decibels, so already a logarithm), and turns the sum back into a linear value with a 256-entry table of powers of two and a shift. gase builds both tables from formulas, and its tests check them against values read off the chip’s die.

Then the analogue side. The YM2612 in early consoles has a flawed DAC that leaks a small level from silent channels, making quiet notes louder and grittier. This “ladder effect” is part of the Mega Drive sound, so gase emulates it by default. The SN76489-style PSG adds three square waves and a noise generator built from a 16-bit shift register. Finally a windowed-sinc resampler converts the chip’s native ≈ 53,267 samples a second to the 48,000 your sound card wants, nudging the ratio by up to half a percent to keep audio and video in step without dropping a frame.

Airstriker booted to its SEGA logo and then, a few seconds later, stopped on a black screen with a message from its own runtime: 68K Address Error. It is a small freeware game written in BasiEgaXorz, a BASIC compiler for the Mega Drive, and it is known to work in popular emulators. So the first suspect was gase.

An address error is the 68000 refusing a word or long access at an odd address. The chip has no address line A0. A 16-bit access always covers two bytes at an even address, chosen with upper- and lower-byte strobes, so there is no way to ask for a word that starts on an odd byte. The CPU aborts the instruction mid-flight and takes exception 3, pushing a 14-byte frame that describes the faulting access. gase implements this exactly, because the MAME-derived test vectors check those frames bit for bit.

The trace shows the last instructions before the fault, at frame 358. The code wants to take the Z80’s bus, which means writing $0100 to $A11100. It loads the address into D0, copies it to A6, loads the value, and then copies the pointer into A0 for the write:

; gase --headless --frames 359 --trace 20000000 Airstriker.md (registers before each instruction)

00F980 move.l #$A11100,d0 D0=00000000 A0=00FF026E

00F986 movea.l d0,a6 D0=00A11100 A0=00FF026E ; A6 = $A11100, the Z80 bus request

00F988 move.l #$100,d0 D0=00A11100 A0=00FF026E

00F98E movea.l d6,a0 D0=00000100 A0=00FF026E ; copies D6, not A6

00F990 move.w d0,(a0) D0=00000100 A0=2FFFFFFF ; a word write to an odd address

00254A movea.l #$2674,a0 D0=00000100 A0=2FFFFFFF ; exception 3: the address-error handlerD6 held $2FFFFFFF, so the write went to an odd address and a real 68000 would fault exactly here. The instruction bytes in the ROM tell the rest. MOVEA.L D6,A0 and MOVEA.L A6,A0, which the code presumably meant, are one bit apart:

The source field of the instruction is a 3-bit mode and a 3-bit register. Mode 000 means a data register, 001 an address register; register 6 is the same in both. The compiler emitted the wrong mode. Emulators that do not model address errors quietly round the address down and carry on, and so the game “works” there.

The decision that followed is the project’s priorities in miniature. The default stays faithful to the hardware, because an emulator you learn from should not hide what a real 68000 does. But a small, documented, opt-in mode was added: with --no-address-errors, odd data accesses ignore bit 0, jumps to odd addresses still fault, and the setting is not saved in save states. The check sits in the already-rare odd-address branch, so it costs nothing otherwise. With it, Airstriker reaches its title screen and menu.

Even then, gase does not pretend the bug is harmless. The stray write lands at $FFFFFE, the top of RAM and the base of the stack, and corrupts the outermost return address. What happens after that depends on how an emulator mishandles the write, and the project chose not to chase another emulator’s memory layout bug for bug.

While it draws a line, the VDP reads VRAM almost continuously: name table entries, tile patterns, scroll values, sprite data. VRAM has a single port, so each line is divided into a fixed sequence of memory slots, and most go to the renderer. Only a few external access slots are left for the 68000 and the DMA engine.

Writes to the data port wait in a four-entry FIFO until a slot comes. Four entries absorb a short burst, a few palette entries during horizontal blanking, without the CPU noticing. A fifth write stalls the 68000 until an entry frees up, which in active display can take a few hundred master clocks per word. Status bits 8 and 9 report the FIFO full and empty, and careful programs poll them.

DMA comes in three kinds. A 68000-to-VDP transfer pushes words through the same FIFO and freezes the 68000 until the last one is in. Fills and copies run in the background while status bit 1 says “busy”; a copy needs a read and a write slot per byte, so it runs at half the speed of a fill. This is why games upload graphics during vertical blanking or with the display off: the same transfer is about eleven times faster there.

gase models all of this with a lazy clock. The VDP remembers how far into the line it has used its slots, and is brought up to date before every port access and at the end of every line. When nothing is queued, catching up just moves a counter.

The emulator became more accurate, and SGDK games started twelve frames later.

SGDK, the C toolkit most homebrew is built with, clears all 64 KiB of VRAM at start-up with a DMA fill, twice, with the display on. At real slot speed each fill takes about five and a half frames, so with this model SGDK software finishes booting about twelve frames later, as it does on a console. Two regression hashes had to be revisited. The Spiral showed the same particle scene at a slightly later point of its animation, and its new hash was recorded after comparing screenshots. The 240p Test Suite’s pattern menu now finished loading after the scripted button press, so the press moved from frames 330–335 to 345–350; the resulting frame was bit-identical to the old one.

A regression test that changes is not a failure if you can explain the change. These two could be explained to the frame.

A save state is the whole console written down: every component implements one trait, writing its fields in a fixed order and reading them back. The format is plain little-endian binary with a magic number, a version and a fingerprint of the game, with no field names and no schema. Loading is transactional: a damaged state leaves the running console untouched.

Rewind then costs almost nothing to build: a ring buffer of save states, one taken every few frames, popped and loaded while you hold the key. A state is roughly 150 KiB, so ten seconds of history fit in a few tens of megabytes. During the performance work, saving byte slices in one copy instead of byte by byte made those snapshots about six times cheaper.

The chip with two wires

Most cartridges save to battery-backed SRAM, ordinary memory at $200000. A few dozen use a serial EEPROM instead: a tiny I²C chip with only a clock and a data pin, which the game drives bit by bit by writing to a latch at an address that depends on who made the board. gase emulates the chip edge by edge (start and stop conditions, the acknowledge bit on every ninth clock, both addressing modes, page writes that wrap within the page) and keeps a table of which games use which chip and wiring, because nothing in the ROM says so.

Press F1 in gase and a second window opens with the console as its chips see it: both CPUs’ registers and disassembly, the VDP’s registers decoded by name, the palettes, every tile in VRAM or the plane maps, and the sprite list in link order. You can pause, step one 68000 instruction, run to the end of the frame or to the next vertical blank, set breakpoints, and mute each FM and PSG channel to hear how the music is arranged.

Experiments to try

- Follow the boot. Start with gase --debug game.binand press S: most games first read the version register atA10001, then set up the VDP (watch the decoded registers change), then clear RAM.

- Find the vertical-blank handler. Press V, then S: the 68000 takes the level-6 interrupt and jumps to the address stored at $000078. Most games do all their VRAM updates there.

- Read a frame apart. Press T to switch between the tiles, plane A, plane B and the window, and compare with the sprite list.

- Hear the channels. Mute FM channels with 1–6 and the PSG with 7–0.

The work started with measurement, not hunches. Two numbers were taken for each of the five test ROMs: frames per second from the headless benchmark, which is what you feel, and the count of host instructions executed over 300 frames under callgrind, which does not depend on what else the machine is doing and shows changes of a fraction of a percent.

The profile was unambiguous. The scanline renderer took 50–57% of all instructions: it recomputed the scroll, the cell and the pixel for every one of the 320 pixels of each plane, and composited them with branchy loops. Next came the 68000’s operand helpers, called out of line, then the YM2612 computing silent channels, the PSG ticking one step at a time, and the resampler.

Read the reference to learn how the hardware works. Read the fast path to learn how to make it quick.

That was the rule for every optimisation. The straightforward version stays in the code as the documented definition, the fast path names it in its own documentation, and a unit test proves the two agree, usually on thousands of random inputs. On top of that, every change had to leave the test-ROM frame hashes, the recorded audio and the CPU test vectors bit-identical. No unsafe, and no hand-written SIMD: the compiler does the vectorising.

Work happens on feature/… and fix/… branches cut from develop and merged back through pull requests; main follows releases. Chips were developed in parallel on their own branches, which the one-crate-per-chip layout made painless, and each pull request description lists what was tested, what is approximate, and which other open pull requests it will conflict with and in what order to merge them.

Continuous integration runs on every pull request, with warnings treated as errors:

- cargo fmt --all --check

- cargo clippy --workspace --all-targets, with the workspace lints,- forbid(unsafe_code)among them

- cargo test --workspace: unit tests, hand-assembled whole-system programs, debugger equivalence

- cargo build -p gase --no-default-features, proving the headless build has no external dependencies

- cargo doc --workspace --no-depswith warnings as errors: the documentation is part of the product

- cargo clippy -p gase-web --target wasm32-unknown-unknown, and clippy for the five phone targets

- scripts/smoke-sdl.sh: the optimised program must still be running after five seconds (chapter 18)

Separate workflows build the browser version and the phone apps: an Android APK, and the iOS app for device and simulator.

One interface, then three platforms at once

The interface and the ports were the biggest piece of work after the emulator itself, and the order mattered. The platform-free interface came first, on its own branch with the desktop shell (#22), because everything else would stand on its Platform contract. As soon as that contract settled, the browser version (#23) and the phone apps (#24) were built in parallel, each in its own working copy, on branches stacked on top of #22. They worked in different directories (crates/web and web/, crates/mobile and mobile/), so they could not step on each other’s code; where they did meet, in the README and ARCHITECTURE.md, the second merge produced conflicts that were resolved by hand.

Each port also fed fixes back. The crash of chapter 18 was found on the interface branch and its fix merged into the other two before they were reviewed; the landscape bug of chapter 14 was found while taking pictures for this site. Twice a follow-up commit arrived a minute after its pull request had been merged, so it went into a new, small pull request instead of being lost: working fast means checking the last word after the merge, not only before.

gase was written with an AI assistant working on its own branches and pull requests; a person reviewed and merged every one of them. Each commit says so in its last line: Co-authored with AI.

The big suites are too large for every push (the Z80 vectors alone are about 1.3 GB), so a second workflow runs them weekly and on demand, together with the test-ROM regression: each ROM run for a fixed number of frames with scripted presses, and an FNV-1a hash of the picture compared with a value that was only recorded after looking at the frame.

Until version 0.1, gase was a command line and a window. Making it pleasant to use meant menus, a way to pick a game, settings that survive a restart, remappable controls, and on phones, buttons under your thumbs. Writing that four times, once per platform toolkit, would have quadrupled the code a learner has to read. So it lives in one crate, gase-app, with no dependencies and no I/O, just like the core.

The app draws itself. Every frame it paints its menus into a plain buffer of pixels with its own bitmap font, the same way the debugger does. A platform only has to show that buffer, play sound, and report keys, pad buttons and fingers. The interface is immediate-mode: there are no widget objects to keep in sync. Each frame the current screen is drawn from the settings and the console’s state, and input is handled as it is drawn.

The platform contract

Everything a platform must provide fits in one trait, Platform: read and write a named file, list a folder, ask for a ROM, report the time. Everything it gives the app is an Event: a key, a pad button or axis, a pointer, a dropped file, the app going to the background. Everything the app asks back comes through Requests: go fullscreen, quit, open the system’s document picker. A shell is the code that keeps that promise on one platform, and there are three: SDL2 for desktops and phones, and a WebAssembly one for browsers.

Every controller at once

The keyboard, any number of gamepads and the touch screen are mapped to the two console pads at the same time. Each device keeps its own set of pressed buttons, and the console sees their union. Nothing has to be switched over, and letting go of a key can’t release a button a gamepad is still holding. Every key and pad button can be remapped for both players.

Thumbs on glass

The touch pad is the app’s own, too, so it is identical on Android, iOS and in a mobile browser. Each finger is tracked separately in a small table of the fingers the pad owns. A finger that lands on the d-pad keeps steering it even if it slides off. A finger in the gap between two buttons presses both, so one thumb can hold B and C. All sizes are derived from the screen size, so the same code serves a small phone and a tablet.

The layout has one rule above all: never cover the game. Writing this chapter caught a bug there. Taking the landscape picture below showed Start and Mode drawn in the middle of the bottom edge, right over Right 2 Repair’s “PRESS START”. The comment in the code said “corners in landscape”, but the code didn’t do it. A failing test came first, then the fix, in pull request #25. The two buttons now sit in the black borders, above each thumb.

Pulling in a library would have been the easy way. But the rule of few dependencies had held so far, and DEFLATE, the compression inside ZIP files (and gzip, and PNG), turns out to be a good thing to learn from: about 900 lines, tests aside, that combine two old ideas. gase-zip reads ZIP archives, inflates DEFLATE streams and checks CRC-32 sums. When Cartridge::from_bytes sees the bytes PK\3\4 at the start of a file, it opens the archive and takes the ROM inside. Every platform gets ZIP support from that one check.

The first idea is LZ77. Instead of storing text it has already seen, the compressor writes “go back distance bytes and copy length bytes from there”. The decompressor’s own output is the dictionary. A copy may overlap what it is writing: distance 1, length 100 repeats one byte a hundred times. Try it:

The second idea is Huffman coding: common symbols get short bit patterns, rare ones long patterns. DEFLATE puts literal bytes, match lengths and an end-of-block marker into one 286-symbol alphabet, with distances in a second one. Each block either uses a code fixed by the standard or sends its own code first. To save space, it sends that code as a list of bit lengths, itself compressed with a third Huffman code. The comments in crates/zip/src/inflate.rs take this apart table by table, and are worth reading even if you never touch an emulator.

Decoding bit by bit is the readable reference. The fast path decodes most codes with one lookup of the next 10 bits, and tests check that both give identical results, errors included, on zlib-made and random streams: the rule from chapter 12 again. The fast path inflates a 4 MiB ROM in about 19 ms.

The usual way to put Rust in a web page is wasm-bindgen, which generates the glue between the two languages. It works well, but it hides exactly what a learner would want to see. So gase-web does without it. The module is built for wasm32-unknown-unknown and exports about twenty plain functions that take and return numbers. The page imports nine functions back: read and write a file, push sound, make a request. Six JavaScript modules, loaded as they are written, do the rest.

One shared memory

A WebAssembly module’s memory is a single ArrayBuffer that JavaScript can see, and a Rust pointer is just an offset into it. So no picture or sound is ever copied across the boundary. Rust returns vec.as_ptr() as a number, and the page wraps those bytes in a typed array and hands them to the canvas. After every frame the module fills a seventeen-word frame description, telling the page where the picture is, how big it is, where to draw it and how to pace. The page reads it through one Uint32Array, like a C struct.

Three things browsers make hard

- Saving. The app expects to read a file and get it back at once, but the browser’s IndexedDB answers later. So at startup the page copies everything stored into memory. Reads come from that copy, and writes go to the database in the background, one transaction per frame.

- Sound. Audio plays on a real-time thread, inside an AudioWorklet. The fastest way to feed it is a ring buffer in shared memory, but browsers only allow that on pages served with special isolation headers, and GitHub Pages can’t send them. So the page also knows how to post chunks of samples, which is the path this site uses.

- Pace. A browser redraws on its own clock. Each redraw owes elapsed time × 59.92 emulated frames, with the remainder carried over. The sound queue only adds or skips a frame when it drifts more than two frames from its target. An early version, which ran frames until the queue was full, stuttered between zero and two frames per redraw.

Neither phone system starts a program by calling main. On Android, the Java virtual machine starts an activity. SDL’s SDLActivity loads two native libraries, libSDL2.so and libmain.so, and calls the C function SDL_main on a thread of its own. On iOS, a tiny Objective-C main hands control to UIKit, and once the app has launched, SDL calls the same SDL_main. The gase-mobile crate exports exactly that function: built as a shared library for Android, and as a static library linked into the iOS app.

From there it is the desktop program with a different Shell. Files live in the app’s private storage, ROMs come from the system’s document picker or “Open with”, the screen rotates, and there is no Quit. Copying a picked file into the app is a few dozen lines of Java and Objective-C. That glue then hands the copy’s path back as an ordinary SDL “file dropped” event, which the shell already understood from the desktop.

A phone can kill you at any moment

A desktop program runs until it is closed. A phone app sent to the background may never be woken again. So when gase goes to the background it saves the game’s battery RAM and the settings at once, opens the pause menu, stops drawing and sleeps without using the CPU. Coming back, it drops the stale sound and resumes. If the system takes the graphics device away, SDL says so and the textures are recreated.

The debug build of the new interface ran fine. The release build died with SIGSEGV within a second of opening its window. Every unit test passed, because none of them opens a window. gdb placed the crash inside SDL, copying pixels into a texture. valgrind was more precise: an uninitialised value created by a stack allocation, followed by writes to memory that belonged to nobody.

Uploading a picture to the GPU went through sdl2’s safe wrapper, Texture::with_lock(rect, …). Inside that crate it looks like this:

The fix is one word: lock the whole texture with None, which passes a null pointer and needs no temporary. SDL hands back the same buffer either way, so it costs nothing. valgrind went quiet. The doc comment on upload in crates/gase/src/sdl.rs records why the rectangle must never come back.

Fixing the crash was not enough on its own: it also had to be impossible to miss next time. scripts/smoke-sdl.sh runs the optimised program with SDL’s dummy video and audio drivers and fails unless it is still running after five seconds. It was checked against the broken build first (exit 139, a segfault) before being trusted on the fixed one. It now runs in CI on every pull request.

When a release is published on GitHub, the Release workflow checks out the tagged commit and builds it everywhere at once: on Linux, Windows and macOS machines for the desktop programs, a WebAssembly build for the browser, and the phone workflow of chapter 17 for Android and iOS. A last job gathers the files, adds their checksums, takes the version’s section of CHANGELOG.md as the release notes and attaches everything. GitHub adds the source code by itself.

The workflow can also be started by hand with a tag name, and then it creates the tag and the release too. That is how 0.2.0 and the older 0.1.0 got their files after the fact; 0.1.0 predates the browser and phone code, so the workflow looks at what the commit contains and builds only those platforms.

Nothing else to install

The desktop program needs SDL2 for its window, sound and gamepads, and asking players to install a library first would be a poor welcome. The sdl2-sys crate happens to ship SDL’s complete source code, and two features make it compile that source with CMake and link it into the executable: cargo build --features sdl2/bundled,sdl2/static-link. The result depends on nothing but the operating system. On Linux, SDL still finds X11 or Wayland and the sound system when it starts, the way it always does, so one file runs on any desktop.

Each desktop build is then copied alone into an empty folder and started: if it still needed an SDL library, the system would refuse to run it. On Linux it must also keep its window open for five seconds.

Three surprises, found before they mattered

On pull requests that change it, the workflow builds every file and publishes nothing, so it was tested long before a release depended on it. Linux worked at once. Windows and macOS failed three times, each failure precise enough to fix in a few lines:

A game that does nothing, forever

The five-second test needs something to run, and the 0.1.0 program will not even start without a game. Downloading a test ROM in every build would make releases depend on someone else’s server, so the workflow writes its own, one kilobyte long, with a single line of Python:

The phone files come with a caveat that the release notes repeat. Android installs the APK after a warning, because it is signed with a development key rather than a store key. An iPhone installs nothing that has not been signed by a registered developer, so the .ipa is a starting point for people who have such an account, or a tool that signs with theirs.