diff --git a/README.md b/README.md index 6dad212..e63aa0c 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,11 @@ against real hardware. (ADF / ADZ / ZIP / DMS, read-only SCP), Gayle IDE, A2091 SCSI, and CDTV/CD32 CD. - **Tooling**: an in-window debugger that can step backwards, an - interactive chip-bus frame analyzer, remote GDB support, deterministic - save states, input recording/replay, and headless screenshot/frame-dump - capture -- the deterministic core makes every replay byte-identical. + interactive chip-bus frame analyzer, a trigger-based VCD waveform export + of the chipset signals for GTKWave (`docs/debugger/waveform.md`), remote + GDB support, deterministic save states, input recording/replay, and + headless screenshot/frame-dump capture -- the deterministic core makes + every replay byte-identical. - **A browser build**: the same core compiled to WebAssembly with a canvas/Web Audio frontend, hosted at [copperline.dev/try](https://copperline.dev/try/) -- boots the bundled diff --git a/docs/debugger/console.md b/docs/debugger/console.md index 9e77303..4dc7470 100644 --- a/docs/debugger/console.md +++ b/docs/debugger/console.md @@ -87,6 +87,8 @@ Inspection and modification: | `SETREG REG VAL` | Set a CPU register (`SETREG D0 1234`) | | `TRACE START [PATH]` | Start a runtime instruction trace: one disassembled line per retired instruction with its beam position, no env var or restart needed (capped at a million lines) | | `TRACE STOP` / `TRACE` | Stop the trace / report its progress | +| `WAVE START [PATH] [TRIGGER] [DURATION] [SIGNALS]` | Arm a trigger-based VCD "logic analyser" capture of chipset signals for GTKWave; the arguments are order-free and all optional (see [](waveform.md)) | +| `WAVE STOP` / `WAVE` | Finish the capture early / report its progress | | `HELP`, `CLEAR`, `CLOSE` | Console housekeeping | Memory hunting (a trainer-style delta search over all writable RAM -- diff --git a/docs/debugger/waveform.md b/docs/debugger/waveform.md new file mode 100644 index 0000000..3ea7427 --- /dev/null +++ b/docs/debugger/waveform.md @@ -0,0 +1,111 @@ +# Waveform export (VCD logic analyser) + +Copperline can record its internal chipset signals -- beam counters, chip-bus +owner, CPU bus accesses, Copper and blitter state, custom-register writes, +interrupt levels, DMA activity -- into a [VCD](https://en.wikipedia.org/wiki/Value_change_dump) +file that [GTKWave](https://gtkwave.sourceforge.net/) (or any VCD viewer) +displays as a logic-analyser trace. Because the emulator arbitrates the chip +bus per colour clock, the trace shows exactly which device owned every bus +slot and how the Copper, blitter, CPU, and DMA interleave -- the view a logic +analyser probing a real Amiga's bus would give, plus internal state no probe +could reach. + +A capture is *trigger-based* and *bounded*: it arms, waits for a trigger (a +CPU PC, a beam position, a register write, an emulated time, or immediately), +records for a fixed window, then finishes the file. This keeps files small +and puts the interesting event at the start of the trace. + +## Starting a capture + +From the command line (works headless, e.g. together with +`--screenshot-after`): + +```sh +copperline --config game.toml --noaudio \ + --waveform out.vcd \ + --wave-trigger pc=0x00C033C2 \ + --wave-duration 20000cck \ + --wave-signals cpu,bus,copper,blitter \ + --screenshot-after 30 /tmp/shot.png +``` + +From the debugger console (`Cmd/Alt+K`), with the same order-free arguments: + +``` +WAVE START out.vcd pc=C033C2 20000cck cpu,bus,copper,blitter +WAVE (status) +WAVE STOP (finish early) +``` + +From the debugger window (`Cmd/Alt+B`): the **Wave** tab has Arm and Stop +buttons; type the same order-free spec into the entry box (empty means all +defaults) and click Arm. + +Everything is optional: the default trigger is `now`, the default duration +one video frame, the default signal set `all`, and an omitted path becomes +`copperline-wave-.vcd` in the working directory. + +## Triggers + +| Spec | Fires when | +|---|---| +| `now` | immediately on arming (default) | +| `pc=ADDR` | the CPU retires the instruction at hex ADDR | +| `beam=VPOS` / `beam=VPOS:HPOS` | the beam crosses the (decimal) position | +| `reg=OFF` | a custom register is written (hex word offset, e.g. `reg=180` for COLOR00) | +| `time=SECS` | emulated time reaches SECS (fractional ok) | + +## Durations + +`20000cck` (colour clocks; a bare number means cck), `2f` / `2frames`, +`50ms`, `1.5s`. The default is one frame. A safety cap bounds any capture at +10 emulated seconds, and an emergency stop finishes the file if it passes +512 MB. + +## Signal groups + +Select with `--wave-signals` (comma list) or the same list as a console/GUI +token. Default: `all`. + +| Group | VCD variables | +|---|---| +| `beam` | `vpos[15:0]`, `hpos[7:0]`, `frame[31:0]` | +| `bus` | `owner[3:0]`, `owner_name` (string), `dmacon[15:0]`, `data[15:0]` (the shared chip data bus latch) | +| `cpu` | `addr[23:0]`, `kind` (fetch/read/write/custom), `rw`, `wait_cck[15:0]` -- one record per granted CPU **chip-bus** slot (fast-RAM traffic never touches the chip bus) | +| `copper` | `pc[23:0]`, `state` (run/wait/skip/jump/stop) | +| `blitter` | `busy`, `slot` (the pipeline cycle label: A/B/C/D, line-mode L1..L4, fill FI, ...), `apt/bpt/cpt/dpt[23:0]` | +| `regs` | `off[8:0]`, `value[15:0]`, `source` (cpu/copper), `strobe` (toggles per write so identical back-to-back writes stay visible) | +| `irq` | `ipl[2:0]` (the level presented to the CPU, after INTEN masking), `intreq[15:0]`, `intena[15:0]` | +| `audio` | `channel[1:0]` + `strobe` per audio DMA grant | + +`owner` uses the bus-accounting indices (0 refresh, 1 bitplane, 2 sprite, +3 disk, 4 audio, 5 copper, 6 blitter, 7 cpu, 8 idle); `owner_name` carries +the same information as text, which GTKWave shows directly on the wave. + +## Time base + +One VCD time unit is one colour clock, with timestamps relative to the +trigger. The file declares `$timescale 1 us` -- the closest legal VCD unit -- +so cursor deltas in GTKWave read directly as colour clocks (a PAL line is +227, a PAL frame 71,364). Signals are sampled at the chip-bus arbitration +point, so a value change lands on the exact colour clock it took effect. + +## Viewing + +```sh +gtkwave out.vcd # macOS: brew install gtkwave +vcd2fst out.vcd out.fst # optional: GTKWave's compact FST format +``` + +In GTKWave, append the signals you care about from the `copperline` scope +tree, set `owner_name`/`state`/`slot` to ASCII display, and use marker +deltas to measure in colour clocks. + +## Notes + +- A capture observes the machine without disturbing it: no timing changes, + and the hot path costs a single branch while nothing is armed. +- Arming replaces (and finishes) any previous capture; captures do not + survive save-state loads. +- The `pc=` trigger matches retired instructions, so it works with every + CPU model including prefetch/cache effects. diff --git a/docs/debugger/window.md b/docs/debugger/window.md index 0cdc989..dd72e68 100644 --- a/docs/debugger/window.md +++ b/docs/debugger/window.md @@ -146,6 +146,15 @@ The Break tab with a PC breakpoint, a memory watchpoint, and a chipset-register watch armed. ``` +**Wave** arms and monitors the VCD "logic analyser" export +([](waveform.md)): **Arm** starts a capture from the order-free spec in the +`$` box -- any of an output path, a trigger (`PC=C033C2`, `BEAM=100:64`, +`REG=180`, `TIME=2.5`, `NOW`), a duration (`20000CCK`, `2F`, `50MS`), and a +signal-group list (`CPU,BUS,COPPER`); an empty box means all defaults. +**Stop** finishes the file early. The tab shows the capture's state +(armed / capturing with progress / done), its trigger, and where the file +is going. The console `WAVE` command drives the same capture. + ## Breakpoints and watchpoints On the Break tab, type an address into the `$` box and toggle any of: diff --git a/docs/debugger/workflows.md b/docs/debugger/workflows.md index 1dbfa69..12777ad 100644 --- a/docs/debugger/workflows.md +++ b/docs/debugger/workflows.md @@ -129,6 +129,25 @@ symbols, while source lines, `next`, and `print` need an ELF-with-DWARF sibling of the binary. The walkthrough lives in the [GDB chapter](gdb), "Source-Level Debugging of Amiga Programs". +## See the bus like a logic analyser + +When the question is *interleaving* -- who owned which DMA slot, when the +blitter stalled the CPU, where the Copper woke relative to the display +fetch -- text traces are the wrong shape. Capture a waveform instead: + +``` +WAVE START glitch.vcd beam=100 2f +``` + +arms a capture that triggers when the beam reaches line 100 and records two +frames of every signal group (bus owner per colour clock, Copper PC/state, +blitter pipeline slots and pointers, register writes, IPL/INTREQ/INTENA, +CPU chip-bus accesses). Open `glitch.vcd` in GTKWave and read the bus +arbitration directly off the screen; marker deltas measure in colour +clocks. Headless runs arm the same capture with +`--waveform glitch.vcd --wave-trigger beam=100 --wave-duration 2f`. +[](waveform.md) has the full trigger/duration/signal reference. + ## Making it reproducible Any of the above is dramatically easier when the failure replays diff --git a/docs/guide/headless.md b/docs/guide/headless.md index 0f5fa88..cbe9348 100644 --- a/docs/guide/headless.md +++ b/docs/guide/headless.md @@ -172,6 +172,11 @@ COPPERLINE_DBG_BREAK=C033C2 COPPERLINE_DBG_DUMP=C09580:4 \ --screenshot-after 30 /tmp/out.png ``` +For chip-bus timing questions, the [waveform export](../debugger/waveform) +records a trigger-based VCD trace of the internal chipset signals during +the same kind of run (`--waveform out.vcd --wave-trigger pc=0xC033C2 +--wave-duration 20000cck`) for viewing in GTKWave. + ## The vAmigaTS compatibility suite An ignored integration test runs ADFs from a local diff --git a/docs/myst.yml b/docs/myst.yml index ec83340..585c799 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -29,6 +29,7 @@ project: - file: debugger/console.md - file: debugger/headless.md - file: debugger/reverse.md + - file: debugger/waveform.md - file: debugger/gdb.md - file: debugger/workflows.md - title: Internals @@ -57,6 +58,7 @@ project: - debugger/console.md - debugger/headless.md - debugger/reverse.md + - debugger/waveform.md - debugger/gdb.md - debugger/workflows.md - internals/architecture.md diff --git a/src/bus.rs b/src/bus.rs index 7e00378..a478d36 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -931,6 +931,16 @@ pub struct Bus { current_frame_bus_trace: FrameBusTrace, #[serde(skip)] last_frame_bus_trace: Option, + /// Waveform (VCD) capture: `wave_on` is the single hot-path gate for + /// every sampling tap (true while armed or capturing), `wave_pc_trigger` + /// the per-instruction gate for the `pc=` trigger. Host-side observer + /// state, never serialized. + #[serde(skip)] + wave_on: bool, + #[serde(skip)] + pub(crate) wave_pc_trigger: bool, + #[serde(skip)] + wave: Option>, /// Debugger-window custom-register watch offsets ($000-$1FE, word /// aligned), mirrored from the CPU machine's InteractiveBreaks, and /// the first pending hit since the debugger last polled. Recorded in @@ -2216,6 +2226,9 @@ impl Bus { frame_analyzer_enabled: false, current_frame_bus_trace: FrameBusTrace::default(), last_frame_bus_trace: None, + wave_on: false, + wave_pc_trigger: false, + wave: None, ui_reg_watches: Vec::new(), ui_reg_hit: None, ui_beam_traps: Vec::new(), @@ -3597,6 +3610,9 @@ impl Bus { // A long-word access stores its second (low-word) slot. self.cpu_custom_access_slot = Some(grant_slot); } + if self.wave_on { + self.wave_note_cpu_access(addr, kind, wait_cck); + } let (cck, tick) = self.advance_one_chip_bus_quantum(Some(ChipBusOwner::Cpu)); self.note_cpu_granted_chip_bus_cycle(); self.record_slice_bus_advance(cck, tick); @@ -7446,6 +7462,7 @@ mod custom_regs; mod ddf_line; mod dma_slots; mod frame_capture; +mod wave; #[cfg(test)] mod tests; diff --git a/src/bus/custom_regs.rs b/src/bus/custom_regs.rs index 849e9eb..b5b794a 100644 --- a/src/bus/custom_regs.rs +++ b/src/bus/custom_regs.rs @@ -246,6 +246,9 @@ impl Bus { hpos: self.agnus.hpos as u16, }); } + if self.wave_on { + self.wave_note_reg_write(off, val, source); + } if is_audio_timing_custom_write(off) { self.flush_audio(); } diff --git a/src/bus/dma_slots.rs b/src/bus/dma_slots.rs index ad9fba7..cd6e7f5 100644 --- a/src/bus/dma_slots.rs +++ b/src/bus/dma_slots.rs @@ -117,6 +117,9 @@ impl Bus { self.blitter.busy, ); } + if self.wave_on { + self.wave_tap_quantum(owner); + } // The Copper was already stepped above (or is held without fetching at // the end-of-line lockout); only drive the other owners here. if !matches!(owner, ChipBusOwner::Copper) { @@ -282,6 +285,9 @@ impl Bus { if !self.ui_beam_traps.is_empty() { self.check_ui_beam_traps((old_vpos, old_hpos), old_frame_lines, tick.new_frames); } + if self.wave_on { + self.wave_note_beam((old_vpos, old_hpos), old_frame_lines, tick.new_frames); + } if tick.new_frames == 0 && tick.new_lines == 0 { self.capture_sprite_dma_words_if_due( old_vpos, diff --git a/src/bus/tests.rs b/src/bus/tests.rs index 27ddf01..fe49e6e 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -10414,3 +10414,144 @@ fn ecs_hires_bplcon1_scroll_counts_lores_pixels_on_late_ddf_row() { ); } } + +// --------------------------------------------------------------------------- +// Waveform (VCD) capture taps +// --------------------------------------------------------------------------- + +fn temp_vcd_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("copperline-wave-{}-{name}.vcd", std::process::id())) +} + +fn wave_opts(path: std::path::PathBuf) -> crate::waveform::WaveOptions { + crate::waveform::WaveOptions::new(path) +} + +#[test] +fn waveform_capture_records_beam_and_owner_changes() { + let mut bus = empty_bus(); + let path = temp_vcd_path("beam-owner"); + let _ = std::fs::remove_file(&path); + let mut opts = wave_opts(path.clone()); + opts.duration = crate::waveform::WaveDuration::Cck(500); + bus.wave_arm(opts).unwrap(); + assert_eq!(bus.wave_status().unwrap().state, "capturing"); + + // Start a small A->D blit so the blitter's owner slots and pipeline + // labels land in the trace alongside the idle bus's refresh slots. + bus.agnus.dmacon = DMACON_DMAEN | DMACON_BLTEN; + bus.blitter.bltcon0 = 0x09F0; + bus.blitter.start_scheduled((4 << 6) | 2, &bus.mem.chip_ram); + + // Refresh slots, beam movement, and line rollovers are sampled even + // with the CPU quiet. Run well past the 500 cck window so it expires. + for _ in 0..2000 { + bus.advance_one_chip_bus_quantum(None); + } + let status = bus.wave_status().unwrap(); + assert_eq!(status.state, "done"); + assert!(status.samples > 0, "{status:?}"); + assert!(status.captured_cck >= 490, "{status:?}"); + + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("$timescale 1 us $end"), "{text}"); + assert!(text.contains("$enddefinitions $end")); + // All eight signal-group scopes are declared by default. + for scope in [ + "beam", "bus", "cpu", "copper", "blitter", "regs", "irq", "audio", + ] { + assert!( + text.contains(&format!("$scope module {scope} $end")), + "missing scope {scope}" + ); + } + // The beam moved: many timestamps, and hpos changes between them. + let stamps = text.lines().filter(|l| l.starts_with('#')).count(); + assert!(stamps > 50, "only {stamps} timestamps:\n{text}"); + // Owner names appear as string-var changes (refresh slots at least). + assert!(text.contains("srefresh "), "{text}"); + // The blit shows up as the blitter owning bus slots and as its + // pipeline labels (A fetches and D writes for an A->D copy). + assert!(text.contains("sblitter "), "{text}"); + assert!(text.contains("sA "), "{text}"); + assert!(text.contains("sD "), "{text}"); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn waveform_reg_trigger_fires_on_custom_register_write() { + let mut bus = empty_bus(); + let path = temp_vcd_path("reg-trigger"); + let _ = std::fs::remove_file(&path); + let mut opts = wave_opts(path.clone()); + opts.trigger = crate::waveform::Trigger::RegWrite(0x180); + opts.duration = crate::waveform::WaveDuration::Cck(100); + bus.wave_arm(opts).unwrap(); + + // Unrelated writes and bus activity leave the capture armed. + bus.write_custom_word_from(0x096, 0x8000, BeamWriteSource::Cpu); + for _ in 0..50 { + bus.advance_one_chip_bus_quantum(None); + } + assert_eq!(bus.wave_status().unwrap().state, "armed"); + + // The matching write (COLOR00) fires the trigger and is itself the + // first recorded regs-group sample. + bus.write_custom_word_from(0x180, 0x0F0F, BeamWriteSource::Cpu); + assert_eq!(bus.wave_status().unwrap().state, "capturing"); + for _ in 0..500 { + bus.advance_one_chip_bus_quantum(None); + } + let status = bus.wave_status().unwrap(); + assert_eq!(status.state, "done"); + + let text = std::fs::read_to_string(&path).unwrap(); + // The regs group recorded offset $180 (9-bit vector 110000000) and + // value $0F0F at the trigger point, attributed to the CPU. + assert!(text.contains("b110000000 "), "{text}"); + assert!(text.contains("b0000111100001111 "), "{text}"); + assert!(text.contains("scpu "), "{text}"); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn waveform_stop_finishes_early_and_rearm_replaces() { + let mut bus = empty_bus(); + let path = temp_vcd_path("stop-early"); + let _ = std::fs::remove_file(&path); + let mut opts = wave_opts(path.clone()); + opts.duration = crate::waveform::WaveDuration::Secs(5.0); + bus.wave_arm(opts).unwrap(); + for _ in 0..50 { + bus.advance_one_chip_bus_quantum(None); + } + let status = bus.wave_stop().unwrap(); + assert_eq!(status.state, "done"); + assert!(bus.wave_status().is_none()); + // The finished file is flushed and parseable. + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("$enddefinitions $end")); + + // Re-arming with a beam trigger works after a stop, and the beam + // crossing fires it (line 5, any hpos). + let path2 = temp_vcd_path("beam-trigger"); + let _ = std::fs::remove_file(&path2); + let mut opts = wave_opts(path2.clone()); + opts.trigger = crate::waveform::Trigger::Beam { + vpos: 5, + hpos: None, + }; + opts.duration = crate::waveform::WaveDuration::Cck(50); + bus.wave_arm(opts).unwrap(); + assert_eq!(bus.wave_status().unwrap().state, "armed"); + // 5 lines of 227 cck arrive within ~2000 quanta on an idle bus. + for _ in 0..4000 { + bus.advance_one_chip_bus_quantum(None); + if bus.wave_status().unwrap().state != "armed" { + break; + } + } + assert_ne!(bus.wave_status().unwrap().state, "armed"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&path2); +} diff --git a/src/bus/wave.rs b/src/bus/wave.rs new file mode 100644 index 0000000..96ca35a --- /dev/null +++ b/src/bus/wave.rs @@ -0,0 +1,241 @@ +//! Waveform-capture taps: the bus-side half of the VCD "logic analyser" +//! export (src/waveform.rs owns the writer and capture state machine). +//! +//! The per-quantum sampler joins the other gated instrumentation sinks in +//! `advance_one_chip_bus_quantum_limited`; register writes and CPU chip-bus +//! grants tap their existing choke points. Every tap is fenced behind the +//! plain `wave_on` bool so the hot path costs a single predictable branch +//! while no capture is armed. + +use super::{BeamWriteSource, Bus, ChipBusOwner, CpuBusAccessKind, CHIP_BUS_OWNER_NAMES}; +use crate::chipset::paula::{pending_ipl, INT_MASTER, PAULA_CLOCK_HZ}; +use crate::waveform::{QuantumSample, Trigger, WaveCapture, WaveOptions, WaveStatus}; + +impl Bus { + /// Arm a waveform capture, replacing (and finishing) any existing one. + /// The VCD declaration section is written immediately; value changes + /// start when the trigger fires. + pub fn wave_arm(&mut self, opts: WaveOptions) -> std::io::Result<()> { + if let Some(mut old) = self.wave.take() { + old.finish(); + } + let capture = WaveCapture::create(opts)?; + let status = capture.status(); + log::info!( + "waveform: armed (trigger {}, duration {}, signals {}) -> {}", + status.trigger, + status.duration, + status.signals, + capture.path().display() + ); + self.wave_pc_trigger = matches!(capture.trigger(), Trigger::Pc(_)); + let immediate = matches!(capture.trigger(), Trigger::Now); + self.wave_on = true; + self.wave = Some(Box::new(capture)); + if immediate { + self.wave_fire(); + } + Ok(()) + } + + /// Stop and discard the capture (finishing its file), returning the + /// final status. None when no capture exists. + pub fn wave_stop(&mut self) -> Option { + self.wave_on = false; + self.wave_pc_trigger = false; + let mut wave = self.wave.take()?; + wave.finish(); + Some(wave.status()) + } + + /// Status of the current capture (armed, capturing, or done), if any. + pub fn wave_status(&self) -> Option { + self.wave.as_deref().map(WaveCapture::status) + } + + /// Transition Armed -> Capturing at the current emulated time. + fn wave_fire(&mut self) { + let cck_per_frame = + u64::from(self.agnus.current_frame_lines()) * u64::from(self.agnus.current_line_cck()); + let now = self.emulated_cck; + self.wave_pc_trigger = false; + if let Some(wave) = self.wave.as_deref_mut() { + wave.fire(now, cck_per_frame, PAULA_CLOCK_HZ as f64); + } + log::info!( + "waveform: trigger fired at [{},{}] frame {}", + self.agnus.vpos, + self.agnus.hpos, + self.emulated_frames + ); + } + + /// Close out the capture window: flush the file and drop back to the + /// zero-cost path. The finished capture stays around for status queries. + fn wave_finish(&mut self) { + self.wave_on = false; + self.wave_pc_trigger = false; + if let Some(wave) = self.wave.as_deref_mut() { + wave.finish(); + if wave.write_failed() { + log::warn!( + "waveform: capture aborted by a write error; {} is incomplete", + wave.path().display() + ); + } else { + log::info!( + "waveform: wrote {} ({} samples)", + wave.path().display(), + wave.samples() + ); + } + } + } + + /// Per-quantum sampler and Now/Time trigger check. Called from the + /// chip-bus arbitration point with the owner of the quantum that is + /// about to elapse; the beam counters and `emulated_cck` still hold + /// the quantum-start position. + pub(super) fn wave_tap_quantum(&mut self, owner: ChipBusOwner) { + if let Some(wave) = self.wave.as_deref() { + if wave.is_armed() { + let fire = match wave.trigger() { + Trigger::Now => true, + Trigger::Time(secs) => self.emulated_seconds() >= secs, + _ => false, + }; + if fire { + self.wave_fire(); + } + } + } + if !self.wave.as_deref().is_some_and(WaveCapture::is_capturing) { + return; + } + let pending = self.paula.intena & self.cpu_visible_intreq(); + let ipl = if self.paula.intena & INT_MASTER == 0 { + 0 + } else { + pending_ipl(pending) + }; + let audio_channel = if matches!(owner, ChipBusOwner::Audio) { + Self::audio_dma_channel_at(self.agnus.hpos).map(|channel| channel as u8) + } else { + None + }; + let sample = QuantumSample { + cck: self.emulated_cck, + vpos: self.agnus.vpos, + hpos: self.agnus.hpos, + frame: self.emulated_frames, + owner_index: owner.accounting_index() as u8, + owner_name: CHIP_BUS_OWNER_NAMES[owner.accounting_index()], + dmacon: self.agnus.dmacon, + data_bus: self.data_bus, + cop_pc: self.copper.pc(), + cop_state: self.copper.state_label(), + blt_busy: self.blitter.busy, + blt_slot: self.blitter.current_slot_label(), + blt_pt: [ + self.blitter.bltapt, + self.blitter.bltbpt, + self.blitter.bltcpt, + self.blitter.bltdpt, + ], + ipl, + intreq: self.paula.intreq, + intena: self.paula.intena, + audio_channel, + }; + let now = self.emulated_cck; + let Some(wave) = self.wave.as_deref_mut() else { + return; + }; + wave.sample_quantum(&sample); + if wave.expired(now) { + self.wave_finish(); + } + } + + /// Beam-trigger check, mirroring the crossing semantics of + /// `check_ui_beam_traps`: called after the beam advanced from `old` + /// (exclusive) to the current position (inclusive). Only flips the + /// capture on -- it never stops the machine. + pub(super) fn wave_note_beam( + &mut self, + old: (u32, u32), + old_frame_lines: u32, + new_frames: u32, + ) { + let Some(wave) = self.wave.as_deref() else { + return; + }; + if !wave.is_armed() { + return; + } + let Trigger::Beam { vpos, hpos } = wave.trigger() else { + return; + }; + let target = (u32::from(vpos), u32::from(hpos.unwrap_or(0))); + let cur = (self.agnus.vpos, self.agnus.hpos); + let hit = match new_frames { + 0 => old < target && target <= cur, + 1 => (target > old && target.0 < old_frame_lines) || target <= cur, + _ => target.0 < old_frame_lines || target <= cur, + }; + if hit { + self.wave_fire(); + } + } + + /// Custom-register-write tap: `reg=` trigger plus the `regs` group + /// sample. Offsets are recorded mirror-free ($000-$1FE). + pub(super) fn wave_note_reg_write(&mut self, off: u16, value: u16, source: BeamWriteSource) { + let off = off & 0x1FE; + if let Some(wave) = self.wave.as_deref() { + if wave.is_armed() && wave.trigger() == Trigger::RegWrite(off) { + self.wave_fire(); + } + } + let cck = self.emulated_cck; + let source = match source { + BeamWriteSource::Copper => "copper", + BeamWriteSource::Cpu | BeamWriteSource::CpuCopperIrq => "cpu", + }; + if let Some(wave) = self.wave.as_deref_mut() { + wave.sample_reg_write(cck, off, value, source); + } + } + + /// CPU chip-bus grant tap (`cpu` group): the granted slot's beam + /// position has been reached but the quantum has not elapsed yet, so + /// `emulated_cck` matches the quantum sampler's timestamp. + pub(super) fn wave_note_cpu_access( + &mut self, + addr: Option, + kind: CpuBusAccessKind, + wait_cck: u32, + ) { + let cck = self.emulated_cck; + let (kind_name, is_write) = match kind { + CpuBusAccessKind::Fetch => ("fetch", false), + CpuBusAccessKind::Read => ("read", false), + CpuBusAccessKind::Write => ("write", true), + CpuBusAccessKind::Custom => ("custom", false), + }; + if let Some(wave) = self.wave.as_deref_mut() { + wave.sample_cpu_access(cck, addr, kind_name, is_write, wait_cck); + } + } + + /// `pc=` trigger check, called per retired instruction while the + /// cheap `wave_pc_trigger` gate is set. + pub(crate) fn wave_note_pc(&mut self, pc: u32) { + let Some(wave) = self.wave.as_deref() else { + return; + }; + if wave.is_armed() && wave.trigger() == Trigger::Pc(pc) { + self.wave_fire(); + } + } +} diff --git a/src/chipset/copper.rs b/src/chipset/copper.rs index 7a7803a..bd06fda 100644 --- a/src/chipset/copper.rs +++ b/src/chipset/copper.rs @@ -603,6 +603,18 @@ impl Copper { self.pc } + /// Coarse state name (run/wait/skip/jump/stop) for diagnostics and + /// the waveform export; the phase detail stays private. + pub fn state_label(&self) -> &'static str { + match self.state { + CopperState::Running => "run", + CopperState::Waiting { .. } => "wait", + CopperState::Skipping { .. } => "skip", + CopperState::Jumping { .. } => "jump", + CopperState::Stopped => "stop", + } + } + /// Whether the first word of an instruction has been fetched but not /// its second (the PC points at the second word). Lets the debugger /// anchor its listing on the current instruction's start address. diff --git a/src/cpu.rs b/src/cpu.rs index ca6c8b3..ddac24d 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1518,6 +1518,22 @@ impl M68kMachine { .map(|trace| (trace.path.as_path(), trace.lines)) } + /// Arm a waveform (VCD) capture on the machine, replacing any + /// existing one. See src/waveform.rs. + pub fn ui_wave_start(&mut self, opts: crate::waveform::WaveOptions) -> std::io::Result<()> { + self.bus.bus.wave_arm(opts) + } + + /// Stop and discard the waveform capture, returning its final status. + pub fn ui_wave_stop(&mut self) -> Option { + self.bus.bus.wave_stop() + } + + /// Status of the waveform capture (armed/capturing/done), if any. + pub fn ui_wave_status(&self) -> Option { + self.bus.bus.wave_status() + } + fn ui_trace_record(&mut self, pc: u32) { use std::io::Write; let (text, _) = crate::disasm::disassemble( @@ -1952,6 +1968,11 @@ impl M68kMachine { if self.ui_trace.is_some() { self.ui_trace_record(dbg_pc_before); } + if self.bus.bus.wave_pc_trigger { + self.bus + .bus + .wave_note_pc(dbg_pc_before & self.cpu.address_mask); + } self.debug_check_ipl(dbg_ipl_before, positive_cpu_cycles(cycles)); if self.ui_breaks.armed() { self.ui_check_breaks_after_step(); diff --git a/src/lib.rs b/src/lib.rs index d4e3d05..6495cbf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,5 +63,6 @@ pub mod video; pub mod wasm_manifest; #[cfg(feature = "wasm-boards")] pub mod wasmboard; +pub mod waveform; pub mod zorro; pub mod zorro_device; diff --git a/src/main.rs b/src/main.rs index 41f77fd..82ede2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,10 @@ pub struct CliArgs { /// is intended for debugging flicker and frame-to-frame palette /// changes that a single screenshot cannot show. pub frame_dump: Option, + /// `--waveform PATH` (+ `--wave-trigger/--wave-duration/--wave-signals`): + /// arm a trigger-based VCD logic-analyser capture of internal chipset + /// signals for GTKWave (see docs/debugger/waveform.md). + pub waveform: Option, /// Scripted key presses to inject after the window opens. Useful /// for headless testing of menus and modifier chords. pub press_after: Vec, @@ -244,6 +248,10 @@ where let mut joy_after: Vec<(f32, JoyButtonKind, u32)> = Vec::new(); let mut mouse_after: Vec<(f32, i32, i32)> = Vec::new(); let mut record_input: Option = None; + let mut wave_path: Option = None; + let mut wave_trigger: Option = None; + let mut wave_duration: Option = None; + let mut wave_signals: Option = None; let mut disk_insert_after: Vec = Vec::new(); let mut audio_live = true; let mut explicit_audio_live = false; @@ -511,6 +519,39 @@ where .ok_or_else(|| anyhow!("--dump-frames requires a directory"))?; dump_dir = Some(PathBuf::from(path)); } + "--waveform" => { + let path = args + .next() + .ok_or_else(|| anyhow!("--waveform requires a VCD output path"))?; + wave_path = Some(PathBuf::from(path)); + } + "--wave-trigger" => { + const USAGE: &str = + "--wave-trigger SPEC: now, pc=ADDR, beam=VPOS[:HPOS], reg=OFF, or time=SECS"; + let spec = args.next().ok_or_else(|| anyhow!(USAGE))?; + wave_trigger = Some( + copperline::waveform::parse_trigger(&spec) + .ok_or_else(|| anyhow!("bad trigger {spec:?}; {USAGE}"))?, + ); + } + "--wave-duration" => { + const USAGE: &str = + "--wave-duration SPEC: Ncck (bare N is cck), Nf/Nframes, Nms, or Ns"; + let spec = args.next().ok_or_else(|| anyhow!(USAGE))?; + wave_duration = Some( + copperline::waveform::parse_duration(&spec) + .ok_or_else(|| anyhow!("bad duration {spec:?}; {USAGE}"))?, + ); + } + "--wave-signals" => { + const USAGE: &str = "--wave-signals LIST: comma list of \ + beam, bus, cpu, copper, blitter, regs, irq, audio, or all"; + let spec = args.next().ok_or_else(|| anyhow!(USAGE))?; + wave_signals = Some( + copperline::waveform::parse_signals(&spec) + .ok_or_else(|| anyhow!("bad signal list {spec:?}; {USAGE}"))?, + ); + } "--dump-start" => { dump_start_secs = next_arg( &mut args, @@ -604,6 +645,29 @@ where None } }; + let waveform = match wave_path { + Some(path) => { + let mut opts = copperline::waveform::WaveOptions::new(path); + if let Some(trigger) = wave_trigger { + opts.trigger = trigger; + } + if let Some(duration) = wave_duration { + opts.duration = duration; + } + if let Some(signals) = wave_signals { + opts.signals = signals; + } + Some(opts) + } + None => { + if wave_trigger.is_some() || wave_duration.is_some() || wave_signals.is_some() { + return Err(anyhow!( + "--wave-trigger/--wave-duration/--wave-signals require --waveform PATH" + )); + } + None + } + }; Ok(CliArgs { config_path, rom_path, @@ -613,6 +677,7 @@ where benchmark_until, gdb, frame_dump, + waveform, press_after, click_after, joy_after, @@ -672,6 +737,14 @@ fn print_help() { --dump-frames DIR dump consecutive PNG frames into DIR, then exit\n \ --dump-start SECS start frame dumping after SECS seconds (default: 0)\n \ --dump-count COUNT number of frames to dump with --dump-frames\n \ + --waveform PATH arm a VCD logic-analyser capture of chipset signals\n \ + \x20 for GTKWave (see docs/debugger/waveform.md)\n \ + --wave-trigger SPEC capture trigger: now (default), pc=ADDR,\n \ + \x20 beam=VPOS[:HPOS], reg=OFF, or time=SECS\n \ + --wave-duration SPEC capture length: Ncck (bare N is cck), Nf,\n \ + \x20 Nms, or Ns (default: 1 frame)\n \ + --wave-signals LIST comma list of beam, bus, cpu, copper, blitter,\n \ + \x20 regs, irq, audio (default: all)\n \ --press-after SECS KEY press/release Amiga KEY after SECS; KEY may be\n \ \x20 decimal, 0x.., or a name like ctrl/lalt/lami/f1\n \ --key-after SECS KEY MS press KEY after SECS, hold for MS milliseconds,\n \ @@ -1166,6 +1239,9 @@ fn main() -> Result<()> { emu.arm_reverse_watch(addr, rr.target_secs); } } + if let Some(opts) = cli.waveform { + emu.machine.ui_wave_start(opts)?; + } if let Some(target_secs) = cli.benchmark_until { return run_headless_benchmark(emu, target_secs); } diff --git a/src/video/ui.rs b/src/video/ui.rs index e9f2afd..87751d3 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -245,9 +245,10 @@ pub enum DebugTab { Memory, IoMap, Break, + Waveform, } -pub const DEBUG_TABS: [DebugTab; 8] = [ +pub const DEBUG_TABS: [DebugTab; 9] = [ DebugTab::Cpu, DebugTab::Chipset, DebugTab::Copper, @@ -256,6 +257,7 @@ pub const DEBUG_TABS: [DebugTab; 8] = [ DebugTab::Memory, DebugTab::IoMap, DebugTab::Break, + DebugTab::Waveform, ]; fn debug_tab_label(tab: DebugTab) -> &'static str { @@ -268,6 +270,7 @@ fn debug_tab_label(tab: DebugTab) -> &'static str { DebugTab::Memory => "Memory", DebugTab::IoMap => "IO Map", DebugTab::Break => "Break", + DebugTab::Waveform => "Wave", } } @@ -363,8 +366,12 @@ impl DebuggerPanel { // Alphanumerics and spaces: hex for addresses/values, letters for // register names (Dn/An/PC/SR), memory operands (M), and the // breakpoint-condition mnemonics (EQ/NE/LT/GT/LE/GE/AND/IGN). A leading - // or doubled space is dropped so the tokens stay clean. - if (!ch.is_ascii_alphanumeric() && ch != ' ') || self.entry.len() >= 40 { + // or doubled space is dropped so the tokens stay clean. The extra + // punctuation set serves the Waveform tab's trigger/duration/signal + // specs (PC=..., BEAM=V:H, CPU,BUS, 2.5S) and output paths (both + // separator styles, for Windows). + let punctuation = matches!(ch, '=' | ':' | ',' | '.' | '-' | '_' | '/' | '\\'); + if (!ch.is_ascii_alphanumeric() && ch != ' ' && !punctuation) || self.entry.len() >= 40 { return; } if ch == ' ' && (self.entry.is_empty() || self.entry.ends_with(' ')) { @@ -609,6 +616,13 @@ pub fn panel_control_at(panel: &Panel, pos: (i32, i32)) -> Option { } } } + if panel.tab == DebugTab::Waveform { + for (control, button_rect) in waveform_tab_button_rects(rect) { + if button_rect.contains(pos) { + return Some(control); + } + } + } } // The console has no controls beyond the shared close button and // the click-swallowing body. @@ -718,6 +732,11 @@ pub enum UiControl { DebugSpriteToggle(usize), /// Break tab: remove all breakpoints and watchpoints. DebugBreaksClear, + /// Waveform tab: arm a VCD capture from the entry box's order-free + /// "[PATH] [TRIGGER] [DURATION] [SIGNALS]" spec (empty = defaults). + DebugWaveArm, + /// Waveform tab: stop the capture, finishing the file. + DebugWaveStop, /// Audio tab: toggle mute for a channel (0..3 = Paula, 4 = CD audio). DebugAudioMute(usize), /// Frame analyzer: run/pause the machine while keeping the pane open. @@ -907,7 +926,9 @@ fn drop_chooser_button_rects(rect: Rect, state: &DropChooserState) -> Vec<(UiCon // Debugger chrome: a tab row under the title and a control row at the // bottom with the transport buttons and the shared hex-entry box. -const DEBUG_TAB_W: usize = 80; +// 9 tabs at 70+4 px fit the 684 px panel; the longest label (Chipset, +// 7 glyphs at 8 px) still leaves 7 px of padding a side. +const DEBUG_TAB_W: usize = 70; const DEBUG_TAB_H: usize = 18; const DEBUG_BUTTON_H: usize = 20; @@ -987,6 +1008,25 @@ fn break_tab_button_rects(rect: Rect) -> [(UiControl, Rect); 6] { ] } +/// Content lines the Waveform tab's view must leave blank so the Arm and +/// Stop buttons drawn at the top of the content area do not overlap text. +pub const WAVEFORM_TAB_HEADER_LINES: usize = 3; + +/// The Waveform tab's buttons, drawn at the top of the content area. +fn waveform_tab_button_rects(rect: Rect) -> [(UiControl, Rect); 2] { + let y = debug_content_top(rect); + let button = |i: usize| Rect { + x: rect.x + 10 + i * 98, + y, + w: 90, + h: DEBUG_BUTTON_H, + }; + [ + (UiControl::DebugWaveArm, button(0)), + (UiControl::DebugWaveStop, button(1)), + ] +} + /// Parse the Break tab's entry as an exception catchpoint: "irq N" /// (interrupt level 1-7), "trap N" (TRAP #0-15), or "vec N" (a raw /// decimal exception vector number). @@ -1980,6 +2020,27 @@ fn draw_debugger( ); } } + // Waveform-tab buttons at the top of the content area (the view leaves + // WAVEFORM_TAB_HEADER_LINES blank so text starts below them). + if panel.tab == DebugTab::Waveform { + for (control, button_rect) in waveform_tab_button_rects(rect) { + let (label, enabled) = match control { + UiControl::DebugWaveArm => ( + "Arm", + crate::waveform::parse_wave_args(panel.entry.split_whitespace()).is_ok(), + ), + _ => ("Stop", true), + }; + draw_text_button( + frame, + button_rect, + label, + enabled, + hover == Some(control), + scale, + ); + } + } // Copper-tab buttons at the top of the content area (the view leaves // COPPER_TAB_HEADER_LINES blank so text starts below them). if panel.tab == DebugTab::Copper { @@ -5442,6 +5503,60 @@ mod tests { assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "debugger-break"); + // Waveform tab: Arm/Stop buttons plus a capture status listing. + let mut frame = vec![0u8; w * h * 4]; + let mut lines: Vec = (0..WAVEFORM_TAB_HEADER_LINES) + .map(|_| DbgLine::plain("")) + .collect(); + lines.push(DbgLine::hilit( + "waveform capturing: trigger pc=0xC033C2, duration 2f, signals all", + )); + lines.push(DbgLine::plain(" -> out.vcd")); + lines.push(DbgLine::plain(" 14204 / 141748 cck, 35872 samples")); + lines.push(DbgLine::plain("")); + lines.push(DbgLine::plain( + "Trigger: NOW PC=ADDR BEAM=VPOS[:HPOS] REG=OFF TIME=SECS", + )); + let data = PanelViewData::Debugger(Box::new(DebuggerView { + running: true, + reverse_available: false, + status: "running frame 1234 24.68s".to_string(), + lines, + bitmap: None, + video: None, + audio: None, + })); + let mut panel = DebuggerPanel::new(); + panel.tab = DebugTab::Waveform; + panel.entry = "PC=C033C2 2F".to_string(); + let ui = UiState { + menu_open: false, + panel: Some(Panel::Debugger(panel)), + }; + draw( + &mut frame, + scale, + &ui, + Some(UiControl::DebugWaveArm), + Some(&data), + false, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + audio_output: "", + }, + ); + assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); + // The Arm button must be enabled: its entry spec parses. + assert!(crate::waveform::parse_wave_args("PC=C033C2 2F".split_whitespace()).is_ok()); + save(&frame, "debugger-waveform"); + // Audio tab: the four Paula channels plus CD, with representative // state, mute buttons (AUD2 shown muted), and synthetic scope traces. let mut frame = vec![0u8; w * h * 4]; diff --git a/src/video/window.rs b/src/video/window.rs index 15c9150..03b80d4 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -2893,6 +2893,10 @@ impl App { UiControl::DebugBreaksClear => { self.activate_tool_control(ToolPanelKind::Debugger, control) } + UiControl::DebugWaveArm => self.activate_tool_control(ToolPanelKind::Debugger, control), + UiControl::DebugWaveStop => { + self.activate_tool_control(ToolPanelKind::Debugger, control) + } UiControl::DebugAudioMute(_) => { self.activate_tool_control(ToolPanelKind::Debugger, control) } @@ -3073,6 +3077,8 @@ impl App { self.last_debug_stop = None; self.show_osd("Cleared all breakpoints and watchpoints"); } + (ToolPanelKind::Debugger, UiControl::DebugWaveArm) => self.debugger_wave_arm(), + (ToolPanelKind::Debugger, UiControl::DebugWaveStop) => self.debugger_wave_stop(), (ToolPanelKind::Debugger, UiControl::DebugAudioMute(idx)) => { let paula = &mut self.emu.bus_mut().paula; let (label, muted) = if idx < 4 { @@ -4535,6 +4541,46 @@ impl App { self.show_osd(msg); } + /// Arm a waveform (VCD) capture from the Waveform tab's entry box: + /// an order-free "[PATH] [TRIGGER] [DURATION] [SIGNALS]" spec, with + /// an empty entry meaning all defaults (trigger now, one frame, all + /// signals, timestamped path). + fn debugger_wave_arm(&mut self) { + let entry = self + .debugger_panel + .as_ref() + .map(|panel| panel.entry.clone()) + .unwrap_or_default(); + let opts = match crate::waveform::parse_wave_args(entry.split_whitespace()) { + Ok(opts) => opts, + Err(e) => { + self.show_osd(format!("Waveform: {e}")); + return; + } + }; + let summary = format!( + "Waveform armed ({}) -> {}", + opts.trigger, + opts.path.display() + ); + match self.emu.machine.ui_wave_start(opts) { + Ok(()) => self.show_osd(summary), + Err(e) => self.show_osd(format!("Waveform: {e}")), + } + } + + /// Stop the waveform capture (Waveform tab), finishing the VCD file. + fn debugger_wave_stop(&mut self) { + match self.emu.machine.ui_wave_stop() { + Some(status) => self.show_osd(format!( + "Waveform stopped: {} samples in {}", + status.samples, + status.path.display() + )), + None => self.show_osd("No waveform capture"), + } + } + /// Toggle bitplane `plane` in the presented picture (Video tab). fn debugger_toggle_plane(&mut self, plane: usize) { let shown = self.emu.bus_mut().ui_toggle_layer_plane(plane); @@ -5864,6 +5910,57 @@ impl App { lines.push(ui::DbgLine::plain(text)); } } + ui::DebugTab::Waveform => { + // Leave room for the Arm/Stop buttons drawn at the top of + // the content area. + for _ in 0..ui::WAVEFORM_TAB_HEADER_LINES { + lines.push(ui::DbgLine::plain("")); + } + match self.emu.machine.ui_wave_status() { + Some(status) => { + for (index, text) in + console::wave_status_lines(&status).into_iter().enumerate() + { + lines.push(if index == 0 { + ui::DbgLine::hilit(text) + } else { + ui::DbgLine::plain(text) + }); + } + } + None => lines.push(ui::DbgLine::plain("No waveform capture.")), + } + lines.push(ui::DbgLine::plain("")); + lines.push(ui::DbgLine::plain( + "Arm records chipset signals to a VCD file for GTKWave.", + )); + lines.push(ui::DbgLine::plain( + "Type an order-free spec in the box, then Arm. Empty = defaults", + )); + lines.push(ui::DbgLine::plain( + "(trigger now, 1 frame, all signals, copperline-wave-*.vcd).", + )); + lines.push(ui::DbgLine::plain("")); + lines.push(ui::DbgLine::plain( + "Trigger: NOW PC=ADDR BEAM=VPOS[:HPOS] REG=OFF TIME=SECS", + )); + lines.push(ui::DbgLine::plain( + "Duration: Ncck (bare N) Nf (frames) Nms Ns", + )); + lines.push(ui::DbgLine::plain( + "Signals: comma list of beam,bus,cpu,copper,blitter,regs,irq,audio", + )); + lines.push(ui::DbgLine::plain( + "Path: any other token (e.g. OUT.VCD)", + )); + lines.push(ui::DbgLine::plain("")); + lines.push(ui::DbgLine::plain( + "Example: OUT.VCD PC=C033C2 20000CCK CPU,BUS,COPPER", + )); + lines.push(ui::DbgLine::plain( + "The console WAVE command does the same (Cmd/Alt+K).", + )); + } } // Keep lines inside the panel; the blitter clips at the texture // edge, not the panel edge. diff --git a/src/video/window/console.rs b/src/video/window/console.rs index 77bcfad..7531ce5 100644 --- a/src/video/window/console.rs +++ b/src/video/window/console.rs @@ -137,6 +137,32 @@ fn parse_hex_pattern(tokens: &[&str]) -> Option> { .collect() } +/// Human-readable status lines for a waveform capture, shared by the +/// console WAVE command and the debugger window's Waveform tab. +pub(super) fn wave_status_lines(status: &crate::waveform::WaveStatus) -> Vec { + let mut lines = vec![ + format!( + "waveform {}: trigger {}, duration {}, signals {}", + status.state, status.trigger, status.duration, status.signals + ), + format!(" -> {}", status.path.display()), + ]; + match status.state { + "capturing" => lines.push(format!( + " {} / {} cck, {} samples", + status.captured_cck, + status.window_cck.unwrap_or(0), + status.samples + )), + "done" => lines.push(format!( + " {} cck captured, {} samples", + status.captured_cck, status.samples + )), + _ => {} + } + lines +} + const CONSOLE_HELP: &[&str] = &[ "execution: run pause step/s [N] over out frame/f line cstep", " runto ADDR toslot V [H] rstep [N] rframe rrun", @@ -151,6 +177,8 @@ const CONSOLE_HELP: &[&str] = &[ "os: tasks libs devs resources ports segments guru [CODE]", "hunt: hunt start [B|W] hunt eq/ne/lt/gt VAL hunt same|diff hunt list", "modify: poke ADDR VAL setreg REG VAL trace start [PATH]|stop", + "waveform: wave start [PATH] [TRIGGER] [DURATION] [SIGNALS] wave stop wave", + " TRIGGER: now pc=ADDR beam=V[:H] reg=OFF time=SECS", "console: help clear close", "Addresses and values are hex; beam positions (V, H) are decimal.", "Cmd/Ctrl+V pastes; a multi-line paste runs each line in order.", @@ -681,6 +709,44 @@ impl App { Some(_) => ConsoleOutcome::error("usage: TRACE [START [PATH] | STOP]"), } } + "WAVE" | "WAVEFORM" => { + let sub = args.first().map(|t| t.to_ascii_uppercase()); + match sub.as_deref() { + Some("START") => { + let opts = match crate::waveform::parse_wave_args(args[1..].iter().copied()) + { + Ok(opts) => opts, + Err(e) => return ConsoleOutcome::error(format!("WAVE START: {e}")), + }; + let summary = format!( + "waveform armed: trigger {}, duration {}, signals {} -> {}", + opts.trigger, + opts.duration, + opts.signals, + opts.path.display() + ); + match self.emu.machine.ui_wave_start(opts) { + Ok(()) => ConsoleOutcome::one(summary), + Err(e) => ConsoleOutcome::error(format!("cannot arm waveform: {e}")), + } + } + Some("STOP") => match self.emu.machine.ui_wave_stop() { + Some(status) => ConsoleOutcome::one(format!( + "waveform stopped: {} samples in {}", + status.samples, + status.path.display() + )), + None => ConsoleOutcome::one("no waveform capture"), + }, + None => match self.emu.machine.ui_wave_status() { + Some(status) => ConsoleOutcome::lines(wave_status_lines(&status)), + None => ConsoleOutcome::one("no waveform capture (WAVE START [PATH] ...)"), + }, + Some(_) => ConsoleOutcome::error( + "usage: WAVE [START [PATH] [TRIGGER] [DURATION] [SIGNALS] | STOP]", + ), + } + } "BLITS" => { let Some(trace) = self.emu.bus().frame_bus_trace() else { return ConsoleOutcome::one( diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index 162359e..b2cb840 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -2173,10 +2173,39 @@ fn debugger_views_reflect_machine_state() { assert!(view.lines.iter().any(|l| l.text == "Breakpoints:")); assert!(view.lines.iter().any(|l| l.text == " (none)")); } + super::ui::DebugTab::Waveform => { + assert!(view.lines.iter().any(|l| l.text == "No waveform capture.")); + assert!(view + .lines + .iter() + .any(|l| l.text.starts_with("Trigger: NOW"))); + } } } } +#[test] +fn waveform_tab_buttons_arm_and_stop_through_dispatch() { + let mut app = test_app(); + app.open_debugger(); + let path = std::env::temp_dir().join(format!("copperline-wave-tab-{}.vcd", std::process::id())); + let _ = std::fs::remove_file(&path); + if let Some(panel) = app.debugger_panel.as_mut() { + panel.tab = super::ui::DebugTab::Waveform; + panel.entry = format!("{} 200CCK", path.display()); + } + app.activate_ui_control(super::ui::UiControl::DebugWaveArm); + let status = app.emu.machine.ui_wave_status().expect("armed capture"); + assert_eq!(status.state, "capturing"); + assert_eq!(status.path, path); + app.activate_ui_control(super::ui::UiControl::DebugWaveStop); + assert!(app.emu.machine.ui_wave_status().is_none()); + assert!(std::fs::read_to_string(&path) + .unwrap() + .contains("$enddefinitions $end")); + let _ = std::fs::remove_file(&path); +} + #[test] fn audio_tab_mute_buttons_toggle_paula_mutes() { let mut app = test_app(); @@ -2911,6 +2940,67 @@ fn console_trace_writes_disassembled_lines() { let _ = std::fs::remove_file(&path); } +#[test] +fn console_wave_arms_captures_and_stops() { + let mut app = test_app(); + app.open_console(); + let path = std::env::temp_dir().join(format!( + "copperline-console-wave-{}.vcd", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + + let out = console_run(&mut app, "WAVE"); + assert!(out[0].contains("no waveform capture"), "{out:?}"); + // Arm with order-free arguments: path, immediate trigger, a short + // window, two signal groups. + let out = console_run( + &mut app, + &format!("WAVE START {} NOW 300CCK BEAM,BUS", path.display()), + ); + assert!(out[0].contains("waveform armed"), "{out:?}"); + assert!(out[0].contains("beam,bus"), "{out:?}"); + let out = console_run(&mut app, "WAVE"); + assert!(out[0].contains("waveform capturing"), "{out:?}"); + // A malformed trigger is rejected, not treated as a path. + let out = console_run(&mut app, "WAVE START PC=ZZ"); + assert!(out[0].contains("bad trigger"), "{out:?}"); + + // Stepping instructions advances the chipset past the 300 cck window. + console_run(&mut app, "S 400"); + let out = console_run(&mut app, "WAVE"); + assert!(out[0].contains("waveform done"), "{out:?}"); + let out = console_run(&mut app, "WAVE STOP"); + assert!(out[0].contains("waveform stopped"), "{out:?}"); + + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("$enddefinitions $end"), "{text}"); + // Only the requested groups are declared. + assert!(text.contains("$scope module beam $end")); + assert!(text.contains("$scope module bus $end")); + assert!(!text.contains("$scope module copper $end")); + let _ = std::fs::remove_file(&path); + + // A pc= trigger stays armed until the CPU retires the instruction at + // that address (a few NOPs ahead on the test machine's sled). + let target = app.emu.machine.pc() + 16; + let out = console_run( + &mut app, + &format!("WAVE START {} PC={target:X} 100CCK", path.display()), + ); + assert!(out[0].contains("waveform armed"), "{out:?}"); + let out = console_run(&mut app, "WAVE"); + assert!(out[0].contains("waveform armed"), "{out:?}"); + console_run(&mut app, "S 20"); + let out = console_run(&mut app, "WAVE"); + assert!( + out[0].contains("capturing") || out[0].contains("done"), + "pc trigger did not fire: {out:?}" + ); + console_run(&mut app, "WAVE STOP"); + let _ = std::fs::remove_file(&path); +} + #[test] fn double_fault_halt_surfaces_once() { let mut app = test_app(); diff --git a/src/waveform.rs b/src/waveform.rs new file mode 100644 index 0000000..150a95d --- /dev/null +++ b/src/waveform.rs @@ -0,0 +1,1047 @@ +//! Trigger-based VCD "logic analyser" export of internal chipset signals. +//! +//! A capture is armed with a [`WaveOptions`] (output path, trigger, duration, +//! signal groups), waits for its trigger (a CPU PC, a beam position, a +//! custom-register write, an emulated time, or immediately), then streams +//! value changes of the selected hardware signals into a VCD file that +//! GTKWave (or any VCD viewer) can display. FST output is produced offline +//! with GTKWave's bundled `vcd2fst`. +//! +//! Time convention: 1 VCD time unit = 1 color clock (cck), declared as +//! `$timescale 1 us` because VCD only allows 1/10/100 of a standard unit +//! and cursor deltas should read directly as cck. Timestamps are relative +//! to the trigger point. +//! +//! The sampling call sites live in the bus (`src/bus/wave.rs`); this module +//! owns the VCD writer, the option/trigger/duration/signal-set parsing, and +//! the capture state machine so it can be unit-tested against an in-memory +//! writer. + +use std::fmt; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// Bound on a single capture so a typo'd duration cannot fill the disk: +/// 10 emulated seconds of color clocks (a few hundred MB worst case). +const MAX_CAPTURE_SECONDS: f64 = 10.0; +/// Emergency stop when the output file grows past this many bytes even +/// within the duration bound (chip-bus activity is workload dependent). +const MAX_CAPTURE_BYTES: u64 = 512 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// Signal groups +// --------------------------------------------------------------------------- + +/// Bitmask of signal groups selected for a capture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SignalSet(u8); + +impl SignalSet { + pub const BEAM: SignalSet = SignalSet(1 << 0); + pub const BUS: SignalSet = SignalSet(1 << 1); + pub const CPU: SignalSet = SignalSet(1 << 2); + pub const COPPER: SignalSet = SignalSet(1 << 3); + pub const BLITTER: SignalSet = SignalSet(1 << 4); + pub const REGS: SignalSet = SignalSet(1 << 5); + pub const IRQ: SignalSet = SignalSet(1 << 6); + pub const AUDIO: SignalSet = SignalSet(1 << 7); + pub const ALL: SignalSet = SignalSet(0xFF); + + pub fn contains(self, other: SignalSet) -> bool { + self.0 & other.0 == other.0 + } +} + +const SIGNAL_GROUP_NAMES: [(&str, SignalSet); 8] = [ + ("beam", SignalSet::BEAM), + ("bus", SignalSet::BUS), + ("cpu", SignalSet::CPU), + ("copper", SignalSet::COPPER), + ("blitter", SignalSet::BLITTER), + ("regs", SignalSet::REGS), + ("irq", SignalSet::IRQ), + ("audio", SignalSet::AUDIO), +]; + +/// Parse a comma-separated signal-group list (`cpu,bus,copper` or `all`). +pub fn parse_signals(spec: &str) -> Option { + let mut set = SignalSet(0); + for part in spec.split(',') { + let part = part.trim().to_ascii_lowercase(); + if part.is_empty() { + continue; + } + if part == "all" { + return Some(SignalSet::ALL); + } + let group = SIGNAL_GROUP_NAMES.iter().find(|(name, _)| *name == part)?.1; + set.0 |= group.0; + } + (set.0 != 0).then_some(set) +} + +impl fmt::Display for SignalSet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if *self == SignalSet::ALL { + return f.write_str("all"); + } + let mut first = true; + for (name, group) in SIGNAL_GROUP_NAMES { + if self.contains(group) { + if !first { + f.write_str(",")?; + } + f.write_str(name)?; + first = false; + } + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Trigger +// --------------------------------------------------------------------------- + +/// What starts the capture window. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Trigger { + /// Start immediately. + Now, + /// The CPU retires an instruction at this address. + Pc(u32), + /// The beam crosses this position (any hpos when None). + Beam { vpos: u16, hpos: Option }, + /// A custom-register write to this word offset ($000-$1FE). + RegWrite(u16), + /// Emulated time reaches this many seconds. + Time(f64), +} + +fn parse_hex_u32(text: &str) -> Option { + let text = text.trim(); + let text = text + .strip_prefix("0x") + .or_else(|| text.strip_prefix("0X")) + .or_else(|| text.strip_prefix('$')) + .unwrap_or(text); + u32::from_str_radix(text, 16).ok() +} + +/// Parse a trigger spec: `now`, `pc=ADDR`, `beam=VPOS[:HPOS]`, `reg=OFF`, +/// or `time=SECS`. Addresses and register offsets are hex (`0x`/`$` +/// prefixes optional); beam positions are decimal; time is fractional +/// seconds. +pub fn parse_trigger(spec: &str) -> Option { + let spec = spec.trim(); + if spec.eq_ignore_ascii_case("now") { + return Some(Trigger::Now); + } + let (key, value) = spec.split_once('=')?; + match key.trim().to_ascii_lowercase().as_str() { + "pc" => Some(Trigger::Pc(parse_hex_u32(value)?)), + "beam" => { + let value = value.trim(); + let (vpos, hpos) = match value.split_once(':') { + Some((v, h)) => (v.trim().parse().ok()?, Some(h.trim().parse().ok()?)), + None => (value.parse().ok()?, None), + }; + Some(Trigger::Beam { vpos, hpos }) + } + "reg" => { + // Custom registers are word offsets; reject odd values rather + // than silently rounding a typo down to the wrong register. + let off = parse_hex_u32(value)?; + (off < 0x200 && off & 1 == 0).then_some(Trigger::RegWrite(off as u16)) + } + "time" => { + let secs: f64 = value.trim().parse().ok()?; + (secs >= 0.0 && secs.is_finite()).then_some(Trigger::Time(secs)) + } + _ => None, + } +} + +impl fmt::Display for Trigger { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Trigger::Now => f.write_str("now"), + Trigger::Pc(pc) => write!(f, "pc={pc:#08X}"), + Trigger::Beam { vpos, hpos: None } => write!(f, "beam={vpos}"), + Trigger::Beam { + vpos, + hpos: Some(h), + } => write!(f, "beam={vpos}:{h}"), + Trigger::RegWrite(off) => write!(f, "reg={off:#05X}"), + Trigger::Time(secs) => write!(f, "time={secs}"), + } + } +} + +// --------------------------------------------------------------------------- +// Duration +// --------------------------------------------------------------------------- + +/// How long the capture runs once triggered. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum WaveDuration { + Cck(u64), + Frames(u64), + Millis(u64), + Secs(f64), +} + +/// Parse a duration spec: `Ncck` (bare `N` is cck), `Nf`/`Nframes`, +/// `Nms`, or `Ns`. +pub fn parse_duration(spec: &str) -> Option { + let spec = spec.trim().to_ascii_lowercase(); + let digits_end = spec + .find(|c: char| !c.is_ascii_digit() && c != '.') + .unwrap_or(spec.len()); + let (number, unit) = spec.split_at(digits_end); + if number.is_empty() { + return None; + } + let duration = match unit.trim() { + "" | "cck" => WaveDuration::Cck(number.parse().ok()?), + "f" | "frames" => WaveDuration::Frames(number.parse().ok()?), + "ms" => WaveDuration::Millis(number.parse().ok()?), + "s" => WaveDuration::Secs(number.parse().ok()?), + _ => return None, + }; + Some(duration) +} + +impl WaveDuration { + /// Resolve to a color-clock count, bounded by the safety cap. + /// `cck_per_frame` and `cck_hz` come from the live machine at trigger + /// time so frame/time durations track the configured video standard. + pub fn to_cck(self, cck_per_frame: u64, cck_hz: f64) -> u64 { + let cck = match self { + WaveDuration::Cck(n) => n, + WaveDuration::Frames(n) => n.saturating_mul(cck_per_frame.max(1)), + WaveDuration::Millis(n) => (n as f64 / 1000.0 * cck_hz) as u64, + WaveDuration::Secs(s) => (s.max(0.0) * cck_hz) as u64, + }; + cck.max(1).min((MAX_CAPTURE_SECONDS * cck_hz) as u64) + } +} + +impl fmt::Display for WaveDuration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WaveDuration::Cck(n) => write!(f, "{n}cck"), + WaveDuration::Frames(n) => write!(f, "{n}f"), + WaveDuration::Millis(n) => write!(f, "{n}ms"), + WaveDuration::Secs(s) => write!(f, "{s}s"), + } + } +} + +// --------------------------------------------------------------------------- +// Options and status +// --------------------------------------------------------------------------- + +/// Everything needed to arm a capture. +#[derive(Debug, Clone)] +pub struct WaveOptions { + pub path: PathBuf, + pub trigger: Trigger, + pub duration: WaveDuration, + pub signals: SignalSet, +} + +impl WaveOptions { + pub fn new(path: PathBuf) -> Self { + WaveOptions { + path, + trigger: Trigger::Now, + duration: WaveDuration::Frames(1), + signals: SignalSet::ALL, + } + } +} + +/// Classify the order-free console/GUI WAVE arguments: a trigger spec +/// contains `=` (or is `now`), a duration starts with a digit, a signal +/// list is made of known group names, and anything else is the output +/// path (at most one). An omitted path gets a timestamped default in +/// the working directory. +pub fn parse_wave_args<'a, I>(tokens: I) -> Result +where + I: IntoIterator, +{ + let mut opts = WaveOptions::new(PathBuf::new()); + let mut path: Option = None; + for token in tokens { + if let Some(trigger) = parse_trigger(token) { + opts.trigger = trigger; + } else if token.contains('=') { + // A malformed trigger must not fall through to the path slot. + return Err(format!( + "bad trigger {token:?} (now, pc=ADDR, beam=VPOS[:HPOS], reg=OFF, time=SECS)" + )); + } else if let Some(duration) = parse_duration(token) { + opts.duration = duration; + } else if let Some(signals) = parse_signals(token) { + opts.signals = signals; + } else if path.is_none() { + path = Some(PathBuf::from(token)); + } else { + return Err(format!("cannot parse {token:?}")); + } + } + opts.path = path.unwrap_or_else(default_wave_path); + Ok(opts) +} + +/// Timestamped default output path in the working directory. +pub fn default_wave_path() -> PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + PathBuf::from(format!("copperline-wave-{stamp}.vcd")) +} + +/// Snapshot of a capture's state for the console/debugger UI. +#[derive(Debug, Clone)] +pub struct WaveStatus { + pub path: PathBuf, + pub state: &'static str, + pub trigger: String, + pub duration: String, + pub signals: String, + pub samples: u64, + /// Color clocks captured so far and the resolved window length + /// (None until the trigger fires). + pub captured_cck: u64, + pub window_cck: Option, +} + +// --------------------------------------------------------------------------- +// VCD writer +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VarId(usize); + +#[derive(Clone, Copy, PartialEq)] +enum LastValue { + None, + Num(u64), + Text(&'static str), +} + +struct Var { + id: String, + width: u8, + last: LastValue, +} + +/// Minimal streaming VCD writer: declaration section, then change-only +/// value emission with lazily written `#time` stamps. +struct VcdWriter { + out: W, + vars: Vec, + /// The `#time` most recently written to the change section. + emitted_time: Option, + bytes: u64, + /// Latched on the first change-section write error (disk full, + /// deleted file). A failed writer emits nothing further, and the + /// capture's expiry check aborts the window instead of silently + /// producing a truncated file. + failed: bool, +} + +fn vcd_identifier(index: usize) -> String { + // Printable ASCII '!'..='~' in as many digits as needed. + let mut id = String::new(); + let mut n = index; + loop { + id.push((b'!' + (n % 94) as u8) as char); + n /= 94; + if n == 0 { + break; + } + } + id +} + +impl VcdWriter { + fn new(out: W) -> Self { + VcdWriter { + out, + vars: Vec::new(), + emitted_time: None, + bytes: 0, + failed: false, + } + } + + fn write_line(&mut self, line: &str) -> io::Result<()> { + self.bytes += line.len() as u64 + 1; + writeln!(self.out, "{line}") + } + + fn header(&mut self, comments: &[String]) -> io::Result<()> { + self.write_line(&format!( + "$version Copperline {} $end", + env!("CARGO_PKG_VERSION") + ))?; + self.write_line( + "$comment 1 VCD time unit = 1 color clock (cck); \ + timestamps are relative to the trigger $end", + )?; + for comment in comments { + self.write_line(&format!("$comment {comment} $end"))?; + } + self.write_line("$timescale 1 us $end") + } + + fn scope(&mut self, name: &str) -> io::Result<()> { + self.write_line(&format!("$scope module {name} $end")) + } + + fn upscope(&mut self) -> io::Result<()> { + self.write_line("$upscope $end") + } + + fn add_wire(&mut self, width: u8, name: &str) -> io::Result { + let id = vcd_identifier(self.vars.len()); + self.write_line(&format!("$var wire {width} {id} {name} $end"))?; + self.vars.push(Var { + id, + width, + last: LastValue::None, + }); + Ok(VarId(self.vars.len() - 1)) + } + + /// A GTKWave-supported string variable (`$var string`, `s` + /// value changes). + fn add_string(&mut self, name: &str) -> io::Result { + let id = vcd_identifier(self.vars.len()); + self.write_line(&format!("$var string 1 {id} {name} $end"))?; + self.vars.push(Var { + id, + width: 0, + last: LastValue::None, + }); + Ok(VarId(self.vars.len() - 1)) + } + + fn enddefinitions(&mut self) -> io::Result<()> { + self.write_line("$enddefinitions $end") + } + + fn stamp(&mut self, time: u64) -> io::Result<()> { + // Keep the change section monotonic even if a caller's clock + // reads behind (it cannot, but a malformed file helps nobody). + let time = self.emitted_time.map_or(time, |t| time.max(t)); + if self.emitted_time != Some(time) { + self.bytes += 12; + writeln!(self.out, "#{time}")?; + self.emitted_time = Some(time); + } + Ok(()) + } + + /// Emit a vector/bit change if the value differs from the last one, + /// returning whether one was written. A write error latches `failed` + /// (silencing further output) so the capture aborts at its next + /// expiry check instead of quietly truncating the file. + fn set(&mut self, time: u64, var: VarId, value: u64) -> bool { + if self.failed { + return false; + } + let slot = &mut self.vars[var.0]; + if slot.last == LastValue::Num(value) { + return false; + } + slot.last = LastValue::Num(value); + let width = slot.width; + if self.stamp(time).is_err() { + self.failed = true; + return false; + } + let var = &self.vars[var.0]; + let written = if width == 1 { + self.bytes += 2 + var.id.len() as u64; + writeln!(self.out, "{}{}", value & 1, var.id) + } else { + let mut bits = String::with_capacity(width as usize + 2); + bits.push('b'); + for bit in (0..width).rev() { + bits.push(if value >> bit & 1 != 0 { '1' } else { '0' }); + } + self.bytes += bits.len() as u64 + 2 + var.id.len() as u64; + writeln!(self.out, "{bits} {}", var.id) + }; + if written.is_err() { + self.failed = true; + return false; + } + true + } + + /// Emit a string change if it differs from the last one, with the + /// same failure latching as `set`. The text must contain no + /// whitespace. + fn set_text(&mut self, time: u64, var: VarId, text: &'static str) -> bool { + if self.failed { + return false; + } + let slot = &mut self.vars[var.0]; + if slot.last == LastValue::Text(text) { + return false; + } + slot.last = LastValue::Text(text); + if self.stamp(time).is_err() { + self.failed = true; + return false; + } + let var = &self.vars[var.0]; + self.bytes += text.len() as u64 + 3 + var.id.len() as u64; + if writeln!(self.out, "s{text} {}", var.id).is_err() { + self.failed = true; + return false; + } + true + } + + fn flush(&mut self) -> io::Result<()> { + self.out.flush() + } +} + +// --------------------------------------------------------------------------- +// Capture state machine +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WaveState { + Armed, + Capturing { end_cck: u64 }, + Done, +} + +/// Per-group variable handles; None when the group is not selected. +#[derive(Default)] +struct WaveVars { + vpos: Option, + hpos: Option, + frame: Option, + bus_owner: Option, + bus_owner_s: Option, + dmacon: Option, + bus_data: Option, + cpu_addr: Option, + cpu_kind_s: Option, + cpu_rw: Option, + cpu_wait: Option, + cop_pc: Option, + cop_state_s: Option, + blt_busy: Option, + blt_slot_s: Option, + blt_pt: [Option; 4], + regw_off: Option, + regw_val: Option, + regw_src_s: Option, + regw_stb: Option, + ipl: Option, + intreq: Option, + intena: Option, + aud_ch: Option, + aud_stb: Option, +} + +/// One quantum's worth of bus-level signals, gathered at the chip-bus +/// arbitration tap. All values are raw hardware state; enum-ish values +/// arrive pre-stringified so this module stays chipset-agnostic. +pub struct QuantumSample { + pub cck: u64, + pub vpos: u32, + pub hpos: u32, + pub frame: u64, + pub owner_index: u8, + pub owner_name: &'static str, + pub dmacon: u16, + pub data_bus: u16, + pub cop_pc: u32, + pub cop_state: &'static str, + pub blt_busy: bool, + pub blt_slot: &'static str, + pub blt_pt: [u32; 4], + pub ipl: u8, + pub intreq: u16, + pub intena: u16, + /// Audio channel granted a DMA slot this quantum, if any. + pub audio_channel: Option, +} + +/// A running (armed, capturing, or finished) waveform export. +pub struct WaveCapture { + writer: VcdWriter>, + path: PathBuf, + trigger: Trigger, + duration: WaveDuration, + signals: SignalSet, + state: WaveState, + start_cck: u64, + samples: u64, + last_cck: u64, + regw_stb_level: bool, + aud_stb_level: bool, + vars: WaveVars, +} + +impl WaveCapture { + /// Create the output file and write the declaration section. The + /// capture starts in the Armed state. + pub fn create(opts: WaveOptions) -> io::Result { + let file = std::fs::File::create(&opts.path)?; + let mut writer = VcdWriter::new(io::BufWriter::new(file)); + writer.header(&[format!( + "trigger: {}; duration: {}; signals: {}", + opts.trigger, opts.duration, opts.signals + )])?; + let mut vars = WaveVars::default(); + writer.scope("copperline")?; + if opts.signals.contains(SignalSet::BEAM) { + writer.scope("beam")?; + vars.vpos = Some(writer.add_wire(16, "vpos")?); + vars.hpos = Some(writer.add_wire(8, "hpos")?); + vars.frame = Some(writer.add_wire(32, "frame")?); + writer.upscope()?; + } + if opts.signals.contains(SignalSet::BUS) { + writer.scope("bus")?; + vars.bus_owner = Some(writer.add_wire(4, "owner")?); + vars.bus_owner_s = Some(writer.add_string("owner_name")?); + vars.dmacon = Some(writer.add_wire(16, "dmacon")?); + vars.bus_data = Some(writer.add_wire(16, "data")?); + writer.upscope()?; + } + if opts.signals.contains(SignalSet::CPU) { + writer.scope("cpu")?; + vars.cpu_addr = Some(writer.add_wire(24, "addr")?); + vars.cpu_kind_s = Some(writer.add_string("kind")?); + vars.cpu_rw = Some(writer.add_wire(1, "rw")?); + vars.cpu_wait = Some(writer.add_wire(16, "wait_cck")?); + writer.upscope()?; + } + if opts.signals.contains(SignalSet::COPPER) { + writer.scope("copper")?; + vars.cop_pc = Some(writer.add_wire(24, "pc")?); + vars.cop_state_s = Some(writer.add_string("state")?); + writer.upscope()?; + } + if opts.signals.contains(SignalSet::BLITTER) { + writer.scope("blitter")?; + vars.blt_busy = Some(writer.add_wire(1, "busy")?); + vars.blt_slot_s = Some(writer.add_string("slot")?); + for (idx, name) in ["apt", "bpt", "cpt", "dpt"].iter().enumerate() { + vars.blt_pt[idx] = Some(writer.add_wire(24, name)?); + } + writer.upscope()?; + } + if opts.signals.contains(SignalSet::REGS) { + writer.scope("regs")?; + vars.regw_off = Some(writer.add_wire(9, "off")?); + vars.regw_val = Some(writer.add_wire(16, "value")?); + vars.regw_src_s = Some(writer.add_string("source")?); + vars.regw_stb = Some(writer.add_wire(1, "strobe")?); + writer.upscope()?; + } + if opts.signals.contains(SignalSet::IRQ) { + writer.scope("irq")?; + vars.ipl = Some(writer.add_wire(3, "ipl")?); + vars.intreq = Some(writer.add_wire(16, "intreq")?); + vars.intena = Some(writer.add_wire(16, "intena")?); + writer.upscope()?; + } + if opts.signals.contains(SignalSet::AUDIO) { + writer.scope("audio")?; + vars.aud_ch = Some(writer.add_wire(2, "channel")?); + vars.aud_stb = Some(writer.add_wire(1, "strobe")?); + writer.upscope()?; + } + writer.upscope()?; + writer.enddefinitions()?; + Ok(WaveCapture { + writer, + path: opts.path, + trigger: opts.trigger, + duration: opts.duration, + signals: opts.signals, + state: WaveState::Armed, + start_cck: 0, + samples: 0, + last_cck: 0, + regw_stb_level: false, + aud_stb_level: false, + vars, + }) + } + + pub fn trigger(&self) -> Trigger { + self.trigger + } + + pub fn is_armed(&self) -> bool { + self.state == WaveState::Armed + } + + pub fn is_capturing(&self) -> bool { + matches!(self.state, WaveState::Capturing { .. }) + } + + pub fn is_done(&self) -> bool { + self.state == WaveState::Done + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn samples(&self) -> u64 { + self.samples + } + + /// Start the capture window at `now_cck`. Frame/time durations + /// resolve against the machine's live frame geometry and cck rate. + pub fn fire(&mut self, now_cck: u64, cck_per_frame: u64, cck_hz: f64) { + if self.state != WaveState::Armed { + return; + } + self.start_cck = now_cck; + self.last_cck = now_cck; + self.state = WaveState::Capturing { + end_cck: now_cck.saturating_add(self.duration.to_cck(cck_per_frame, cck_hz)), + }; + } + + /// Whether the capture window has run out at `now_cck` -- or must + /// abort because the file grew past the emergency byte cap or a + /// write failed. The caller finishes the capture. + pub fn expired(&self, now_cck: u64) -> bool { + match self.state { + WaveState::Capturing { end_cck } => { + now_cck >= end_cck || self.writer.bytes >= MAX_CAPTURE_BYTES || self.writer.failed + } + _ => false, + } + } + + /// Whether a change-section write failed (the file is incomplete). + pub fn write_failed(&self) -> bool { + self.writer.failed + } + + fn rel(&self, cck: u64) -> u64 { + cck.saturating_sub(self.start_cck) + } + + /// Record one chip-bus quantum's signals. + pub fn sample_quantum(&mut self, s: &QuantumSample) { + if !self.is_capturing() { + return; + } + self.last_cck = s.cck; + let t = self.rel(s.cck); + let mut changed = false; + let w = &mut self.writer; + let v = &self.vars; + let mut set = |var: Option, value: u64| { + if let Some(var) = var { + changed |= w.set(t, var, value); + } + }; + set(v.vpos, u64::from(s.vpos as u16)); + set(v.hpos, u64::from(s.hpos as u8)); + set(v.frame, s.frame & 0xFFFF_FFFF); + set(v.bus_owner, u64::from(s.owner_index)); + set(v.dmacon, u64::from(s.dmacon)); + set(v.bus_data, u64::from(s.data_bus)); + set(v.cop_pc, u64::from(s.cop_pc & 0x00FF_FFFF)); + set(v.blt_busy, u64::from(s.blt_busy)); + for (idx, pt) in s.blt_pt.iter().enumerate() { + set(v.blt_pt[idx], u64::from(pt & 0x00FF_FFFF)); + } + set(v.ipl, u64::from(s.ipl)); + set(v.intreq, u64::from(s.intreq)); + set(v.intena, u64::from(s.intena)); + if let Some(channel) = s.audio_channel { + set(v.aud_ch, u64::from(channel)); + if let Some(stb) = v.aud_stb { + self.aud_stb_level = !self.aud_stb_level; + changed |= w.set(t, stb, u64::from(self.aud_stb_level)); + } + } + let mut set_text = |var: Option, text: &'static str| { + if let Some(var) = var { + changed |= w.set_text(t, var, text); + } + }; + set_text(v.bus_owner_s, s.owner_name); + set_text(v.cop_state_s, s.cop_state); + set_text(v.blt_slot_s, s.blt_slot); + if changed { + self.samples += 1; + } + } + + /// Record a custom-register write (`regs` group). + pub fn sample_reg_write(&mut self, cck: u64, off: u16, value: u16, source: &'static str) { + if !self.is_capturing() || !self.signals.contains(SignalSet::REGS) { + return; + } + let t = self.rel(cck); + let w = &mut self.writer; + let v = &self.vars; + if let Some(var) = v.regw_off { + w.set(t, var, u64::from(off & 0x1FF)); + } + if let Some(var) = v.regw_val { + w.set(t, var, u64::from(value)); + } + if let Some(var) = v.regw_src_s { + w.set_text(t, var, source); + } + if let Some(var) = v.regw_stb { + self.regw_stb_level = !self.regw_stb_level; + w.set(t, var, u64::from(self.regw_stb_level)); + } + self.samples += 1; + } + + /// Record a granted CPU chip-bus access (`cpu` group). + pub fn sample_cpu_access( + &mut self, + cck: u64, + addr: Option, + kind: &'static str, + is_write: bool, + wait_cck: u32, + ) { + if !self.is_capturing() || !self.signals.contains(SignalSet::CPU) { + return; + } + let t = self.rel(cck); + let w = &mut self.writer; + let v = &self.vars; + if let (Some(var), Some(addr)) = (v.cpu_addr, addr) { + w.set(t, var, u64::from(addr & 0x00FF_FFFF)); + } + if let Some(var) = v.cpu_kind_s { + w.set_text(t, var, kind); + } + if let Some(var) = v.cpu_rw { + w.set(t, var, u64::from(is_write)); + } + if let Some(var) = v.cpu_wait { + w.set(t, var, u64::from(wait_cck.min(0xFFFF) as u16)); + } + self.samples += 1; + } + + /// Flush and close out the capture. Idempotent. + pub fn finish(&mut self) { + if self.state == WaveState::Done { + return; + } + self.state = WaveState::Done; + let _ = self.writer.flush(); + } + + pub fn status(&self) -> WaveStatus { + let (state, window_cck) = match self.state { + WaveState::Armed => ("armed", None), + WaveState::Capturing { end_cck } => { + ("capturing", Some(end_cck.saturating_sub(self.start_cck))) + } + WaveState::Done if self.writer.failed => ("failed (write error)", None), + WaveState::Done => ("done", None), + }; + WaveStatus { + path: self.path.clone(), + state, + trigger: self.trigger.to_string(), + duration: self.duration.to_string(), + signals: self.signals.to_string(), + samples: self.samples, + captured_cck: self.last_cck.saturating_sub(self.start_cck), + window_cck, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signal_list_parses_groups_and_all() { + let set = parse_signals("cpu,bus,copper").unwrap(); + assert!(set.contains(SignalSet::CPU)); + assert!(set.contains(SignalSet::BUS)); + assert!(set.contains(SignalSet::COPPER)); + assert!(!set.contains(SignalSet::AUDIO)); + assert_eq!(parse_signals("all"), Some(SignalSet::ALL)); + assert_eq!(parse_signals("beam").unwrap().to_string(), "beam"); + assert_eq!(parse_signals(""), None); + assert_eq!(parse_signals("bogus"), None); + } + + #[test] + fn trigger_specs_round_trip() { + assert_eq!(parse_trigger("now"), Some(Trigger::Now)); + assert_eq!(parse_trigger("pc=0x00C033C2"), Some(Trigger::Pc(0xC033C2))); + assert_eq!(parse_trigger("pc=$c033c2"), Some(Trigger::Pc(0xC033C2))); + assert_eq!( + parse_trigger("beam=100"), + Some(Trigger::Beam { + vpos: 100, + hpos: None + }) + ); + assert_eq!( + parse_trigger("beam=44:129"), + Some(Trigger::Beam { + vpos: 44, + hpos: Some(129) + }) + ); + assert_eq!(parse_trigger("reg=180"), Some(Trigger::RegWrite(0x180))); + assert_eq!(parse_trigger("time=1.5"), Some(Trigger::Time(1.5))); + assert_eq!(parse_trigger("reg=200"), None); + // Odd register offsets are typos, not something to round down. + assert_eq!(parse_trigger("reg=181"), None); + assert_eq!(parse_trigger("pc="), None); + assert_eq!(parse_trigger("bogus=1"), None); + } + + #[test] + fn duration_specs_resolve_to_cck() { + assert_eq!(parse_duration("20000cck"), Some(WaveDuration::Cck(20000))); + assert_eq!(parse_duration("500"), Some(WaveDuration::Cck(500))); + assert_eq!(parse_duration("2f"), Some(WaveDuration::Frames(2))); + assert_eq!(parse_duration("3frames"), Some(WaveDuration::Frames(3))); + assert_eq!(parse_duration("10ms"), Some(WaveDuration::Millis(10))); + assert_eq!(parse_duration("2s"), Some(WaveDuration::Secs(2.0))); + assert_eq!(parse_duration("x"), None); + let frame = 312 * 227; + assert_eq!( + WaveDuration::Frames(2).to_cck(frame, 3_546_895.0), + 2 * frame + ); + assert_eq!( + WaveDuration::Millis(1).to_cck(frame, 3_546_895.0), + 3_546 // 1 ms of PAL color clocks + ); + // The safety cap bounds silly durations. + let capped = WaveDuration::Secs(1e9).to_cck(frame, 3_546_895.0); + assert_eq!(capped, (10.0 * 3_546_895.0) as u64); + } + + #[test] + fn wave_args_classify_order_free() { + let opts = parse_wave_args(["out.vcd", "pc=0xC033C2", "2f", "cpu,bus"]).unwrap(); + assert_eq!(opts.path, PathBuf::from("out.vcd")); + assert_eq!(opts.trigger, Trigger::Pc(0xC033C2)); + assert_eq!(opts.duration, WaveDuration::Frames(2)); + assert!(opts.signals.contains(SignalSet::CPU)); + assert!(!opts.signals.contains(SignalSet::REGS)); + // Order-free, uppercased (the debugger entry box uppercases). + let opts = parse_wave_args(["BEAM=100:64", "500CCK"]).unwrap(); + assert_eq!( + opts.trigger, + Trigger::Beam { + vpos: 100, + hpos: Some(64) + } + ); + assert_eq!(opts.duration, WaveDuration::Cck(500)); + assert!(opts.path.to_string_lossy().starts_with("copperline-wave-")); + // Defaults with no arguments at all. + let opts = parse_wave_args([]).unwrap(); + assert_eq!(opts.trigger, Trigger::Now); + assert_eq!(opts.duration, WaveDuration::Frames(1)); + // A malformed trigger is an error, not a path. + assert!(parse_wave_args(["pc=zz"]).is_err()); + // Two path-looking tokens are an error. + assert!(parse_wave_args(["a.vcd", "b.vcd"]).is_err()); + } + + #[test] + fn vcd_writer_emits_changes_once() { + let mut w = VcdWriter::new(Vec::new()); + w.header(&["test".into()]).unwrap(); + w.scope("top").unwrap(); + let a = w.add_wire(4, "a").unwrap(); + let b = w.add_wire(1, "b").unwrap(); + let s = w.add_string("s").unwrap(); + w.upscope().unwrap(); + w.enddefinitions().unwrap(); + assert!(w.set(0, a, 5)); + assert!(!w.set(0, a, 5)); // dedup + assert!(w.set(0, b, 1)); + assert!(w.set_text(0, s, "run")); + assert!(!w.set_text(0, s, "run")); + assert!(w.set(3, a, 6)); + let text = String::from_utf8(w.out).unwrap(); + assert!(text.contains("$timescale 1 us $end")); + assert!(text.contains("$var wire 4 ! a $end")); + assert!(text.contains("$var wire 1 \" b $end")); + assert!(text.contains("$var string 1 # s $end")); + assert!(text.contains("$enddefinitions $end")); + // One #0 stamp, then the three changes, then #3 with the new value. + let tail: Vec<&str> = text + .lines() + .skip_while(|l| !l.starts_with("$enddefinitions")) + .skip(1) + .collect(); + assert_eq!(tail, ["#0", "b0101 !", "1\"", "srun #", "#3", "b0110 !"]); + } + + #[test] + fn vcd_writer_latches_failure_on_write_error() { + /// A sink that accepts `remaining` bytes and then fails, like a + /// full disk. + struct FailAfter { + remaining: usize, + } + impl Write for FailAfter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if self.remaining < buf.len() { + return Err(io::Error::other("disk full")); + } + self.remaining -= buf.len(); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let mut w = VcdWriter::new(FailAfter { remaining: 64 }); + let a = w.add_wire(4, "a").unwrap(); + assert!(!w.failed); + for i in 0..100u64 { + w.set(i, a, i); + } + assert!(w.failed, "the byte budget must have run out"); + // A failed writer emits nothing further. + assert!(!w.set(1000, a, 0xF)); + } + + #[test] + fn vcd_identifiers_stay_printable_and_unique() { + let mut seen = std::collections::HashSet::new(); + for index in 0..500 { + let id = vcd_identifier(index); + assert!(id.bytes().all(|b| (b'!'..=b'~').contains(&b))); + assert!(seen.insert(id)); + } + } +}