Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Architecture overview

This chapter is the map; the following chapters zoom into the timing model, the chipset modules, the video pipeline, the CPU integration, the peripherals, and the save-state format.

Source layout

src/
  main.rs           # thin CLI binary: config load, boot
  cli.rs            # binary-local command-line parser
  bin/bench.rs      # copperline-bench headless benchmark (native + wasm32-wasip1)
  lib.rs            # the copperline library crate all of src/ lives in
  config/           # TOML config + validation + machine profiles
                    #   (mod.rs, raw.rs, validate.rs, resolve.rs, about.rs, tests.rs)
  envcfg.rs         # cached COPPERLINE_* environment-variable snapshot
  emulator.rs       # frame loop driving CPU, chipset, and host I/O
  debugger.rs       # env-driven headless debugger
  waveform.rs       # trigger-based VCD chipset-signal capture (GTKWave)
  disasm.rs         # 68000 + Copper-list disassemblers
  gdbstub.rs        # GDB remote-protocol stub (host debugger transport)
  amigaos.rs        # read-only exec.library structure walking for the debugger
  cpu.rs            # m68k core wrapper and CPU-visible bus adapter
  cache.rs          # 68020/030/040 on-chip instruction/data cache model
  bus.rs            # shared RAM, ROM, chipset, CIA, RTC, and I/O state
  bus/              # size-split impl Bus continuations + the bus test suite
    custom_regs.rs  #   custom-chip register read/write dispatch
    dma_slots.rs    #   chip-bus slot arbitration + DMA scheduling
    ddf_line.rs     #   per-line DDF sequencer walk for slot planning
    collisions.rs   #   live (beam-timed) collision accumulation
    frame_capture.rs#   per-frame sprite/bitplane DMA + render capture
    wave.rs         #   waveform-capture signal sampling hooks
  memory.rs         # chip/slow RAM, ROM, extended ROM containers
  romsearch.rs      # locate the bundled AROS default boot ROM
  zorro.rs          # Zorro II/III autoconfig chain and boards
  zorro_device.rs   # the functional-Zorro-board boundary (ZorroDevice trait)
  wasmboard.rs      # WASM plugin boards under wasmtime (wasm-boards feature)
  wasm_manifest.rs  # plugin manifest types shared with wasmtime-less builds
  floppy/           # timed disk DMA controller + disk image decoding
                    #   (mod.rs controller/drive core, formats.rs images, tests.rs)
  dms.rs            # DMS archive decompression
  gzip.rs           # gzip member loop behind ADZ/HDZ (multi-member, padding-tolerant)
  drive_sounds.rs   # synthesized floppy-drive sound effects
  gary.rs           # Gary motherboard address decode (big-box machines)
  ramsey.rs         # Ramsey memory controller registers (A3000/A4000)
  gayle.rs          # A600/A1200 Gayle gate array + IDE
  ata.rs            # shared ATA task-file/drive model behind Gayle and A4000 IDE
  ide_a4000.rs      # A4000 motherboard IDE at $DD2020
  a2091.rs          # A2091 SCSI controller board (DMAC + boot ROM)
  a4091.rs          # A4091 Zorro III SCSI-2 controller (NCR 53C710)
  scsi.rs           # WD33C93A SBIC + SCSI-2 disk targets
  sdmac.rs          # A3000 Super DMAC fronting the WD33C93
  harddrive.rs      # shared hard-drive image backend (IDE + SCSI)
  dirfs.rs          # host directory -> in-memory FFS/OFS partition image
  filesys.rs        # host directories mounted live as AmigaDOS volumes
  a2065.rs          # A2065 Zorro II Ethernet board (Am7990 LANCE)
  net/              # loopback, userspace NAT, and host-adapter bridge backends
  cdrom.rs          # CD images: cue sheets (BINARY/WAVE/MP3), ISO, NRG, CHD
  cdtv.rs           # CDTV DMAC + Matshita drive model
  akiko.rs          # CD32 Akiko (C2P, NVRAM, Chinon drive)
  cd32_fmv.rs       # CD32 FMV cartridge (CL450 video + L64111 MPEG audio)
  rtc.rs            # MSM6242-compatible battery RTC
  serial.rs         # Paula serial sinks (stdout, TCP, pty)
  midi/             # host MIDI serial bridge (CoreMIDI / ALSA / WinMM)
  audio.rs          # AudioSink trait + cpal/WAV/null outputs
  priority.rs       # opt-in realtime-like thread scheduling (pacer + audio)
  gamepad.rs        # gilrs input: database layout + calibration override
  screenshot.rs     # PNG export helpers
  recorder.rs       # video+audio capture (ZMBV/PCM AVI writer)
  inputrec.rs       # live-input recording to the scripted-input format
  inputsched.rs     # deterministic input replay for reverse debugging
  savestate.rs      # whole-machine snapshot/restore (versioned file format)
  timetravel.rs     # reverse-debugging snapshot ring + replay
  timestamp.rs      # compact wall-clock stamps for generated filenames
  timebase.rs       # host clock imports shared with the browser build
  chipset/
    agnus.rs        # beam counters, DMACON, display fetch, arbitration data
    copper.rs       # Copper decode + cycle-stepped execution
    ddf_sequencer.rs#   Agnus DDF comparator/flop model (bitplane fetch gating)
    blitter.rs      # scheduled per-DMA-slot blitter engine
    paula.rs        # interrupts, audio DMA, serial, disk regs
    denise.rs       # palette + bitplane/sprite control registers
    cia.rs          # 8520 CIA model (CIA-A and CIA-B)
    keyboard.rs     # bit-timed 6500/1 keyboard MCU into CIA-A
  video/
    beam.rs         # beam-position event index for the renderer
    bitplane.rs     # event replay + planar->RGBA renderer
    bitplane/       # size-split renderer submodules + its test suite
                    #   (sprite.rs, fetch.rs, output.rs, diag.rs, tests.rs)
    deinterlace.rs  # motion-adaptive deinterlacer
    present_common.rs # frontend-independent post-processing + TV apertures
    window.rs       # winit ApplicationHandler + render worker + main/tool pixels surfaces + status bar
    window/         # size-split App impl blocks + window submodules + its test suite
                    #   (app_input.rs, app_menus.rs, app_debugger.rs, app_launcher.rs,
                    #    app_session.rs, app_display.rs, app_panels.rs, app_media.rs,
                    #    statusbar.rs, present.rs, host_input.rs, console.rs, tests.rs)
    ui.rs           # pop-up menu, overlay panels, and debugger/analyzer panel drawing
    launcher.rs     # machine-configuration (launcher) screen
    font.rs         # 8x8 overlay font
