From c559dedb884d728cb257b12ecb12a423fc963c64 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 4 Sep 2026 12:07:35 -0700 Subject: [PATCH 01/12] Add FST waveform writing and block reading support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../03-development-recommendations.md | 4 +- lib/rohd.dart | 3 + lib/src/diagnostics/diagnostics.dart | 1 + lib/src/diagnostics/waveform_service.dart | 591 +++------- lib/src/diagnostics/waveform_writer.dart | 340 ++++++ lib/src/fst/fst_block_reader.dart | 599 ++++++++++ lib/src/fst/fst_types.dart | 236 ++++ lib/src/fst/fst_writer.dart | 1046 +++++++++++++++++ lib/src/module.dart | 3 +- lib/src/wave_dumper.dart | 2 +- test/config_test.dart | 30 +- test/fst_writer_test.dart | 441 +++++++ test/waveform_service_test.dart | 876 +++++++------- 13 files changed, 3254 insertions(+), 918 deletions(-) create mode 100644 lib/src/diagnostics/waveform_writer.dart create mode 100644 lib/src/fst/fst_block_reader.dart create mode 100644 lib/src/fst/fst_types.dart create mode 100644 lib/src/fst/fst_writer.dart create mode 100644 test/fst_writer_test.dart diff --git a/doc/user_guide/_get-started/03-development-recommendations.md b/doc/user_guide/_get-started/03-development-recommendations.md index d224e54df..6ffb5ab7b 100644 --- a/doc/user_guide/_get-started/03-development-recommendations.md +++ b/doc/user_guide/_get-started/03-development-recommendations.md @@ -10,9 +10,9 @@ toc: true - The [ROHD Cosimulation](https://github.com/intel/rohd-cosim) package allows you to cosimulate the ROHD simulator with a variety of SystemVerilog simulators. - The [ROHD Hardware Component Library](https://github.com/intel/rohd-vf) provides a set of reusable and configurable components for design and verification. - Visual Studio Code (vscode) is a great, free IDE with excellent support for Dart. It works well on all platforms, including native Windows or Windows Subsystem for Linux (WSL) which allows you to run a native Linux kernel (e.g. Ubuntu) within Windows. You can also use vscode to develop on a remote machine with the Remote SSH extension. - - vscode: + - vscode: - WSL: - - Remote SSH: + - Remote SSH: - Dart extension for vscode: Head over to the [user guide]({{ site.baseurl }}{% link _docs/A01-sample-example.md %}) to learn more about how to use ROHD. diff --git a/lib/rohd.dart b/lib/rohd.dart index bda2229c1..4ebac2244 100644 --- a/lib/rohd.dart +++ b/lib/rohd.dart @@ -11,6 +11,9 @@ export 'src/diagnostics/diagnostics.dart'; export 'src/exceptions/exceptions.dart'; export 'src/external.dart'; export 'src/finite_state_machine.dart'; +export 'src/fst/fst_block_reader.dart'; +export 'src/fst/fst_types.dart'; +export 'src/fst/fst_writer.dart'; export 'src/interfaces/interfaces.dart'; export 'src/module.dart'; export 'src/modules/modules.dart'; diff --git a/lib/src/diagnostics/diagnostics.dart b/lib/src/diagnostics/diagnostics.dart index 8ff34cf81..150721f21 100644 --- a/lib/src/diagnostics/diagnostics.dart +++ b/lib/src/diagnostics/diagnostics.dart @@ -10,3 +10,4 @@ export 'module_service.dart'; export 'module_services.dart'; export 'waveform_service.dart'; +export 'waveform_writer.dart'; diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart index 640a54c4c..e6fddec18 100644 --- a/lib/src/diagnostics/waveform_service.dart +++ b/lib/src/diagnostics/waveform_service.dart @@ -2,170 +2,64 @@ // SPDX-License-Identifier: BSD-3-Clause // // waveform_service.dart -// Base waveform service: file output with filtering, timescale, and -// flush/overwrite control. Designed to be subclassed by the DevTools -// streaming variant. +// Base waveform service: capture module signal changes to waveform writers. // // 2026 June // Author: Desmond Kirkpatrick import 'dart:collection'; -import 'dart:convert'; import 'dart:io'; import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; -import 'package:rohd/src/utilities/config.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; -import 'package:rohd/src/utilities/timestamper.dart'; import 'package:rohd/src/utilities/uniquifier.dart'; -// ─── Supporting types ──────────────────────────────────────────────────────── - -/// The output format for waveform capture. -enum WaveOutputFormat { - /// Value Change Dump — the classic text-based waveform format. - vcd, - - /// Fast Signal Trace — a compact binary format. - /// - /// Requires an FST writer to be available; see the DevTools subclass for - /// a fully FST-backed implementation. - fst; - - /// The filename extension associated with this format. - String get fileExtension => switch (this) { - WaveOutputFormat.vcd => 'vcd', - WaveOutputFormat.fst => 'fst', - }; - - /// The media type associated with this format. - String get mediaType => switch (this) { - WaveOutputFormat.vcd => 'text/x-vcd', - WaveOutputFormat.fst => 'application/vnd.gtkwave.fst', - }; - - /// Whether this format supports querying waveform data directly from a file. - /// - /// FST is indexed and can support on-disk queries without retaining the - /// entire waveform in memory. VCD is a sequential text format and cannot. - bool get supportsOnDiskQueries => switch (this) { - WaveOutputFormat.vcd => false, - WaveOutputFormat.fst => true, - }; -} - -/// Policy applied when the output file already exists at construction time. -enum OverwritePolicy { - /// Silently overwrite any existing file. - overwrite, - - /// Throw a [FileSystemException] if the file already exists. - failIfExists, -} - -// ─── Service ───────────────────────────────────────────────────────────────── - -/// A waveform capture service that records signal changes. -/// -/// This is the base class for waveform capture. It handles: -/// - Signal collection (with optional [signalFilter]) -/// - Optional whole-history in-memory VCD output with configurable [timescale] -/// - Selective recording via [startTime] / [stopTime] -/// - Optional file output with periodic buffer flushing and [overwritePolicy] -/// - Optional registration with [ModuleServices] +/// A waveform capture service that writes signal changes to a file. /// -/// **Subclassing for DevTools streaming:** -/// -/// Override the protected hooks below to intercept the simulation event loop -/// without re-implementing the file-writing logic: -/// -/// - [onSignalCollected] — called once per tracked signal at startup; use -/// it to register signals in a VM-service index. -/// - [onValueChange] — called once for each signal in a captured callback -/// batch's coalesced final-value set; use it to feed an in-memory store for -/// streaming. A window-entry snapshot is a separate batch from captured -/// changes and may share their timestamp. -/// - [onTimestampCapture] — called after each captured timestamp batch, -/// including the possibly empty finalization batch. -/// - [onSimulationEnd] — called after the final timestamp is written and -/// the file is closed; use it to finalise any streaming buffers. -/// -/// Example subclass skeleton: -/// ```dart -/// class DevToolsWaveformService extends WaveformService { -/// DevToolsWaveformService( -/// super.module, { -/// super.outputDirectory, -/// super.outputBaseName, -/// }); -/// -/// @override -/// void onSignalCollected(Logic signal) { -/// super.onSignalCollected(signal); -/// _registerWithVmService(signal); -/// } -/// -/// @override -/// void onValueChange(Logic signal, int timestamp) { -/// super.onValueChange(signal, timestamp); -/// _recordInMemory(signal, timestamp); -/// } -/// } -/// ``` +/// Selects the output backend via [format]; each format is emitted by a +/// dedicated [WaveformWriter] implementation ([VcdWaveformWriter] for +/// [WaveOutputFormat.vcd], [FstWaveformWriter] for [WaveOutputFormat.fst]). class WaveformService extends ArtifactProducingService { /// The most recently registered [WaveformService], or `null`. + static WaveformService? current; + + /// Exact output filename override. /// - /// This is backed by [ModuleServices], so it is cleared by unregistering - /// this service type or resetting the registry. - static WaveformService? get current => - ModuleServices.instance.lookup(); + /// Prefer [outputBaseName] for new service code. This override exists for + /// compatibility with legacy APIs that accepted an arbitrary output path. + final String? outputFileName; /// Path of the output waveform file. /// - /// Derived from [outputDirectory], [outputBaseName], and [format]. - String get outputFilePath => '$outputDirectory${Platform.pathSeparator}' + /// Derived from [outputDirectory], [outputBaseName], [outputFileName], + /// and [format]. + String get outputPath => '$outputDirectory${Platform.pathSeparator}' '${outputFileName ?? '$outputBaseName.${format.fileExtension}'}'; - /// The output filepath of the generated waveforms. + /// Path of the output waveform file. /// - /// This matches the legacy waveform dumper's `outputPath` name. - String get outputPath => outputFilePath; - - /// Exact output filename override. - /// - /// Prefer [outputBaseName] for new service code. This override exists for - /// compatibility with legacy APIs that accepted an arbitrary output path. - final String? outputFileName; + /// This compatibility alias matches the legacy waveform service API. + String get outputFilePath => outputPath; /// Output format. final WaveOutputFormat format; /// Optional predicate that determines whether a given [Logic] signal is /// captured. - /// - /// When `null`, all non-[Const] signals in the hierarchy are captured, - /// matching the legacy waveform dumper behaviour. final bool Function(Logic signal)? signalFilter; /// VCD timescale string, e.g. `'1ps'`, `'1ns'`. final String timescale; /// Simulation time at which recording begins. - /// - /// Signals are still collected before this time so they appear in the scope - /// definition, but value-change events are suppressed until [startTime] is - /// reached. `null` means "from the very start". final int? startTime; /// Simulation time at which recording ends. - /// - /// Value-change events after this time are suppressed. `null` means "until - /// end of simulation". final int? stopTime; - /// Number of characters accumulated in the write buffer before it is flushed - /// to disk. + /// Number of characters accumulated in the VCD write buffer before it is + /// flushed to disk. final int flushBufferSize; /// What to do when the output file already exists. @@ -174,48 +68,14 @@ class WaveformService extends ArtifactProducingService { /// Whether to register this service with [ModuleServices] for inspection. final bool register; - /// Whether waveform bytes are written to [outputFilePath]. - /// - /// File-backed captures retain only the current [flushBufferSize]-bounded - /// write buffer unless [retainInMemory] is enabled. - final bool writeToFile; - - /// Whether to retain the complete waveform in memory. - /// - /// By default, this is `true` for in-memory-only VCD debugging captures and - /// `false` for file-backed captures. Set it explicitly to override those - /// defaults when consumers need whole-history waveform queries during or - /// after simulation. - final bool retainInMemory; - - /// Whether this service can service debugger waveform-data queries. - /// - /// A `true` result promises that a debugger can request captured waveform - /// values, such as selected signals over a time interval. It does not - /// promise that [artifacts] can transfer waveform-file bytes. Capture can be - /// queried when complete history is retained in memory, or when a - /// file-backed [format] supports indexed on-disk queries. VCD requires - /// [retainInMemory]; FST can provide this capability from a file once FST - /// writing is supported. - bool canSendWaveforms() => - retainInMemory || (writeToFile && format.supportsOnDiskQueries); - - // ─── Internal file-writing state ───────────────────────────── - - /// Sink writing to [outputFilePath] when [writeToFile] is true. - IOSink? _outFileSink; - - /// Write buffer; flushed when it exceeds [flushBufferSize]. - final StringBuffer _fileBuffer = StringBuffer(); + /// The FST writer configuration (only used when [format] is + /// [WaveOutputFormat.fst]). + final FstWriterConfig? fstConfig; - /// The complete waveform output when [retainInMemory] is enabled. - final StringBuffer _inMemoryOutput = StringBuffer(); + late final WaveformWriter _writer; - /// Counter for assigning compact signal markers in the VCD. - int _signalMarkerIdx = 0; - - /// Maps each captured [Logic] to its VCD marker string. - final Map _signalToMarkerMap = {}; + /// Maps each captured [Logic] to its writer-specific signal handle. + final Map _signalHandles = {}; /// Signals that changed during the current simulation timestamp. final Set _changedThisTimestamp = HashSet(); @@ -223,27 +83,13 @@ class WaveformService extends ArtifactProducingService { /// The timestamp currently being accumulated. int _currentDumpingTimestamp = Simulator.time; - /// Whether the recording window's initial signal snapshot has been written. - bool _hasWrittenWindowSnapshot = false; - - // ─── Constructor ───────────────────────────────────────────── - /// Creates a [WaveformService] for [module]. /// - /// [module] must be built before construction. - /// - /// [outputDirectory] defaults to the current directory and [outputBaseName] - /// defaults to [Module.definitionName]. The selected [format] determines the - /// output filename extension. Only [WaveOutputFormat.vcd] is currently - /// supported by this service. - /// - /// Use the optional constructor parameters to configure format, filtering, - /// timescale, start/stop times, flush size, and overwrite policy. - /// - /// In-memory-only VCD debugging captures retain the complete waveform by - /// default. Set [retainInMemory] to override these defaults; file-backed - /// captures default to bounded memory while retaining a streamable artifact - /// on disk. + /// [module] must be built before construction. [outputDirectory] defaults to + /// the current directory and [outputBaseName] defaults to + /// [Module.definitionName]; the on-disk file is + /// `/.`. Pass + /// [outputFileName] to override the filename explicitly. WaveformService( Module module, { super.outputDirectory, @@ -257,39 +103,23 @@ class WaveformService extends ArtifactProducingService { this.flushBufferSize = 100000, this.overwritePolicy = OverwritePolicy.overwrite, this.register = true, - this.writeToFile = false, - bool? retainInMemory, - }) : retainInMemory = retainInMemory ?? !writeToFile, - super(module) { + this.fstConfig, + }) : super(module) { if (!module.hasBuilt) { - throw ModuleNotBuiltException(module); - } - if (format != WaveOutputFormat.vcd) { - throw UnsupportedError( - 'Waveform format ${format.name} is not supported by WaveformService.', + throw Exception( + 'Module must be built before creating WaveformService. ' + 'Call build() first.', ); } - if (writeToFile && overwritePolicy == OverwritePolicy.failIfExists) { - final f = File(outputFilePath); - if (f.existsSync()) { - throw FileSystemException( - 'Waveform output file already exists and overwritePolicy is ' - 'failIfExists.', - outputFilePath, - ); - } - } - - if (writeToFile) { - _outFileSink = - (File(outputFilePath)..createSync(recursive: true)).openWrite(); - } - - _collectSignals(); - _writeHeader(); - _writeScope(); - _hasWrittenWindowSnapshot = startTime == null || startTime == 0; + _writer = _createWriter(); + _collectSignals(module); + _writer.finishDeclarations( + _signalHandles.entries.map( + (entry) => WaveformInitialValue(entry.value, _binaryValue(entry.key)), + ), + timestamp: Simulator.time, + ); Simulator.preTick.listen((_) { if (Simulator.time != _currentDumpingTimestamp) { @@ -297,7 +127,6 @@ class WaveformService extends ArtifactProducingService { _captureTimestamp(_currentDumpingTimestamp); } _currentDumpingTimestamp = Simulator.time; - _writeWindowSnapshotIfNeeded(Simulator.time); } }); @@ -308,149 +137,142 @@ class WaveformService extends ArtifactProducingService { }); if (register) { + current = this; ModuleServices.instance.register(this); } } - // ─── Extensibility hooks ────────────────────────────────────── - - /// Called once for each [Logic] signal that passes - /// [signalFilter] during initial signal collection. + /// Legacy factory that accepts a single `outputPath` argument. /// - /// Override in a subclass to register signals with an in-memory store, - /// VM service index, or FST handle map. Always call `super` first. + /// Splits [outputPath] into an [outputDirectory] and [outputFileName] and + /// delegates to the main constructor. Provided so that pre-services-API + /// callers of the form `WaveformService(module, outputPath: '/tmp/foo.vcd')` + /// still compile. + factory WaveformService.fromOutputPath( + Module module, { + required String outputPath, + WaveOutputFormat format = WaveOutputFormat.vcd, + bool Function(Logic signal)? signalFilter, + String timescale = '1ps', + int? startTime, + int? stopTime, + int flushBufferSize = 100000, + OverwritePolicy overwritePolicy = OverwritePolicy.overwrite, + bool register = true, + FstWriterConfig? fstConfig, + }) { + final normalized = outputPath.replaceAll(r'\', '/'); + final sep = normalized.lastIndexOf('/'); + final directory = switch (sep) { + -1 => '.', + 0 => '/', + _ => normalized.substring(0, sep), + }; + final filename = normalized.substring(sep + 1); + return WaveformService( + module, + outputDirectory: directory, + outputFileName: filename, + format: format, + signalFilter: signalFilter, + timescale: timescale, + startTime: startTime, + stopTime: stopTime, + flushBufferSize: flushBufferSize, + overwritePolicy: overwritePolicy, + register: register, + fstConfig: fstConfig, + ); + } + + /// The concrete output writer used by this service. + @protected + WaveformWriter get writer => _writer; + + /// Called once for each [Logic] signal that passes [signalFilter]. @protected void onSignalCollected(Logic signal) {} - /// Called once for each signal's final value in a callback batch at - /// [timestamp]. - /// - /// Multiple changes to the same signal within a simulation timestamp are - /// coalesced within a value-change batch, so this hook receives that signal - /// once with its final value for that batch. - /// - /// When [startTime] is set, this includes one window-entry value for every - /// tracked signal at [startTime]. Those calls describe the state entering - /// the recording window, rather than physical transitions, and form a - /// separate callback batch. A signal that then changes at [startTime] is - /// delivered again in the following value-change batch with the same - /// timestamp. - /// - /// Override in a subclass to feed an in-memory waveform store or - /// streaming buffer. Always call `super` first. + /// Called for every value-change event on [signal] at [timestamp]. @protected void onValueChange(Logic signal, int timestamp) {} - /// Called once after each captured timestamp batch. - /// - /// When [startTime] is set, the complete window-entry signal snapshot is - /// delivered as a batch at [startTime] before the value-change batch, which - /// may have the same timestamp. Finalization invokes this hook even when its - /// [changed] set is empty. - /// - /// Override in a subclass to flush incremental streaming payloads. - /// Always call `super` first. + /// Called once per simulation timestamp that contains at least one change. @protected void onTimestampCapture(int timestamp, Set changed) {} /// Called after the final timestamp has been written and the file is closed. - /// - /// Override in a subclass to finalise any streaming buffers or emit - /// end-of-simulation notifications. @protected void onSimulationEnd() {} - // ─── Internal signal collection ────────────────────────────── - - void _collectSignals() { - final modulesToParse = [module]; - for (var i = 0; i < modulesToParse.length; i++) { - final m = modulesToParse[i]; - for (final sig in m.signals) { - if (sig is Const) { - continue; - } - if (signalFilter != null && !signalFilter!(sig)) { - continue; - } - - _signalToMarkerMap[sig] = 's${_signalMarkerIdx++}'; - onSignalCollected(sig); - - sig.changed.listen((_) { - _changedThisTimestamp.add(sig); - }); - } - - for (final subm in m.subModules) { - if (subm is InlineSystemVerilog) { - continue; - } - modulesToParse.add(subm); - } + WaveformWriter _createWriter() { + switch (format) { + case WaveOutputFormat.vcd: + return VcdWaveformWriter( + outputPath, + timescale: timescale, + flushBufferSize: flushBufferSize, + overwritePolicy: overwritePolicy, + ); + case WaveOutputFormat.fst: + return FstWaveformWriter( + outputPath, + config: fstConfig ?? const FstWriterConfig(), + ); } } - // ─── VCD output helpers ─────────────────────────────────────── - - void _writeHeader() { - final header = ''' -\$date - ${Timestamper.stamp()} -\$end -\$version - ROHD v${Config.version} -\$end -\$comment - Generated by ROHD - www.github.com/intel/rohd -\$end -\$timescale $timescale \$end -'''; - _writeToBuffer(header); - } - - void _writeScope() { - var scopeString = _computeScopeString(module); - scopeString += '\$enddefinitions \$end\n'; - scopeString += '\$dumpvars\n'; - _writeToBuffer(scopeString); - _signalToMarkerMap.keys.forEach(_writeSignalValueUpdate); - _writeToBuffer('\$end\n'); - } - - String _computeScopeString(Module m, {int indent = 0}) { + bool _collectSignals(Module module) { final moduleSignalUniquifier = Uniquifier(); - final padding = List.filled(indent, ' ').join(); - var scopeString = '$padding\$scope module ${m.uniqueInstanceName} \$end\n'; - final innerScopeString = StringBuffer(); + var hasContents = false; + + _writer.pushScope(module.uniqueInstanceName); - for (final sig in m.signals) { - if (!_signalToMarkerMap.containsKey(sig)) { + for (final sig in module.signals) { + if (sig is Const) { continue; } - final width = sig.width; - final marker = _signalToMarkerMap[sig]; - var signalName = Sanitizer.sanitizeSV(sig.name); - signalName = moduleSignalUniquifier.getUniqueName( - initialName: signalName, + if (signalFilter != null && !signalFilter!(sig)) { + continue; + } + + hasContents = true; + final baseName = Sanitizer.sanitizeSV(sig.name); + final signalName = moduleSignalUniquifier.getUniqueName( + initialName: baseName, reserved: sig.isPort, ); - innerScopeString.write( - ' $padding\$var wire $width $marker $signalName \$end\n', - ); - } - for (final subModule in m.subModules) { - innerScopeString.write( - _computeScopeString(subModule, indent: indent + 1), + final handle = _writer.declareSignal( + signalName, + sig.width, + direction: _directionOf(sig), ); + _signalHandles[sig] = handle; + onSignalCollected(sig); + + sig.changed.listen((_) { + _changedThisTimestamp.add(sig); + }); } - if (innerScopeString.isEmpty) { - return ''; + + for (final subModule in module.subModules) { + if (subModule is InlineSystemVerilog) { + continue; + } + hasContents = _collectSignals(subModule) || hasContents; } - scopeString += innerScopeString.toString(); - scopeString += '$padding\$upscope \$end\n'; - return scopeString; + _writer.popScope(); + return hasContents; + } + + WaveformSignalDirection _directionOf(Logic signal) { + if (!signal.isPort) { + return WaveformSignalDirection.implicit; + } + return signal.isInput + ? WaveformSignalDirection.input + : WaveformSignalDirection.output; } bool _isInRecordingWindow(int timestamp) { @@ -469,116 +291,49 @@ class WaveformService extends ArtifactProducingService { return; } - _writeWindowSnapshotIfNeeded(timestamp); - _writeToBuffer('#$timestamp\n'); - final snapshot = Set.of(_changedThisTimestamp); + final changes = [ + for (final sig in snapshot) + WaveformValueChange(_signalHandles[sig]!, _binaryValue(sig)), + ]; + + if (changes.isNotEmpty) { + _writer.emitValueChanges(timestamp, changes); + } + for (final sig in snapshot) { - _writeSignalValueUpdate(sig); onValueChange(sig, timestamp); } _changedThisTimestamp.clear(); - onTimestampCapture(timestamp, snapshot); - } - - void _writeWindowSnapshotIfNeeded(int timestamp) { - if (_hasWrittenWindowSnapshot || - startTime == null || - timestamp < startTime! || - !_isInRecordingWindow(startTime!)) { - return; - } - - _writeToBuffer('#$startTime\n'); - final snapshot = Set.of(_signalToMarkerMap.keys); - for (final signal in snapshot) { - _writeSignalValueUpdate(signal); - onValueChange(signal, startTime!); - } - _hasWrittenWindowSnapshot = true; if (snapshot.isNotEmpty) { - onTimestampCapture(startTime!, snapshot); + onTimestampCapture(timestamp, snapshot); } } - void _writeSignalValueUpdate(Logic signal) { - final binaryValue = signal.value.reversed - .toList() - .map((e) => e.toString(includeWidth: false)) - .join(); - final updateValue = signal.width > 1 - ? 'b$binaryValue ' - : signal.value.toString(includeWidth: false); - final marker = _signalToMarkerMap[signal]; - _writeToBuffer('$updateValue$marker\n'); - } + String _binaryValue(Logic signal) => signal.value.reversed + .toList() + .map((e) => e.toString(includeWidth: false)) + .join(); - // ─── Buffered I/O ───────────────────────────────────────────── + Future _terminate() => _writer.close(); - void _writeToBuffer(String contents) { - if (writeToFile) { - _fileBuffer.write(contents); - } - if (retainInMemory) { - _inMemoryOutput.write(contents); - } - if (writeToFile && _fileBuffer.length > flushBufferSize) { - _flushBuffer(); - } - } - - void _flushBuffer() { - if (writeToFile) { - _outFileSink!.write(_fileBuffer.toString()); - _fileBuffer.clear(); - } - } - - Future _terminate() async { - _flushBuffer(); - await _outFileSink?.flush(); - await _outFileSink?.close(); - } - - // ─── Inspection ─────────────────────────────────────────────── - - /// The waveform artifact produced by this service. + /// The artifacts this service produces. /// - /// The artifact is complete after simulation finalization. During capture, - /// file-backed artifacts expose only bytes already flushed to - /// [outputFilePath]; the current write buffer is not visible. Each - /// file-backed [ModuleServiceArtifact.openRead] opens the current file, not - /// an immutable snapshot or a live tail, so concurrent capture may change - /// what a read observes. In-memory artifacts are available whenever - /// [retainInMemory] is enabled, including its automatic memory-only default. + /// The waveform is written on-the-fly through [WaveformWriter], so this + /// service does not retain artifacts to report. @override - Iterable get artifacts sync* { - if (!writeToFile && !retainInMemory) { - return; - } - - yield ModuleServiceArtifact( - fileName: outputFileName ?? '$outputBaseName.${format.fileExtension}', - mediaType: format.mediaType, - openRead: writeToFile - ? () => File(outputFilePath).openRead() - : () => Stream.value(utf8.encode(_inMemoryOutput.toString())), - ); - } + Iterable get artifacts => const []; /// Returns a JSON-serialisable summary of this service. @override - Map toJson() => { - 'outputDirectory': outputDirectory, - 'outputBaseName': outputBaseName, - 'outputFilePath': outputFilePath, - 'writeToFile': writeToFile, - 'retainInMemory': retainInMemory, + Map toJson() => { + 'outputPath': outputPath, 'format': format.name, - 'signalCount': _signalToMarkerMap.length, + 'signalCount': _signalHandles.length, 'timescale': timescale, - if (startTime != null) 'startTime': startTime!, - if (stopTime != null) 'stopTime': stopTime!, + if (startTime != null) 'startTime': startTime, + if (stopTime != null) 'stopTime': stopTime, + 'writer': _writer.toJson(), }; } diff --git a/lib/src/diagnostics/waveform_writer.dart b/lib/src/diagnostics/waveform_writer.dart new file mode 100644 index 000000000..67a7272b9 --- /dev/null +++ b/lib/src/diagnostics/waveform_writer.dart @@ -0,0 +1,340 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_writer.dart +// Common output backend API for waveform capture services. +// +// 2026 July 17 +// Author: Desmond Kirkpatrick + +import 'dart:io'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/config.dart'; +import 'package:rohd/src/utilities/timestamper.dart'; + +/// The output format for waveform capture. +enum WaveOutputFormat { + /// Value Change Dump, the classic text-based waveform format. + vcd, + + /// Fast Signal Trace, a compact binary format. + fst; + + /// The filename extension associated with this format. + String get fileExtension => switch (this) { + WaveOutputFormat.vcd => 'vcd', + WaveOutputFormat.fst => 'fst', + }; + + /// The media type associated with this format. + String get mediaType => switch (this) { + WaveOutputFormat.vcd => 'text/x-vcd', + WaveOutputFormat.fst => 'application/vnd.gtkwave.fst', + }; +} + +/// Policy applied when the output file already exists at construction time. +enum OverwritePolicy { + /// Silently overwrite any existing file. + overwrite, + + /// Throw a [FileSystemException] if the file already exists. + failIfExists, +} + +/// Direction metadata for a signal emitted into a waveform file. +enum WaveformSignalDirection { + /// Input port. + input, + + /// Output port. + output, + + /// Internal or implicit signal. + implicit, +} + +/// Initial value for a declared waveform signal. +class WaveformInitialValue { + /// The writer-specific handle returned by [WaveformWriter.declareSignal]. + final Object handle; + + /// The MSB-first binary value string. + final String value; + + /// Creates an initial value entry. + const WaveformInitialValue(this.handle, this.value); +} + +/// Timestamped value change for a declared waveform signal. +class WaveformValueChange extends WaveformInitialValue { + /// Creates a value-change entry. + const WaveformValueChange(super.handle, super.value); +} + +/// Common backend contract for waveform file formats. +abstract class WaveformWriter { + /// The file format emitted by this writer. + WaveOutputFormat get format; + + /// Pushes a scope onto the declaration hierarchy. + void pushScope(String name); + + /// Pops the current declaration scope. + void popScope(); + + /// Declares a signal and returns a writer-specific handle. + Object declareSignal( + String name, + int width, { + required WaveformSignalDirection direction, + }); + + /// Finishes declarations and emits initial values. + void finishDeclarations( + Iterable initialValues, { + required int timestamp, + }); + + /// Emits all value changes for [timestamp]. + void emitValueChanges(int timestamp, Iterable changes); + + /// Flushes and closes the waveform output. + Future close(); + + /// Returns a JSON-serialisable summary of writer state. + Map toJson(); +} + +/// VCD implementation of [WaveformWriter]. +class VcdWaveformWriter implements WaveformWriter { + /// Creates a VCD writer at [outputPath]. + VcdWaveformWriter( + this.outputPath, { + this.timescale = '1ps', + this.flushBufferSize = 100000, + this.overwritePolicy = OverwritePolicy.overwrite, + }) { + if (overwritePolicy == OverwritePolicy.failIfExists) { + final existingFile = File(outputPath); + if (existingFile.existsSync()) { + throw FileSystemException( + 'Waveform output file already exists and overwritePolicy is ' + 'failIfExists.', + outputPath, + ); + } + } + + _outputFile = File(outputPath)..createSync(recursive: true); + _outFileSink = _outputFile.openWrite(); + _writeHeader(); + } + + /// The output file path. + final String outputPath; + + /// VCD timescale string, e.g. `'1ps'`, `'1ns'`. + final String timescale; + + /// Number of characters accumulated before flushing to disk. + final int flushBufferSize; + + /// Existing-file policy. + final OverwritePolicy overwritePolicy; + + late final File _outputFile; + late final IOSink _outFileSink; + final StringBuffer _fileBuffer = StringBuffer(); + final StringBuffer _scopeBuffer = StringBuffer(); + final Map _handleWidths = {}; + var _signalMarkerIdx = 0; + var _indent = 0; + var _closed = false; + + @override + WaveOutputFormat get format => WaveOutputFormat.vcd; + + @override + void pushScope(String name) { + final padding = List.filled(_indent, ' ').join(); + _scopeBuffer.write('$padding\$scope module $name \$end\n'); + _indent++; + } + + @override + void popScope() { + _indent--; + final padding = List.filled(_indent, ' ').join(); + _scopeBuffer.write('$padding\$upscope \$end\n'); + } + + @override + Object declareSignal( + String name, + int width, { + required WaveformSignalDirection direction, + }) { + final marker = 's${_signalMarkerIdx++}'; + final padding = List.filled(_indent, ' ').join(); + _scopeBuffer.write('$padding\$var wire $width $marker $name \$end\n'); + _handleWidths[marker] = width; + return marker; + } + + @override + void finishDeclarations( + Iterable initialValues, { + required int timestamp, + }) { + _writeToBuffer(_scopeBuffer.toString()); + _writeToBuffer('\$enddefinitions \$end\n'); + _writeToBuffer('\$dumpvars\n'); + for (final initialValue in initialValues) { + _writeValueUpdate(initialValue.handle, initialValue.value); + } + _writeToBuffer('\$end\n'); + } + + @override + void emitValueChanges(int timestamp, Iterable changes) { + _writeToBuffer('#$timestamp\n'); + for (final change in changes) { + _writeValueUpdate(change.handle, change.value); + } + } + + @override + Future close() async { + if (_closed) { + return; + } + _closed = true; + _flushBuffer(); + await _outFileSink.flush(); + await _outFileSink.close(); + } + + @override + Map toJson() => { + 'format': format.name, + 'signalCount': _handleWidths.length, + 'timescale': timescale, + }; + + void _writeHeader() { + final header = ''' +\$date + ${Timestamper.stamp()} +\$end +\$version + ROHD v${Config.version} +\$end +\$comment + Generated by ROHD - www.github.com/intel/rohd +\$end +\$timescale $timescale \$end +'''; + _writeToBuffer(header); + } + + void _writeValueUpdate(Object handle, String value) { + final width = _handleWidths[handle]; + if (width == null) { + throw StateError('Unknown VCD signal handle: $handle'); + } + final updateValue = width > 1 ? 'b$value ' : value; + _writeToBuffer('$updateValue$handle\n'); + } + + void _writeToBuffer(String contents) { + _fileBuffer.write(contents); + if (_fileBuffer.length > flushBufferSize) { + _flushBuffer(); + } + } + + void _flushBuffer() { + _outFileSink.write(_fileBuffer.toString()); + _fileBuffer.clear(); + } +} + +/// FST implementation of [WaveformWriter]. +class FstWaveformWriter implements WaveformWriter { + /// Creates an FST writer at [outputPath]. + FstWaveformWriter( + String outputPath, { + FstWriterConfig config = const FstWriterConfig(), + }) : writer = FstWriter(outputPath, config: config); + + /// The low-level FST binary writer. + final FstWriter writer; + + @override + WaveOutputFormat get format => WaveOutputFormat.fst; + + @override + void pushScope(String name) { + writer.pushScope(name); + } + + @override + void popScope() { + writer.popScope(); + } + + @override + Object declareSignal( + String name, + int width, { + required WaveformSignalDirection direction, + }) => + writer.declareSignal(name, width, direction: _fstDirection(direction)); + + @override + void finishDeclarations( + Iterable initialValues, { + required int timestamp, + }) { + writer.writeHeader(); + for (final initialValue in initialValues) { + writer.emitValueChange( + timestamp, + initialValue.handle as FstSignalHandle, + initialValue.value, + ); + } + } + + @override + void emitValueChanges(int timestamp, Iterable changes) { + for (final change in changes) { + writer.emitValueChange( + timestamp, + change.handle as FstSignalHandle, + change.value, + ); + } + } + + @override + Future close() async { + writer.finish(); + } + + @override + Map toJson() => {'format': format.name}; + + FstVarDirection _fstDirection(WaveformSignalDirection direction) { + switch (direction) { + case WaveformSignalDirection.input: + return FstVarDirection.input; + case WaveformSignalDirection.output: + return FstVarDirection.output; + case WaveformSignalDirection.implicit: + return FstVarDirection.implicit; + } + } +} diff --git a/lib/src/fst/fst_block_reader.dart b/lib/src/fst/fst_block_reader.dart new file mode 100644 index 000000000..7b35843d3 --- /dev/null +++ b/lib/src/fst/fst_block_reader.dart @@ -0,0 +1,599 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// fst_block_reader.dart +// Lightweight companion reader for FstWriter that reads back VcData blocks +// from disk using the writer's block index. +// +// This is NOT a general-purpose FST reader. For full FST file reading +// (post-simulation), use the wellen library via the rohd-wave-viewer's +// dart_wellen package which wraps the Rust fst-reader crate. +// +// 2026 February +// Author: Desmond Kirkpatrick + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:rohd/src/fst/fst_writer.dart'; + +/// A value change record decoded from an FST VcData block. +class FstValueChange { + /// The simulation timestamp. + final int time; + + /// The signal value as a string. + final String value; + + /// Creates a decoded value change. + const FstValueChange(this.time, this.value); + + @override + String toString() => 'FstValueChange(t=$time, v=$value)'; +} + +/// Lightweight reader that decodes VcData DynamicAlias2 blocks written by +/// [FstWriter]. +/// +/// Uses the [FstWriter.blockIndex] to locate blocks on disk and reads only +/// the requested signals, avoiding loading the entire FST file into memory. +/// +/// ## Architecture +/// +/// During a live simulation the data flow is: +/// +/// ```dart +/// FstWriter ──(flushBlock)──> .fst file (VcData blocks on disk) +/// │ +/// FstBlockReader ────(readBlock)────┘ +/// │ +/// WaveformDataService <──(merge)──── disk data + hot buffer +/// │ +/// └──(VM Service extensions)──> DevTools extension +/// ``` +/// +/// Post-simulation, the finalized `.fst` file can be read by the **wellen** +/// library (Rust, via `dart_wellen`) in the rohd-wave-viewer for full +/// waveform browsing. +class FstBlockReader { + /// Signal metadata from the writer (indexed by handle - 1). + final List _signals; + + /// Path to the FST file on disk. + final String filePath; + + /// Creates a block reader for the FST file at [filePath]. + /// + /// [signals] must match the signals declared in the associated [FstWriter]. + /// The reader opens its own file handle for each read, so it is safe to use + /// after the writer has been finished or closed. + FstBlockReader(this.filePath, List signals) + : _signals = List.unmodifiable(signals); + + /// Reads a single VcData block and returns value changes for the + /// specified signal handles. + /// + /// [block] identifies which block to read (from [FstWriter.blockIndex]). + /// [handleIndices] are the 0-based signal indices to extract. + /// If [startTime] / [endTime] are provided, only changes within that + /// range are returned. + /// + /// Returns a map from handle index to a list of value changes. + Map> readBlock( + FstBlockIndex block, { + Set? handleIndices, + int? startTime, + int? endTime, + }) { + final file = File(filePath).openSync(); + try { + return _readBlockImpl( + file, + block, + handleIndices: handleIndices, + startTime: startTime, + endTime: endTime, + ); + } finally { + file.closeSync(); + } + } + + /// Read the frame (initial values at block start) for all signals. + /// + /// Returns a list indexed by handle (0-based) of initial value strings. + List readBlockFrame(FstBlockIndex block) { + final file = File(filePath).openSync(); + try { + return _readFrameImpl(file, block); + } finally { + file.closeSync(); + } + } + + // ─────────────── Implementation ─────────────── + + Map> _readBlockImpl( + RandomAccessFile file, + FstBlockIndex block, { + Set? handleIndices, + int? startTime, + int? endTime, + }) { + // The block layout (DynamicAlias2): + // block_type(1) + section_length(8) + start_time(8) + end_time(8) + // + mem_required(8) = 33 bytes of header + // Then: frame, vc data, offset chain, time table + // + // We read from the end backwards to get time table first, + // then offset chain, then signal data. + + final sectionStart = block.fileOffset + 1; // after block_type byte + final sectionEnd = sectionStart + block.sectionLength; + + // 1. Read the time table (last 24 bytes of section) + final timeTable = _readTimeTable(file, sectionStart, sectionEnd); + + // 2. Locate the offset chain + // Last 24 bytes: time_uncomp(8), time_comp(8), time_items(8) + final timeTableMetaStart = sectionEnd - 24; + file.setPositionSync(timeTableMetaStart + 8); + final compressedTimeLen = _readU64(file); + final timeDataStart = timeTableMetaStart - compressedTimeLen; + + // chain_compressed_length is 8 bytes before time data + final chainLenOffset = timeDataStart - 8; + file.setPositionSync(chainLenOffset); + final chainLen = _readU64(file); + + // 3. Read the offset chain bytes + final chainStart = chainLenOffset - chainLen; + file.setPositionSync(chainStart); + final chainBytes = file.readSync(chainLen); + + // 4. Read frame and VC header to find signal data positions + // Skip: section_length(8) + start_time(8) + end_time(8) + mem(8) + file.setPositionSync(sectionStart + 32); + final frameUncLen = _readVarint(file); + final frameCompLen = _readVarint(file); + _readVarint(file); // maxHandle + final frameDataStart = file.positionSync(); + file.setPositionSync(frameDataStart + frameCompLen); + + // VC header: max_handle varint + pack_type byte + _readVarint(file); // vcMaxHandle + final vcBase = file.positionSync(); // position of pack_type byte + final packType = file.readSync(1)[0]; + // Signal data starts at vcBase + 1 (offset 1 in writer's scheme) + + // Read and decompress the frame + file.setPositionSync(frameDataStart); + final frameCompressed = file.readSync(frameCompLen); + final frameBytes = (frameCompLen == frameUncLen) + ? Uint8List.fromList(frameCompressed) + : _zlibDecompress(Uint8List.fromList(frameCompressed)); + final frameValues = _decodeFrame(frameBytes); + + // 5. Parse offset chain to get signal data locations + // Offsets are relative to vcBase (pack_type position). + // Signal data section ends at chainStart. + final signalDataEndRel = chainStart - vcBase; + final signalLocations = _parseOffsetChain( + chainBytes, + _signals.length, + signalDataEndRel, + ); + + // 6. Decode each requested signal + final result = >{}; + final requestedHandles = handleIndices ?? + Set.from(List.generate(_signals.length, (i) => i)); + + for (final handleIdx in requestedHandles) { + if (handleIdx < 0 || handleIdx >= _signals.length) { + continue; + } + + final loc = signalLocations[handleIdx]; + if (loc == null) { + // No data for this signal in this block — use frame value + if ((startTime == null || block.startTime >= startTime) && + (endTime == null || block.startTime <= endTime)) { + result[handleIdx] = [ + FstValueChange(block.startTime, frameValues[handleIdx]), + ]; + } else { + result[handleIdx] = []; + } + continue; + } + + // Read the packed signal data (offset relative to vcBase) + file.setPositionSync(vcBase + loc.offset); + final packedData = file.readSync(loc.length); + + // Unpack: varint prefix indicates compression + final unpacked = _unpackSignalData( + Uint8List.fromList(packedData), + packType, + ); + + // Decode value changes using the time table + final sig = _signals[handleIdx]; + final changes = _decodeSignalChanges( + unpacked, + sig, + timeTable, + startTime: startTime, + endTime: endTime, + ); + + // Prepend frame value at block start time if in range + final allChanges = []; + if ((startTime == null || block.startTime >= startTime) && + (endTime == null || block.startTime <= endTime)) { + allChanges.add(FstValueChange(block.startTime, frameValues[handleIdx])); + } + allChanges.addAll(changes); + result[handleIdx] = allChanges; + } + + return result; + } + + List _readFrameImpl(RandomAccessFile file, FstBlockIndex block) { + final sectionStart = block.fileOffset + 1; + file.setPositionSync(sectionStart + 32); + final frameUncLen = _readVarint(file); + final frameCompLen = _readVarint(file); + _readVarint(file); // maxHandle + final frameCompressed = file.readSync(frameCompLen); + final frameBytes = (frameCompLen == frameUncLen) + ? Uint8List.fromList(frameCompressed) + : _zlibDecompress(Uint8List.fromList(frameCompressed)); + return _decodeFrame(frameBytes); + } + + // ─────────────── Time table decoding ─────────────── + + /// Reads the time table from the end of a VcData section. + List _readTimeTable( + RandomAccessFile file, + int sectionStart, + int sectionEnd, + ) { + file.setPositionSync(sectionEnd - 24); + final uncLen = _readU64(file); + final compLen = _readU64(file); + final numItems = _readU64(file); + + final timeDataOffset = sectionEnd - 24 - compLen; + file.setPositionSync(timeDataOffset); + final compressedData = file.readSync(compLen); + + final Uint8List uncompressed; + if (compLen == uncLen) { + uncompressed = Uint8List.fromList(compressedData); + } else { + uncompressed = _zlibDecompress(Uint8List.fromList(compressedData)); + } + + final timeTable = []; + var offset = 0; + var prevTime = 0; + for (var i = 0; i < numItems; i++) { + final (delta, newOffset) = _decodeVarintFromBytes(uncompressed, offset); + offset = newOffset; + prevTime += delta; + timeTable.add(prevTime); + } + return timeTable; + } + + // ─────────────── Offset chain parsing ─────────────── + + /// Parses the DynamicAlias2 offset chain to locate each signal's data. + /// + /// [signalDataEndRel] is the byte offset (relative to vcBase) where + /// signal data ends (i.e., the chain start position). + Map _parseOffsetChain( + Uint8List chainBytes, + int signalCount, + int signalDataEndRel, + ) { + final locs = {}; + var offset = 0; + var handleIdx = 0; + var currentOffset = 0; + final offsets = {}; + + while (offset < chainBytes.length && handleIdx < signalCount) { + final firstByte = chainBytes[offset]; + + if ((firstByte & 1) == 1) { + final (raw, newOffset) = _decodeSignedVarintFromBytes( + chainBytes, + offset, + ); + offset = newOffset; + final shval = raw >> 1; + + if (shval > 0) { + currentOffset += shval; + offsets[handleIdx] = currentOffset; + handleIdx++; + } else if (shval < 0) { + locs[handleIdx] = null; // alias + handleIdx++; + } else { + locs[handleIdx] = null; // same alias + handleIdx++; + } + } else { + final (raw, newOffset) = _decodeVarintFromBytes(chainBytes, offset); + offset = newOffset; + final zeros = raw >> 1; + for (var i = 0; i < zeros && handleIdx < signalCount; i++) { + locs[handleIdx] = null; + handleIdx++; + } + } + } + + while (handleIdx < signalCount) { + locs[handleIdx] = null; + handleIdx++; + } + + // Compute lengths from consecutive offsets + final sortedHandles = offsets.keys.toList()..sort(); + for (var i = 0; i < sortedHandles.length; i++) { + final h = sortedHandles[i]; + final start = offsets[h]!; + final end = (i + 1 < sortedHandles.length) + ? offsets[sortedHandles[i + 1]]! + : signalDataEndRel; + locs[h] = _SignalLoc(start, end - start); + } + + return locs; + } + + // ─────────────── Frame decoding ─────────────── + + /// Decodes frame bytes back to string values, one per signal. + List _decodeFrame(Uint8List frameBytes) { + final result = []; + var offset = 0; + + for (final sig in _signals) { + if (sig.isReal) { + if (offset + 8 <= frameBytes.length) { + final bd = ByteData.sublistView(frameBytes, offset, offset + 8); + final d = bd.getFloat64(0, Endian.little); + result.add(d.toString()); + } else { + result.add('0.0'); + } + offset += 8; + } else { + final width = sig.width; + final buf = StringBuffer(); + for (var j = 0; j < width; j++) { + if (offset + j < frameBytes.length) { + buf.writeCharCode(frameBytes[offset + j]); + } else { + buf.write('x'); + } + } + result.add(buf.toString()); + offset += width; + } + } + return result; + } + + // ─────────────── Signal data decoding ─────────────── + + /// Unpacks a signal's data: reads the varint(uncomp_len) prefix and + /// decompresses if needed. + Uint8List _unpackSignalData(Uint8List packedData, int packType) { + if (packedData.isEmpty) { + return Uint8List(0); + } + + var offset = 0; + final (uncLen, newOffset) = _decodeVarintFromBytes(packedData, offset); + offset = newOffset; + + final payload = packedData.sublist(offset); + if (uncLen == 0) { + // Raw/uncompressed (writer stores varint(0) prefix for raw data) + return Uint8List.fromList(payload); + } + + // Compressed — decompress + return _zlibDecompress(Uint8List.fromList(payload)); + } + + /// Decodes value changes from a signal's uncompressed data stream. + List _decodeSignalChanges( + Uint8List data, + FstSignalInfo sig, + List timeTable, { + int? startTime, + int? endTime, + }) { + if (data.isEmpty) { + return []; + } + + final changes = []; + var offset = 0; + var timeIdx = 0; + + while (offset < data.length) { + if (sig.width == 1 && !sig.isReal) { + // 1-bit signal + final (vli, newOffset) = _decodeVarintFromBytes(data, offset); + offset = newOffset; + + String value; + int timeDelta; + if ((vli & 1) == 0) { + value = ((vli >> 1) & 1) == 0 ? '0' : '1'; + timeDelta = vli >> 2; + } else { + const rcvChars = 'xzhuwl-?'; + final rcvIdx = (vli >> 1) & 7; + value = rcvIdx < rcvChars.length ? rcvChars[rcvIdx] : 'x'; + timeDelta = vli >> 4; + } + timeIdx += timeDelta; + if (timeIdx < timeTable.length) { + final t = timeTable[timeIdx]; + if ((startTime == null || t >= startTime) && + (endTime == null || t <= endTime)) { + changes.add(FstValueChange(t, value)); + } + } + } else if (sig.isReal) { + // Real signal + final (vli, newOffset) = _decodeVarintFromBytes(data, offset); + offset = newOffset; + final timeDelta = vli >> 1; + timeIdx += timeDelta; + + if (offset + 8 <= data.length) { + final bd = ByteData.sublistView(data, offset, offset + 8); + final d = bd.getFloat64(0, Endian.little); + offset += 8; + if (timeIdx < timeTable.length) { + final t = timeTable[timeIdx]; + if ((startTime == null || t >= startTime) && + (endTime == null || t <= endTime)) { + changes.add(FstValueChange(t, d.toString())); + } + } + } else { + break; + } + } else { + // Multi-bit signal + final (vli, newOffset) = _decodeVarintFromBytes(data, offset); + offset = newOffset; + final timeDelta = vli >> 1; + final is4State = (vli & 1) == 1; + timeIdx += timeDelta; + + String value; + if (!is4State) { + final byteCount = (sig.width + 7) ~/ 8; + if (offset + byteCount > data.length) { + break; + } + final buf = StringBuffer(); + for (var i = 0; i < sig.width; i++) { + final byteIdx = i ~/ 8; + final bitIdx = 7 - (i % 8); + final bit = (data[offset + byteIdx] >> bitIdx) & 1; + buf.write(bit == 0 ? '0' : '1'); + } + value = buf.toString(); + offset += byteCount; + } else { + if (offset + sig.width > data.length) { + break; + } + value = String.fromCharCodes(data, offset, offset + sig.width); + offset += sig.width; + } + + if (timeIdx < timeTable.length) { + final t = timeTable[timeIdx]; + if ((startTime == null || t >= startTime) && + (endTime == null || t <= endTime)) { + changes.add(FstValueChange(t, value)); + } + } + } + } + + return changes; + } + + // ─────────────── Low-level I/O helpers ─────────────── + + /// Reads a big-endian u64 from [file] at the current position. + static int _readU64(RandomAccessFile file) { + final bytes = file.readSync(8); + final bd = ByteData.sublistView(Uint8List.fromList(bytes)); + return bd.getUint64(0); + } + + /// Reads an unsigned LEB128 varint from [file] at the current position. + static int _readVarint(RandomAccessFile file) { + var result = 0; + var shift = 0; + while (true) { + final byte = file.readSync(1)[0]; + result |= (byte & 0x7F) << shift; + if ((byte & 0x80) == 0) { + break; + } + shift += 7; + } + return result; + } + + /// Decodes an unsigned LEB128 varint from [bytes] at [offset]. + static (int, int) _decodeVarintFromBytes(Uint8List bytes, int offset) { + var result = 0; + var shift = 0; + var pos = offset; + while (pos < bytes.length) { + final byte = bytes[pos++]; + result |= (byte & 0x7F) << shift; + if ((byte & 0x80) == 0) { + break; + } + shift += 7; + } + return (result, pos); + } + + /// Decodes a signed LEB128 varint from [bytes] at [offset]. + static (int, int) _decodeSignedVarintFromBytes(Uint8List bytes, int offset) { + var result = 0; + var shift = 0; + var pos = offset; + int byte; + do { + byte = bytes[pos++]; + result |= (byte & 0x7F) << shift; + shift += 7; + } while ((byte & 0x80) != 0 && pos < bytes.length); + + if (shift < 64 && (byte & 0x40) != 0) { + result |= ~0 << shift; + } + return (result, pos); + } + + /// Decompresses zlib-compressed data. + static Uint8List _zlibDecompress(Uint8List data) { + final decompressed = ZLibCodec().decode(data); + return Uint8List.fromList(decompressed); + } +} + +/// Internal: location of a signal's data within the VC section. +class _SignalLoc { + /// Byte offset relative to vcBase (pack_type byte position). + final int offset; + + /// Length in bytes. + final int length; + + const _SignalLoc(this.offset, this.length); +} diff --git a/lib/src/fst/fst_types.dart b/lib/src/fst/fst_types.dart new file mode 100644 index 000000000..13e829c28 --- /dev/null +++ b/lib/src/fst/fst_types.dart @@ -0,0 +1,236 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// fst_types.dart +// Enumerations and constants for the FST (Fast Signal Trace) binary format. +// +// 2026 February +// Author: Desmond Kirkpatrick + +/// FST block types (from fstapi.h). +enum FstBlockType { + /// File header. + header(0), + + /// Value change data (zlib compressed). + vcData(1), + + /// Blackout regions. + blackout(2), + + /// Geometry (per-variable back-pointers for random access). + geometry(3), + + /// Hierarchy (zlib compressed). + hierarchy(4), + + /// Value changes with dynamic aliases (zlib). + vcDataDynamicAlias(5), + + /// Hierarchy (LZ4 compressed). + hierarchyLz4(6), + + /// Hierarchy (LZ4 double compressed). + hierarchyLz4Duo(7), + + /// Value changes with dynamic aliases v2 (modern recommended format). + vcDataDynamicAlias2(8), + + /// GZip wrapper. + gzipWrapper(254), + + /// Skip/padding. + skip(255); + + const FstBlockType(this.value); + + /// The numeric value of this block type as written in FST files. + final int value; +} + +/// FST scope types. +enum FstScopeType { + /// A Verilog/SystemVerilog module instantiation scope. + module(0), + + /// A Verilog/SystemVerilog task scope. + task(1), + + /// A Verilog/SystemVerilog function scope. + function_(2), + + /// A named `begin`..`end` block scope (Verilog). + begin(3), + + /// A named `fork`..`join` block scope (Verilog). + fork(4), + + /// A `generate` block scope (SystemVerilog). + generate(5), + + /// A `struct` type scope (SystemVerilog). + struct_(6), + + /// A `union` type scope (SystemVerilog). + union(7), + + /// A `class` scope (SystemVerilog). + class_(8), + + /// An `interface` scope (SystemVerilog). + interface(9), + + /// A `package` scope (SystemVerilog). + package(10), + + /// A `program` scope (SystemVerilog). + program(11); + + const FstScopeType(this.value); + + /// The numeric value of this scope type as written in FST files. + final int value; +} + +/// FST variable types. +enum FstVarType { + /// An event variable. + event(0), + + /// A Verilog `integer` variable (32-bit, 4-state). + integer(1), + + /// A Verilog `parameter` or `localparam`. + parameter(2), + + /// A `real` variable (double-precision floating point). + real(3), + + /// A `real` parameter. + realParameter(4), + + /// A `reg` variable (Verilog 4-state storage). + reg(5), + + /// A `supply0` net (logic-0 power supply). + supply0(6), + + /// A `supply1` net (logic-1 power supply). + supply1(7), + + /// A `time` variable. + time(8), + + /// A `tri` net (tri-state, same resolution as `wire`). + tri(9), + + /// A `triand` net (tri-state with wired-AND resolution). + triAnd(10), + + /// A `trior` net (tri-state with wired-OR resolution). + triOr(11), + + /// A `trireg` net (retains last driven value when undriven). + triReg(12), + + /// A `tri0` net (pulls to 0 when undriven). + tri0(13), + + /// A `tri1` net (pulls to 1 when undriven). + tri1(14), + + /// A `wand` net (wired-AND). + wand(15), + + /// A `wire` net (standard Verilog interconnect). + wire(16), + + /// A `wor` net (wired-OR). + wor(17), + + /// A port variable. + port(18), + + /// A sparse array variable. + sparseArray(19), + + /// A `realtime` variable. + realTime(20), + + /// A generic string variable. + genericString(21), + + // SystemVerilog types + + /// A SystemVerilog `bit` type (2-state, unsigned). + bit(22), + + /// A SystemVerilog `logic` type (4-state). + logic(23), + + /// A SystemVerilog `int` type (32-bit, 2-state, signed). + int_(24), + + /// A SystemVerilog `shortint` type (16-bit, 2-state, signed). + shortInt(25), + + /// A SystemVerilog `longint` type (64-bit, 2-state, signed). + longInt(26), + + /// A SystemVerilog `byte` type (8-bit, 2-state, signed). + byte_(27), + + /// A SystemVerilog `enum` type. + enum_(28), + + /// A SystemVerilog `shortreal` type (single-precision float). + shortReal(29); + + const FstVarType(this.value); + + /// The numeric value of this variable type as written in FST files. + final int value; +} + +/// FST variable direction. +enum FstVarDirection { + /// No direction specified (implicit net). + implicit(0), + + /// Input port. + input(1), + + /// Output port. + output(2), + + /// Bidirectional (inout) port. + inout(3), + + /// Buffer port (output that can be read back). + buffer(4), + + /// Linkage port (VHDL linkage mode). + linkage(5); + + const FstVarDirection(this.value); + + /// The numeric value of this direction as written in FST files. + final int value; +} + +/// FST file type. +enum FstFileType { + /// Verilog source. + verilog(0), + + /// VHDL source. + vhdl(1), + + /// Mixed Verilog and VHDL source. + verilogVhdl(2); + + const FstFileType(this.value); + + /// The numeric value of this file type as written in FST files. + final int value; +} diff --git a/lib/src/fst/fst_writer.dart b/lib/src/fst/fst_writer.dart new file mode 100644 index 000000000..c9fe9058c --- /dev/null +++ b/lib/src/fst/fst_writer.dart @@ -0,0 +1,1046 @@ +// Copyright (C) 2021-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// fst_writer.dart +// Pure Dart implementation of FST (Fast Signal Trace) binary writer. +// +// Writes FST files compatible with GTKWave, Surfer, and wellen/fst-reader. +// Implements the public FST binary format in pure Dart. +// +// 2026 February +// Author: Desmond Kirkpatrick + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:rohd/rohd.dart'; + +/// Configuration for the FST writer. +class FstWriterConfig { + /// Timescale exponent. The timescale is 10^exponent seconds. + /// Default: -12 (picoseconds). + final int timescaleExponent; + + /// Zlib compression level (0-9). Higher = smaller but slower. + /// Default: 4. + final int compressionLevel; + + /// Writer version string embedded in the file header. + final String version; + + /// File type: Verilog, VHDL, or combined. + final FstFileType fileType; + + /// Maximum number of value changes to buffer before auto-flushing + /// a VcData block to disk. Set to 0 (default) to disable auto-flush + /// and write a single block at [FstWriter.finish]. + /// + /// When non-zero, [FstWriter.emitValueChange] automatically calls + /// [FstWriter.flushBlock] once the buffer reaches this threshold. + /// This bounds memory usage and makes historical data available on + /// disk for read-back. + final int maxChangesPerBlock; + + /// Creates configuration for the FST writer. + const FstWriterConfig({ + this.timescaleExponent = -12, + this.compressionLevel = 4, + this.version = 'ROHD FST Writer', + this.fileType = FstFileType.verilog, + this.maxChangesPerBlock = 0, + }); +} + +/// A handle to a declared signal in the FST file. +/// +/// Handles are 1-based (matching VST convention). Index 0 is unused. +class FstSignalHandle { + /// The 1-based handle value. + final int handle; + + /// Creates a signal handle from a 1-based handle value. + const FstSignalHandle(this.handle); +} + +/// Metadata about a flushed VcData block in the FST file. +/// +/// Each entry in [FstWriter.blockIndex] represents a block that has been +/// written to disk and can be read back independently for on-demand +/// signal queries without loading the entire file into memory. +class FstBlockIndex { + /// File offset of the block_type byte in the FST file. + final int fileOffset; + + /// Section length (the section_length field from the block header). The full + /// block occupies bytes [fileOffset .. fileOffset + 1 + sectionLength). + final int sectionLength; + + /// First timestamp in this block. + final int startTime; + + /// Last timestamp in this block. + final int endTime; + + /// Creates a block index entry. + const FstBlockIndex({ + required this.fileOffset, + required this.sectionLength, + required this.startTime, + required this.endTime, + }); +} + +/// Public metadata about a declared signal in the FST writer. +class FstSignalInfo { + /// Signal name. + final String name; + + /// Bit width (number of bits for digital signals, 8 for real). + final int width; + + /// Whether this is a real-valued (f64) signal. + final bool isReal; + + /// Creates signal info. + const FstSignalInfo({ + required this.name, + required this.width, + required this.isReal, + }); +} + +/// Internal: information about a declared signal. +class _SignalDecl { + final String name; + final int width; + final FstVarType varType; + final FstVarDirection direction; + final bool isReal; + + _SignalDecl({ + required this.name, + required this.width, + required this.varType, + required this.direction, + this.isReal = false, + }); + + /// The geometry file_format value for this signal. + int get geometryValue { + if (isReal) { + return 0; + } + return width; // 1 for 1-bit, N for N-bit + } + + /// The number of bytes this signal occupies in the frame section. + int get frameLength { + if (isReal) { + return 8; + } + return width; // 1 byte per bit for character-encoded values + } +} + +/// Internal: a buffered value change. +class _ValueChange { + final int time; + final int handleIndex; // 0-based + final String value; + + _ValueChange(this.time, this.handleIndex, this.value); +} + +/// Internal: an entry in the hierarchy being built. +sealed class _HierarchyEntry {} + +class _ScopeEntry extends _HierarchyEntry { + final FstScopeType type; + final String name; + final String component; + _ScopeEntry(this.type, this.name, {this.component = ''}); +} + +class _UpScopeEntry extends _HierarchyEntry {} + +class _VarEntry extends _HierarchyEntry { + final FstVarType varType; + final FstVarDirection direction; + final String name; + final int width; + final int handle; // 1-based + _VarEntry(this.varType, this.direction, this.name, this.width, this.handle); +} + +/// Pure Dart writer for the FST (Fast Signal Trace) binary format. +/// +/// Usage: +/// ```dart +/// final writer = FstWriter('output.fst'); +/// writer.pushScope('top'); +/// final clk = writer.declareSignal('clk', 1); +/// final data = writer.declareSignal('data', 8); +/// writer.popScope(); +/// writer.writeHeader(); +/// +/// writer.emitValueChange(0, clk, '0'); +/// writer.emitValueChange(0, data, '00000000'); +/// writer.emitValueChange(5, clk, '1'); +/// writer.emitValueChange(10, clk, '0'); +/// +/// writer.finish(); +/// ``` +class FstWriter { + /// The output file path. + final String filePath; + + /// Writer configuration. + final FstWriterConfig config; + + /// All declared signals (0-indexed). + final List<_SignalDecl> _signals = []; + + /// Hierarchy entries in declaration order. + final List<_HierarchyEntry> _hierEntries = []; + + /// Scope counts for header. + int _scopeCount = 0; + + /// Variable counts for header (including aliases). + int _varCount = 0; + + /// Buffered value changes. + final List<_ValueChange> _changes = []; + + /// The start time of the simulation. + int _startTime = 0; + + /// The end time of the simulation. + int _endTime = 0; + + /// Whether the header has been written yet. + bool _headerWritten = false; + + /// The output file random access handle. + late final RandomAccessFile _file; + + /// Current value of each signal (tracks latest emitted value). + /// Initialized in [writeHeader]. + late List _currentValues; + + /// Base values for the next block's frame section. + /// Updated after each [flushBlock] call. + late List _nextFrameBase; + + /// Index of flushed VcData blocks for read-back. + final List _blockIndex = []; + + /// Number of VcData blocks written so far. + int _vcSectionCount = 0; + + /// Creates an FST writer that will write to [filePath]. + FstWriter(this.filePath, {this.config = const FstWriterConfig()}) { + final file = File(filePath)..createSync(recursive: true); + _file = file.openSync(mode: FileMode.write); + } + + /// Pushes a new scope onto the hierarchy. + void pushScope( + String name, { + FstScopeType type = FstScopeType.module, + String component = '', + }) { + _hierEntries.add(_ScopeEntry(type, name, component: component)); + _scopeCount++; + } + + /// Pops the current scope. + void popScope() { + _hierEntries.add(_UpScopeEntry()); + } + + /// Declares a signal and returns its handle. + /// + /// [name] is the signal name. [width] is the bit width (1 for single bit). + /// Returns an [FstSignalHandle] used for emitting value changes. + FstSignalHandle declareSignal( + String name, + int width, { + FstVarType varType = FstVarType.wire, + FstVarDirection direction = FstVarDirection.implicit, + }) { + final handle = _signals.length + 1; // 1-based + final decl = _SignalDecl( + name: name, + width: width, + varType: varType, + direction: direction, + isReal: varType == FstVarType.real || varType == FstVarType.realParameter, + ); + _signals.add(decl); + _hierEntries.add(_VarEntry(varType, direction, name, width, handle)); + _varCount++; + return FstSignalHandle(handle); + } + + /// Writes the FST file header. + /// + /// Must be called after all signals are declared and before any value + /// changes. The header is initially written with placeholder values for + /// start_time and end_time, which are fixed up during [finish]. + void writeHeader() { + if (_headerWritten) { + throw StateError('Header already written'); + } + _writeHeaderBlock(); + _headerWritten = true; + + // Initialize value tracking for incremental block flushing + final defaults = List.generate(_signals.length, (i) { + final sig = _signals[i]; + return sig.isReal ? '0.0' : 'x' * sig.width; + }); + _currentValues = List.from(defaults); + _nextFrameBase = List.from(defaults); + } + + /// Records a value change for a signal at a given simulation time. + /// + /// [time] is the simulation timestamp. + /// [handle] is the signal handle returned by [declareSignal]. + /// [value] is the new value as a string (e.g., '0', '1', '01010101', 'x'). + void emitValueChange(int time, FstSignalHandle handle, String value) { + if (!_headerWritten) { + throw StateError('Must call writeHeader() before emitting value changes'); + } + if (_endTime < time) { + _endTime = time; + } + _changes.add(_ValueChange(time, handle.handle - 1, value)); + _currentValues[handle.handle - 1] = value; + + // Auto-flush if threshold is reached + if (config.maxChangesPerBlock > 0 && + _changes.length >= config.maxChangesPerBlock) { + flushBlock(); + } + } + + /// Finalizes the FST file: flushes remaining value changes, writes + /// geometry and hierarchy blocks, fixes up the header, and closes the file. + void finish() { + if (!_headerWritten) { + writeHeader(); + } + + // Flush any remaining buffered changes as a final VcData block + flushBlock(); + + _writeGeometryBlock(); + _writeHierarchyBlock(); + _fixupHeader(); + + _file.closeSync(); + } + + /// Releases resources. Call [finish] first for a valid file. + void dispose() { + try { + _file.closeSync(); + } on FileSystemException { + // already closed + } + } + + /// Flushes buffered value changes to disk as a VcData block. + /// + /// After flushing, the changes are cleared from memory and the block + /// is recorded in [blockIndex] for later read-back. This enables + /// incremental writing where only recent unflushed changes remain + /// in memory while historical data lives on disk. + /// + /// Does nothing if no changes are buffered. + void flushBlock() { + if (_changes.isEmpty) { + return; + } + if (!_headerWritten) { + throw StateError('Must call writeHeader() before flushing blocks'); + } + + // Sort changes by time, then by handle + _changes.sort((a, b) { + final cmp = a.time.compareTo(b.time); + return cmp != 0 ? cmp : a.handleIndex.compareTo(b.handleIndex); + }); + + final blockStart = _changes.first.time; + final blockEnd = _changes.last.time; + + // Build frame: carry-over state from previous block, overridden by + // any changes at this block's start time. + final frameValues = List.from(_nextFrameBase); + for (final c in _changes) { + if (c.time == blockStart) { + frameValues[c.handleIndex] = c.value; + } + } + + final blockOffset = _file.positionSync(); + _writeVcDataBlock( + blockStartTime: blockStart, + blockEndTime: blockEnd, + frameValues: frameValues, + ); + final blockEndPos = _file.positionSync(); + + // Record block in the index for read-back + _blockIndex.add( + FstBlockIndex( + fileOffset: blockOffset, + sectionLength: blockEndPos - blockOffset - 1, + startTime: blockStart, + endTime: blockEnd, + ), + ); + _vcSectionCount++; + + // Update global time range + if (_vcSectionCount == 1) { + _startTime = blockStart; + } + _endTime = blockEnd; + + // Carry-over state for next block's frame + _nextFrameBase = List.from(_currentValues); + _changes.clear(); + } + + // ─── Public query API for hybrid disk+memory access ─── + + /// Index of all flushed VcData blocks. + /// + /// Each entry contains the file offset and time range, enabling + /// the `FstBlockReader` to read specific blocks on demand. + List get blockIndex => List.unmodifiable(_blockIndex); + + /// Number of declared signals. + int get signalCount => _signals.length; + + /// Public metadata about each declared signal (indexed by handle-1). + List get signalInfoList => _signals + .map((s) => FstSignalInfo(name: s.name, width: s.width, isReal: s.isReal)) + .toList(); + + /// The output file handle for read-back by `FstBlockReader`. + /// + /// **Warning**: The caller must not close or modify the file position + /// without restoring it. The writer uses this same handle for writing. + RandomAccessFile get file => _file; + + /// Query unflushed value changes for a specific signal handle. + /// + /// Returns changes from the hot buffer for signal [handleIndex] (0-based) + /// within the time range \[startTime, endTime\]. + List<({int time, String value})> queryHotBuffer( + int handleIndex, + int startTime, + int endTime, + ) => + _changes + .where( + (c) => + c.handleIndex == handleIndex && + c.time >= startTime && + c.time <= endTime, + ) + .map((c) => (time: c.time, value: c.value)) + .toList(); + + /// Returns the current (latest) value of signal [handleIndex] (0-based). + String getCurrentValue(int handleIndex) => _currentValues[handleIndex]; + + /// Returns the latest known values of all signals (read-only). + List get currentValues => List.unmodifiable(_currentValues); + + // ─────────────── Header Block ─────────────── + + static const int _headerLength = 329; + static const int _headerVersionMaxLen = 128; + static const int _headerDateMaxLen = 119; + + /// Writes the FST_BL_HDR block. + void _writeHeaderBlock() { + _file.writeByteSync(FstBlockType.header.value); + _writeU64(_headerLength); // section_length (fixed size) + _writeU64(_startTime); // start_time (placeholder) + _writeU64(_endTime); // end_time (placeholder) + _writeF64LE(math.e); // double endian test + _writeU64(0); // memory_used_by_writer + _writeU64(_scopeCount); // scope_count + _writeU64(_varCount); // var_count + _writeU64(_signals.length); // max_var_id_code + _writeU64(1); // vc_section_count (we write one block) + _file.writeByteSync(config.timescaleExponent & 0xFF); // timescale_exponent + _writeFixedString(config.version, _headerVersionMaxLen); + _writeFixedString(_dateString(), _headerDateMaxLen); + _file.writeByteSync(config.fileType.value); // file_type + _writeU64(0); // time_zero + } + + /// Fixes up the header with actual start/end times and block count. + void _fixupHeader() { + final savedPos = _file.positionSync(); + _file.setPositionSync(1 + 8); // skip block_type + section_length + _writeU64(_startTime); + _writeU64(_endTime); + // Fix vc_section_count with actual number of blocks written + // Layout: block_type(1) + section_length(8) + start_time(8) + + // end_time(8) + endian_test(8) + memory_used(8) + scope_count(8) + + // var_count(8) + max_var_id(8) = offset 65 + _file.setPositionSync( + 1 + 8 + 8 + 8 + 8 + 8 + 8 + 8 + 8, + ); // at vc_section_count + _writeU64(_vcSectionCount); + _file.setPositionSync(savedPos); + } + + // ─────────────── Hierarchy Block ─────────────── + + static const int _hierTypeScopeBegin = 254; + static const int _hierTypeUpScope = 255; + + /// Writes the FST_BL_HIER block (zlib/gzip compressed hierarchy). + void _writeHierarchyBlock() { + // Build uncompressed hierarchy bytes + final buf = BytesBuilder(copy: false); + var handleCount = 0; + + for (final entry in _hierEntries) { + switch (entry) { + case _ScopeEntry(): + buf + ..addByte(_hierTypeScopeBegin) + ..addByte(entry.type.value) + ..add(_cString(entry.name)) + ..add(_cString(entry.component)); + case _UpScopeEntry(): + buf.addByte(_hierTypeUpScope); + case _VarEntry(): + buf + ..addByte(entry.varType.value) + ..addByte(entry.direction.value) + ..add(_cString(entry.name)) + ..add(encodeVarint(entry.width)) // length + // alias = 0 means "new handle, not an alias" + ..add(encodeVarint(0)); + handleCount++; + } + } + + final uncompressed = buf.toBytes(); + assert( + handleCount == _signals.length, + 'Handle count mismatch: $handleCount vs ${_signals.length}', + ); + + // Write as FST_BL_HIER (type 4) with gzip compression + _file.writeByteSync(FstBlockType.hierarchy.value); + final sectionLengthPos = _file.positionSync(); + _writeU64(0); // placeholder section_length + _writeU64(uncompressed.length); // uncompressed_length + + // Write gzip header + deflate-compressed data + _writeGzipCompressed(uncompressed); + + // Fix section_length + final endPos = _file.positionSync(); + final sectionLength = endPos - sectionLengthPos; + _file.setPositionSync(sectionLengthPos); + _writeU64(sectionLength); + _file.setPositionSync(endPos); + } + + // ─────────────── Geometry Block ─────────────── + + /// Writes the FST_BL_GEOM block. + void _writeGeometryBlock() { + // Build uncompressed geometry: one varint per signal + final buf = BytesBuilder(copy: false); + for (final sig in _signals) { + buf.add(encodeVarint(sig.geometryValue)); + } + final uncompressed = buf.toBytes(); + final compressed = _zlibCompress( + uncompressed, + config.compressionLevel, + allowRaw: true, + ); + + _file.writeByteSync(FstBlockType.geometry.value); + final sectionLength = 3 * 8 + compressed.length; + _writeU64(sectionLength); // section_length + _writeU64(uncompressed.length); // uncompressed_length + _writeU64(_signals.length); // max_handle + _file.writeFromSync(compressed); + } + + // ─────────────── VcData Block (DynamicAlias2) ─────────────── + + /// Writes a single FST_BL_VCDATA_DYN_ALIAS2 block from the current + /// `_changes` buffer. + /// + /// [blockStartTime] and [blockEndTime] are the time range for this block. + /// [frameValues] contains the initial value of each signal at the block's + /// start time (carry-over state plus changes at blockStartTime). + /// + /// Assumes `_changes` is already sorted by time, then by handle. + void _writeVcDataBlock({ + required int blockStartTime, + required int blockEndTime, + required List frameValues, + }) { + // Build sorted unique time table. + // Only include timestamps that have signal chain entries (i.e., after + // blockStartTime). Changes at blockStartTime go into the frame section. + // The fst-reader only reads the frame when time_table[0] > start_time; + // if blockStartTime were included, the frame would be skipped and all + // signals would appear as 'x'. + final timeSet = {}; + for (final c in _changes) { + if (c.time != blockStartTime) { + timeSet.add(c.time); + } + } + final timeTable = timeSet.toList()..sort(); + // Map timestamp → index + final timeToIndex = {}; + for (var i = 0; i < timeTable.length; i++) { + timeToIndex[timeTable[i]] = i; + } + + // Build per-signal value change chains + final signalData = _buildSignalData(timeToIndex, blockStartTime); + + // Pack each signal's data (store uncompressed with varint(0) prefix) + final packedSignals = []; + for (final data in signalData) { + if (data.isEmpty) { + packedSignals.add(Uint8List(0)); + } else { + final packed = BytesBuilder(copy: false) + ..add(encodeVarint(0)) // means "uncompressed" + ..add(data); + packedSignals.add(packed.toBytes()); + } + } + + // Build frame bytes + final frameBytes = _buildFrameBytes(frameValues); + final frameCompressed = _zlibCompress( + frameBytes, + config.compressionLevel, + allowRaw: true, + ); + + // Build the signal offset chain (DynamicAlias2 format) + final chainBytes = _buildOffsetChain(packedSignals); + + // Build time table bytes + final timeTableBytes = _buildTimeTableBytes(timeTable); + + // Compute memory required for traversal + var memRequired = 0; + for (final ps in packedSignals) { + memRequired += ps.length; + } + + // Now assemble the VcData block + _file.writeByteSync(FstBlockType.vcDataDynamicAlias2.value); + final sectionLengthPos = _file.positionSync(); + _writeU64(0); // placeholder section_length + _writeU64(blockStartTime); // start_time + _writeU64(blockEndTime); // end_time + _writeU64(memRequired); // mem_required_for_traversal + + // Frame section + _file + ..writeFromSync(encodeVarint(frameBytes.length)) // unc len + ..writeFromSync(encodeVarint(frameCompressed.length)) // comp len + ..writeFromSync(encodeVarint(_signals.length)) // max_handle + ..writeFromSync(frameCompressed) + // Value change section + ..writeFromSync(encodeVarint(_signals.length)) // max_handle + ..writeByteSync(0x5A); // pack_type = 'Z' (zlib) + + // Write per-signal packed data + packedSignals.forEach(_file.writeFromSync); + + // Write offset chain + _file.writeFromSync(chainBytes); + _writeU64(chainBytes.length); // chain_compressed_length + + // Write time table + _file.writeFromSync(timeTableBytes); + + // Fix section_length + final endPos = _file.positionSync(); + final sectionLength = endPos - sectionLengthPos; + _file.setPositionSync(sectionLengthPos); + _writeU64(sectionLength); + _file.setPositionSync(endPos); + } + + /// Builds frame bytes: the initial value of each signal concatenated. + Uint8List _buildFrameBytes(List initialValues) { + final buf = BytesBuilder(copy: false); + for (var i = 0; i < _signals.length; i++) { + final sig = _signals[i]; + if (sig.isReal) { + // Encode as f64 little-endian bytes + final d = double.tryParse(initialValues[i]) ?? 0.0; + final bd = ByteData(8)..setFloat64(0, d, Endian.little); + buf.add(bd.buffer.asUint8List()); + } else { + // Character-encoded value: one byte per bit + final val = initialValues[i]; + for (var j = 0; j < sig.width; j++) { + buf.addByte(j < val.length ? val.codeUnitAt(j) : 0x78); // 'x' + } + } + } + return buf.toBytes(); + } + + /// Builds per-signal value change encoded data. + /// + /// Returns a list of byte arrays, one per signal (0-indexed). + /// Each byte array contains the encoded value change chain for that signal. + /// Changes at [blockStartTime] are skipped (captured in the frame). + List _buildSignalData( + Map timeToIndex, + int blockStartTime, + ) { + // Group changes by signal handle index + final signalChanges = List>.generate( + _signals.length, + (_) => [], + ); + for (final c in _changes) { + // Skip changes at blockStartTime — those are captured in the frame + if (c.time == blockStartTime) { + continue; + } + signalChanges[c.handleIndex].add(c); + } + + final result = []; + for (var sigIdx = 0; sigIdx < _signals.length; sigIdx++) { + final changes = signalChanges[sigIdx]; + if (changes.isEmpty) { + result.add(Uint8List(0)); + continue; + } + + final sig = _signals[sigIdx]; + final buf = BytesBuilder(copy: false); + var prevTimeIndex = 0; + + for (final c in changes) { + final timeIndex = timeToIndex[c.time]!; + final timeDelta = timeIndex - prevTimeIndex; + prevTimeIndex = timeIndex; + + if (sig.frameLength == 1) { + // 1-bit signal: compact encoding + buf.add(_encodeOneBitChange(timeDelta, c.value)); + } else if (sig.isReal) { + // Real signal + buf.add(_encodeRealChange(timeDelta, c.value)); + } else { + // Multi-bit signal + buf.add(_encodeMultiBitChange(timeDelta, c.value, sig.width)); + } + } + result.add(buf.toBytes()); + } + return result; + } + + /// Encodes a 1-bit signal value change. + /// + /// Format: varint where: + /// - Normal (0/1): bit0=0, bit1=value, bits2+= time_index_delta + /// - Special (x/z/etc): bit0=1, bits1-3=rcv_index, bits4+=time_index_delta + Uint8List _encodeOneBitChange(int timeDelta, String value) { + // RCV_STR: [x, z, h, u, w, l, -, ?] + const rcvChars = 'xzhuwl-?'; + final ch = value.isNotEmpty ? value[value.length - 1] : 'x'; + + int vli; + if (ch == '0') { + vli = (timeDelta << 2) | (0 << 1) | 0; // bit0=0, bit1=0 + } else if (ch == '1') { + vli = (timeDelta << 2) | (1 << 1) | 0; // bit0=0, bit1=1 + } else { + final rcvIdx = rcvChars.indexOf(ch); + final idx = rcvIdx >= 0 ? rcvIdx : 0; // default to 'x' + vli = (timeDelta << 4) | (idx << 1) | 1; // bit0=1, bits1-3=idx + } + return encodeVarint(vli); + } + + /// Encodes a multi-bit signal value change. + /// + /// Format: varint(time_delta << 1 | encoding_bit) then value bytes. + /// encoding_bit=0: 2-state packed bits; encoding_bit=1: 4-state characters. + Uint8List _encodeMultiBitChange(int timeDelta, String value, int width) { + final buf = BytesBuilder(copy: false); + + // Check if value contains only 0/1 (2-state) + final is2State = value.runes.every((c) => c == 0x30 || c == 0x31); + + if (is2State) { + // 2-state: pack bits into bytes, MSB first + buf.add(encodeVarint((timeDelta << 1) | 0)); + final byteCount = (width + 7) ~/ 8; + final bytes = Uint8List(byteCount); + for (var i = 0; i < width; i++) { + if (i < value.length && value[i] == '1') { + final byteIdx = i ~/ 8; + final bitIdx = 7 - (i % 8); + bytes[byteIdx] |= 1 << bitIdx; + } + } + buf.add(bytes); + } else { + // 4-state: raw character bytes + buf.add(encodeVarint((timeDelta << 1) | 1)); + for (var i = 0; i < width; i++) { + buf.addByte(i < value.length ? value.codeUnitAt(i) : 0x78); + } + } + return buf.toBytes(); + } + + /// Encodes a real signal value change. + Uint8List _encodeRealChange(int timeDelta, String value) { + final buf = BytesBuilder(copy: false) + ..add(encodeVarint((timeDelta << 1) | 1)); + final d = double.tryParse(value) ?? 0.0; + final bd = ByteData(8)..setFloat64(0, d, Endian.little); + buf.add(bd.buffer.asUint8List()); + return buf.toBytes(); + } + + /// Builds the offset chain for DynamicAlias2 format. + /// + /// The chain encodes the byte offset and presence of each signal's + /// packed data within the value change section. + Uint8List _buildOffsetChain(List packedSignals) { + final buf = BytesBuilder(copy: false); + var currentOffset = 0; // byte offset within vc section (after pack_type) + var prevOffset = 0; + var consecutiveEmpty = 0; + + // Offset 0 is the pack_type byte itself. Signal data starts at offset 1. + currentOffset = 1; // skip the pack_type byte + + for (var i = 0; i < packedSignals.length; i++) { + final ps = packedSignals[i]; + if (ps.isEmpty) { + consecutiveEmpty++; + } else { + // Flush any consecutive empty signals + if (consecutiveEmpty > 0) { + // Write: varint((count << 1) | 0) — bit0=0 means "zero block" + buf.add(encodeVarint(consecutiveEmpty << 1)); + consecutiveEmpty = 0; + } + // Write positive offset delta (signed varint with bit0=1) + // In DynamicAlias2: bit0=1 + signed_varint >> 1 > 0 means + // new incremental offset delta. + // Encoding: signed_varint((delta << 1) | 1) + // Reader does: shval = read_variant_i64() >> 1 = delta + final offsetDelta = currentOffset - prevOffset; + buf.add(encodeSignedVarint((offsetDelta << 1) | 1)); + prevOffset = currentOffset; + currentOffset += ps.length; + } + } + + // Flush trailing empty signals + if (consecutiveEmpty > 0) { + buf.add(encodeVarint(consecutiveEmpty << 1)); + } + + return buf.toBytes(); + } + + /// Builds the time table section (appended at end of VcData block). + /// + /// The time table is: compressed delta-encoded timestamps, followed by + /// 3 u64s: uncompressed_length, compressed_length, num_entries. + Uint8List _buildTimeTableBytes(List timeTable) { + // Delta-encode the time table + final deltaBuf = BytesBuilder(copy: false); + var prevTime = 0; + for (final t in timeTable) { + deltaBuf.add(encodeVarint(t - prevTime)); + prevTime = t; + } + final uncompressed = deltaBuf.toBytes(); + final compressed = _zlibCompress( + uncompressed, + config.compressionLevel, + allowRaw: true, + ); + + // Build the full time section: compressed data + 3 u64s + final result = BytesBuilder(copy: false) + ..add(compressed) + ..add(_encodeU64(uncompressed.length)) + ..add(_encodeU64(compressed.length)) + ..add(_encodeU64(timeTable.length)); + return result.toBytes(); + } + + // ─────────────── Low-level I/O helpers ─────────────── + + /// Writes a big-endian u64. + void _writeU64(int value) { + final bd = ByteData(8)..setUint64(0, value); + _file.writeFromSync(bd.buffer.asUint8List()); + } + + /// Encodes a big-endian u64 to bytes. + Uint8List _encodeU64(int value) { + final bd = ByteData(8)..setUint64(0, value); + return bd.buffer.asUint8List(); + } + + /// Writes a little-endian f64 (for double endian test). + void _writeF64LE(double value) { + final bd = ByteData(8)..setFloat64(0, value, Endian.little); + _file.writeFromSync(bd.buffer.asUint8List()); + } + + /// Writes a fixed-length NUL-padded string. + void _writeFixedString(String value, int maxLen) { + final bytes = utf8.encode(value); + final len = bytes.length < maxLen ? bytes.length : maxLen - 1; + _file + ..writeFromSync(bytes.sublist(0, len)) + // Pad with zeros + ..writeFromSync(Uint8List(maxLen - len)); + } + + /// Encodes a NUL-terminated string. + Uint8List _cString(String value) { + final bytes = utf8.encode(value); + final result = Uint8List(bytes.length + 1) + ..setRange(0, bytes.length, bytes); + // last byte is already 0 + return result; + } + + /// Encodes an unsigned integer as LEB128 varint. + static Uint8List encodeVarint(int value) { + if (value < 0) { + throw ArgumentError('Value must be non-negative: $value'); + } + if (value <= 0x7F) { + return Uint8List.fromList([value]); + } + final bytes = []; + var v = value; + while (v != 0) { + final nextV = v >> 7; + final mask = nextV == 0 ? 0 : 0x80; + bytes.add((v & 0x7F) | mask); + v = nextV; + } + return Uint8List.fromList(bytes); + } + + /// Encodes a signed integer as signed LEB128 varint. + static Uint8List encodeSignedVarint(int value) { + if (value >= -64 && value <= 63) { + return Uint8List.fromList([value & 0x7F]); + } + + final bytes = []; + var v = value; + var more = true; + while (more) { + var byte_ = v & 0x7F; + v >>= 7; + // Check if we're done + if ((v == 0 && (byte_ & 0x40) == 0) || (v == -1 && (byte_ & 0x40) != 0)) { + more = false; + } else { + byte_ |= 0x80; + } + bytes.add(byte_); + } + return Uint8List.fromList(bytes); + } + + /// Writes gzip-compressed bytes (gzip header + deflate data). + void _writeGzipCompressed(Uint8List data) { + // Gzip header (10 bytes) + const gzipHeader = [ + 0x1F, 0x8B, // magic + 0x08, // deflate + 0x00, // no flags + 0x00, 0x00, 0x00, 0x00, // timestamp = 0 + 0x00, // compression level + 0xFF, // OS = unknown + ]; + _file.writeFromSync(Uint8List.fromList(gzipHeader)); + + // Deflate-compressed data (raw deflate, not zlib-wrapped) + final compressed = _deflateCompress(data, config.compressionLevel); + _file.writeFromSync(compressed); + } + + /// Compresses bytes using zlib (with zlib header, for geometry/frame/etc). + static Uint8List _zlibCompress( + Uint8List data, + int level, { + bool allowRaw = false, + }) { + final compressed = ZLibCodec(level: level).encode(data); + final result = Uint8List.fromList(compressed); + if (allowRaw && result.length >= data.length) { + // Compression didn't help, return uncompressed + return data; + } + return result; + } + + /// Compresses bytes using raw deflate (no zlib header, for gzip hierarchy). + static Uint8List _deflateCompress(Uint8List data, int level) { + final compressed = ZLibCodec(level: level, raw: true).encode(data); + return Uint8List.fromList(compressed); + } + + /// Generates a date string for the header. + String _dateString() { + final now = DateTime.now(); + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', // + ]; + final day = days[now.weekday - 1]; + final month = months[now.month - 1]; + final d = now.day.toString().padLeft(2); + final h = now.hour.toString().padLeft(2, '0'); + final m = now.minute.toString().padLeft(2, '0'); + final s = now.second.toString().padLeft(2, '0'); + return '$day $month $d $h:$m:$s ${now.year}\n'; + } +} diff --git a/lib/src/module.dart b/lib/src/module.dart index 95e1f3149..5583bcfe4 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -1146,7 +1146,8 @@ abstract class Module { /// Attaches waveform dumping for this [Module] to a VCD at [outputPath]. /// /// This quick file-dump API uses bounded memory. For debugger capture, - /// filtering, or retained history, create a [WaveformService] directly. + /// filtering, alternative formats, or retained history, create a + /// [WaveformService] directly. WaveformService dumpWaves({String outputPath = 'waves.vcd'}) { final normalized = outputPath.replaceAll(r'\', '/'); final separatorIndex = normalized.lastIndexOf('/'); diff --git a/lib/src/wave_dumper.dart b/lib/src/wave_dumper.dart index 563c38e94..c53741cea 100644 --- a/lib/src/wave_dumper.dart +++ b/lib/src/wave_dumper.dart @@ -50,7 +50,7 @@ class WaveDumper { Module get module => _service.module; /// The output filepath of the generated waveforms. - String get outputPath => _service.outputFilePath; + String get outputPath => _service.outputPath; /// Attaches a [WaveDumper] to record all signal changes in a simulation of /// [module] in a VCD file at [outputPath]. diff --git a/test/config_test.dart b/test/config_test.dart index ad2bc306f..133e746d0 100644 --- a/test/config_test.dart +++ b/test/config_test.dart @@ -9,11 +9,13 @@ // Author: Yao Jing Quek import 'dart:io'; + import 'package:rohd/rohd.dart'; import 'package:rohd/src/utilities/config.dart'; import 'package:rohd/src/utilities/web.dart'; import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; + import 'waveform_service_test.dart'; class SimpleModule extends Module { @@ -23,7 +25,7 @@ class SimpleModule extends Module { final c = addOutput('c'); Combinational([ - If(a, then: [c < a], orElse: [c < b]) + If(a, then: [c < a], orElse: [c < b]), ]); } } @@ -52,23 +54,25 @@ void main() { }); test( - 'should contains ROHD version number when deprecated synth is generated.', - () async { - const version = Config.version; + 'should contains ROHD version number when deprecated synth is generated.', + () async { + const version = Config.version; - final mod = SimpleModule(Logic(), Logic()); - await mod.build(); + final mod = SimpleModule(Logic(), Logic()); + await mod.build(); - // This test verifies that the deprecated API still includes the version. - // ignore: deprecated_member_use_from_same_package - final sv = mod.generateSynth(); + // This test verifies that the deprecated API still includes the version. + // ignore: deprecated_member_use_from_same_package + final sv = mod.generateSynth(); - expect(sv, contains(version)); - }); + expect(sv, contains(version)); + }, + ); if (!kIsWeb) { - test('should contains ROHD version number when wavedumper is generated.', - () async { + test( + 'should contains ROHD version number when ' + 'waveform service is generated.', () async { const version = Config.version; final mod = SimpleModule(Logic(), Logic()); diff --git a/test/fst_writer_test.dart b/test/fst_writer_test.dart new file mode 100644 index 000000000..8da79a2ff --- /dev/null +++ b/test/fst_writer_test.dart @@ -0,0 +1,441 @@ +// Copyright (C) 2021-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// fst_writer_test.dart +// Tests for FST writer and WaveformService FST format support. +// +// 2026 February +// Author: Desmond Kirkpatrick + +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import 'pipeline_test.dart' show SimplePipelineModule; + +/// A simple module for testing. +class _SimpleModule extends Module { + _SimpleModule(Logic a) { + a = addInput('a', a); + addOutput('b') <= a; + } +} + +/// A module with multi-bit signals for testing. +class _MultiBitModule extends Module { + _MultiBitModule(Logic a, Logic clk) { + a = addInput('a', a, width: a.width); + final aClk = addInput('clk', clk); + addOutput('q', width: a.width) <= FlipFlop(aClk, a).q; + } +} + +const _tempDumpDir = 'tmp_test'; + +/// Gets the path of the FST file based on a name. +String _temporaryFstPath(String name) => '$_tempDumpDir/temp_dump_$name.fst'; + +/// Attaches a [WaveformService] to [module] with FST format. +void _createFstDump(Module module, String name) { + Directory(_tempDumpDir).createSync(recursive: true); + final tmpDumpFile = _temporaryFstPath(name); + WaveformService.fromOutputPath( + module, + outputPath: tmpDumpFile, + format: WaveOutputFormat.fst, + ); +} + +/// Deletes the temporary FST file associated with [name]. +void _deleteFstDump(String name) { + final tmpDumpFile = _temporaryFstPath(name); + if (File(tmpDumpFile).existsSync()) { + File(tmpDumpFile).deleteSync(); + } +} + +/// Reads a big-endian u64 from [data] at [offset]. +int _readU64(Uint8List data, int offset) { + var result = 0; + for (var i = 0; i < 8; i++) { + result = (result << 8) | data[offset + i]; + } + return result; +} + +/// Parses FST file blocks and returns a map of block types to counts. +Map _parseFstBlocks(Uint8List data) { + final blocks = {}; + var pos = 0; + while (pos < data.length) { + final blockType = data[pos]; + pos++; + if (pos + 8 > data.length) { + break; + } + final sectionLength = _readU64(data, pos); + blocks[blockType] = (blocks[blockType] ?? 0) + 1; + pos += sectionLength; + if (sectionLength == 0) { + break; + } + } + return blocks; +} + +/// Parses FST header and returns key fields. +Map _parseFstHeader(Uint8List data) { + // Skip block type byte (0) + if (data[0] != 0) { + throw FormatException('Expected header block type 0, got ${data[0]}'); + } + final sectionLength = _readU64(data, 1); + if (sectionLength != 329) { + throw FormatException( + 'Expected header section length 329, got $sectionLength', + ); + } + return { + 'start_time': _readU64(data, 9), + 'end_time': _readU64(data, 17), + // skip double_endian_test (8 bytes at offset 25) + 'scope_count': _readU64(data, 41), + 'var_count': _readU64(data, 49), + 'max_var_id': _readU64(data, 57), + 'vc_section_count': _readU64(data, 65), + 'timescale_exponent': data[73], // offset 73 = 1 + 8*9 + }; +} + +void main() { + tearDown(() async { + await Simulator.reset(); + }); + + group('FstWriter unit tests', () { + test('writes valid header block', () { + const path = '$_tempDumpDir/fst_header_test.fst'; + Directory(_tempDumpDir).createSync(recursive: true); + + FstWriter(path) + ..pushScope('top') + ..declareSignal('clk', 1) + ..declareSignal('data', 8) + ..popScope() + ..finish(); + + final data = File(path).readAsBytesSync(); + expect(data[0], equals(0), reason: 'First byte should be header type'); + final sectionLength = _readU64(data, 1); + expect(sectionLength, equals(329), reason: 'Header is 329 bytes'); + + // Parse header fields + final header = _parseFstHeader(data); + expect(header['scope_count'], equals(1)); + expect(header['var_count'], equals(2)); + expect(header['max_var_id'], equals(2)); + + File(path).deleteSync(); + }); + + test('writes all required block types', () { + const path = '$_tempDumpDir/fst_blocks_test.fst'; + Directory(_tempDumpDir).createSync(recursive: true); + + final writer = FstWriter(path)..pushScope('top'); + final clk = writer.declareSignal('clk', 1); + writer + ..popScope() + ..writeHeader() + ..emitValueChange(0, clk, '0') + ..emitValueChange(5, clk, '1') + ..finish(); + + final data = File(path).readAsBytesSync(); + final blocks = _parseFstBlocks(data); + + // Must have: Header(0), VcDataDynamicAlias2(8), Geometry(3), + // Hierarchy(4) + expect(blocks.containsKey(0), isTrue, reason: 'Must have header'); + expect(blocks.containsKey(8), isTrue, reason: 'Must have VcData block'); + expect(blocks.containsKey(3), isTrue, reason: 'Must have geometry'); + expect(blocks.containsKey(4), isTrue, reason: 'Must have hierarchy'); + + File(path).deleteSync(); + }); + + test('geometry encodes signal widths correctly', () { + const path = '$_tempDumpDir/fst_geometry_test.fst'; + Directory(_tempDumpDir).createSync(recursive: true); + + FstWriter(path) + ..pushScope('top') + ..declareSignal('bit1', 1) + ..declareSignal('byte8', 8) + ..declareSignal('word32', 32) + ..popScope() + ..finish(); + + final data = File(path).readAsBytesSync(); + + // Find the geometry block (type 3) + var pos = 0; + while (pos < data.length) { + if (data[pos] == 3) { + // Geometry block + final sectionLength = _readU64(data, pos + 1); + final maxHandle = _readU64(data, pos + 1 + 16); + expect(maxHandle, equals(3)); + + // Geometry data is after section_length(8) + unc_len(8) + + // max_handle(8) = 24 bytes from section_length start + // May be compressed, so just check the block exists + expect(sectionLength, greaterThan(24)); + break; + } + pos++; + if (pos + 8 > data.length) { + break; + } + final sl = _readU64(data, pos); + pos += sl; + if (sl == 0) { + break; + } + } + + File(path).deleteSync(); + }); + }); + + group('WaveformService FST format', () { + test('basic 1-bit signal FST dump', () async { + final a = Logic(name: 'a'); + final mod = _SimpleModule(a); + await mod.build(); + + const dumpName = 'fstBasic'; + _createFstDump(mod, dumpName); + + a.put(0); + Simulator.setMaxSimTime(100); + await Simulator.run(); + + final fstFile = File(_temporaryFstPath(dumpName)); + expect(fstFile.existsSync(), isTrue); + + final data = fstFile.readAsBytesSync(); + // File should have valid FST header + expect(data[0], equals(0), reason: 'First byte is header block type'); + expect(_readU64(data, 1), equals(329)); + + // Check blocks are present + final blocks = _parseFstBlocks(data); + expect(blocks.containsKey(0), isTrue, reason: 'header'); + expect(blocks.containsKey(3), isTrue, reason: 'geometry'); + expect(blocks.containsKey(4), isTrue, reason: 'hierarchy'); + + _deleteFstDump(dumpName); + }); + + test('multi-bit signal FST dump', () async { + final a = Logic(name: 'a', width: 8); + final clk = SimpleClockGenerator(10).clk; + final mod = _MultiBitModule(a, clk); + await mod.build(); + + const dumpName = 'fstMultiBit'; + _createFstDump(mod, dumpName); + + a.put(0); + Simulator.setMaxSimTime(100); + unawaited(Simulator.run()); + + await clk.nextPosedge; + a.inject(0xAB); + await clk.nextPosedge; + a.inject(0xFF); + + await Simulator.simulationEnded; + + final fstFile = File(_temporaryFstPath(dumpName)); + expect(fstFile.existsSync(), isTrue); + + final data = fstFile.readAsBytesSync(); + final blocks = _parseFstBlocks(data); + expect(blocks.containsKey(0), isTrue); + expect( + blocks.containsKey(8), + isTrue, + reason: 'VcData block with changes', + ); + + _deleteFstDump(dumpName); + }); + + test('FST file creates non-existent directories', () async { + final a = Logic(name: 'a'); + final mod = _SimpleModule(a); + await mod.build(); + + const dir1Path = '$_tempDumpDir/fst_dir1'; + const fstPath = '$dir1Path/dir2/waves.fst'; + + WaveformService.fromOutputPath( + mod, + outputPath: fstPath, + format: WaveOutputFormat.fst, + ); + + a.put(0); + Simulator.setMaxSimTime(10); + await Simulator.run(); + + expect(File(fstPath).existsSync(), isTrue); + + if (Directory(dir1Path).existsSync()) { + Directory(dir1Path).deleteSync(recursive: true); + } + }); + + test('FST header has correct signal counts', () async { + final a = Logic(name: 'a'); + final mod = _SimpleModule(a); + await mod.build(); + + const dumpName = 'fstCounts'; + _createFstDump(mod, dumpName); + + a.put(0); + Simulator.setMaxSimTime(10); + await Simulator.run(); + + final data = File(_temporaryFstPath(dumpName)).readAsBytesSync(); + final header = _parseFstHeader(data); + + // _SimpleModule has 2 signals: input 'a' and output 'b' + expect(header['var_count'], equals(2)); + + _deleteFstDump(dumpName); + }); + + test('FST and VCD both produce output', () async { + // Create a module + final a = Logic(name: 'a'); + final mod = _SimpleModule(a); + await mod.build(); + + // Dump as FST + const fstName = 'fstCompare'; + _createFstDump(mod, fstName); + + a.put(0); + Simulator.setMaxSimTime(50); + unawaited(Simulator.run()); + + a.inject(1); + + await Simulator.simulationEnded; + + final fstFile = File(_temporaryFstPath(fstName)); + expect(fstFile.existsSync(), isTrue); + final fstSize = fstFile.lengthSync(); + expect(fstSize, greaterThan(330), reason: 'FST should be > header size'); + + _deleteFstDump(fstName); + + // Reset and dump as VCD + await Simulator.reset(); + + final a2 = Logic(name: 'a'); + final mod2 = _SimpleModule(a2); + await mod2.build(); + + const vcdPath = '$_tempDumpDir/temp_dump_vcdCompare.vcd'; + Directory(_tempDumpDir).createSync(recursive: true); + WaveformService.fromOutputPath(mod2, outputPath: vcdPath); + + a2.put(0); + Simulator.setMaxSimTime(50); + unawaited(Simulator.run()); + + a2.inject(1); + + await Simulator.simulationEnded; + + final vcdFile = File(vcdPath); + expect(vcdFile.existsSync(), isTrue); + expect(vcdFile.lengthSync(), greaterThan(0)); + + vcdFile.deleteSync(); + }); + + test('pipeline FST has VcData and is readable by fst2vcd', () async { + // Build a 3-stage 8-bit pipeline that generates many signal changes. + final a = Logic(name: 'a', width: 8); + final mod = SimplePipelineModule(a); + await mod.build(); + + const dumpName = 'fstPipeline'; + _createFstDump(mod, dumpName); + + // Drive 200 clock cycles worth of incrementing inputs. + // The 10ps clock gives 2000ps total, producing many VcData changes. + a.put(0); + Simulator.setMaxSimTime(2000); + unawaited(Simulator.run()); + + // Inject a new value every 10ps to keep signals active + for (var i = 1; i <= 200; i++) { + await Future.delayed(Duration.zero); + a.inject(i & 0xFF); + } + + await Simulator.simulationEnded; + + final fstFile = File(_temporaryFstPath(dumpName)); + expect(fstFile.existsSync(), isTrue); + + // File should be substantially larger than just the header (329 bytes) + final fileSize = fstFile.lengthSync(); + expect( + fileSize, + greaterThan(600), + reason: 'Pipeline FST should have VcData content', + ); + + // Parse blocks: must include at least one VcData block (type 8) + final data = fstFile.readAsBytesSync(); + final blocks = _parseFstBlocks(data); + expect(blocks.containsKey(0), isTrue, reason: 'header block'); + expect(blocks.containsKey(8), isTrue, reason: 'VcData block'); + expect(blocks.containsKey(3), isTrue, reason: 'geometry block'); + expect(blocks.containsKey(4), isTrue, reason: 'hierarchy block'); + + // Validate with fst2vcd (GTKWave tool) if available. + final fst2vcd = Process.runSync('which', ['fst2vcd']); + if (fst2vcd.exitCode == 0) { + final result = Process.runSync('fst2vcd', [fstFile.path]); + expect( + result.exitCode, + equals(0), + reason: 'fst2vcd failed: ${result.stdout}\n${result.stderr}', + ); + final vcdOutput = result.stdout as String; + expect( + vcdOutput, + contains(r'$timescale'), + reason: 'fst2vcd output should be valid VCD', + ); + } + + _deleteFstDump(dumpName); + }); + }); +} diff --git a/test/waveform_service_test.dart b/test/waveform_service_test.dart index b0ce33c3c..d8a99747e 100644 --- a/test/waveform_service_test.dart +++ b/test/waveform_service_test.dart @@ -1,76 +1,39 @@ -// Copyright (C) 2021-2026 Intel Corporation +// Copyright (C) 2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // waveform_service_test.dart -// Tests for the WaveformService +// Tests for WaveformService output and VCD/FST event parity. // -// 2021 November 4 -// Author: Max Korbel +// 2026 July 17 +// Author: Desmond Kirkpatrick @TestOn('vm') library; -import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/utilities/vcd_parser.dart'; import 'package:test/test.dart'; -class SimpleModule extends Module { - SimpleModule(Logic a) { +class _SimpleWaveModule extends Module { + _SimpleWaveModule(Logic a) { a = addInput('a', a, width: a.width); addOutput('b', width: a.width) <= ~a; } } -class WindowWaveModule extends Module { - late final Logic a; +const _tempDumpDir = 'tmp_test'; - WindowWaveModule() { - a = addOutput('a'); - } -} - -class HistoryWaveformService extends WaveformService { - final Map> history = {}; - final List capturedTimestamps = []; - - HistoryWaveformService(super.module, {super.startTime}); - - @override - void onSignalCollected(Logic signal) { - history[signal] = [(Simulator.time, binaryValue(signal))]; - } - - @override - void onValueChange(Logic signal, int timestamp) { - history[signal]!.add((timestamp, binaryValue(signal))); - } - - @override - void onTimestampCapture(int timestamp, Set changed) { - capturedTimestamps.add(timestamp); - } -} +String _temporaryVcdPath(String name) => '$_tempDumpDir/temp_wave_$name.vcd'; -String binaryValue(Logic signal) => signal.value.reversed - .toList() - .map((value) => value.toString(includeWidth: false)) - .join(); +String _temporaryFstPath(String name) => '$_tempDumpDir/temp_wave_$name.fst'; -class SimpleModWithSeq extends Module { - Logic get val => output('val'); - SimpleModWithSeq(Logic asyncReset, Logic clk) { - clk = addInput('clk', clk); - asyncReset = addInput('asyncReset', asyncReset); - addOutput('val'); - - val <= flop(clk, Const(1), reset: asyncReset, asyncReset: true); - } -} +// ─── Public helpers used by sibling tests (e.g. config_test.dart) ──────────── +/// Directory into which sibling tests place their temporary waveform dumps. const tempDumpDir = 'tmp_test'; /// Gets the path of the VCD file based on a name. @@ -83,23 +46,10 @@ void createTemporaryDump(Module module, String name) { module, outputDirectory: tempDumpDir, outputBaseName: 'temp_dump_$name', - writeToFile: true, ); } -// The helper intentionally exercises the deprecated WaveDumper compatibility -// path. -// ignore: deprecated_member_use_from_same_package -/// Attaches the deprecated [WaveDumper] to [module] to VCD with [name]. -void createTemporaryWaveDumperDump(Module module, String name) { - Directory(tempDumpDir).createSync(recursive: true); - final tmpDumpFile = temporaryDumpPath(name); - // The deprecated WaveDumper constructor is invoked to test its behavior. - // ignore: deprecated_member_use_from_same_package - WaveDumper(module, outputPath: tmpDumpFile); -} - -/// Deletes the temporary VCD file associated with [name]. +/// Deletes the VCD file previously created by [createTemporaryDump]. void deleteTemporaryDump(String name) { final tmpDumpFile = temporaryDumpPath(name); File(tmpDumpFile).deleteSync(); @@ -108,503 +58,463 @@ void deleteTemporaryDump(String name) { void main() { tearDown(() async { await Simulator.reset(); + ModuleServices.instance.reset(); }); - test('attach dumper after put', () async { + test('registers with ModuleServices by default', () async { final a = Logic(name: 'a'); - final mod = SimpleModule(a); + final mod = _SimpleWaveModule(a); await mod.build(); - const dumpName = 'dumpAfterPut'; - - a.put(1); - createTemporaryDump(mod, dumpName); - - Simulator.registerAction(10, () => a.put(0)); - await Simulator.run(); + Directory(_tempDumpDir).createSync(recursive: true); + final dumpPath = _temporaryVcdPath('serviceRegistration'); - final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); + WaveformService.fromOutputPath(mod, outputPath: dumpPath); - expect( - VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 5, LogicValue.ofString('1')), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), - equals(true)); + final service = ModuleServices.instance.lookup(); + expect(service, isNotNull); + final waveformJson = jsonEncode(service!.toJson()); + expect(waveformJson, contains('"format":"vcd"')); - deleteTemporaryDump(dumpName); + File(dumpPath).deleteSync(); }); - test('attach deprecated wave dumper after put', () async { + test('captures waveform to VCD output path', () async { final a = Logic(name: 'a'); - final mod = SimpleModule(a); + final mod = _SimpleWaveModule(a); await mod.build(); - const dumpName = 'deprecatedDumpAfterPut'; + Directory(_tempDumpDir).createSync(recursive: true); + final dumpPath = _temporaryVcdPath('serviceCapture'); - a.put(1); - createTemporaryWaveDumperDump(mod, dumpName); + WaveformService.fromOutputPath(mod, outputPath: dumpPath, register: false); + a.inject(1); Simulator.registerAction(10, () => a.put(0)); await Simulator.run(); - final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); - - expect( - VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 5, LogicValue.ofString('1')), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), - equals(true)); - - deleteTemporaryDump(dumpName); - }); - - test('dumpWaves returns a waveform service', () async { - final mod = SimpleModule(Logic()); - await mod.build(); - - const dumpName = 'moduleDumpWaveforms'; - final outputPath = temporaryDumpPath(dumpName); - Directory(tempDumpDir).createSync(recursive: true); - final service = mod.dumpWaves(outputPath: outputPath); - - expect(service, isA()); - expect(service.module, same(mod)); - expect(service.outputPath, outputPath); - expect(service.outputFilePath, outputPath); - expect(service.retainInMemory, isFalse); - expect(service.canSendWaveforms(), isFalse); - expect(File(service.outputPath).existsSync(), isTrue); - - await Simulator.run(); - - expect( - (await service.artifacts.single - .openRead() - .expand((bytes) => bytes) - .toList()) - .isNotEmpty, - isTrue, - ); - deleteTemporaryDump(dumpName); - }); - - test('throws if module not built', () { - final mod = SimpleModule(Logic()); - + final vcdContents = File(dumpPath).readAsStringSync(); expect( - () => WaveformService(mod), - throwsA(isA()), - ); - }); - - test('dumpWaves preserves an arbitrary legacy output filename', () async { - final mod = SimpleModule(Logic()); - await mod.build(); - - const outputPath = '$tempDumpDir/capture.trace'; - final service = mod.dumpWaves(outputPath: outputPath); - - expect(service.outputPath, equals(outputPath)); - expect(File(outputPath).existsSync(), isTrue); - - await Simulator.run(); - File(outputPath).deleteSync(); - }); - - test('file-only capture streams its artifact after simulation finalization', - () async { - final mod = SimpleModule(Logic()); - await mod.build(); - - final waveformService = WaveformService( - mod, - outputDirectory: tempDumpDir, - outputBaseName: 'capture', - writeToFile: true, + VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), + equals(true), ); - - final artifact = waveformService.artifacts.single; - - expect(artifact.fileName, equals('capture.vcd')); - expect(artifact.mediaType, equals('text/x-vcd')); - expect(waveformService.retainInMemory, isFalse); - expect(waveformService.canSendWaveforms(), isFalse); - expect(File(waveformService.outputFilePath).existsSync(), isTrue); - - await Simulator.run(); - expect( - (await artifact.openRead().expand((bytes) => bytes).toList()).isNotEmpty, - isTrue, - ); - - File(waveformService.outputFilePath).deleteSync(); - }); - - test('in-memory-only debugging capture retains waveform history', () async { - final mod = SimpleModule(Logic()); - await mod.build(); - - final waveformService = WaveformService( - mod, - outputDirectory: tempDumpDir, - outputBaseName: 'in_memory_capture', - ); - - expect(waveformService.writeToFile, isFalse); - expect(waveformService.retainInMemory, isTrue); - expect(waveformService.canSendWaveforms(), isTrue); - expect(File(waveformService.outputFilePath).existsSync(), isFalse); - - await Simulator.run(); - - final bytes = await waveformService.artifacts.single - .openRead() - .expand((bytes) => bytes) - .toList(); - expect(utf8.decode(bytes), contains(r'$enddefinitions')); - }); - - test('retained file-backed capture can send waveforms', () async { - final mod = SimpleModule(Logic()); - await mod.build(); - - final waveformService = WaveformService( - mod, - outputDirectory: tempDumpDir, - outputBaseName: 'retained_file_capture', - writeToFile: true, - retainInMemory: true, + VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), + equals(true), ); - expect(waveformService.canSendWaveforms(), isTrue); - - await Simulator.run(); - File(waveformService.outputFilePath).deleteSync(); + File(dumpPath).deleteSync(); }); - test('capture without a file or retained history has no artifact', () async { - final mod = SimpleModule(Logic()); - await mod.build(); - - final waveformService = WaveformService(mod, retainInMemory: false); - - expect(waveformService.artifacts, isEmpty); - expect(waveformService.canSendWaveforms(), isFalse); - }); - - test('FST supports querying waveforms from a file', () { - expect(WaveOutputFormat.vcd.supportsOnDiskQueries, isFalse); - expect(WaveOutputFormat.fst.supportsOnDiskQueries, isTrue); - }); - - test('recording window snapshots stable signal values at its start', - () async { + test('captures waveform to FST format', () async { final a = Logic(name: 'a'); - final mod = SimpleModule(a); + final mod = _SimpleWaveModule(a); await mod.build(); - a.inject(0); - - const dumpName = 'windowInitialSnapshot'; - Directory(tempDumpDir).createSync(recursive: true); - WaveformService( - mod, - outputDirectory: tempDumpDir, - outputBaseName: 'temp_dump_$dumpName', - writeToFile: true, - startTime: 10, - ); - Simulator.registerAction(5, () => a.put(1)); - Simulator.registerAction(15, () {}); - Simulator.registerAction(20, () {}); - await Simulator.run(); + Directory(_tempDumpDir).createSync(recursive: true); + final dumpPath = _temporaryFstPath('fstCapture'); - final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); - expect( - VcdParser.confirmValue(vcdContents, 'a', 15, LogicValue.one), - isTrue, - reason: 'the stable value at startTime must seed the recording window', - ); - - deleteTemporaryDump(dumpName); - }); - - test('window-entry snapshot reaches waveform hooks', () async { - final mod = WindowWaveModule(); - await mod.build(); - mod.a.put(0); - final service = HistoryWaveformService(mod, startTime: 10); - - Simulator.registerAction(5, () => mod.a.put(1)); - Simulator.registerAction(15, () {}); - Simulator.registerAction(20, () {}); - await Simulator.run(); - - final vcdContents = utf8.decode( - await service.artifacts.single - .openRead() - .expand((bytes) => bytes) - .toList(), - ); - expect( - VcdParser.confirmValue( - vcdContents, - 'a', - 10, - LogicValue.one, - ), - isTrue, - ); - expect(service.history[mod.a], equals([(0, '0'), (10, '1')])); - expect(service.capturedTimestamps, contains(10)); - }); - - test('window entry and boundary change are separate hook batches', () async { - final mod = WindowWaveModule(); - await mod.build(); - mod.a.put(0); - final service = HistoryWaveformService(mod, startTime: 10); - - Simulator.registerAction(5, () => mod.a.put(1)); - Simulator.registerAction(10, () => mod.a.put(0)); - await Simulator.run(); - - expect( - service.history[mod.a], - equals([(0, '0'), (10, '1'), (10, '0')]), - ); - expect(service.capturedTimestamps, equals([10, 10])); - }); - - test('rejects formats without a matching waveform writer', () async { - final mod = SimpleModule(Logic()); - await mod.build(); - - expect( - () => WaveformService(mod, format: WaveOutputFormat.fst), - throwsUnsupportedError, + WaveformService.fromOutputPath( + mod, + outputPath: dumpPath, + format: WaveOutputFormat.fst, + register: false, ); - }); - - test('attach dumper before put', () async { - final a = Logic(name: 'a'); - final mod = SimpleModule(a); - await mod.build(); - - const dumpName = 'dumpBeforePut'; - createTemporaryDump(mod, dumpName); a.inject(1); - Simulator.registerAction(10, () => a.put(0)); - Simulator.registerAction(20, () => a.put(1)); await Simulator.run(); - final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); + final fstFile = File(dumpPath); + expect(fstFile.existsSync(), isTrue); + expect(fstFile.lengthSync(), greaterThan(100)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 1, LogicValue.ofString('1')), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 20, LogicValue.ofString('1')), - equals(true)); - - deleteTemporaryDump(dumpName); + fstFile.deleteSync(); }); - test('multiple injects in the same timestamp', () async { - final clk = SimpleClockGenerator(10).clk; - final a = Logic(name: 'a'); - final mod = SimpleModule(a); - a <= clk; - - await mod.build(); - - const dumpName = 'multiInject'; - - createTemporaryDump(mod, dumpName); - - Simulator.setMaxSimTime(100); - unawaited(Simulator.run()); - - await clk.nextPosedge; - await clk.nextPosedge; - await clk.nextPosedge; - - // inject a 0 on a when it should be 1 already from the clock - a.inject(0); + test('VCD and FST contain matching value-change events', () async { + final vcdPath = _temporaryVcdPath('parity'); + final fstPath = _temporaryFstPath('parity'); - await Simulator.simulationEnded; + await _dumpParityWaveform(vcdPath, WaveOutputFormat.vcd); + final vcdEvents = _readVcdEvents(vcdPath, const {'a', 'b'}); - final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); - - expect( - VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('0')), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 5, LogicValue.ofString('1')), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 35, LogicValue.ofString('0')), - equals(true)); - - deleteTemporaryDump(dumpName); - }); - - test('multi-bit value', () async { - final a = Logic(name: 'a', width: 8); - final mod = SimpleModule(a); - await mod.build(); - - const dumpName = 'multiBit'; - - createTemporaryDump(mod, dumpName); - a.inject(0x5a); - - Simulator.registerAction(10, () => a.put(0xa5)); - await Simulator.run(); + await Simulator.reset(); + ModuleServices.instance.reset(); - final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); + await _dumpParityWaveform(fstPath, WaveOutputFormat.fst); + final fstEvents = _readFstEvents( + fstPath, + signalNames: const ['a', 'b'], + signalWidths: const [4, 4], + ); - expect( - VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofInt(0x5a, 8)), - equals(true)); - expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofInt(0xa5, 8)), - equals(true)); + expect(fstEvents, equals(vcdEvents)); - deleteTemporaryDump(dumpName); + File(vcdPath).deleteSync(); + File(fstPath).deleteSync(); }); +} - test('multi-bit value mixed invalid', () async { - final a = Logic(name: 'a', width: 8); - final mod = SimpleModule(a); - await mod.build(); +Future _dumpParityWaveform( + String outputPath, + WaveOutputFormat format, +) async { + Directory(_tempDumpDir).createSync(recursive: true); + + final a = Logic(name: 'a', width: 4); + final mod = _SimpleWaveModule(a); + await mod.build(); + + a.put(0x1); + WaveformService.fromOutputPath( + mod, + outputPath: outputPath, + format: format, + register: false, + ); - const dumpName = 'multiBitInvalid'; + Simulator.registerAction(10, () => a.put(0x2)); + Simulator.registerAction(20, () => a.put(0xf)); + await Simulator.run(); +} - createTemporaryDump(mod, dumpName); - a.inject(LogicValue.ofString('01xzzx10')); +Map> _readVcdEvents( + String path, + Set signalNames, +) { + final lines = File(path).readAsLinesSync(); + final markerToSignal = {}; + final markerToWidth = {}; + final events = >{ + for (final name in signalNames) name: {}, + }; + + final sigNameRegexp = RegExp( + r'\s*\$var\s(wire|reg)\s(\d+)\s(\S*)\s(\S*)\s+(\[\d+\:\d+\])?\s*\$end', + ); + var currentTime = 0; + var inValues = false; + + for (final line in lines) { + final match = sigNameRegexp.firstMatch(line); + if (match != null) { + final width = int.parse(match.group(2)!); + final marker = match.group(3)!; + final name = match.group(4)!; + if (signalNames.contains(name)) { + markerToSignal[marker] = name; + markerToWidth[marker] = width; + } + continue; + } - Simulator.registerAction(10, () => a.put(LogicValue.ofString('0x0x1z1z'))); - await Simulator.run(); + if (line == r'$dumpvars') { + inValues = true; + continue; + } + if (!inValues) { + continue; + } + if (line == r'$end') { + continue; + } + if (line.startsWith('#')) { + currentTime = int.parse(line.substring(1)); + continue; + } - final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); + final parsed = _parseVcdValueUpdate(line, markerToWidth); + if (parsed == null) { + continue; + } - expect( - VcdParser.confirmValue( - vcdContents, 'a', 0, LogicValue.ofString('01xzzx10')), - equals(true)); - expect( - VcdParser.confirmValue( - vcdContents, 'a', 10, LogicValue.ofString('0x0x1z1z')), - equals(true)); + final signalName = markerToSignal[parsed.marker]; + if (signalName != null) { + events[signalName]![currentTime] = parsed.value; + } + } - deleteTemporaryDump(dumpName); - }); + return events; +} - test('dump after max sim time works', () async { - final a = SimpleClockGenerator(10).clk; - final mod = SimpleModule(a); - await mod.build(); +({String marker, String value})? _parseVcdValueUpdate( + String line, + Map markerToWidth, +) { + if (line.startsWith('b')) { + final parts = line.split(' '); + if (parts.length != 2 || !markerToWidth.containsKey(parts[1])) { + return null; + } + return (marker: parts[1], value: parts[0].substring(1)); + } - const dumpName = 'maxSimTime'; + for (final marker in markerToWidth.keys) { + if (line.endsWith(marker)) { + return (marker: marker, value: line[0]); + } + } + return null; +} - createTemporaryDump(mod, dumpName); +Map> _readFstEvents( + String path, { + required List signalNames, + required List signalWidths, +}) { + final data = File(path).readAsBytesSync(); + final events = >{ + for (final name in signalNames) name: {}, + }; + + var blockOffset = 0; + while (blockOffset < data.length) { + final blockType = data[blockOffset]; + final sectionLength = _readU64(data, blockOffset + 1); + final blockEnd = blockOffset + 1 + sectionLength; + + if (blockType == 8) { + _readFstVcDataBlock( + data, + blockOffset, + blockEnd, + signalNames: signalNames, + signalWidths: signalWidths, + events: events, + ); + } - Simulator.setMaxSimTime(100); + blockOffset = blockEnd; + } - await Simulator.run(); + return events; +} - final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); +void _readFstVcDataBlock( + Uint8List data, + int blockOffset, + int blockEnd, { + required List signalNames, + required List signalWidths, + required Map> events, +}) { + final startTime = _readU64(data, blockOffset + 9); + var offset = blockOffset + 33; + + final frameUncompressed = _readVarint(data, offset); + offset = frameUncompressed.next; + final frameCompressed = _readVarint(data, offset); + offset = frameCompressed.next; + final maxHandle = _readVarint(data, offset); + offset = maxHandle.next; + + final frameBytes = _inflateIfNeeded( + data.sublist(offset, offset + frameCompressed.value), + frameUncompressed.value, + ); + offset += frameCompressed.value; - expect( - VcdParser.confirmValue(vcdContents, 'a', 99, LogicValue.one), - equals(true), + var frameOffset = 0; + for (var i = 0; i < signalNames.length; i++) { + final width = signalWidths[i]; + final value = String.fromCharCodes( + frameBytes.sublist(frameOffset, frameOffset + width), ); + frameOffset += width; + events[signalNames[i]]![startTime] = value; + } - deleteTemporaryDump(dumpName); - }); + final valueMaxHandle = _readVarint(data, offset); + offset = valueMaxHandle.next; + final valueSectionStart = offset; + offset++; // pack_type + + final timeCount = _readU64(data, blockEnd - 8); + final timeCompressedLength = _readU64(data, blockEnd - 16); + final timeUncompressedLength = _readU64(data, blockEnd - 24); + final timeDataStart = blockEnd - 24 - timeCompressedLength; + final timeBytes = _inflateIfNeeded( + data.sublist(timeDataStart, timeDataStart + timeCompressedLength), + timeUncompressedLength, + ); + final timeTable = _decodeTimeTable(timeBytes, timeCount); - test('create non-existent output directories', () async { - final mod = SimpleModule(Logic()); - await mod.build(); + final chainLength = _readU64(data, timeDataStart - 8); + final chainStart = timeDataStart - 8 - chainLength; + final signalOffsets = _decodeFstOffsetChain( + data.sublist(chainStart, timeDataStart - 8), + valueMaxHandle.value, + ); - const dir1Path = '$tempDumpDir/dir1'; + for (var signalIndex = 0; signalIndex < signalNames.length; signalIndex++) { + final signalOffset = signalOffsets[signalIndex]; + if (signalOffset == null) { + continue; + } - final waveformService = WaveformService( - mod, - outputDirectory: '$dir1Path/dir2', - outputBaseName: 'waves', - writeToFile: true, + final nextOffset = signalOffsets + .skip(signalIndex + 1) + .whereType() + .cast() + .firstWhere((offset) => offset != null, orElse: () => null); + final signalDataStart = valueSectionStart + signalOffset; + final signalDataEnd = + nextOffset == null ? chainStart : valueSectionStart + nextOffset; + _decodeFstSignalData( + data.sublist(signalDataStart, signalDataEnd), + width: signalWidths[signalIndex], + signalName: signalNames[signalIndex], + timeTable: timeTable, + events: events, ); + } +} - expect(File(waveformService.outputFilePath).existsSync(), equals(true)); - - // Let the service close its asynchronous file sink before cleanup. - await Simulator.run(); +List _decodeTimeTable(Uint8List bytes, int count) { + final times = []; + var offset = 0; + var previousTime = 0; + for (var i = 0; i < count; i++) { + final delta = _readVarint(bytes, offset); + offset = delta.next; + previousTime += delta.value; + times.add(previousTime); + } + return times; +} - if (File(waveformService.outputFilePath).existsSync()) { - File(dir1Path).deleteSync(recursive: true); +List _decodeFstOffsetChain(Uint8List bytes, int maxHandle) { + final offsets = List.filled(maxHandle, null); + var byteOffset = 0; + var signalIndex = 0; + var previousOffset = 0; + + while (signalIndex < maxHandle && byteOffset < bytes.length) { + final encoded = _readSignedVarint(bytes, byteOffset); + byteOffset = encoded.next; + if (encoded.value.isEven) { + signalIndex += encoded.value >> 1; + } else { + previousOffset += encoded.value >> 1; + offsets[signalIndex] = previousOffset; + signalIndex++; } - }); + } - test('async reset shown in waves correctly', () async { - final reset = Logic(); - final clk = SimpleClockGenerator(10).clk; - final mod = SimpleModWithSeq(reset, clk); + return offsets; +} - await mod.build(); +void _decodeFstSignalData( + Uint8List bytes, { + required int width, + required String signalName, + required List timeTable, + required Map> events, +}) { + var offset = 0; + final compression = _readVarint(bytes, offset); + offset = compression.next; + expect( + compression.value, + equals(0), + reason: 'Only uncompressed signal chains are expected', + ); - const dumpName = 'asyncReset'; + var timeIndex = 0; + while (offset < bytes.length) { + if (width == 1) { + final encoded = _readVarint(bytes, offset); + offset = encoded.next; + String value; + int timeDelta; + if (encoded.value.isEven) { + value = ((encoded.value >> 1) & 1).toString(); + timeDelta = encoded.value >> 2; + } else { + const rcvChars = 'xzhuwl-?'; + value = rcvChars[(encoded.value >> 1) & 0x7]; + timeDelta = encoded.value >> 4; + } + timeIndex += timeDelta; + events[signalName]![timeTable[timeIndex]] = value; + } else { + final encoded = _readVarint(bytes, offset); + offset = encoded.next; + timeIndex += encoded.value >> 1; + + final isFourState = encoded.value.isOdd; + String value; + if (isFourState) { + value = String.fromCharCodes(bytes.sublist(offset, offset + width)); + offset += width; + } else { + final byteCount = (width + 7) ~/ 8; + final packed = bytes.sublist(offset, offset + byteCount); + offset += byteCount; + value = _unpackTwoStateBits(packed, width); + } + + events[signalName]![timeTable[timeIndex]] = value; + } + } +} - Simulator.setMaxSimTime(100); - Simulator.registerAction(13, () => reset.put(1)); - reset.put(0); +String _unpackTwoStateBits(Uint8List bytes, int width) { + final bits = StringBuffer(); + for (var i = 0; i < width; i++) { + final byteIndex = i ~/ 8; + final bitIndex = 7 - (i % 8); + bits.write(((bytes[byteIndex] >> bitIndex) & 1).toString()); + } + return bits.toString(); +} - // add waveform service *after* the put to reset - createTemporaryDump(mod, dumpName); +Uint8List _inflateIfNeeded(Uint8List bytes, int uncompressedLength) { + if (bytes.length == uncompressedLength) { + return bytes; + } + return Uint8List.fromList(ZLibCodec().decode(bytes)); +} - // check functional matches - Simulator.registerAction(0, () => expect(reset.value.toInt(), 0)); - Simulator.registerAction(6, () => expect(mod.val.value.toInt(), 1)); - Simulator.registerAction(14, () => expect(mod.val.value.toInt(), 0)); +({int value, int next}) _readVarint(Uint8List data, int offset) { + var value = 0; + var shift = 0; + var next = offset; - await Simulator.run(); + while (true) { + final byte = data[next++]; + value |= (byte & 0x7f) << shift; + if ((byte & 0x80) == 0) { + return (value: value, next: next); + } + shift += 7; + } +} - final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); +({int value, int next}) _readSignedVarint(Uint8List data, int offset) { + var value = 0; + var shift = 0; + var next = offset; + late int byte; - // reset is 0 initially - expect( - VcdParser.confirmValue(vcdContents, 'asyncReset', 1, LogicValue.zero), - equals(true)); + do { + byte = data[next++]; + value |= (byte & 0x7f) << shift; + shift += 7; + } while ((byte & 0x80) != 0); - // 1 after first clock edge - expect(VcdParser.confirmValue(vcdContents, 'val', 6, LogicValue.one), - equals(true)); + if (shift < 64 && (byte & 0x40) != 0) { + value |= -(1 << shift); + } - // 0 after async reset - expect(VcdParser.confirmValue(vcdContents, 'val', 14, LogicValue.zero), - equals(true)); + return (value: value, next: next); +} - deleteTemporaryDump(dumpName); - }); +int _readU64(Uint8List data, int offset) { + var result = 0; + for (var i = 0; i < 8; i++) { + result = (result << 8) | data[offset + i]; + } + return result; } From a36e37b996a42cdd1c8c7c50875b064cd011551f Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Thu, 17 Sep 2026 06:01:36 -0700 Subject: [PATCH 02/12] streaming waveform fix --- lib/src/diagnostics/waveform_service.dart | 13 +++++++++++++ lib/src/diagnostics/waveform_writer.dart | 20 ++++++++++++++++++++ test/waveform_service_test.dart | 17 +++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart index e6fddec18..95ec489d1 100644 --- a/lib/src/diagnostics/waveform_service.dart +++ b/lib/src/diagnostics/waveform_service.dart @@ -68,6 +68,15 @@ class WaveformService extends ArtifactProducingService { /// Whether to register this service with [ModuleServices] for inspection. final bool register; + /// Whether to retain a complete in-memory copy for debugging consumers. + final bool retainInMemory; + + /// Whether this service can provide waveform data to a consumer. + bool canSendWaveforms() => retainInMemory || format.supportsOnDiskQueries; + + /// The retained VCD waveform, or `null` when retention is disabled. + String? get inMemoryOutput => _writer.inMemoryOutput; + /// The FST writer configuration (only used when [format] is /// [WaveOutputFormat.fst]). final FstWriterConfig? fstConfig; @@ -103,6 +112,7 @@ class WaveformService extends ArtifactProducingService { this.flushBufferSize = 100000, this.overwritePolicy = OverwritePolicy.overwrite, this.register = true, + this.retainInMemory = false, this.fstConfig, }) : super(module) { if (!module.hasBuilt) { @@ -159,6 +169,7 @@ class WaveformService extends ArtifactProducingService { int flushBufferSize = 100000, OverwritePolicy overwritePolicy = OverwritePolicy.overwrite, bool register = true, + bool retainInMemory = false, FstWriterConfig? fstConfig, }) { final normalized = outputPath.replaceAll(r'\', '/'); @@ -181,6 +192,7 @@ class WaveformService extends ArtifactProducingService { flushBufferSize: flushBufferSize, overwritePolicy: overwritePolicy, register: register, + retainInMemory: retainInMemory, fstConfig: fstConfig, ); } @@ -213,6 +225,7 @@ class WaveformService extends ArtifactProducingService { timescale: timescale, flushBufferSize: flushBufferSize, overwritePolicy: overwritePolicy, + memoryBuffer: retainInMemory ? StringBuffer() : null, ); case WaveOutputFormat.fst: return FstWaveformWriter( diff --git a/lib/src/diagnostics/waveform_writer.dart b/lib/src/diagnostics/waveform_writer.dart index 67a7272b9..62c329be9 100644 --- a/lib/src/diagnostics/waveform_writer.dart +++ b/lib/src/diagnostics/waveform_writer.dart @@ -32,6 +32,12 @@ enum WaveOutputFormat { WaveOutputFormat.vcd => 'text/x-vcd', WaveOutputFormat.fst => 'application/vnd.gtkwave.fst', }; + + /// Whether waveform data can be queried from the output file. + bool get supportsOnDiskQueries => switch (this) { + WaveOutputFormat.vcd => false, + WaveOutputFormat.fst => true, + }; } /// Policy applied when the output file already exists at construction time. @@ -105,6 +111,9 @@ abstract class WaveformWriter { /// Returns a JSON-serialisable summary of writer state. Map toJson(); + + /// Returns the retained waveform text, when supported and enabled. + String? get inMemoryOutput; } /// VCD implementation of [WaveformWriter]. @@ -115,6 +124,7 @@ class VcdWaveformWriter implements WaveformWriter { this.timescale = '1ps', this.flushBufferSize = 100000, this.overwritePolicy = OverwritePolicy.overwrite, + this.memoryBuffer, }) { if (overwritePolicy == OverwritePolicy.failIfExists) { final existingFile = File(outputPath); @@ -144,6 +154,9 @@ class VcdWaveformWriter implements WaveformWriter { /// Existing-file policy. final OverwritePolicy overwritePolicy; + /// Optional buffer receiving a complete copy of the VCD output. + final StringBuffer? memoryBuffer; + late final File _outputFile; late final IOSink _outFileSink; final StringBuffer _fileBuffer = StringBuffer(); @@ -156,6 +169,9 @@ class VcdWaveformWriter implements WaveformWriter { @override WaveOutputFormat get format => WaveOutputFormat.vcd; + @override + String? get inMemoryOutput => memoryBuffer?.toString(); + @override void pushScope(String name) { final padding = List.filled(_indent, ' ').join(); @@ -249,6 +265,7 @@ class VcdWaveformWriter implements WaveformWriter { } void _writeToBuffer(String contents) { + memoryBuffer?.write(contents); _fileBuffer.write(contents); if (_fileBuffer.length > flushBufferSize) { _flushBuffer(); @@ -275,6 +292,9 @@ class FstWaveformWriter implements WaveformWriter { @override WaveOutputFormat get format => WaveOutputFormat.fst; + @override + String? get inMemoryOutput => null; + @override void pushScope(String name) { writer.pushScope(name); diff --git a/test/waveform_service_test.dart b/test/waveform_service_test.dart index d8a99747e..61bb3a435 100644 --- a/test/waveform_service_test.dart +++ b/test/waveform_service_test.dart @@ -61,6 +61,23 @@ void main() { ModuleServices.instance.reset(); }); + test('dumpWaves can retain history for debugging', () async { + final mod = _SimpleWaveModule(Logic()); + await mod.build(); + + final service = mod.dumpWaves( + outputPath: temporaryDumpPath('debugDump'), + retainInMemory: true, + ); + + expect(service.retainInMemory, isTrue); + expect(service.canSendWaveforms(), isTrue); + expect(service.inMemoryOutput, isNotNull); + + await Simulator.run(); + deleteTemporaryDump('debugDump'); + }); + test('registers with ModuleServices by default', () async { final a = Logic(name: 'a'); final mod = _SimpleWaveModule(a); From 0863d7bdc356b60f03c6d3e7fc6ba21ba463da49 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Thu, 17 Sep 2026 22:17:31 -0700 Subject: [PATCH 03/12] Keep waveform service current registry-backed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/src/diagnostics/waveform_service.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart index 95ec489d1..9eca5ad39 100644 --- a/lib/src/diagnostics/waveform_service.dart +++ b/lib/src/diagnostics/waveform_service.dart @@ -22,7 +22,11 @@ import 'package:rohd/src/utilities/uniquifier.dart'; /// [WaveOutputFormat.vcd], [FstWaveformWriter] for [WaveOutputFormat.fst]). class WaveformService extends ArtifactProducingService { /// The most recently registered [WaveformService], or `null`. - static WaveformService? current; + /// + /// This is backed by [ModuleServices], so it is cleared by unregistering + /// this service type or resetting the registry. + static WaveformService? get current => + ModuleServices.instance.lookup(); /// Exact output filename override. /// @@ -147,7 +151,6 @@ class WaveformService extends ArtifactProducingService { }); if (register) { - current = this; ModuleServices.instance.register(this); } } From 8e831aa2e0a281950dde3b238747aac0ab2f4448 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 18 Sep 2026 07:41:13 -0700 Subject: [PATCH 04/12] fix: preserve window snapshots with waveform writers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/src/diagnostics/waveform_service.dart | 43 +++++++++++++- test/waveform_service_test.dart | 72 +++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart index 9eca5ad39..0a898e14e 100644 --- a/lib/src/diagnostics/waveform_service.dart +++ b/lib/src/diagnostics/waveform_service.dart @@ -96,6 +96,9 @@ class WaveformService extends ArtifactProducingService { /// The timestamp currently being accumulated. int _currentDumpingTimestamp = Simulator.time; + /// Whether the recording window's initial signal snapshot was emitted. + bool _hasWrittenWindowSnapshot = false; + /// Creates a [WaveformService] for [module]. /// /// [module] must be built before construction. [outputDirectory] defaults to @@ -134,6 +137,7 @@ class WaveformService extends ArtifactProducingService { ), timestamp: Simulator.time, ); + _hasWrittenWindowSnapshot = startTime == null || startTime == 0; Simulator.preTick.listen((_) { if (Simulator.time != _currentDumpingTimestamp) { @@ -141,6 +145,7 @@ class WaveformService extends ArtifactProducingService { _captureTimestamp(_currentDumpingTimestamp); } _currentDumpingTimestamp = Simulator.time; + _writeWindowSnapshotIfNeeded(Simulator.time); } }); @@ -208,11 +213,18 @@ class WaveformService extends ArtifactProducingService { @protected void onSignalCollected(Logic signal) {} - /// Called for every value-change event on [signal] at [timestamp]. + /// Called for every captured value on [signal] at [timestamp]. + /// + /// When [startTime] is set, this includes one window-entry value for every + /// tracked signal at [startTime]. Those calls describe the state entering + /// the recording window, rather than physical transitions. @protected void onValueChange(Logic signal, int timestamp) {} - /// Called once per simulation timestamp that contains at least one change. + /// Called once after each batch of captured values at [timestamp]. + /// + /// When [startTime] is set, the complete window-entry signal snapshot is + /// delivered as a batch at [startTime] before later value-change batches. @protected void onTimestampCapture(int timestamp, Set changed) {} @@ -307,6 +319,7 @@ class WaveformService extends ArtifactProducingService { return; } + _writeWindowSnapshotIfNeeded(timestamp); final snapshot = Set.of(_changedThisTimestamp); final changes = [ for (final sig in snapshot) @@ -327,6 +340,32 @@ class WaveformService extends ArtifactProducingService { } } + void _writeWindowSnapshotIfNeeded(int timestamp) { + if (_hasWrittenWindowSnapshot || + startTime == null || + timestamp < startTime! || + !_isInRecordingWindow(startTime!)) { + return; + } + + final snapshot = Set.of(_signalHandles.keys); + _writer.emitValueChanges( + startTime!, + [ + for (final signal in snapshot) + WaveformValueChange(_signalHandles[signal]!, _binaryValue(signal)), + ], + ); + _hasWrittenWindowSnapshot = true; + + for (final signal in snapshot) { + onValueChange(signal, startTime!); + } + if (snapshot.isNotEmpty) { + onTimestampCapture(startTime!, snapshot); + } + } + String _binaryValue(Logic signal) => signal.value.reversed .toList() .map((e) => e.toString(includeWidth: false)) diff --git a/test/waveform_service_test.dart b/test/waveform_service_test.dart index 61bb3a435..8fdf46264 100644 --- a/test/waveform_service_test.dart +++ b/test/waveform_service_test.dart @@ -25,6 +25,47 @@ class _SimpleWaveModule extends Module { } } +class _WindowWaveModule extends Module { + late final Logic a; + + _WindowWaveModule() { + a = addOutput('a'); + } +} + +class _HistoryWaveformService extends WaveformService { + final Map> history = {}; + final List capturedTimestamps = []; + + _HistoryWaveformService( + super.module, { + super.outputDirectory, + super.outputBaseName, + super.startTime, + super.register, + }); + + @override + void onSignalCollected(Logic signal) { + history[signal] = [(Simulator.time, _binaryValue(signal))]; + } + + @override + void onValueChange(Logic signal, int timestamp) { + history[signal]!.add((timestamp, _binaryValue(signal))); + } + + @override + void onTimestampCapture(int timestamp, Set changed) { + capturedTimestamps.add(timestamp); + } +} + +String _binaryValue(Logic signal) => signal.value.reversed + .toList() + .map((value) => value.toString(includeWidth: false)) + .join(); + const _tempDumpDir = 'tmp_test'; String _temporaryVcdPath(String name) => '$_tempDumpDir/temp_wave_$name.vcd'; @@ -96,6 +137,37 @@ void main() { File(dumpPath).deleteSync(); }); + test('window-entry snapshot reaches waveform hooks', () async { + final mod = _WindowWaveModule(); + await mod.build(); + mod.a.put(0); + + Directory(_tempDumpDir).createSync(recursive: true); + final dumpPath = _temporaryVcdPath('windowHookSnapshot'); + final service = _HistoryWaveformService( + mod, + outputDirectory: _tempDumpDir, + outputBaseName: 'temp_wave_windowHookSnapshot', + startTime: 10, + register: false, + ); + + Simulator.registerAction(5, () => mod.a.put(1)); + Simulator.registerAction(15, () {}); + Simulator.registerAction(20, () {}); + await Simulator.run(); + + final vcdContents = File(dumpPath).readAsStringSync(); + expect( + VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.one), + isTrue, + ); + expect(service.history[mod.a], equals([(0, '0'), (10, '1')])); + expect(service.capturedTimestamps, contains(10)); + + File(dumpPath).deleteSync(); + }); + test('captures waveform to VCD output path', () async { final a = Logic(name: 'a'); final mod = _SimpleWaveModule(a); From 916961b30deece98e50e7a58c1d4ab03b7135f5a Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 18 Sep 2026 07:55:24 -0700 Subject: [PATCH 05/12] feat: add FST waveform query provider Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/rohd.dart | 1 + lib/src/diagnostics/waveform_service.dart | 10 +++ lib/src/diagnostics/waveform_writer.dart | 4 + lib/src/fst/fst_waveform_query.dart | 104 ++++++++++++++++++++++ test/fst_writer_test.dart | 36 ++++++++ 5 files changed, 155 insertions(+) create mode 100644 lib/src/fst/fst_waveform_query.dart diff --git a/lib/rohd.dart b/lib/rohd.dart index 4ebac2244..7a242c1e8 100644 --- a/lib/rohd.dart +++ b/lib/rohd.dart @@ -13,6 +13,7 @@ export 'src/external.dart'; export 'src/finite_state_machine.dart'; export 'src/fst/fst_block_reader.dart'; export 'src/fst/fst_types.dart'; +export 'src/fst/fst_waveform_query.dart'; export 'src/fst/fst_writer.dart'; export 'src/interfaces/interfaces.dart'; export 'src/module.dart'; diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart index 0a898e14e..4c38cb5ef 100644 --- a/lib/src/diagnostics/waveform_service.dart +++ b/lib/src/diagnostics/waveform_service.dart @@ -12,6 +12,7 @@ import 'dart:io'; import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; +import 'package:rohd/src/fst/fst_waveform_query.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; import 'package:rohd/src/utilities/uniquifier.dart'; @@ -87,6 +88,15 @@ class WaveformService extends ArtifactProducingService { late final WaveformWriter _writer; + /// Creates a bounded-memory query provider for an FST capture. + /// + /// Returns `null` for VCD captures, whose text output is not indexed for + /// time-range queries. + FstWaveformQuery? createFstQuery() => switch (_writer) { + FstWaveformWriter() => (_writer as FstWaveformWriter).createQuery(), + _ => null, + }; + /// Maps each captured [Logic] to its writer-specific signal handle. final Map _signalHandles = {}; diff --git a/lib/src/diagnostics/waveform_writer.dart b/lib/src/diagnostics/waveform_writer.dart index 62c329be9..38285af7f 100644 --- a/lib/src/diagnostics/waveform_writer.dart +++ b/lib/src/diagnostics/waveform_writer.dart @@ -10,6 +10,7 @@ import 'dart:io'; import 'package:rohd/rohd.dart'; +import 'package:rohd/src/fst/fst_waveform_query.dart'; import 'package:rohd/src/utilities/config.dart'; import 'package:rohd/src/utilities/timestamper.dart'; @@ -289,6 +290,9 @@ class FstWaveformWriter implements WaveformWriter { /// The low-level FST binary writer. final FstWriter writer; + /// Creates a bounded-memory query provider for this writer's waveform. + FstWaveformQuery createQuery() => FstWaveformQuery(writer); + @override WaveOutputFormat get format => WaveOutputFormat.fst; diff --git a/lib/src/fst/fst_waveform_query.dart b/lib/src/fst/fst_waveform_query.dart new file mode 100644 index 000000000..7fe576336 --- /dev/null +++ b/lib/src/fst/fst_waveform_query.dart @@ -0,0 +1,104 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// fst_waveform_query.dart +// Live, bounded-memory queries over FST writer output. + +import 'package:rohd/src/fst/fst_block_reader.dart'; +import 'package:rohd/src/fst/fst_writer.dart'; + +/// Queries an [FstWriter]'s flushed blocks and unflushed hot buffer. +/// +/// This provider does not own the writer. It may be used while simulation is +/// running, after [FstWriter.flushBlock] has moved historical changes to disk, +/// or after the writer has finished. +class FstWaveformQuery { + /// Creates a query provider for a writer whose signals have been declared. + FstWaveformQuery(this.writer) + : _reader = FstBlockReader(writer.filePath, writer.signalInfoList); + + /// The writer supplying the indexed disk blocks and hot buffer. + final FstWriter writer; + + final FstBlockReader _reader; + + /// Returns all value changes for [signal] within the inclusive time range. + /// + /// Results merge matching flushed blocks with the writer's unflushed changes + /// and are ordered by timestamp. + List changes( + FstSignalHandle signal, { + required int startTime, + required int endTime, + }) { + if (startTime > endTime) { + throw ArgumentError.value( + endTime, + 'endTime', + 'must be greater than or equal to startTime', + ); + } + + final handleIndex = signal.handle - 1; + _validateHandle(handleIndex); + final result = []; + for (final block in writer.blockIndex) { + if (block.endTime < startTime || block.startTime > endTime) { + continue; + } + result.addAll( + _reader.readBlock( + block, + handleIndices: {handleIndex}, + startTime: startTime, + endTime: endTime, + )[handleIndex] ?? + const [], + ); + } + result.addAll( + writer + .queryHotBuffer(handleIndex, startTime, endTime) + .map((change) => FstValueChange(change.time, change.value)), + ); + result.sort((a, b) => a.time.compareTo(b.time)); + return result; + } + + /// Returns [signal]'s value at or immediately before [time]. + /// + /// Returns `null` when the writer has not emitted a value for [signal]. + String? valueAt(FstSignalHandle signal, int time) { + final handleIndex = signal.handle - 1; + _validateHandle(handleIndex); + + final hotChanges = writer.queryHotBuffer(handleIndex, 0, time); + if (hotChanges.isNotEmpty) { + return hotChanges.last.value; + } + + final blocks = writer.blockIndex; + for (var index = blocks.length - 1; index >= 0; index--) { + final block = blocks[index]; + if (block.startTime > time) { + continue; + } + final changes = _reader.readBlock( + block, + handleIndices: {handleIndex}, + endTime: time, + )[handleIndex]; + if (changes != null && changes.isNotEmpty) { + return changes.last.value; + } + return _reader.readBlockFrame(block)[handleIndex]; + } + return null; + } + + void _validateHandle(int handleIndex) { + if (handleIndex < 0 || handleIndex >= writer.signalCount) { + throw RangeError.index(handleIndex, writer.signalInfoList, 'signal'); + } + } +} diff --git a/test/fst_writer_test.dart b/test/fst_writer_test.dart index 8da79a2ff..b067f18c7 100644 --- a/test/fst_writer_test.dart +++ b/test/fst_writer_test.dart @@ -119,6 +119,42 @@ void main() { }); group('FstWriter unit tests', () { + test('queries flushed blocks and the hot buffer', () { + const path = '$_tempDumpDir/fst_query_test.fst'; + Directory(_tempDumpDir).createSync(recursive: true); + + final writer = FstWriter( + path, + config: const FstWriterConfig(maxChangesPerBlock: 2), + )..pushScope('top'); + final signal = writer.declareSignal('signal', 1); + writer + ..popScope() + ..writeHeader() + ..emitValueChange(0, signal, '0') + ..emitValueChange(5, signal, '1') + ..emitValueChange(10, signal, '0'); + + final query = FstWaveformQuery(writer); + expect( + query + .changes(signal, startTime: 0, endTime: 10) + .map((change) => (change.time, change.value)), + equals([(0, '0'), (5, '1'), (10, '0')]), + ); + expect(query.valueAt(signal, 7), equals('1')); + expect(query.valueAt(signal, 10), equals('0')); + + writer.finish(); + expect( + query + .changes(signal, startTime: 0, endTime: 10) + .map((change) => (change.time, change.value)), + equals([(0, '0'), (5, '1'), (10, '0')]), + ); + File(path).deleteSync(); + }); + test('writes valid header block', () { const path = '$_tempDumpDir/fst_header_test.fst'; Directory(_tempDumpDir).createSync(recursive: true); From 358a4f8061df7ac573977801eb65fceb6a926972 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 18 Sep 2026 08:05:49 -0700 Subject: [PATCH 06/12] test: isolate waveform registry artifacts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/module_services_test.dart | 35 ++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/test/module_services_test.dart b/test/module_services_test.dart index c95a58926..a35e8310c 100644 --- a/test/module_services_test.dart +++ b/test/module_services_test.dart @@ -35,10 +35,33 @@ class FakeService implements ModuleService { Map toJson() => {'kind': 'fake'}; } +final _waveformOutputDirectories = []; + +WaveformService _createWaveformService( + Module module, { + bool register = true, +}) { + final directory = Directory.systemTemp.createTempSync( + 'rohd_module_services_waveform_', + ); + _waveformOutputDirectories.add(directory); + return WaveformService.fromOutputPath( + module, + outputPath: '${directory.path}/capture.vcd', + register: register, + ); +} + void main() { tearDown(() async { await Simulator.reset(); ModuleServices.instance.reset(); + for (final directory in _waveformOutputDirectories) { + if (directory.existsSync()) { + directory.deleteSync(recursive: true); + } + } + _waveformOutputDirectories.clear(); }); group('ModuleServices registry', () { @@ -102,8 +125,8 @@ void main() { final mod = SimpleModule(Logic()); await mod.build(); - final firstWaveform = WaveformService(mod); - final secondWaveform = WaveformService(mod); + final firstWaveform = _createWaveformService(mod); + final secondWaveform = _createWaveformService(mod); final firstNetlist = NetlistService(mod); final secondNetlist = NetlistService(mod); final firstSv = SystemVerilogService(mod); @@ -124,10 +147,10 @@ void main() { final mod = SimpleModule(Logic()); await mod.build(); - final waveform = WaveformService(mod); + final waveform = _createWaveformService(mod); final netlist = NetlistService(mod); final sv = SystemVerilogService(mod); - WaveformService(mod, register: false); + _createWaveformService(mod, register: false); NetlistService(mod, register: false); SystemVerilogService(mod, register: false); @@ -139,7 +162,7 @@ void main() { test('unregister clears matching service current accessors', () async { final mod = SimpleModule(Logic()); await mod.build(); - WaveformService(mod); + _createWaveformService(mod); NetlistService(mod); SystemVerilogService(mod); @@ -155,7 +178,7 @@ void main() { test('reset clears every service current accessor', () async { final mod = SimpleModule(Logic()); await mod.build(); - WaveformService(mod); + _createWaveformService(mod); NetlistService(mod); SystemVerilogService(mod); From 7697fb7d75f4061c5ebb35f88f2d06d62b710c20 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 18 Sep 2026 08:28:00 -0700 Subject: [PATCH 07/12] style: resolve FST analyzer lints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/src/diagnostics/waveform_service.dart | 1 - lib/src/diagnostics/waveform_writer.dart | 1 - lib/src/fst/fst_waveform_query.dart | 13 +++++++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart index 4c38cb5ef..6ca8f41bd 100644 --- a/lib/src/diagnostics/waveform_service.dart +++ b/lib/src/diagnostics/waveform_service.dart @@ -12,7 +12,6 @@ import 'dart:io'; import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; -import 'package:rohd/src/fst/fst_waveform_query.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; import 'package:rohd/src/utilities/uniquifier.dart'; diff --git a/lib/src/diagnostics/waveform_writer.dart b/lib/src/diagnostics/waveform_writer.dart index 38285af7f..c598acd84 100644 --- a/lib/src/diagnostics/waveform_writer.dart +++ b/lib/src/diagnostics/waveform_writer.dart @@ -10,7 +10,6 @@ import 'dart:io'; import 'package:rohd/rohd.dart'; -import 'package:rohd/src/fst/fst_waveform_query.dart'; import 'package:rohd/src/utilities/config.dart'; import 'package:rohd/src/utilities/timestamper.dart'; diff --git a/lib/src/fst/fst_waveform_query.dart b/lib/src/fst/fst_waveform_query.dart index 7fe576336..4116518c3 100644 --- a/lib/src/fst/fst_waveform_query.dart +++ b/lib/src/fst/fst_waveform_query.dart @@ -56,12 +56,13 @@ class FstWaveformQuery { const [], ); } - result.addAll( - writer - .queryHotBuffer(handleIndex, startTime, endTime) - .map((change) => FstValueChange(change.time, change.value)), - ); - result.sort((a, b) => a.time.compareTo(b.time)); + result + ..addAll( + writer + .queryHotBuffer(handleIndex, startTime, endTime) + .map((change) => FstValueChange(change.time, change.value)), + ) + ..sort((a, b) => a.time.compareTo(b.time)); return result; } From f6aebdfed8cab1d3b11169cf5e41b81780957c6e Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 18 Sep 2026 10:29:03 -0700 Subject: [PATCH 08/12] Align FST material_ui with main Remove the obsolete material_ui upper bound and cupertino_ui override. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rohd_devtools_extension/pubspec.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rohd_devtools_extension/pubspec.yaml b/rohd_devtools_extension/pubspec.yaml index 99c70653d..f352a624f 100644 --- a/rohd_devtools_extension/pubspec.yaml +++ b/rohd_devtools_extension/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: flutter_bloc: ^9.0.0 flutter_simple_treeview: ^3.0.2 logging: ^1.2.0 - material_ui: ">=1.1.0 <1.3.0" + material_ui: ^1.1.0 rohd_devtools_widgets: path: ../packages/rohd_devtools_widgets vm_service: ^14.3.1 @@ -40,8 +40,6 @@ flutter: - assets/icons/ dependency_overrides: - # Keep the transitive Cupertino UI package below the Dart 3.6-incompatible release. - cupertino_ui: ">=1.0.0 <1.1.0" rohd: path: ../ rohd_hierarchy: From 82c541d564872b030ecb04f85a7876e7be78ff97 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 18 Sep 2026 10:34:14 -0700 Subject: [PATCH 09/12] Revert "Align FST material_ui with main" This reverts commit 4842ff5101850584f6302a458fd29a9c566f60f8. --- rohd_devtools_extension/pubspec.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rohd_devtools_extension/pubspec.yaml b/rohd_devtools_extension/pubspec.yaml index f352a624f..99c70653d 100644 --- a/rohd_devtools_extension/pubspec.yaml +++ b/rohd_devtools_extension/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: flutter_bloc: ^9.0.0 flutter_simple_treeview: ^3.0.2 logging: ^1.2.0 - material_ui: ^1.1.0 + material_ui: ">=1.1.0 <1.3.0" rohd_devtools_widgets: path: ../packages/rohd_devtools_widgets vm_service: ^14.3.1 @@ -40,6 +40,8 @@ flutter: - assets/icons/ dependency_overrides: + # Keep the transitive Cupertino UI package below the Dart 3.6-incompatible release. + cupertino_ui: ">=1.0.0 <1.1.0" rohd: path: ../ rohd_hierarchy: From e9d48c918c8b226aa3360b6373a6211d8355a256 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 30 Sep 2026 15:55:00 -0700 Subject: [PATCH 10/12] fst/vcd round-trip Signed-off-by: Desmond A. Kirkpatrick --- lib/src/diagnostics/waveform_service.dart | 65 ++++++++++++------ lib/src/diagnostics/waveform_writer.dart | 31 ++++++--- test/waveform_service_test.dart | 83 +++++++++++++++++++---- 3 files changed, 137 insertions(+), 42 deletions(-) diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart index 6ca8f41bd..bbd6c2ad5 100644 --- a/lib/src/diagnostics/waveform_service.dart +++ b/lib/src/diagnostics/waveform_service.dart @@ -8,6 +8,7 @@ // Author: Desmond Kirkpatrick import 'dart:collection'; +import 'dart:convert'; import 'dart:io'; import 'package:meta/meta.dart'; @@ -72,11 +73,18 @@ class WaveformService extends ArtifactProducingService { /// Whether to register this service with [ModuleServices] for inspection. final bool register; + /// Whether waveform bytes are written to [outputPath]. + final bool writeToFile; + /// Whether to retain a complete in-memory copy for debugging consumers. final bool retainInMemory; - /// Whether this service can provide waveform data to a consumer. - bool canSendWaveforms() => retainInMemory || format.supportsOnDiskQueries; + /// Whether this service can service debugger waveform-data queries. + /// + /// A `true` result promises waveform-value queries, not artifact-byte + /// transfer. VCD requires [retainInMemory]; FST can query a written file. + bool canSendWaveforms() => + retainInMemory || (writeToFile && format.supportsOnDiskQueries); /// The retained VCD waveform, or `null` when retention is disabled. String? get inMemoryOutput => _writer.inMemoryOutput; @@ -128,14 +136,19 @@ class WaveformService extends ArtifactProducingService { this.flushBufferSize = 100000, this.overwritePolicy = OverwritePolicy.overwrite, this.register = true, - this.retainInMemory = false, + this.writeToFile = false, + bool? retainInMemory, this.fstConfig, - }) : super(module) { + }) : retainInMemory = retainInMemory ?? !writeToFile, + super(module) { if (!module.hasBuilt) { - throw Exception( - 'Module must be built before creating WaveformService. ' - 'Call build() first.', - ); + throw ModuleNotBuiltException(module); + } + if (format == WaveOutputFormat.fst && !writeToFile) { + throw UnsupportedError('FST capture requires writeToFile: true.'); + } + if (format == WaveOutputFormat.fst && this.retainInMemory) { + throw UnsupportedError('FST capture does not support retainInMemory.'); } _writer = _createWriter(); @@ -186,7 +199,7 @@ class WaveformService extends ArtifactProducingService { int flushBufferSize = 100000, OverwritePolicy overwritePolicy = OverwritePolicy.overwrite, bool register = true, - bool retainInMemory = false, + bool? retainInMemory, FstWriterConfig? fstConfig, }) { final normalized = outputPath.replaceAll(r'\', '/'); @@ -209,6 +222,7 @@ class WaveformService extends ArtifactProducingService { flushBufferSize: flushBufferSize, overwritePolicy: overwritePolicy, register: register, + writeToFile: true, retainInMemory: retainInMemory, fstConfig: fstConfig, ); @@ -250,6 +264,7 @@ class WaveformService extends ArtifactProducingService { flushBufferSize: flushBufferSize, overwritePolicy: overwritePolicy, memoryBuffer: retainInMemory ? StringBuffer() : null, + writeToFile: writeToFile, ); case WaveOutputFormat.fst: return FstWaveformWriter( @@ -344,9 +359,7 @@ class WaveformService extends ArtifactProducingService { } _changedThisTimestamp.clear(); - if (snapshot.isNotEmpty) { - onTimestampCapture(timestamp, snapshot); - } + onTimestampCapture(timestamp, snapshot); } void _writeWindowSnapshotIfNeeded(int timestamp) { @@ -370,9 +383,7 @@ class WaveformService extends ArtifactProducingService { for (final signal in snapshot) { onValueChange(signal, startTime!); } - if (snapshot.isNotEmpty) { - onTimestampCapture(startTime!, snapshot); - } + onTimestampCapture(startTime!, snapshot); } String _binaryValue(Logic signal) => signal.value.reversed @@ -382,17 +393,33 @@ class WaveformService extends ArtifactProducingService { Future _terminate() => _writer.close(); - /// The artifacts this service produces. + /// The waveform artifact produced by this service. /// - /// The waveform is written on-the-fly through [WaveformWriter], so this - /// service does not retain artifacts to report. + /// It is complete after simulation finalization. During capture, + /// file-backed reads expose only data already flushed, and each file-backed + /// [ModuleServiceArtifact.openRead] opens the current file rather than a + /// snapshot or live tail. @override - Iterable get artifacts => const []; + Iterable get artifacts sync* { + if (!writeToFile && !retainInMemory) { + return; + } + + yield ModuleServiceArtifact( + fileName: outputFileName ?? '$outputBaseName.${format.fileExtension}', + mediaType: format.mediaType, + openRead: writeToFile + ? () => File(outputPath).openRead() + : () => Stream.value(utf8.encode(inMemoryOutput!)), + ); + } /// Returns a JSON-serialisable summary of this service. @override Map toJson() => { 'outputPath': outputPath, + 'writeToFile': writeToFile, + 'retainInMemory': retainInMemory, 'format': format.name, 'signalCount': _signalHandles.length, 'timescale': timescale, diff --git a/lib/src/diagnostics/waveform_writer.dart b/lib/src/diagnostics/waveform_writer.dart index c598acd84..f7ccfea7b 100644 --- a/lib/src/diagnostics/waveform_writer.dart +++ b/lib/src/diagnostics/waveform_writer.dart @@ -125,8 +125,9 @@ class VcdWaveformWriter implements WaveformWriter { this.flushBufferSize = 100000, this.overwritePolicy = OverwritePolicy.overwrite, this.memoryBuffer, + this.writeToFile = true, }) { - if (overwritePolicy == OverwritePolicy.failIfExists) { + if (writeToFile && overwritePolicy == OverwritePolicy.failIfExists) { final existingFile = File(outputPath); if (existingFile.existsSync()) { throw FileSystemException( @@ -137,8 +138,10 @@ class VcdWaveformWriter implements WaveformWriter { } } - _outputFile = File(outputPath)..createSync(recursive: true); - _outFileSink = _outputFile.openWrite(); + if (writeToFile) { + _outFileSink = + (File(outputPath)..createSync(recursive: true)).openWrite(); + } _writeHeader(); } @@ -157,8 +160,10 @@ class VcdWaveformWriter implements WaveformWriter { /// Optional buffer receiving a complete copy of the VCD output. final StringBuffer? memoryBuffer; - late final File _outputFile; - late final IOSink _outFileSink; + /// Whether VCD bytes are written to [outputPath]. + final bool writeToFile; + + IOSink? _outFileSink; final StringBuffer _fileBuffer = StringBuffer(); final StringBuffer _scopeBuffer = StringBuffer(); final Map _handleWidths = {}; @@ -228,8 +233,8 @@ class VcdWaveformWriter implements WaveformWriter { } _closed = true; _flushBuffer(); - await _outFileSink.flush(); - await _outFileSink.close(); + await _outFileSink?.flush(); + await _outFileSink?.close(); } @override @@ -266,15 +271,19 @@ class VcdWaveformWriter implements WaveformWriter { void _writeToBuffer(String contents) { memoryBuffer?.write(contents); - _fileBuffer.write(contents); - if (_fileBuffer.length > flushBufferSize) { + if (writeToFile) { + _fileBuffer.write(contents); + } + if (writeToFile && _fileBuffer.length > flushBufferSize) { _flushBuffer(); } } void _flushBuffer() { - _outFileSink.write(_fileBuffer.toString()); - _fileBuffer.clear(); + if (writeToFile) { + _outFileSink!.write(_fileBuffer.toString()); + _fileBuffer.clear(); + } } } diff --git a/test/waveform_service_test.dart b/test/waveform_service_test.dart index 8fdf46264..ea766c082 100644 --- a/test/waveform_service_test.dart +++ b/test/waveform_service_test.dart @@ -39,8 +39,6 @@ class _HistoryWaveformService extends WaveformService { _HistoryWaveformService( super.module, { - super.outputDirectory, - super.outputBaseName, super.startTime, super.register, }); @@ -87,6 +85,7 @@ void createTemporaryDump(Module module, String name) { module, outputDirectory: tempDumpDir, outputBaseName: 'temp_dump_$name', + writeToFile: true, ); } @@ -102,12 +101,15 @@ void main() { ModuleServices.instance.reset(); }); - test('dumpWaves can retain history for debugging', () async { + test('file-backed debugging capture can retain history', () async { final mod = _SimpleWaveModule(Logic()); await mod.build(); - final service = mod.dumpWaves( - outputPath: temporaryDumpPath('debugDump'), + final service = WaveformService( + mod, + outputDirectory: tempDumpDir, + outputBaseName: 'temp_dump_debugDump', + writeToFile: true, retainInMemory: true, ); @@ -142,12 +144,8 @@ void main() { await mod.build(); mod.a.put(0); - Directory(_tempDumpDir).createSync(recursive: true); - final dumpPath = _temporaryVcdPath('windowHookSnapshot'); final service = _HistoryWaveformService( mod, - outputDirectory: _tempDumpDir, - outputBaseName: 'temp_wave_windowHookSnapshot', startTime: 10, register: false, ); @@ -157,15 +155,18 @@ void main() { Simulator.registerAction(20, () {}); await Simulator.run(); - final vcdContents = File(dumpPath).readAsStringSync(); + final vcdContents = utf8.decode( + await service.artifacts.single + .openRead() + .expand((bytes) => bytes) + .toList(), + ); expect( VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.one), isTrue, ); expect(service.history[mod.a], equals([(0, '0'), (10, '1')])); expect(service.capturedTimestamps, contains(10)); - - File(dumpPath).deleteSync(); }); test('captures waveform to VCD output path', () async { @@ -221,6 +222,64 @@ void main() { fstFile.deleteSync(); }); + test( + 'FST converted to VCD preserves values and window snapshots', + () async { + final a = Logic(name: 'a'); + final mod = _SimpleWaveModule(a); + await mod.build(); + a.put(0); + + Directory(_tempDumpDir).createSync(recursive: true); + final fstPath = _temporaryFstPath('convertedValues'); + WaveformService.fromOutputPath( + mod, + outputPath: fstPath, + format: WaveOutputFormat.fst, + startTime: 10, + register: false, + ); + + Simulator.registerAction(5, () => a.put(1)); + Simulator.registerAction(15, () => a.put(0)); + Simulator.registerAction(20, () {}); + await Simulator.run(); + + final conversion = Process.runSync('fst2vcd', [fstPath]); + expect( + conversion.exitCode, + equals(0), + reason: 'fst2vcd failed: ${conversion.stdout}\n${conversion.stderr}', + ); + final vcdContents = conversion.stdout as String; + expect( + VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.one), + isTrue, + reason: 'the recording-window snapshot must retain the value at t=10', + ); + expect( + VcdParser.confirmValue(vcdContents, 'a', 15, LogicValue.zero), + isTrue, + reason: 'the later input transition must retain its timestamp', + ); + expect( + VcdParser.confirmValue(vcdContents, 'b', 10, LogicValue.zero), + isTrue, + reason: 'the inverted output must match the input at the window start', + ); + expect( + VcdParser.confirmValue(vcdContents, 'b', 15, LogicValue.one), + isTrue, + reason: 'the inverted output must match the later input transition', + ); + + File(fstPath).deleteSync(); + }, + skip: Process.runSync('which', ['fst2vcd']).exitCode != 0 + ? 'fst2vcd is not installed' + : false, + ); + test('VCD and FST contain matching value-change events', () async { final vcdPath = _temporaryVcdPath('parity'); final fstPath = _temporaryFstPath('parity'); From 20871b241af3c659a86b0597ffb3a57e9696b1d6 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Mon, 5 Oct 2026 13:30:37 -0700 Subject: [PATCH 11/12] cleanup formatting Signed-off-by: Desmond A. Kirkpatrick --- lib/src/diagnostics/waveform_service.dart | 109 +++++----- test/config_test.dart | 23 +-- test/module_services_test.dart | 17 +- test/waveform_service_test.dart | 232 ++++++++-------------- 4 files changed, 142 insertions(+), 239 deletions(-) diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart index bbd6c2ad5..93ee01b89 100644 --- a/lib/src/diagnostics/waveform_service.dart +++ b/lib/src/diagnostics/waveform_service.dart @@ -154,11 +154,9 @@ class WaveformService extends ArtifactProducingService { _writer = _createWriter(); _collectSignals(module); _writer.finishDeclarations( - _signalHandles.entries.map( - (entry) => WaveformInitialValue(entry.value, _binaryValue(entry.key)), - ), - timestamp: Simulator.time, - ); + _signalHandles.entries.map((entry) => + WaveformInitialValue(entry.value, _binaryValue(entry.key))), + timestamp: Simulator.time); _hasWrittenWindowSnapshot = startTime == null || startTime == 0; Simulator.preTick.listen((_) { @@ -188,44 +186,37 @@ class WaveformService extends ArtifactProducingService { /// delegates to the main constructor. Provided so that pre-services-API /// callers of the form `WaveformService(module, outputPath: '/tmp/foo.vcd')` /// still compile. - factory WaveformService.fromOutputPath( - Module module, { - required String outputPath, - WaveOutputFormat format = WaveOutputFormat.vcd, - bool Function(Logic signal)? signalFilter, - String timescale = '1ps', - int? startTime, - int? stopTime, - int flushBufferSize = 100000, - OverwritePolicy overwritePolicy = OverwritePolicy.overwrite, - bool register = true, - bool? retainInMemory, - FstWriterConfig? fstConfig, - }) { + factory WaveformService.fromOutputPath(Module module, + {required String outputPath, + WaveOutputFormat format = WaveOutputFormat.vcd, + bool Function(Logic signal)? signalFilter, + String timescale = '1ps', + int? startTime, + int? stopTime, + int flushBufferSize = 100000, + OverwritePolicy overwritePolicy = OverwritePolicy.overwrite, + bool register = true, + bool? retainInMemory, + FstWriterConfig? fstConfig}) { final normalized = outputPath.replaceAll(r'\', '/'); final sep = normalized.lastIndexOf('/'); - final directory = switch (sep) { - -1 => '.', - 0 => '/', - _ => normalized.substring(0, sep), - }; + final directory = + switch (sep) { -1 => '.', 0 => '/', _ => normalized.substring(0, sep) }; final filename = normalized.substring(sep + 1); - return WaveformService( - module, - outputDirectory: directory, - outputFileName: filename, - format: format, - signalFilter: signalFilter, - timescale: timescale, - startTime: startTime, - stopTime: stopTime, - flushBufferSize: flushBufferSize, - overwritePolicy: overwritePolicy, - register: register, - writeToFile: true, - retainInMemory: retainInMemory, - fstConfig: fstConfig, - ); + return WaveformService(module, + outputDirectory: directory, + outputFileName: filename, + format: format, + signalFilter: signalFilter, + timescale: timescale, + startTime: startTime, + stopTime: stopTime, + flushBufferSize: flushBufferSize, + overwritePolicy: overwritePolicy, + register: register, + writeToFile: true, + retainInMemory: retainInMemory, + fstConfig: fstConfig); } /// The concrete output writer used by this service. @@ -258,19 +249,15 @@ class WaveformService extends ArtifactProducingService { WaveformWriter _createWriter() { switch (format) { case WaveOutputFormat.vcd: - return VcdWaveformWriter( - outputPath, - timescale: timescale, - flushBufferSize: flushBufferSize, - overwritePolicy: overwritePolicy, - memoryBuffer: retainInMemory ? StringBuffer() : null, - writeToFile: writeToFile, - ); + return VcdWaveformWriter(outputPath, + timescale: timescale, + flushBufferSize: flushBufferSize, + overwritePolicy: overwritePolicy, + memoryBuffer: retainInMemory ? StringBuffer() : null, + writeToFile: writeToFile); case WaveOutputFormat.fst: - return FstWaveformWriter( - outputPath, - config: fstConfig ?? const FstWriterConfig(), - ); + return FstWaveformWriter(outputPath, + config: fstConfig ?? const FstWriterConfig()); } } @@ -294,11 +281,8 @@ class WaveformService extends ArtifactProducingService { initialName: baseName, reserved: sig.isPort, ); - final handle = _writer.declareSignal( - signalName, - sig.width, - direction: _directionOf(sig), - ); + final handle = _writer.declareSignal(signalName, sig.width, + direction: _directionOf(sig)); _signalHandles[sig] = handle; onSignalCollected(sig); @@ -371,13 +355,10 @@ class WaveformService extends ArtifactProducingService { } final snapshot = Set.of(_signalHandles.keys); - _writer.emitValueChanges( - startTime!, - [ - for (final signal in snapshot) - WaveformValueChange(_signalHandles[signal]!, _binaryValue(signal)), - ], - ); + _writer.emitValueChanges(startTime!, [ + for (final signal in snapshot) + WaveformValueChange(_signalHandles[signal]!, _binaryValue(signal)), + ]); _hasWrittenWindowSnapshot = true; for (final signal in snapshot) { diff --git a/test/config_test.dart b/test/config_test.dart index 133e746d0..a377d37e2 100644 --- a/test/config_test.dart +++ b/test/config_test.dart @@ -25,7 +25,7 @@ class SimpleModule extends Module { final c = addOutput('c'); Combinational([ - If(a, then: [c < a], orElse: [c < b]), + If(a, then: [c < a], orElse: [c < b]) ]); } } @@ -54,20 +54,19 @@ void main() { }); test( - 'should contains ROHD version number when deprecated synth is generated.', - () async { - const version = Config.version; + 'should contains ROHD version number when deprecated synth is generated.', + () async { + const version = Config.version; - final mod = SimpleModule(Logic(), Logic()); - await mod.build(); + final mod = SimpleModule(Logic(), Logic()); + await mod.build(); - // This test verifies that the deprecated API still includes the version. - // ignore: deprecated_member_use_from_same_package - final sv = mod.generateSynth(); + // This test verifies that the deprecated API still includes the version. + // ignore: deprecated_member_use_from_same_package + final sv = mod.generateSynth(); - expect(sv, contains(version)); - }, - ); + expect(sv, contains(version)); + }); if (!kIsWeb) { test( diff --git a/test/module_services_test.dart b/test/module_services_test.dart index a35e8310c..010d3ee42 100644 --- a/test/module_services_test.dart +++ b/test/module_services_test.dart @@ -37,19 +37,12 @@ class FakeService implements ModuleService { final _waveformOutputDirectories = []; -WaveformService _createWaveformService( - Module module, { - bool register = true, -}) { - final directory = Directory.systemTemp.createTempSync( - 'rohd_module_services_waveform_', - ); +WaveformService _createWaveformService(Module module, {bool register = true}) { + final directory = + Directory.systemTemp.createTempSync('rohd_module_services_waveform_'); _waveformOutputDirectories.add(directory); - return WaveformService.fromOutputPath( - module, - outputPath: '${directory.path}/capture.vcd', - register: register, - ); + return WaveformService.fromOutputPath(module, + outputPath: '${directory.path}/capture.vcd', register: register); } void main() { diff --git a/test/waveform_service_test.dart b/test/waveform_service_test.dart index ea766c082..a68b043b0 100644 --- a/test/waveform_service_test.dart +++ b/test/waveform_service_test.dart @@ -37,11 +37,7 @@ class _HistoryWaveformService extends WaveformService { final Map> history = {}; final List capturedTimestamps = []; - _HistoryWaveformService( - super.module, { - super.startTime, - super.register, - }); + _HistoryWaveformService(super.module, {super.startTime, super.register}); @override void onSignalCollected(Logic signal) { @@ -144,11 +140,8 @@ void main() { await mod.build(); mod.a.put(0); - final service = _HistoryWaveformService( - mod, - startTime: 10, - register: false, - ); + final service = + _HistoryWaveformService(mod, startTime: 10, register: false); Simulator.registerAction(5, () => mod.a.put(1)); Simulator.registerAction(15, () {}); @@ -185,13 +178,11 @@ void main() { final vcdContents = File(dumpPath).readAsStringSync(); expect( - VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), - equals(true), - ); + VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), + equals(true)); expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), - equals(true), - ); + VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), + equals(true)); File(dumpPath).deleteSync(); }); @@ -204,12 +195,8 @@ void main() { Directory(_tempDumpDir).createSync(recursive: true); final dumpPath = _temporaryFstPath('fstCapture'); - WaveformService.fromOutputPath( - mod, - outputPath: dumpPath, - format: WaveOutputFormat.fst, - register: false, - ); + WaveformService.fromOutputPath(mod, + outputPath: dumpPath, format: WaveOutputFormat.fst, register: false); a.inject(1); Simulator.registerAction(10, () => a.put(0)); @@ -222,63 +209,45 @@ void main() { fstFile.deleteSync(); }); - test( - 'FST converted to VCD preserves values and window snapshots', - () async { - final a = Logic(name: 'a'); - final mod = _SimpleWaveModule(a); - await mod.build(); - a.put(0); - - Directory(_tempDumpDir).createSync(recursive: true); - final fstPath = _temporaryFstPath('convertedValues'); - WaveformService.fromOutputPath( - mod, + test('FST converted to VCD preserves values and window snapshots', () async { + final a = Logic(name: 'a'); + final mod = _SimpleWaveModule(a); + await mod.build(); + a.put(0); + + Directory(_tempDumpDir).createSync(recursive: true); + final fstPath = _temporaryFstPath('convertedValues'); + WaveformService.fromOutputPath(mod, outputPath: fstPath, format: WaveOutputFormat.fst, startTime: 10, - register: false, - ); - - Simulator.registerAction(5, () => a.put(1)); - Simulator.registerAction(15, () => a.put(0)); - Simulator.registerAction(20, () {}); - await Simulator.run(); - - final conversion = Process.runSync('fst2vcd', [fstPath]); - expect( - conversion.exitCode, - equals(0), - reason: 'fst2vcd failed: ${conversion.stdout}\n${conversion.stderr}', - ); - final vcdContents = conversion.stdout as String; - expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.one), - isTrue, - reason: 'the recording-window snapshot must retain the value at t=10', - ); - expect( - VcdParser.confirmValue(vcdContents, 'a', 15, LogicValue.zero), - isTrue, - reason: 'the later input transition must retain its timestamp', - ); - expect( - VcdParser.confirmValue(vcdContents, 'b', 10, LogicValue.zero), - isTrue, - reason: 'the inverted output must match the input at the window start', - ); - expect( - VcdParser.confirmValue(vcdContents, 'b', 15, LogicValue.one), - isTrue, - reason: 'the inverted output must match the later input transition', - ); - - File(fstPath).deleteSync(); - }, - skip: Process.runSync('which', ['fst2vcd']).exitCode != 0 - ? 'fst2vcd is not installed' - : false, - ); + register: false); + + Simulator.registerAction(5, () => a.put(1)); + Simulator.registerAction(15, () => a.put(0)); + Simulator.registerAction(20, () {}); + await Simulator.run(); + + final conversion = Process.runSync('fst2vcd', [fstPath]); + expect(conversion.exitCode, equals(0), + reason: 'fst2vcd failed: ${conversion.stdout}\n${conversion.stderr}'); + final vcdContents = conversion.stdout as String; + expect(VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.one), isTrue, + reason: 'the recording-window snapshot must retain the value at t=10'); + expect( + VcdParser.confirmValue(vcdContents, 'a', 15, LogicValue.zero), isTrue, + reason: 'the later input transition must retain its timestamp'); + expect( + VcdParser.confirmValue(vcdContents, 'b', 10, LogicValue.zero), isTrue, + reason: 'the inverted output must match the input at the window start'); + expect(VcdParser.confirmValue(vcdContents, 'b', 15, LogicValue.one), isTrue, + reason: 'the inverted output must match the later input transition'); + + File(fstPath).deleteSync(); + }, + skip: Process.runSync('which', ['fst2vcd']).exitCode != 0 + ? 'fst2vcd is not installed' + : false); test('VCD and FST contain matching value-change events', () async { final vcdPath = _temporaryVcdPath('parity'); @@ -291,11 +260,8 @@ void main() { ModuleServices.instance.reset(); await _dumpParityWaveform(fstPath, WaveOutputFormat.fst); - final fstEvents = _readFstEvents( - fstPath, - signalNames: const ['a', 'b'], - signalWidths: const [4, 4], - ); + final fstEvents = _readFstEvents(fstPath, + signalNames: const ['a', 'b'], signalWidths: const [4, 4]); expect(fstEvents, equals(vcdEvents)); @@ -305,9 +271,7 @@ void main() { } Future _dumpParityWaveform( - String outputPath, - WaveOutputFormat format, -) async { + String outputPath, WaveOutputFormat format) async { Directory(_tempDumpDir).createSync(recursive: true); final a = Logic(name: 'a', width: 4); @@ -315,12 +279,8 @@ Future _dumpParityWaveform( await mod.build(); a.put(0x1); - WaveformService.fromOutputPath( - mod, - outputPath: outputPath, - format: format, - register: false, - ); + WaveformService.fromOutputPath(mod, + outputPath: outputPath, format: format, register: false); Simulator.registerAction(10, () => a.put(0x2)); Simulator.registerAction(20, () => a.put(0xf)); @@ -328,19 +288,16 @@ Future _dumpParityWaveform( } Map> _readVcdEvents( - String path, - Set signalNames, -) { + String path, Set signalNames) { final lines = File(path).readAsLinesSync(); final markerToSignal = {}; final markerToWidth = {}; final events = >{ - for (final name in signalNames) name: {}, + for (final name in signalNames) name: {} }; final sigNameRegexp = RegExp( - r'\s*\$var\s(wire|reg)\s(\d+)\s(\S*)\s(\S*)\s+(\[\d+\:\d+\])?\s*\$end', - ); + r'\s*\$var\s(wire|reg)\s(\d+)\s(\S*)\s(\S*)\s+(\[\d+\:\d+\])?\s*\$end'); var currentTime = 0; var inValues = false; @@ -387,9 +344,7 @@ Map> _readVcdEvents( } ({String marker, String value})? _parseVcdValueUpdate( - String line, - Map markerToWidth, -) { + String line, Map markerToWidth) { if (line.startsWith('b')) { final parts = line.split(' '); if (parts.length != 2 || !markerToWidth.containsKey(parts[1])) { @@ -406,14 +361,11 @@ Map> _readVcdEvents( return null; } -Map> _readFstEvents( - String path, { - required List signalNames, - required List signalWidths, -}) { +Map> _readFstEvents(String path, + {required List signalNames, required List signalWidths}) { final data = File(path).readAsBytesSync(); final events = >{ - for (final name in signalNames) name: {}, + for (final name in signalNames) name: {} }; var blockOffset = 0; @@ -423,14 +375,8 @@ Map> _readFstEvents( final blockEnd = blockOffset + 1 + sectionLength; if (blockType == 8) { - _readFstVcDataBlock( - data, - blockOffset, - blockEnd, - signalNames: signalNames, - signalWidths: signalWidths, - events: events, - ); + _readFstVcDataBlock(data, blockOffset, blockEnd, + signalNames: signalNames, signalWidths: signalWidths, events: events); } blockOffset = blockEnd; @@ -439,14 +385,10 @@ Map> _readFstEvents( return events; } -void _readFstVcDataBlock( - Uint8List data, - int blockOffset, - int blockEnd, { - required List signalNames, - required List signalWidths, - required Map> events, -}) { +void _readFstVcDataBlock(Uint8List data, int blockOffset, int blockEnd, + {required List signalNames, + required List signalWidths, + required Map> events}) { final startTime = _readU64(data, blockOffset + 9); var offset = blockOffset + 33; @@ -458,17 +400,15 @@ void _readFstVcDataBlock( offset = maxHandle.next; final frameBytes = _inflateIfNeeded( - data.sublist(offset, offset + frameCompressed.value), - frameUncompressed.value, - ); + data.sublist(offset, offset + frameCompressed.value), + frameUncompressed.value); offset += frameCompressed.value; var frameOffset = 0; for (var i = 0; i < signalNames.length; i++) { final width = signalWidths[i]; final value = String.fromCharCodes( - frameBytes.sublist(frameOffset, frameOffset + width), - ); + frameBytes.sublist(frameOffset, frameOffset + width)); frameOffset += width; events[signalNames[i]]![startTime] = value; } @@ -483,17 +423,14 @@ void _readFstVcDataBlock( final timeUncompressedLength = _readU64(data, blockEnd - 24); final timeDataStart = blockEnd - 24 - timeCompressedLength; final timeBytes = _inflateIfNeeded( - data.sublist(timeDataStart, timeDataStart + timeCompressedLength), - timeUncompressedLength, - ); + data.sublist(timeDataStart, timeDataStart + timeCompressedLength), + timeUncompressedLength); final timeTable = _decodeTimeTable(timeBytes, timeCount); final chainLength = _readU64(data, timeDataStart - 8); final chainStart = timeDataStart - 8 - chainLength; final signalOffsets = _decodeFstOffsetChain( - data.sublist(chainStart, timeDataStart - 8), - valueMaxHandle.value, - ); + data.sublist(chainStart, timeDataStart - 8), valueMaxHandle.value); for (var signalIndex = 0; signalIndex < signalNames.length; signalIndex++) { final signalOffset = signalOffsets[signalIndex]; @@ -509,13 +446,11 @@ void _readFstVcDataBlock( final signalDataStart = valueSectionStart + signalOffset; final signalDataEnd = nextOffset == null ? chainStart : valueSectionStart + nextOffset; - _decodeFstSignalData( - data.sublist(signalDataStart, signalDataEnd), - width: signalWidths[signalIndex], - signalName: signalNames[signalIndex], - timeTable: timeTable, - events: events, - ); + _decodeFstSignalData(data.sublist(signalDataStart, signalDataEnd), + width: signalWidths[signalIndex], + signalName: signalNames[signalIndex], + timeTable: timeTable, + events: events); } } @@ -553,21 +488,16 @@ List _decodeFstOffsetChain(Uint8List bytes, int maxHandle) { return offsets; } -void _decodeFstSignalData( - Uint8List bytes, { - required int width, - required String signalName, - required List timeTable, - required Map> events, -}) { +void _decodeFstSignalData(Uint8List bytes, + {required int width, + required String signalName, + required List timeTable, + required Map> events}) { var offset = 0; final compression = _readVarint(bytes, offset); offset = compression.next; - expect( - compression.value, - equals(0), - reason: 'Only uncompressed signal chains are expected', - ); + expect(compression.value, equals(0), + reason: 'Only uncompressed signal chains are expected'); var timeIndex = 0; while (offset < bytes.length) { From 0715d18b127f3f1f6c5e931727f993ca9b26cab2 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Mon, 5 Oct 2026 16:27:18 -0700 Subject: [PATCH 12/12] fix: preserve waveform service compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/src/diagnostics/waveform_service.dart | 18 ++++++++++++---- test/waveform_service_test.dart | 25 ++++++++++++++++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart index 93ee01b89..85db0fdf0 100644 --- a/lib/src/diagnostics/waveform_service.dart +++ b/lib/src/diagnostics/waveform_service.dart @@ -227,18 +227,28 @@ class WaveformService extends ArtifactProducingService { @protected void onSignalCollected(Logic signal) {} - /// Called for every captured value on [signal] at [timestamp]. + /// Called once for each signal's final value in a callback batch at + /// [timestamp]. + /// + /// Multiple changes to the same signal within a simulation timestamp are + /// coalesced within a value-change batch, so this hook receives that signal + /// once with its final value for that batch. /// /// When [startTime] is set, this includes one window-entry value for every /// tracked signal at [startTime]. Those calls describe the state entering - /// the recording window, rather than physical transitions. + /// the recording window, rather than physical transitions, and form a + /// separate callback batch. A signal that then changes at [startTime] is + /// delivered again in the following value-change batch with the same + /// timestamp. @protected void onValueChange(Logic signal, int timestamp) {} - /// Called once after each batch of captured values at [timestamp]. + /// Called once after each captured timestamp batch. /// /// When [startTime] is set, the complete window-entry signal snapshot is - /// delivered as a batch at [startTime] before later value-change batches. + /// delivered as a batch at [startTime] before the value-change batch, which + /// may have the same timestamp. Finalization invokes this hook even when its + /// [changed] set is empty. @protected void onTimestampCapture(int timestamp, Set changed) {} diff --git a/test/waveform_service_test.dart b/test/waveform_service_test.dart index a68b043b0..f5e600474 100644 --- a/test/waveform_service_test.dart +++ b/test/waveform_service_test.dart @@ -162,6 +162,24 @@ void main() { expect(service.capturedTimestamps, contains(10)); }); + test('window entry and boundary change are separate hook batches', () async { + final mod = _WindowWaveModule(); + await mod.build(); + mod.a.put(0); + final service = + _HistoryWaveformService(mod, startTime: 10, register: false); + + Simulator.registerAction(5, () => mod.a.put(1)); + Simulator.registerAction(10, () => mod.a.put(0)); + await Simulator.run(); + + expect( + service.history[mod.a], + equals([(0, '0'), (10, '1'), (10, '0')]), + ); + expect(service.capturedTimestamps, equals([10, 10])); + }); + test('captures waveform to VCD output path', () async { final a = Logic(name: 'a'); final mod = _SimpleWaveModule(a); @@ -170,7 +188,12 @@ void main() { Directory(_tempDumpDir).createSync(recursive: true); final dumpPath = _temporaryVcdPath('serviceCapture'); - WaveformService.fromOutputPath(mod, outputPath: dumpPath, register: false); + final service = WaveformService.fromOutputPath( + mod, + outputPath: dumpPath, + register: false, + ); + expect(service.outputFilePath, dumpPath); a.inject(1); Simulator.registerAction(10, () => a.put(0));