Skip to content

Architecture Overview

DoubleGate edited this page Jun 29, 2026 · 1 revision

Architecture Overview

Purpose

This document fixes the high-level architectural decisions for RustyNES. Subsystem docs take these as given and elaborate one chip each.

Decision: Tight Lockstep at PPU-Dot Resolution

Three scheduling strategies were viable: (A) tight lockstep with PPU as master, (B) catch-up at CPU-instruction granularity, (C) hybrid. Option A is selected.

  • The user's stated goal is cycle accuracy; lockstep is the only model that makes mid-instruction PPU events (sprite-zero hit, mid-scanline scroll writes, MMC3 IRQ at PPU dot 260) trivially correct rather than requiring per-quirk patches.
  • Mesen2 and ares — the two leading accuracy-first emulators — both use lockstep; this puts our design on the same axis they have validated.
  • The performance penalty is modest on modern hardware (≤ 2 ms per frame core work on a 2018-era laptop chip).

Workspace Shape (Cargo Workspace)

The no_std-friendly chip stack + the glue core form the deterministic emulation foundation. The remaining crates are additive host/platform/tooling layers that never perturb the core's per-frame output.

crates/
├── rustynes-core/           # Glue: NES struct, run loop, scheduler, save state, region config
├── rustynes-cpu/            # Ricoh 2A03 CPU (6502 + interrupt logic). No PPU/APU deps.
├── rustynes-ppu/            # 2C02 PPU. Depends on rustynes-mappers.
├── rustynes-apu/            # 2A03 APU. Depends on rustynes-cpu only for DMC DMA hooks.
├── rustynes-mappers/        # Cartridge + mapper trait + 172 mapper families.
├── rustynes-frontend/       # winit + wgpu + cpal + egui desktop app.
├── rustynes-netplay/        # GGPO-style rollback netplay (UDP + WebRTC).
├── rustynes-script/         # Sandboxed Lua 5.4 scripting engine.
├── rustynes-mobile/         # Shared UniFFI mobile bridge.
└── rustynes-android/        # Android JNI platform glue.

Why split this way

  • Standalone components so they can be fuzzed and benchmarked in isolation.
  • rustynes-mappers standalone so that adding a mapper does not touch CPU, PPU, or APU code.
  • Bus ownership: The bus type owns the PPU, APU, mapper, controllers, and WRAM. The CPU borrows the bus during tick(). This single choice avoids borrow-checker fights.

Scheduling Model

The Master Clock is the PPU Dot

NTSC: 5.369318 MHz. PAL: 5.320342 MHz. Dendy: 5.320342 MHz. The CPU advances 1/3 of the time on NTSC and Dendy, 1/3.2 on PAL. The APU clocks every other CPU cycle.

fn tick_one_dot(&mut self) {
    self.ppu.tick(&mut self.ppu_bus);
    self.dot_count += 1;
    if self.dot_count % 3 == self.cpu_phase {
        self.cpu_tick();
    }
}

Bus Design

The bus owns: PPU, APU, mapper (via cart), WRAM, controllers, open-bus latch. The CPU borrows &mut Bus during tick(). PPU and APU each get their own bus trait for the things they need:

  • PpuBus: ppu_read/write (delegates to mapper); notify_a12 (mapper IRQ).
  • ApuBus: dmc_read; dmc_halt_request (queues DMA); raise_irq.

DMA Controller

DMA only halts on a CPU read cycle. DMC DMA gets precedence over OAM DMA. Load and reload DMC DMA are strictly separated to preserve accurate $4015 observable side-effects.

Module Boundaries and Key Invariants

  • CPU never touches PPU or APU directly. Always through the bus, which already has them synced.
  • PPU owns its 2 KB internal VRAM but reads CHR through the mapper.
  • APU owns its register state and channel timers, and asks the CPU bus for DMC sample bytes via a DMA request channel.
  • Mappers see two independent buses: CPU bus ($4020-$FFFF) and PPU bus ($0000-$1FFF + $2000-$3FFF).
  • Open-bus state belongs to the device that drives the bus.
  • Region timing is data, not a build-time fork.

Concurrency Model

The core is single-threaded. Frontend runs the audio callback on cpal's audio thread, which reads from a lock-free SPSC ring buffer that the run-loop thread fills. Rendering (wgpu) and emulator stepping share the main thread; winit's event loop drives Nes::run_frame().

Determinism Contract

The scheduler is fully deterministic given:

  • A fixed cpu_phase (chosen at power-cycle from a seedable PRNG).
  • A fixed initial WRAM pattern (deterministic seeded fill).
  • A fixed sequence of controller inputs.
  • A fixed initial DMA get/put phase.
  • A fixed region timing profile and reset/power-up mode.

This guarantees that save/load round-trips and a re-played input sequence produce bit-identical framebuffer + audio output. Required for TAS playback, regression tests, and rollback netplay.

Clone this wiki locally