crates/copperline-web/   # standalone wasm-bindgen browser frontend (WebEmu + page glue)
crates/cputest-runner/   # WinUAE cputest instruction-suite runner for the m68k core
tests/              # ignored integration tests (need local ROM assets)
timing-test/        # bootable cross-emulator timing-measurement disk

The big picture

Copperline’s emulated machine runs synchronously on the main thread inside winit’s event loop. There is no emulation thread and no locking between CPU and chipset: each turn of the loop (Emulator::step_real, src/emulator.rs) advances the deterministic core a frame’s worth of emulated time, cycle-stepping the CPU and the chipset together. By default, the completed-frame renderer runs one frame behind on a worker thread; set COPPERLINE_THREADED_RENDER=0 to use the synchronous renderer for comparison.

The winit window is only the default frontend. With the default frontend cargo feature disabled, the crate builds as the portable headless core plus the frontend-independent presentation helpers (video/present_common.rs), with no desktop dependencies; that is the surface the browser (WebAssembly) frontend in crates/copperline-web wraps (Browser build and WebAssembly integration), and cargo check --no-default-features is the CI-enforced portability invariant. Native default builds also enable the profile-stats feature for detailed MMIO-poll and render-phase diagnostics. The browser dependency deliberately leaves it disabled: browser status still measures coarse core and render cost at the wrapper boundary, but the instruction and renderer hot paths do not maintain native-only counters or sample host clocks.

