YM2149 in Rust, Part 1: Building a Cycle-Accurate Emulator

Published: (December 10, 2025 at 01:51 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

Overview

There’s a particular sound that defined a generation of computing. If you grew up with an Atari ST, ZX Spectrum, Amstrad CPC, or MSX, you know it immediately: bright, buzzy square waves layered into melodies that somehow felt more alive than they had any right to be. That sound came from a single chip — the Yamaha YM2149, or its near‑identical sibling, the General Instrument AY‑3‑8910.

I spent the past year building ym2149‑rs.org, a complete Rust ecosystem for emulating this chip and playing back the music created for it. What started as a weekend experiment turned into something much larger: a cycle‑accurate emulator, seven song‑format replayers, a Bevy game‑engine plugin, a terminal player, and a browser demo.

This is Part 1 of a 4‑part series covering the project. We’ll start with the chip itself and what it takes to emulate it properly.

The Chip That Defined an Era

The YM2149 is deceptively simple on paper:

  • Three square‑wave tone generators
  • One noise generator
  • A hardware envelope unit with 10 shapes
  • A mixer that combines them

That’s it.

But simplicity breeds creativity. Demoscene musicians in the 1980s and 1990s discovered that by manipulating these limited resources in clever ways they could produce sounds the chip was never designed to make. Examples:

  • Rapid envelope manipulation created additional waveforms (the “SID voice” technique, named after the Commodore 64’s more capable chip).
  • Synchronizing envelope restarts to tone periods produced the distinctive “Sync Buzzer” effect.
  • Sample playback through volume‑register abuse gave us “digi‑drums.”

These weren’t bugs — they were features discovered by programmers who knew the hardware intimately.

What Makes Emulation “Cycle‑Accurate”?

Most audio emulators take shortcuts: they read the register values and approximate the output. This works for casual listening but fails to reproduce the timing‑dependent effects that make YM2149 music distinctive.

The real chip runs on a master clock divided by 8 — roughly 250 kHz on most systems. At every tick:

  • Tone counters decrement and flip their output when they hit zero.
  • The noise LFSR shifts, generating pseudo‑random bits.
  • The envelope generator steps through its 128‑entry shape table.
  • All outputs feed into a logarithmic DAC.

To reproduce effects like SID voice or Sync Buzzer, you have to emulate every one of these ticks:

pub fn clock(&mut self) {
    self.subclock_counter += 1;
    if self.subclock_counter >= self.subclock_divisor {
        self.subclock_counter = 0;
        self.tick(); // Full internal state update
    }
}
fn tick(&mut self) {
    // Tone generators
    for ch in 0..3 {
        self.tone_counters[ch] -= 1;
        if self.tone_counters[ch] == 0 {
            self.tone_counters[ch] = self.tone_periods[ch];
            self.tone_outputs[ch] ^= 1;
        }
    }

    // Noise generator (17‑bit LFSR)
    self.noise_counter -= 1;
    if self.noise_counter == 0 {
        self.noise_counter = self.noise_period;
        let bit = (self.noise_lfsr ^ (self.noise_lfsr >> 3)) & 1;
        self.noise_lfsr = (self.noise_lfsr >> 1) | (bit ,          // 512 KB‑4 MB depending on model
psg: Ym2149,           // Our sound chip
mfp: Mfp68901,         // Timer chip (for interrupts)
ste_dac: Option, // STE‑only DMA audio
}
impl AtariMachine {
    pub fn run_frame(&mut self) -> Vec {
        // Execute 68000 code until next VBL
        while !self.vbl_reached() {
            self.cpu.execute_instruction(&mut self.bus);
            self.mfp.tick();

            // Collect PSG samples at audio rate
            if self.sample_clock_elapsed() {
                samples.push(self.psg.get_sample());
            }
        }
        samples
    }
}

The MFP 68901 timer chip was essential — many SNDH files use timer interrupts to trigger envelope restarts for SID‑voice effects. Without accurate timer emulation, those tracks would sound wrong.

AY files (ZXAY/EMUL)

Similar to SNDH but for the ZX Spectrum: they contain Z80 machine code, requiring a Z80 CPU emulator, a different memory map, and distinct I/O ports.

GIST files (.snd)

A lesser‑known format from the Atari ST era. GIST (Graphics, Images, Sound, Text) was a sound‑effect editor that allowed developers to create short, punchy audio — laser shots, explosions, menu beeps, item pickups. Unlike music formats, GIST files describe single sound effects using instrument definitions and envelope sequences. They’re tiny (often just a few bytes) but surprisingly expressive. The replayer supports multi‑voice playback, so effects that used multiple channels play back correctly.

Supporting all four format families (plus YMT tracker variants and GIST sound effects) required a layered architecture where the YM2149 emulator sits at the bottom, format‑specific replayers sit in the middle, and applications sit on top.

The Architecture: Layers That Don’t Leak

The ecosystem is organized into strict layers, each with a single responsibility:

  • ym2149-common – shared traits and types.
  • ym2149-core – the cycle‑accurate chip emulation.
  • Replayers – YM, SNDH, AY, Arkos, GIST, built on the core.
  • Applications – CLI player, Bevy plugin, WASM demo, consuming the replayers.

ym2149‑rs architecture diagram

Back to Blog

Related posts

Read more »