From 3b71c3ce18fdd9057ad3ba569547e2547b3ac82d Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 15 Jul 2026 22:01:11 +0100 Subject: [PATCH 1/2] debugger: trigger-based VCD waveform export of chipset signals Record internal hardware signals -- beam counters, chip-bus owner per colour clock, CPU chip-bus accesses, Copper PC/state, blitter pipeline slots and pointers, custom-register writes, IPL/INTREQ/INTENA, and audio DMA grants -- into a VCD file for GTKWave, like a logic analyser probing the chip bus. A capture arms with an output path, a trigger (now, pc=ADDR, beam=VPOS[:HPOS], reg=OFF, time=SECS), a bounded duration (cck, frames, ms, s; default one frame, capped at 10 emulated seconds), and a signal group selection. It is exposed three ways: --waveform/--wave-trigger/ --wave-duration/--wave-signals on the CLI (works headless), a WAVE START/STOP console command, and a new Wave tab in the debugger window. The sampler joins the existing gated instrumentation sinks at the chip-bus arbitration point in advance_one_chip_bus_quantum_limited; register writes and CPU grants tap their existing choke points. Every tap sits behind a plain wave_on bool, so the hot path costs a single branch while nothing is armed, and a capture observes without changing timing (the golden probe renders are unaffected). One VCD time unit is one colour clock, so GTKWave cursor deltas read directly in cck. The hand-rolled change-only VCD writer lives in src/waveform.rs with the trigger/duration/signal parsing and capture state machine; the bus-side taps live in src/bus/wave.rs. Documented in docs/debugger/waveform.md. --- README.md | 8 +- docs/debugger/console.md | 2 + docs/debugger/waveform.md | 111 ++++ docs/debugger/window.md | 9 + docs/debugger/workflows.md | 19 + docs/guide/headless.md | 5 + docs/myst.yml | 2 + src/bus.rs | 17 + src/bus/custom_regs.rs | 3 + src/bus/dma_slots.rs | 6 + src/bus/tests.rs | 141 ++++++ src/bus/wave.rs | 234 +++++++++ src/chipset/copper.rs | 12 + src/cpu.rs | 21 + src/lib.rs | 1 + src/main.rs | 76 +++ src/video/ui.rs | 122 ++++- src/video/window.rs | 97 ++++ src/video/window/console.rs | 66 +++ src/video/window/tests.rs | 90 ++++ src/waveform.rs | 979 ++++++++++++++++++++++++++++++++++++ 21 files changed, 2014 insertions(+), 7 deletions(-) create mode 100644 docs/debugger/waveform.md create mode 100644 src/bus/wave.rs create mode 100644 src/waveform.rs diff --git a/README.md b/README.md index 6dad212a..e63aa0cd 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 9e773035..4dc7470b 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 00000000..3ea74277 --- /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 0cdc989b..dd72e684 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 1dbfa693..12777ad7 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 0f5fa886..cbe93487 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 ec833406..585c7996 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 7e003786..a478d36c 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 849e9ebb..b5b794ad 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 ad9fba70..cd6e7f50 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 27ddf018..fe49e6e8 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 00000000..c133ef23 --- /dev/null +++ b/src/bus/wave.rs @@ -0,0 +1,234 @@ +//! 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(); + 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 7a7803a1..bd06fda3 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 ca6c8b3b..ddac24dd 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 d4e3d05a..6495cbf6 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 41f77fdf..82ede2d8 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 e9f2afdc..db17f872 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,11 @@ 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. + 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 +615,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 +731,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 +925,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 +1007,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 +2019,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 +5502,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 15c91508..03b80d48 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 77bcfad5..7531ce54 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 162359ef..b2cb8404 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 00000000..e57b3807 --- /dev/null +++ b/src/waveform.rs @@ -0,0 +1,979 @@ +//! 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" => { + let off = parse_hex_u32(value)?; + (off < 0x200).then_some(Trigger::RegWrite(off as u16 & 0x1FE)) + } + "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, +} + +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, + } + } + + 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. + fn set(&mut self, time: u64, var: VarId, value: u64) -> io::Result { + let slot = &mut self.vars[var.0]; + if slot.last == LastValue::Num(value) { + return Ok(false); + } + slot.last = LastValue::Num(value); + let width = slot.width; + self.stamp(time)?; + let var = &self.vars[var.0]; + 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)?; + } + Ok(true) + } + + /// Emit a string change if it differs from the last one. The text + /// must contain no whitespace. + fn set_text(&mut self, time: u64, var: VarId, text: &'static str) -> io::Result { + let slot = &mut self.vars[var.0]; + if slot.last == LastValue::Text(text) { + return Ok(false); + } + slot.last = LastValue::Text(text); + self.stamp(time)?; + let var = &self.vars[var.0]; + self.bytes += text.len() as u64 + 3 + var.id.len() as u64; + writeln!(self.out, "s{text} {}", var.id)?; + Ok(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 (or the file grew past the + /// emergency byte cap) at `now_cck`. 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 + } + _ => false, + } + } + + 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).unwrap_or(false); + } + }; + 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)) + .unwrap_or(false); + } + } + let mut set_text = |var: Option, text: &'static str| { + if let Some(var) = var { + changed |= w.set_text(t, var, text).unwrap_or(false); + } + }; + 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 { + let _ = w.set(t, var, u64::from(off & 0x1FF)); + } + if let Some(var) = v.regw_val { + let _ = w.set(t, var, u64::from(value)); + } + if let Some(var) = v.regw_src_s { + let _ = w.set_text(t, var, source); + } + if let Some(var) = v.regw_stb { + self.regw_stb_level = !self.regw_stb_level; + let _ = 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) { + let _ = w.set(t, var, u64::from(addr & 0x00FF_FFFF)); + } + if let Some(var) = v.cpu_kind_s { + let _ = w.set_text(t, var, kind); + } + if let Some(var) = v.cpu_rw { + let _ = w.set(t, var, u64::from(is_write)); + } + if let Some(var) = v.cpu_wait { + let _ = 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 => ("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); + 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).unwrap()); + assert!(!w.set(0, a, 5).unwrap()); // dedup + assert!(w.set(0, b, 1).unwrap()); + assert!(w.set_text(0, s, "run").unwrap()); + assert!(!w.set_text(0, s, "run").unwrap()); + assert!(w.set(3, a, 6).unwrap()); + 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_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)); + } + } +} From ce2048a5e45c706d55ba769eeecfa6a6c66656e4 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 15 Jul 2026 22:14:26 +0100 Subject: [PATCH 2/2] waveform: address Copilot review - Reject odd reg= trigger offsets instead of silently rounding them down to the neighbouring word register. - Latch VCD write errors in the writer: a failed writer emits nothing further, the capture aborts at its next expiry check (instead of running on while silently truncating the file), the status reports "failed (write error)", and the finish path logs a warning. - Allow backslash in the debugger entry box so Windows paths can be typed on the Waveform tab. --- src/bus/wave.rs | 17 ++++-- src/video/ui.rs | 5 +- src/waveform.rs | 144 +++++++++++++++++++++++++++++++++++------------- 3 files changed, 121 insertions(+), 45 deletions(-) diff --git a/src/bus/wave.rs b/src/bus/wave.rs index c133ef23..96ca35a1 100644 --- a/src/bus/wave.rs +++ b/src/bus/wave.rs @@ -77,11 +77,18 @@ impl Bus { self.wave_pc_trigger = false; if let Some(wave) = self.wave.as_deref_mut() { wave.finish(); - log::info!( - "waveform: wrote {} ({} samples)", - wave.path().display(), - wave.samples() - ); + 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() + ); + } } } diff --git a/src/video/ui.rs b/src/video/ui.rs index db17f872..87751d3f 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -368,8 +368,9 @@ impl DebuggerPanel { // breakpoint-condition mnemonics (EQ/NE/LT/GT/LE/GE/AND/IGN). A leading // 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. - let punctuation = matches!(ch, '=' | ':' | ',' | '.' | '-' | '_' | '/'); + // 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; } diff --git a/src/waveform.rs b/src/waveform.rs index e57b3807..150a95d7 100644 --- a/src/waveform.rs +++ b/src/waveform.rs @@ -149,8 +149,10 @@ pub fn parse_trigger(spec: &str) -> Option { 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).then_some(Trigger::RegWrite(off as u16 & 0x1FE)) + (off < 0x200 && off & 1 == 0).then_some(Trigger::RegWrite(off as u16)) } "time" => { let secs: f64 = value.trim().parse().ok()?; @@ -345,6 +347,11 @@ struct VcdWriter { /// 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 { @@ -368,6 +375,7 @@ impl VcdWriter { vars: Vec::new(), emitted_time: None, bytes: 0, + failed: false, } } @@ -439,19 +447,28 @@ impl VcdWriter { Ok(()) } - /// Emit a vector/bit change if the value differs from the last one. - fn set(&mut self, time: u64, var: VarId, value: u64) -> io::Result { + /// 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 Ok(false); + return false; } slot.last = LastValue::Num(value); let width = slot.width; - self.stamp(time)?; + if self.stamp(time).is_err() { + self.failed = true; + return false; + } let var = &self.vars[var.0]; - if width == 1 { + let written = if width == 1 { self.bytes += 2 + var.id.len() as u64; - writeln!(self.out, "{}{}", value & 1, var.id)?; + writeln!(self.out, "{}{}", value & 1, var.id) } else { let mut bits = String::with_capacity(width as usize + 2); bits.push('b'); @@ -459,24 +476,38 @@ impl VcdWriter { 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)?; + writeln!(self.out, "{bits} {}", var.id) + }; + if written.is_err() { + self.failed = true; + return false; } - Ok(true) + true } - /// Emit a string change if it differs from the last one. The text - /// must contain no whitespace. - fn set_text(&mut self, time: u64, var: VarId, text: &'static str) -> io::Result { + /// 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 Ok(false); + return false; } slot.last = LastValue::Text(text); - self.stamp(time)?; + 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; - writeln!(self.out, "s{text} {}", var.id)?; - Ok(true) + if writeln!(self.out, "s{text} {}", var.id).is_err() { + self.failed = true; + return false; + } + true } fn flush(&mut self) -> io::Result<()> { @@ -691,17 +722,23 @@ impl WaveCapture { }; } - /// Whether the capture window has run out (or the file grew past the - /// emergency byte cap) at `now_cck`. The caller finishes the capture. + /// 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 + 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) } @@ -718,7 +755,7 @@ impl WaveCapture { let v = &self.vars; let mut set = |var: Option, value: u64| { if let Some(var) = var { - changed |= w.set(t, var, value).unwrap_or(false); + changed |= w.set(t, var, value); } }; set(v.vpos, u64::from(s.vpos as u16)); @@ -739,14 +776,12 @@ impl WaveCapture { 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)) - .unwrap_or(false); + 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).unwrap_or(false); + changed |= w.set_text(t, var, text); } }; set_text(v.bus_owner_s, s.owner_name); @@ -766,17 +801,17 @@ impl WaveCapture { let w = &mut self.writer; let v = &self.vars; if let Some(var) = v.regw_off { - let _ = w.set(t, var, u64::from(off & 0x1FF)); + w.set(t, var, u64::from(off & 0x1FF)); } if let Some(var) = v.regw_val { - let _ = w.set(t, var, u64::from(value)); + w.set(t, var, u64::from(value)); } if let Some(var) = v.regw_src_s { - let _ = w.set_text(t, var, source); + w.set_text(t, var, source); } if let Some(var) = v.regw_stb { self.regw_stb_level = !self.regw_stb_level; - let _ = w.set(t, var, u64::from(self.regw_stb_level)); + w.set(t, var, u64::from(self.regw_stb_level)); } self.samples += 1; } @@ -797,16 +832,16 @@ impl WaveCapture { let w = &mut self.writer; let v = &self.vars; if let (Some(var), Some(addr)) = (v.cpu_addr, addr) { - let _ = w.set(t, var, u64::from(addr & 0x00FF_FFFF)); + w.set(t, var, u64::from(addr & 0x00FF_FFFF)); } if let Some(var) = v.cpu_kind_s { - let _ = w.set_text(t, var, kind); + w.set_text(t, var, kind); } if let Some(var) = v.cpu_rw { - let _ = w.set(t, var, u64::from(is_write)); + w.set(t, var, u64::from(is_write)); } if let Some(var) = v.cpu_wait { - let _ = w.set(t, var, u64::from(wait_cck.min(0xFFFF) as u16)); + w.set(t, var, u64::from(wait_cck.min(0xFFFF) as u16)); } self.samples += 1; } @@ -826,6 +861,7 @@ impl WaveCapture { 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 { @@ -880,6 +916,8 @@ mod tests { 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); } @@ -946,12 +984,12 @@ mod tests { let s = w.add_string("s").unwrap(); w.upscope().unwrap(); w.enddefinitions().unwrap(); - assert!(w.set(0, a, 5).unwrap()); - assert!(!w.set(0, a, 5).unwrap()); // dedup - assert!(w.set(0, b, 1).unwrap()); - assert!(w.set_text(0, s, "run").unwrap()); - assert!(!w.set_text(0, s, "run").unwrap()); - assert!(w.set(3, a, 6).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")); @@ -967,6 +1005,36 @@ mod tests { 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();