The flow of a frame:

  1. The frame loop hands the CPU an instruction budget. The CPU executes one instruction at a time through the published m68k core; every memory access the instruction makes is routed through the bus adapter and billed in colour clocks (CCK, 3.546895 MHz -- the chip bus clock). The precise CPU loop selects its diagnostic-capable or branch-free normal form once per slice. Arming a debugger, trace, watchpoint, waveform PC trigger or diagnostic recorder selects the former; normal and browser runs therefore do not retest every inactive hook after every instruction. An instruction-granular control stop retains the unfinished budget and its STOP fast-forward state in Emulator; resume completes that same quantum before starting another, so a host-side observation cannot move an emulated interrupt race by repartitioning the execution loop.

  2. Advancing the clock for a CPU access also advances everything else: Agnus beam counters, Copper fetches, blitter slots, Paula audio and disk DMA, CIA timers. The chip bus is arbitrated per colour clock, so a CPU chip-RAM access that loses arbitration genuinely waits (The timing model).

  3. When the CPU sits in STOP, the loop fast-forwards device time to the next event (timer underflow, raised interrupt) instead of spinning.

  4. Render-relevant register writes (by Copper or CPU) are recorded as beam-position events. At the frame boundary, the bus turns the completed frame’s events, chip-RAM snapshot, display geometry, and Agnus blanking latches into an owned RenderInput envelope. Large immutable RAM and bitplane-row snapshots are reference-counted across the handoff; the renderer replays that frozen data, never the live chipset state (The video pipeline).

  5. In the default path window.rs sends RenderInput to the copperline-render worker while the main thread advances the next frame. The worker paints into a CPU framebuffer, owns the deinterlacer history, and returns a presentation buffer tagged with the emulated frame. GPU upload and winit/window operations stay on the main thread.

  6. For screenshots, frame dumps, video recording, debugger stepping, and run-to-PC commands, the window code waits for the worker result for the exact emulated frame being captured or inspected. For normal interactive display, one frame of presentation latency is allowed.

  7. For the interactive window the loop sleeps to pace emulated time to wall-clock; for headless captures it does not. The emulated result is identical either way -- pacing only schedules host work.

So an interactive run uses three host threads: the main thread (event loop, core, and pacer), the copperline-render worker, and the cpal audio callback that cpal owns. Only the last two cross a thread boundary with the main thread, and both do so through an owned RenderInput/presentation-buffer envelope (with immutable shared frame snapshots) and a lock-free sample ring buffer rather than shared mutable state. The pacer and the audio callback are latency-critical and can optionally be given above-normal scheduling priority ([emulation] realtime_priority, src/priority.rs); see The timing model for what that does per platform.

The Bus

Bus (src/bus.rs) owns all shared machine state: chip/slow/fast RAM and ROM (Memory), both CIAs, the RTC, all custom-chip state (Agnus, Copper, blitter, Paula, Denise), the floppy controller, the Zorro chain, and the optional Gayle, Akiko, and CDTV subsystems. Everything routes through it; there is exactly one owner for every register.

The CPU-visible memory map:

RangeContents
$000000 - chip topChip RAM (512K-2M; ROM overlaid at $0 after reset until CIA-A releases /OVL)
$200000 -Zorro II space (fast RAM autoconfig boards)
$A00000 / $BFxxxxCIA-A (odd bytes, /LDS) and CIA-B (even bytes, /UDS)
$B80000Akiko (CD32 only)
$C00000Slow (“ranger”) RAM, up to 512K
$DA0000 / $DE1000Gayle IDE task file / Gayle ID and status (A600/A1200)
$DC0000Battery RTC (MSM6242 view)
$DFF000Custom-chip register window
$E80000Zorro autoconfig window (then CDTV DMAC first on CDTV)
$E00000 / $F00000Extended ROM (CD32 / CDTV)
$F80000Kickstart ROM (512 KiB)
$40000000+Zorro III space (32-bit CPUs)

MMIO stubs cover every “gap” region DiagROM probes during fast-RAM detection, so probing software sees bus-like behaviour everywhere.

On the small-box machines (A1000/A500/A2000/CDTV), Gary’s coarse decode mirrors the custom-chip registers throughout the chip-register space where nothing else answers: the ranger space above the fitted slow RAM in $C00000-$D7FFFF, the reserved $D80000-$DBFFFF pages, and $DE0000-$DFEFFF (CpuBus::custom_reg_page_offset). Kickstart’s slow-RAM sizing depends on this: exec probes each 256K step by writing INTENA at $xxF09A and reading INTENAR at $xxF01C, extending RAM until the mirror answers -- on a floating bus KS 1.2 sizes RAM into unmapped space and dies on a yellow screen. Machines with a Gayle or Fat Gary/Ramsey decode the space properly and have no mirror.

A CPU read of an address no region claims floats to the last value the chip data bus carried (Bus.data_bus, fed by the live display and audio DMA fetches), as on real Agnus-arbitrated hardware -- not a fixed all-ones pattern; on a blank screen that value is 0. The constant matters: software that chases a pointer off into unmapped space (e.g. a filesystem walking a corrupted buffer-cache chain) can loop forever on a fixed value, where the ever-changing floating value lets the chase wander to a zero terminator as on silicon. Device windows that decode their own floating bus keep their own values -- e.g. the A2091 board’s unpopulated XT-interface bytes read $FF from its own model, not the chip data bus.

Write-only and unmapped custom-register offsets float the same way: the chips decode the address but drive no data, so move.w $DFF106,d0 returns the bus residue rather than the register’s internal latch (which the debugger still inspects separately). Each driven word cycle recharges the bus, so a longword read pairing a readable register with a write-only one floats the second word to the first -- move.l $DFF01E,d0 reads INTREQR in both halves. Reading a write-only register back and OR-ing the result into a fresh write therefore picks up garbage bits exactly as on real hardware -- a floating BPLCON3 LOCT bit, for example, misroutes AGA palette writes into the low nibbles and darkens the whole palette. The one deliberate exception is DENISEID ($07C) on OCS Denise, held at $FFFF so ECS-detection code (low byte $FC) cannot false-match a residue; see the TODO in read_custom_word.

Determinism and the host boundary

The core’s only inputs are the config, the loaded images, and the timestamped input events (host keyboard/mouse/gamepad in windowed runs; --press-after-style scripted events in headless runs). Audio is rendered in emulated time and resampled at the host boundary; wall-clock affects scheduling only. This is what makes --screenshot-after runs exactly reproducible, lets the headless debugger replay a failure deterministically, and makes save states exact: a restored run is byte-identical to one that was never interrupted.

The one host-clock value that reaches emulated state is the battery RTC: a guest that reads it ($DC0000) sees the host date and time, so RTC reads are not reproducible across wall-clock runs. COPPERLINE_RTC_FIXED_SECS= unix-seconds pins the clock to a fixed value, which is what makes differential traces against another emulator line up.

Input scripting and recording (inputrec.rs)

Scripted input (--press-after and friends, or a --script file) fires at the first frame boundary at-or-after each event’s emulated timestamp. The input recorder (window shortcut / --record-input) produces those scripts from a live session by combining two capture styles: direct hooks where event identity matters (the keyboard choke point handle_amiga_key_event, floppy inserts) and a once-per-quantum diff of the live InputState for port-1 mouse buttons, mouse motion (wrapped quadrature-counter deltas become mouse-after directives), and the port-2 joystick/CD32 pad. Two details keep record-then-replay byte-identical: recorded timestamps are the emulated times the events were applied (frame boundaries, not the wall-clock moment the host delivered them), and times/holds are floored -- never rounded -- to milliseconds, because rounding a boundary time up would push the replayed event one frame late. The end-to-end gate is the same as for save states: record a scripted run, replay the recording, cmp the screenshots. User-facing usage is in Input recording and scripts.

envcfg: environment variables are start-up settings

All COPPERLINE_* knobs are read through src/envcfg.rs, which snapshots the entire environment once into a static map on first access. Hot paths (per-instruction, per-cycle) consult these knobs; a live std::env::var call there would take the process-wide environment lock millions of times a second and starve the audio thread of the same lock.

Three consequences for contributors: