diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml
index 5be099df24..0529e597fd 100644
--- a/.github/workflows/general.yml
+++ b/.github/workflows/general.yml
@@ -104,6 +104,10 @@ jobs:
if: matrix.platform == 'vm'
run: bash tool/gh_actions/install_verilator.sh
+ - name: Install software - GTKWave
+ if: matrix.platform == 'vm'
+ run: bash tool/gh_actions/install_gtkwave.sh
+
- name: Run project tests
run: tool/gh_actions/run_tests.sh ${{ matrix.platform }}
diff --git a/doc/user_guide/_docs/A21-generation.md b/doc/user_guide/_docs/A21-generation.md
index 08f710d010..f806a9e9e5 100644
--- a/doc/user_guide/_docs/A21-generation.md
+++ b/doc/user_guide/_docs/A21-generation.md
@@ -1,7 +1,7 @@
---
title: "Generating Outputs"
permalink: /docs/generation/
-last_modified_at: 2026-10-08
+last_modified_at: 2026-10-12
toc: true
---
@@ -262,7 +262,7 @@ final waveform = WaveformService(
outputDirectory: 'build/waves',
outputBaseName: 'interesting-signals',
writeToFile: true,
- timescale: '1ns',
+ timescale: WaveformTimeScale.oneNanosecond,
startTime: 100,
stopTime: 1000,
signalFilter: (signal) => signal.name.startsWith('debug_'),
@@ -270,3 +270,9 @@ final waveform = WaveformService(
final vcdArtifact = waveform.artifacts.single;
```
+
+`timescale` is the physical duration represented by one unit of ROHD
+simulation time. For example, `WaveformTimeScale.oneNanosecond` writes an
+event at `Simulator.time == 100` at timestamp `100` with a `1ns` timescale;
+it does not rescale timestamps or change simulator scheduling. The same
+validated scale is used for VCD and FST output.
diff --git a/doc/user_guide/_get-started/03-development-recommendations.md b/doc/user_guide/_get-started/03-development-recommendations.md
index d224e54df5..6ffb5ab7b3 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 5b7cc0ce19..c77e6811fa 100644
--- a/lib/rohd.dart
+++ b/lib/rohd.dart
@@ -11,6 +11,10 @@ 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_waveform_query.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 8ff34cf81b..150721f218 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/module_services.dart b/lib/src/diagnostics/module_services.dart
index 7ed82e87ef..cfaeb90b77 100644
--- a/lib/src/diagnostics/module_services.dart
+++ b/lib/src/diagnostics/module_services.dart
@@ -63,6 +63,10 @@ class ModuleServices {
/// DevTools client from the relevant registered service.
String get hierarchyJson => ModuleTree.instance.hierarchyJson;
+ /// Returns the module hierarchy as a JSON string.
+ @Deprecated('Use hierarchyJson instead.')
+ String get hierarchyJSON => hierarchyJson;
+
// ─── Type-keyed service registry ──────────────────────────────
final Map _services = {};
diff --git a/lib/src/diagnostics/waveform_service.dart b/lib/src/diagnostics/waveform_service.dart
index 640a54c4c9..057573a019 100644
--- a/lib/src/diagnostics/waveform_service.dart
+++ b/lib/src/diagnostics/waveform_service.dart
@@ -2,9 +2,7 @@
// 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
@@ -15,104 +13,13 @@ 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.
+/// A waveform capture service that writes signal changes to a file.
///
-/// 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]
-///
-/// **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]. FST-specific writer tuning is
+/// available through [WaveformService.fst].
class WaveformService extends ArtifactProducingService {
/// The most recently registered [WaveformService], or `null`.
///
@@ -121,51 +28,45 @@ class WaveformService extends ArtifactProducingService {
static WaveformService? get current =>
ModuleServices.instance.lookup();
- /// Path of the output waveform file.
- ///
- /// Derived from [outputDirectory], [outputBaseName], and [format].
- String get outputFilePath => '$outputDirectory${Platform.pathSeparator}'
- '${outputFileName ?? '$outputBaseName.${format.fileExtension}'}';
-
- /// The output filepath of the generated waveforms.
- ///
- /// 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;
+ /// Path of the output waveform file.
+ ///
+ /// Derived from [outputDirectory], [outputBaseName], [outputFileName],
+ /// and [format].
+ String get outputPath => '$outputDirectory${Platform.pathSeparator}'
+ '${outputFileName ?? '$outputBaseName.${format.fileExtension}'}';
+
+ /// Path of the output waveform file.
+ ///
+ /// 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;
+ /// The physical duration represented by one unit of [Simulator.time].
+ ///
+ /// This controls waveform-file metadata only; it does not rescale timestamp
+ /// numbers or affect simulator scheduling.
+ final WaveformTimeScale 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 +75,42 @@ 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.
+ /// Whether waveform bytes are written to [outputPath].
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.
+ /// Whether to retain a complete in-memory copy for debugging consumers.
final bool retainInMemory;
- /// Whether this service can service debugger waveform-data queries.
+ /// Whether this service can provide waveform data to debugging consumers.
///
- /// 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.
+ /// This describes whether waveform data is available, not whether the
+ /// backend supports indexed value queries. VCD requires [retainInMemory];
+ /// FST can query a written file.
bool canSendWaveforms() =>
- retainInMemory || (writeToFile && format.supportsOnDiskQueries);
-
- // ─── Internal file-writing state ─────────────────────────────
-
- /// Sink writing to [outputFilePath] when [writeToFile] is true.
- IOSink? _outFileSink;
+ retainInMemory || (writeToFile && _writer.supportsQueries);
- /// Write buffer; flushed when it exceeds [flushBufferSize].
- final StringBuffer _fileBuffer = StringBuffer();
+ /// The retained VCD waveform, or `null` when retention is disabled.
+ String? get inMemoryOutput => _writer.inMemoryOutput;
- /// The complete waveform output when [retainInMemory] is enabled.
- final StringBuffer _inMemoryOutput = StringBuffer();
+ late final WaveformWriter _writer;
+ final FstWriterConfig? _fstWriterConfig;
- /// Counter for assigning compact signal markers in the VCD.
- int _signalMarkerIdx = 0;
+ /// Creates a format-independent query provider for this capture.
+ ///
+ /// Throws [UnsupportedError] when the selected backend does not support
+ /// waveform queries.
+ WaveformQuery createWaveformQuery() {
+ final query = _writer.createWaveformQuery();
+ if (query == null) {
+ throw UnsupportedError(
+ '${format.name.toUpperCase()} waveform queries are not supported.',
+ );
+ }
+ return query;
+ }
- /// 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,35 +118,90 @@ class WaveformService extends ArtifactProducingService {
/// The timestamp currently being accumulated.
int _currentDumpingTimestamp = Simulator.time;
- /// Whether the recording window's initial signal snapshot has been written.
+ /// Whether the recording window's initial signal snapshot was emitted.
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, {
+ String? outputDirectory,
+ String? outputBaseName,
+ String? outputFileName,
+ WaveOutputFormat format = WaveOutputFormat.vcd,
+ bool Function(Logic signal)? signalFilter,
+ WaveformTimeScale timescale = WaveformTimeScale.onePicosecond,
+ int? startTime,
+ int? stopTime,
+ int flushBufferSize = 100000,
+ OverwritePolicy overwritePolicy = OverwritePolicy.overwrite,
+ bool register = true,
+ bool writeToFile = false,
+ bool? retainInMemory,
+ }) : this._(
+ module,
+ outputDirectory: outputDirectory ?? '.',
+ outputBaseName: outputBaseName,
+ outputFileName: outputFileName,
+ format: format,
+ signalFilter: signalFilter,
+ timescale: timescale,
+ startTime: startTime,
+ stopTime: stopTime,
+ flushBufferSize: flushBufferSize,
+ overwritePolicy: overwritePolicy,
+ register: register,
+ writeToFile: writeToFile,
+ retainInMemory: retainInMemory,
+ );
+
+ /// Creates an FST capture with backend-specific [config] tuning.
+ ///
+ /// Use this constructor when advanced FST writer settings are needed;
+ /// ordinary format selection remains available through [WaveformService].
+ WaveformService.fst(
+ Module module, {
+ String? outputDirectory,
+ String? outputBaseName,
+ String? outputFileName,
+ bool Function(Logic signal)? signalFilter,
+ WaveformTimeScale timescale = WaveformTimeScale.onePicosecond,
+ int? startTime,
+ int? stopTime,
+ OverwritePolicy overwritePolicy = OverwritePolicy.overwrite,
+ bool register = true,
+ bool writeToFile = true,
+ FstWriterConfig config = const FstWriterConfig(),
+ }) : this._(
+ module,
+ outputDirectory: outputDirectory ?? '.',
+ outputBaseName: outputBaseName,
+ outputFileName: outputFileName,
+ format: WaveOutputFormat.fst,
+ signalFilter: signalFilter,
+ timescale: timescale,
+ startTime: startTime,
+ stopTime: stopTime,
+ overwritePolicy: overwritePolicy,
+ register: register,
+ writeToFile: writeToFile,
+ retainInMemory: false,
+ fstWriterConfig: config,
+ );
+
+ WaveformService._(
Module module, {
super.outputDirectory,
super.outputBaseName,
this.outputFileName,
this.format = WaveOutputFormat.vcd,
this.signalFilter,
- this.timescale = '1ps',
+ this.timescale = WaveformTimeScale.onePicosecond,
this.startTime,
this.stopTime,
this.flushBufferSize = 100000,
@@ -259,36 +209,36 @@ class WaveformService extends ArtifactProducingService {
this.register = true,
this.writeToFile = false,
bool? retainInMemory,
+ FstWriterConfig? fstWriterConfig,
}) : retainInMemory = retainInMemory ?? !writeToFile,
+ _fstWriterConfig = fstWriterConfig,
super(module) {
if (!module.hasBuilt) {
throw ModuleNotBuiltException(module);
}
- if (format != WaveOutputFormat.vcd) {
- throw UnsupportedError(
- 'Waveform format ${format.name} is not supported by WaveformService.',
- );
+ 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.');
}
-
if (writeToFile && overwritePolicy == OverwritePolicy.failIfExists) {
- final f = File(outputFilePath);
- if (f.existsSync()) {
+ final existingFile = File(outputPath);
+ if (existingFile.existsSync()) {
throw FileSystemException(
'Waveform output file already exists and overwritePolicy is '
'failIfExists.',
- outputFilePath,
+ outputPath,
);
}
}
- if (writeToFile) {
- _outFileSink =
- (File(outputFilePath)..createSync(recursive: true)).openWrite();
- }
-
- _collectSignals();
- _writeHeader();
- _writeScope();
+ _writer = _createWriter();
+ _collectSignals(module);
+ _writer.finishDeclarations(
+ _signalHandles.entries.map((entry) =>
+ WaveformInitialValue(entry.value, _binaryValue(entry.key))),
+ timestamp: Simulator.time);
_hasWrittenWindowSnapshot = startTime == null || startTime == 0;
Simulator.preTick.listen((_) {
@@ -312,13 +262,48 @@ class WaveformService extends ArtifactProducingService {
}
}
- // ─── 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,
+ WaveformTimeScale timescale = WaveformTimeScale.onePicosecond,
+ int? startTime,
+ int? stopTime,
+ int flushBufferSize = 100000,
+ OverwritePolicy overwritePolicy = OverwritePolicy.overwrite,
+ bool register = true,
+ bool? retainInMemory}) {
+ 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,
+ writeToFile: true,
+ retainInMemory: retainInMemory);
+ }
+
+ /// 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) {}
@@ -335,9 +320,6 @@ class WaveformService extends ArtifactProducingService {
/// 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.
@protected
void onValueChange(Logic signal, int timestamp) {}
@@ -347,110 +329,76 @@ class WaveformService extends ArtifactProducingService {
/// 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.
@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,
+ memoryBuffer: retainInMemory ? StringBuffer() : null,
+ writeToFile: writeToFile);
+ case WaveOutputFormat.fst:
+ return FstWaveformWriter(outputPath,
+ timescale: timescale,
+ config: _fstWriterConfig ?? 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) {
@@ -470,11 +418,17 @@ class WaveformService extends ArtifactProducingService {
}
_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();
@@ -490,68 +444,32 @@ class WaveformService extends ArtifactProducingService {
return;
}
- _writeToBuffer('#$startTime\n');
- final snapshot = Set.of(_signalToMarkerMap.keys);
- for (final signal in snapshot) {
- _writeSignalValueUpdate(signal);
- onValueChange(signal, startTime!);
- }
+ final snapshot = Set.of(_signalHandles.keys);
+ _writer.emitValueChanges(startTime!, [
+ for (final signal in snapshot)
+ WaveformValueChange(_signalHandles[signal]!, _binaryValue(signal)),
+ ]);
_hasWrittenWindowSnapshot = true;
- if (snapshot.isNotEmpty) {
- onTimestampCapture(startTime!, 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');
- }
-
- // ─── Buffered I/O ─────────────────────────────────────────────
-
- 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();
+ for (final signal in snapshot) {
+ onValueChange(signal, startTime!);
}
+ onTimestampCapture(startTime!, snapshot);
}
- Future _terminate() async {
- _flushBuffer();
- await _outFileSink?.flush();
- await _outFileSink?.close();
- }
+ String _binaryValue(Logic signal) => signal.value.reversed
+ .toList()
+ .map((e) => e.toString(includeWidth: false))
+ .join();
- // ─── Inspection ───────────────────────────────────────────────
+ Future _terminate() => _writer.close();
/// The waveform artifact produced by this service.
///
- /// 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.
+ /// 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 sync* {
if (!writeToFile && !retainInMemory) {
@@ -562,23 +480,22 @@ class WaveformService extends ArtifactProducingService {
fileName: outputFileName ?? '$outputBaseName.${format.fileExtension}',
mediaType: format.mediaType,
openRead: writeToFile
- ? () => File(outputFilePath).openRead()
- : () => Stream.value(utf8.encode(_inMemoryOutput.toString())),
+ ? () => File(outputPath).openRead()
+ : () => Stream.value(utf8.encode(inMemoryOutput!)),
);
}
/// Returns a JSON-serialisable summary of this service.
@override
- Map toJson() => {
- 'outputDirectory': outputDirectory,
- 'outputBaseName': outputBaseName,
- 'outputFilePath': outputFilePath,
+ Map toJson() => {
+ 'outputPath': outputPath,
'writeToFile': writeToFile,
'retainInMemory': retainInMemory,
'format': format.name,
- 'signalCount': _signalToMarkerMap.length,
- 'timescale': timescale,
- if (startTime != null) 'startTime': startTime!,
- if (stopTime != null) 'stopTime': stopTime!,
+ 'signalCount': _signalHandles.length,
+ 'timescale': timescale.vcdValue,
+ 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 0000000000..0dbeb4ba58
--- /dev/null
+++ b/lib/src/diagnostics/waveform_writer.dart
@@ -0,0 +1,572 @@
+// 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',
+ };
+
+ /// Whether waveform data can be queried from the output file.
+ bool get supportsOnDiskQueries => switch (this) {
+ WaveOutputFormat.vcd => false,
+ WaveOutputFormat.fst => true,
+ };
+}
+
+/// The physical duration represented by one unit of [Simulator.time].
+///
+/// This only controls the time metadata emitted to waveform files. It does
+/// not rescale timestamp numbers or change simulator scheduling. The supported
+/// values are the VCD-standard 1, 10, and 100 multiples from femtoseconds
+/// through seconds.
+enum WaveformTimeScale {
+ /// 100 seconds per simulation-time unit.
+ hundredSeconds(2, '100s'),
+
+ /// 10 seconds per simulation-time unit.
+ tenSeconds(1, '10s'),
+
+ /// 1 second per simulation-time unit.
+ oneSecond(0, '1s'),
+
+ /// 100 milliseconds per simulation-time unit.
+ hundredMilliseconds(-1, '100ms'),
+
+ /// 10 milliseconds per simulation-time unit.
+ tenMilliseconds(-2, '10ms'),
+
+ /// 1 millisecond per simulation-time unit.
+ oneMillisecond(-3, '1ms'),
+
+ /// 100 microseconds per simulation-time unit.
+ hundredMicroseconds(-4, '100us'),
+
+ /// 10 microseconds per simulation-time unit.
+ tenMicroseconds(-5, '10us'),
+
+ /// 1 microsecond per simulation-time unit.
+ oneMicrosecond(-6, '1us'),
+
+ /// 100 nanoseconds per simulation-time unit.
+ hundredNanoseconds(-7, '100ns'),
+
+ /// 10 nanoseconds per simulation-time unit.
+ tenNanoseconds(-8, '10ns'),
+
+ /// 1 nanosecond per simulation-time unit.
+ oneNanosecond(-9, '1ns'),
+
+ /// 100 picoseconds per simulation-time unit.
+ hundredPicoseconds(-10, '100ps'),
+
+ /// 10 picoseconds per simulation-time unit.
+ tenPicoseconds(-11, '10ps'),
+
+ /// 1 picosecond per simulation-time unit.
+ onePicosecond(-12, '1ps'),
+
+ /// 100 femtoseconds per simulation-time unit.
+ hundredFemtoseconds(-13, '100fs'),
+
+ /// 10 femtoseconds per simulation-time unit.
+ tenFemtoseconds(-14, '10fs'),
+
+ /// 1 femtosecond per simulation-time unit.
+ oneFemtosecond(-15, '1fs');
+
+ /// Creates a waveform timescale with its base-10 seconds [exponent] and
+ /// VCD representation.
+ const WaveformTimeScale(this.exponent, this.vcdValue);
+
+ /// The base-10 exponent in seconds used by the FST encoding.
+ final int exponent;
+
+ /// The corresponding VCD timescale value.
+ final String vcdValue;
+
+ /// Returns the supported timescale with the given base-10 seconds
+ /// [exponent].
+ ///
+ /// Throws an [ArgumentError] when [exponent] cannot be represented by both
+ /// waveform formats.
+ static WaveformTimeScale fromExponent(int exponent) {
+ for (final timeScale in values) {
+ if (timeScale.exponent == exponent) {
+ return timeScale;
+ }
+ }
+ throw ArgumentError.value(
+ exponent,
+ 'exponent',
+ 'must be between -15 and 2 inclusive',
+ );
+ }
+}
+
+/// 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);
+}
+
+/// A signal available from a format-independent waveform query.
+class WaveformQuerySignal {
+ /// Creates a query signal.
+ const WaveformQuerySignal({
+ required this.index,
+ required this.name,
+ required this.width,
+ });
+
+ /// The zero-based index of this signal within its waveform.
+ final int index;
+
+ /// The signal's declared name.
+ final String name;
+
+ /// The signal's declared width.
+ final int width;
+}
+
+/// A timestamped value returned by a waveform query.
+class WaveformQueryValueChange {
+ /// Creates a queried value change.
+ const WaveformQueryValueChange(this.time, this.value);
+
+ /// Simulation timestamp of this change.
+ final int time;
+
+ /// The MSB-first binary value string.
+ final String value;
+}
+
+/// Format-independent capability for querying waveform values.
+abstract interface class WaveformQuery {
+ /// The signals available for querying.
+ List get signals;
+
+ /// Returns emitted values for [signal] within the inclusive time range.
+ List changes(
+ WaveformQuerySignal signal, {
+ required int startTime,
+ required int endTime,
+ });
+
+ /// Returns [signal]'s value at or immediately before [time].
+ String? valueAt(WaveformQuerySignal signal, int time);
+}
+
+/// Common backend contract for waveform file formats.
+abstract class WaveformWriter {
+ /// The file format emitted by this writer.
+ WaveOutputFormat get format;
+
+ /// Whether this writer can query waveform values from its output.
+ bool get supportsQueries;
+
+ /// Creates a format-independent query capability, when supported.
+ WaveformQuery? createWaveformQuery();
+
+ /// 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();
+
+ /// Returns the retained waveform text, when supported and enabled.
+ String? get inMemoryOutput;
+}
+
+/// VCD implementation of [WaveformWriter].
+class VcdWaveformWriter implements WaveformWriter {
+ /// Creates a VCD writer at [outputPath].
+ VcdWaveformWriter(
+ this.outputPath, {
+ this.timescale = WaveformTimeScale.onePicosecond,
+ this.flushBufferSize = 100000,
+ this.memoryBuffer,
+ this.writeToFile = true,
+ }) {
+ if (writeToFile) {
+ _outFileSink =
+ (File(outputPath)..createSync(recursive: true)).openWrite();
+ }
+ _writeHeader();
+ }
+
+ /// The output file path.
+ final String outputPath;
+
+ /// The physical duration represented by one simulation-time unit.
+ final WaveformTimeScale timescale;
+
+ /// Number of characters accumulated before flushing to disk.
+ final int flushBufferSize;
+
+ /// Optional buffer receiving a complete copy of the VCD output.
+ final StringBuffer? memoryBuffer;
+
+ /// Whether VCD bytes are written to [outputPath].
+ final bool writeToFile;
+
+ 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
+ bool get supportsQueries => false;
+
+ @override
+ WaveformQuery? createWaveformQuery() => null;
+
+ @override
+ String? get inMemoryOutput => memoryBuffer?.toString();
+
+ @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.vcdValue,
+ };
+
+ 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.vcdValue} \$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) {
+ memoryBuffer?.write(contents);
+ if (writeToFile) {
+ _fileBuffer.write(contents);
+ }
+ if (writeToFile && _fileBuffer.length > flushBufferSize) {
+ _flushBuffer();
+ }
+ }
+
+ void _flushBuffer() {
+ if (writeToFile) {
+ _outFileSink!.write(_fileBuffer.toString());
+ _fileBuffer.clear();
+ }
+ }
+}
+
+/// FST implementation of [WaveformWriter].
+class FstWaveformWriter implements WaveformWriter {
+ /// Creates an FST writer at [outputPath].
+ FstWaveformWriter(
+ String outputPath, {
+ required this.timescale,
+ FstWriterConfig config = const FstWriterConfig(),
+ }) : writer = FstWriter(
+ outputPath,
+ config: config,
+ timescaleExponent: timescale.exponent,
+ );
+
+ /// The physical duration represented by one simulation-time unit.
+ final WaveformTimeScale timescale;
+
+ /// 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;
+
+ @override
+ bool get supportsQueries => true;
+
+ @override
+ WaveformQuery createWaveformQuery() =>
+ _FstWaveformQueryAdapter(createQuery());
+
+ @override
+ String? get inMemoryOutput => null;
+
+ @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,
+ 'timescale': timescale.vcdValue,
+ 'maxChangesPerBlock': writer.config.maxChangesPerBlock,
+ };
+
+ FstVarDirection _fstDirection(WaveformSignalDirection direction) {
+ switch (direction) {
+ case WaveformSignalDirection.input:
+ return FstVarDirection.input;
+ case WaveformSignalDirection.output:
+ return FstVarDirection.output;
+ case WaveformSignalDirection.implicit:
+ return FstVarDirection.implicit;
+ }
+ }
+}
+
+class _FstWaveformQueryAdapter implements WaveformQuery {
+ _FstWaveformQueryAdapter(this._query)
+ : signals = List.unmodifiable([
+ for (var index = 0;
+ index < _query.writer.signalInfoList.length;
+ index++)
+ WaveformQuerySignal(
+ index: index,
+ name: _query.writer.signalInfoList[index].name,
+ width: _query.writer.signalInfoList[index].width,
+ ),
+ ]);
+
+ final FstWaveformQuery _query;
+
+ @override
+ final List signals;
+
+ FstSignalHandle _handle(WaveformQuerySignal signal) {
+ if (signal.index < 0 || signal.index >= signals.length) {
+ throw RangeError.index(signal.index, signals, 'signal');
+ }
+ return FstSignalHandle(signal.index + 1);
+ }
+
+ @override
+ List changes(
+ WaveformQuerySignal signal, {
+ required int startTime,
+ required int endTime,
+ }) =>
+ _query
+ .changes(
+ _handle(signal),
+ startTime: startTime,
+ endTime: endTime,
+ )
+ .map(
+ (change) => WaveformQueryValueChange(change.time, change.value),
+ )
+ .toList(growable: false);
+
+ @override
+ String? valueAt(WaveformQuerySignal signal, int time) =>
+ _query.valueAt(_handle(signal), time);
+}
diff --git a/lib/src/fst/fst_block_reader.dart b/lib/src/fst/fst_block_reader.dart
new file mode 100644
index 0000000000..d7a96eea52
--- /dev/null
+++ b/lib/src/fst/fst_block_reader.dart
@@ -0,0 +1,607 @@
+// 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.
+ /// By default, the block's frame checkpoint is included at its start time
+ /// for every requested signal. Set [includeFrame] to `false` to read only
+ /// encoded changes.
+ ///
+ /// Returns a map from handle index to a list of value changes.
+ Map> readBlock(
+ FstBlockIndex block, {
+ Set? handleIndices,
+ int? startTime,
+ int? endTime,
+ bool includeFrame = true,
+ }) {
+ final file = File(filePath).openSync();
+ try {
+ return _readBlockImpl(
+ file,
+ block,
+ handleIndices: handleIndices,
+ startTime: startTime,
+ endTime: endTime,
+ includeFrame: includeFrame,
+ );
+ } finally {
+ file.closeSync();
+ }
+ }
+
+ /// Reads the frame checkpoint (values immediately before block changes).
+ ///
+ /// 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,
+ bool includeFrame = true,
+ }) {
+ // 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 encoded changes for this signal in this block.
+ if (includeFrame &&
+ (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 (includeFrame &&
+ (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 0000000000..13e829c284
--- /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_waveform_query.dart b/lib/src/fst/fst_waveform_query.dart
new file mode 100644
index 0000000000..27c7978274
--- /dev/null
+++ b/lib/src/fst/fst_waveform_query.dart
@@ -0,0 +1,125 @@
+// 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 emitted values for [signal] within the inclusive time range.
+ ///
+ /// Results merge matching flushed blocks with the writer's unflushed changes
+ /// and are ordered by timestamp. FST block frames are carry-over
+ /// checkpoints, so only encoded signal-chain entries are returned.
+ /// If a signal has multiple emitted values at the same timestamp, only its
+ /// final value is returned.
+ 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,
+ includeFrame: false,
+ )[handleIndex] ??
+ const [],
+ );
+ }
+ result.addAll(
+ writer
+ .queryHotBuffer(handleIndex, startTime, endTime)
+ .map((change) => FstValueChange(change.time, change.value)),
+ );
+
+ final ordered = [
+ for (var index = 0; index < result.length; index++)
+ (index, result[index]),
+ ]..sort((a, b) {
+ final timeCmp = a.$2.time.compareTo(b.$2.time);
+ return timeCmp != 0 ? timeCmp : a.$1.compareTo(b.$1);
+ });
+
+ final coalesced = [];
+ for (final entry in ordered) {
+ final change = entry.$2;
+ if (coalesced.isNotEmpty && coalesced.last.time == change.time) {
+ coalesced[coalesced.length - 1] = change;
+ } else {
+ coalesced.add(change);
+ }
+ }
+ return coalesced;
+ }
+
+ /// 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/lib/src/fst/fst_writer.dart b/lib/src/fst/fst_writer.dart
new file mode 100644
index 0000000000..83444aa5a4
--- /dev/null
+++ b/lib/src/fst/fst_writer.dart
@@ -0,0 +1,1044 @@
+// 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 {
+ /// 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;
+
+ /// Default number of value changes to buffer before auto-flushing a VcData
+ /// block to disk.
+ ///
+ /// This keeps normal waveform captures incremental while avoiding frequent
+ /// repeated frame snapshots for typical designs.
+ static const int defaultMaxChangesPerBlock = 4096;
+
+ /// Maximum number of value changes to buffer before auto-flushing
+ /// a VcData block to disk. Set to 0 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.compressionLevel = 4,
+ this.version = 'ROHD FST Writer',
+ this.fileType = FstFileType.verilog,
+ this.maxChangesPerBlock = defaultMaxChangesPerBlock,
+ });
+}
+
+/// 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;
+ final int sequence;
+
+ _ValueChange(this.time, this.handleIndex, this.value, this.sequence);
+}
+
+/// 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;
+
+ /// Signed base-10 exponent in seconds written into the FST header.
+ final int timescaleExponent;
+
+ /// 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 = [];
+
+ int _changeSequence = 0;
+
+ /// 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(),
+ this.timescaleExponent = -12,
+ }) {
+ if (timescaleExponent < -128 || timescaleExponent > 127) {
+ throw ArgumentError.value(
+ timescaleExponent,
+ 'timescaleExponent',
+ 'must fit in a signed 8-bit FST header field',
+ );
+ }
+ 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,
+ _changeSequence++,
+ ));
+ _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');
+ }
+
+ // Keep equal-time updates to each signal in emission order. They may
+ // otherwise be reversed by List.sort, changing the final value in a frame.
+ _changes.sort((a, b) {
+ final cmp = a.time.compareTo(b.time);
+ if (cmp != 0) {
+ return cmp;
+ }
+ final handleCmp = a.handleIndex.compareTo(b.handleIndex);
+ return handleCmp != 0 ? handleCmp : a.sequence.compareTo(b.sequence);
+ });
+
+ final blockStart = _changes.first.time;
+ final blockEnd = _changes.last.time;
+
+ // A frame is the carry-over state before any changes at the block's
+ // first timestamp. Those changes must remain in the encoded chains so
+ // standard FST readers can observe them.
+ final frameValues = List.from(_nextFrameBase);
+
+ 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(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 a complete gzip member.
+ _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 carry-over value of each signal immediately
+ /// before [blockStartTime].
+ ///
+ /// Assumes `_changes` is already sorted by time, then by handle.
+ void _writeVcDataBlock({
+ required int blockStartTime,
+ required int blockEndTime,
+ required List frameValues,
+ }) {
+ // Build a sorted unique time table for every emitted timestamp. FST
+ // frames are checkpoints, not replacements for changes at block start.
+ final timeSet = {};
+ for (final c in _changes) {
+ 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);
+
+ // 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.
+ List _buildSignalData(
+ Map timeToIndex,
+ ) {
+ // Group changes by signal handle index
+ final signalChanges = List>.generate(
+ _signals.length,
+ (_) => [],
+ );
+ for (final c in _changes) {
+ 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.
+ void _writeGzipCompressed(Uint8List data) {
+ _file.writeFromSync(
+ Uint8List.fromList(
+ GZipCodec(level: config.compressionLevel).encode(data),
+ ),
+ );
+ }
+
+ /// 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;
+ }
+
+ /// 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 95e1f3149d..5583bcfe41 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 563c38e940..8dfe49e2e6 100644
--- a/lib/src/wave_dumper.dart
+++ b/lib/src/wave_dumper.dart
@@ -35,7 +35,7 @@ import 'package:rohd/rohd.dart';
/// outputDirectory: 'build',
/// outputBaseName: 'debug',
/// writeToFile: true,
-/// timescale: '1ns',
+/// timescale: WaveformTimeScale.oneNanosecond,
/// startTime: 100,
/// signalFilter: (signal) => signal.name.startsWith('debug_'),
/// );
@@ -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 ad2bc306f2..a377d37e24 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 {
@@ -67,8 +69,9 @@ void main() {
});
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 0000000000..54bf001640
--- /dev/null
+++ b/test/fst_writer_test.dart
@@ -0,0 +1,858 @@
+// 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:rohd/src/utilities/vcd_parser.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;
+}
+
+int _readU32LittleEndian(Uint8List data, int offset) =>
+ data[offset] |
+ (data[offset + 1] << 8) |
+ (data[offset + 2] << 16) |
+ (data[offset + 3] << 24);
+
+int _crc32(List bytes) {
+ var crc = 0xffffffff;
+ for (final byte in bytes) {
+ crc ^= byte;
+ for (var bit = 0; bit < 8; bit++) {
+ crc = (crc & 1) == 1 ? (crc >> 1) ^ 0xedb88320 : crc >> 1;
+ }
+ }
+ return (crc ^ 0xffffffff) & 0xffffffff;
+}
+
+String _binaryByte(int value) => value.toRadixString(2).padLeft(8, '0');
+
+String _convertFstToVcd(String fstPath) {
+ final result = Process.runSync('fst2vcd', [fstPath]);
+ expect(
+ result.exitCode,
+ equals(0),
+ reason: 'fst2vcd failed: ${result.stdout}\n${result.stderr}',
+ );
+ return result.stdout as String;
+}
+
+Map> _readEightBitVcdEvents(
+ String vcd,
+ Set signalNames,
+) {
+ final markerToSignal = {};
+ final events = >{
+ for (final name in signalNames) name: {},
+ };
+ final declaration = RegExp(r'^\s*\$var\s+\S+\s+8\s+(\S+)\s+(\S+)');
+ var currentTime = 0;
+ var inValues = false;
+
+ for (final sourceLine in vcd.split('\n')) {
+ final line = sourceLine.trim();
+ final declarationMatch = declaration.firstMatch(line);
+ if (declarationMatch != null) {
+ final marker = declarationMatch.group(1)!;
+ final name = declarationMatch.group(2)!;
+ if (signalNames.contains(name)) {
+ markerToSignal[marker] = name;
+ }
+ continue;
+ }
+ if (line == r'$dumpvars') {
+ inValues = true;
+ continue;
+ }
+ if (!inValues || line == r'$end') {
+ continue;
+ }
+ if (line.startsWith('#')) {
+ currentTime = int.parse(line.substring(1));
+ continue;
+ }
+ if (!line.startsWith('b')) {
+ continue;
+ }
+
+ final parts = line.substring(1).split(RegExp(r'\s+'));
+ if (parts.length != 2) {
+ continue;
+ }
+ final signalName = markerToSignal[parts[1]];
+ if (signalName != null) {
+ events[signalName]![currentTime] = parts[0];
+ }
+ }
+
+ return events;
+}
+
+/// 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('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('query changes exclude unchanged frame samples across blocks', () {
+ const path = '$_tempDumpDir/fst_query_quiet_signal.fst';
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final file = File(path);
+ addTearDown(() {
+ if (file.existsSync()) {
+ file.deleteSync();
+ }
+ });
+
+ final writer = FstWriter(
+ path,
+ config: const FstWriterConfig(maxChangesPerBlock: 1),
+ )..pushScope('top');
+ final quiet = writer.declareSignal('quiet', 1);
+ final active = writer.declareSignal('active', 1);
+ writer
+ ..popScope()
+ ..writeHeader();
+
+ final query = FstWaveformQuery(writer);
+ writer.emitValueChange(0, quiet, '0');
+ expect(query.changes(quiet, startTime: 1, endTime: 10), isEmpty);
+
+ writer.emitValueChange(5, active, '1');
+ expect(query.changes(quiet, startTime: 1, endTime: 10), isEmpty);
+ writer.emitValueChange(10, active, '0');
+ expect(query.changes(quiet, startTime: 1, endTime: 10), isEmpty);
+ expect(
+ query
+ .changes(active, startTime: 1, endTime: 10)
+ .map((change) => (change.time, change.value)),
+ equals([(5, '1'), (10, '0')]),
+ );
+
+ writer.finish();
+ expect(query.changes(quiet, startTime: 1, endTime: 10), isEmpty);
+ expect(query.valueAt(quiet, 10), equals('0'));
+ expect(
+ query
+ .changes(active, startTime: 1, endTime: 10)
+ .map((change) => (change.time, change.value)),
+ equals([(5, '1'), (10, '0')]),
+ );
+ });
+
+ for (final compressionLevel in [0, 9]) {
+ for (final signalCount in [1, 64]) {
+ test(
+ 'hierarchy gzip framing is complete at level $compressionLevel '
+ 'with $signalCount signals',
+ () {
+ final path = '$_tempDumpDir/fst_hierarchy_gzip_${compressionLevel}_'
+ '$signalCount.fst';
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final file = File(path);
+ addTearDown(() {
+ if (file.existsSync()) {
+ file.deleteSync();
+ }
+ });
+
+ final writer = FstWriter(
+ path,
+ config: FstWriterConfig(compressionLevel: compressionLevel),
+ )..pushScope('top');
+ for (var i = 0; i < signalCount; i++) {
+ writer.declareSignal('signal$i', 1 + (i % 32));
+ }
+ writer
+ ..popScope()
+ ..finish();
+
+ final data = file.readAsBytesSync();
+ var offset = 0;
+ while (offset < data.length && data[offset] != 4) {
+ offset += 1 + _readU64(data, offset + 1);
+ }
+ expect(offset, lessThan(data.length),
+ reason: 'hierarchy block exists');
+
+ final sectionLength = _readU64(data, offset + 1);
+ final uncompressedLength = _readU64(data, offset + 9);
+ final payloadStart = offset + 17;
+ final payloadEnd = offset + 1 + sectionLength;
+ expect(
+ payloadEnd,
+ equals(data.length),
+ reason: 'the hierarchy block ends at the end of the file',
+ );
+
+ final payload = data.sublist(payloadStart, payloadEnd);
+ final uncompressed = GZipCodec().decode(payload);
+ expect(uncompressed, isNotEmpty);
+ expect(uncompressed.length, equals(uncompressedLength));
+ expect(
+ _readU32LittleEndian(data, payloadEnd - 8),
+ equals(_crc32(uncompressed)),
+ reason: 'gzip trailer CRC32 is present at the block boundary',
+ );
+ expect(
+ _readU32LittleEndian(data, payloadEnd - 4),
+ equals(uncompressed.length & 0xffffffff),
+ reason: 'gzip trailer ISIZE is present at the block boundary',
+ );
+ },
+ );
+ }
+ }
+
+ test('default block limit flushes before finalization and retains timeline',
+ () {
+ const path = '$_tempDumpDir/fst_default_block_limit.fst';
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final file = File(path);
+ addTearDown(() {
+ if (file.existsSync()) {
+ file.deleteSync();
+ }
+ });
+
+ final writer = FstWriter(path)..pushScope('top');
+ final signal = writer.declareSignal('signal', 1);
+ writer
+ ..popScope()
+ ..writeHeader();
+
+ const maxChangesPerBlock = FstWriterConfig.defaultMaxChangesPerBlock;
+ for (var time = 0; time <= maxChangesPerBlock; time++) {
+ writer.emitValueChange(
+ time,
+ signal,
+ time.isEven ? '0' : '1',
+ );
+ }
+
+ expect(writer.blockIndex, hasLength(1));
+ final query = FstWaveformQuery(writer);
+ expect(
+ query
+ .changes(
+ signal,
+ startTime: maxChangesPerBlock - 1,
+ endTime: maxChangesPerBlock,
+ )
+ .map((change) => (change.time, change.value)),
+ equals([
+ (
+ maxChangesPerBlock - 1,
+ (maxChangesPerBlock - 1).isEven ? '0' : '1',
+ ),
+ (
+ maxChangesPerBlock,
+ maxChangesPerBlock.isEven ? '0' : '1',
+ ),
+ ]),
+ );
+
+ writer.finish();
+ });
+
+ test(
+ 'fst2vcd preserves an eight-bit change at the default block boundary',
+ () {
+ const path = '$_tempDumpDir/fst_block_boundary.fst';
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final file = File(path);
+ addTearDown(() {
+ if (file.existsSync()) {
+ file.deleteSync();
+ }
+ });
+
+ final writer = FstWriter(path)..pushScope('top');
+ final signal = writer.declareSignal('signal', 8);
+ writer
+ ..popScope()
+ ..writeHeader();
+ for (var time = 0; time <= 5000; time++) {
+ writer.emitValueChange(time, signal, _binaryByte(time % 256));
+ }
+ writer.finish();
+
+ expect(writer.blockIndex, hasLength(2));
+ final vcd = _convertFstToVcd(path);
+ expect(
+ VcdParser.confirmValue(
+ vcd,
+ 'signal',
+ FstWriterConfig.defaultMaxChangesPerBlock - 1,
+ LogicValue.ofInt(255, 8),
+ ),
+ isTrue,
+ );
+ expect(
+ VcdParser.confirmValue(
+ vcd,
+ 'signal',
+ FstWriterConfig.defaultMaxChangesPerBlock,
+ LogicValue.ofInt(0, 8),
+ ),
+ isTrue,
+ );
+ },
+ skip: Process.runSync('which', ['fst2vcd']).exitCode != 0
+ ? 'fst2vcd is not installed'
+ : false,
+ );
+
+ test(
+ 'fst2vcd preserves all signals across default block boundaries',
+ () {
+ const path = '$_tempDumpDir/fst_multisignal_block_boundaries.fst';
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final file = File(path);
+ addTearDown(() {
+ if (file.existsSync()) {
+ file.deleteSync();
+ }
+ });
+
+ final writer = FstWriter(path)..pushScope('top');
+ final signals = [
+ writer.declareSignal('a', 8),
+ writer.declareSignal('b', 8),
+ writer.declareSignal('c', 8),
+ ];
+ writer
+ ..popScope()
+ ..writeHeader();
+ for (var time = 0; time <= 5000; time++) {
+ writer
+ ..emitValueChange(time, signals[0], _binaryByte(time % 256))
+ ..emitValueChange(time, signals[1], _binaryByte((time + 85) % 256))
+ ..emitValueChange(
+ time,
+ signals[2],
+ _binaryByte((255 - time) % 256),
+ );
+ }
+ writer.finish();
+
+ final events = _readEightBitVcdEvents(
+ _convertFstToVcd(path),
+ const {'a', 'b', 'c'},
+ );
+ final mismatches = [];
+ for (var time = 0; time <= 5000; time++) {
+ final expected = {
+ 'a': _binaryByte(time % 256),
+ 'b': _binaryByte((time + 85) % 256),
+ 'c': _binaryByte((255 - time) % 256),
+ };
+ for (final entry in expected.entries) {
+ final actual = events[entry.key]![time];
+ if (actual != entry.value) {
+ mismatches.add(
+ '${entry.key} at $time: expected ${entry.value}, got $actual',
+ );
+ }
+ }
+ }
+ expect(mismatches, isEmpty);
+ },
+ skip: Process.runSync('which', ['fst2vcd']).exitCode != 0
+ ? 'fst2vcd is not installed'
+ : false,
+ );
+
+ test(
+ 'fst2vcd preserves a constant-only initial value',
+ () {
+ const path = '$_tempDumpDir/fst_constant_initial_value.fst';
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final file = File(path);
+ addTearDown(() {
+ if (file.existsSync()) {
+ file.deleteSync();
+ }
+ });
+
+ final writer = FstWriter(path)..pushScope('top');
+ final signal = writer.declareSignal('constant', 8);
+ writer
+ ..popScope()
+ ..writeHeader()
+ ..emitValueChange(0, signal, _binaryByte(42))
+ ..finish();
+
+ final vcd = _convertFstToVcd(path);
+ expect(
+ VcdParser.confirmValue(vcd, 'constant', 0, LogicValue.ofInt(42, 8)),
+ isTrue,
+ );
+ },
+ skip: Process.runSync('which', ['fst2vcd']).exitCode != 0
+ ? 'fst2vcd is not installed'
+ : false,
+ );
+
+ 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/module_services_test.dart b/test/module_services_test.dart
index 117844bf04..294bc7a713 100644
--- a/test/module_services_test.dart
+++ b/test/module_services_test.dart
@@ -35,10 +35,26 @@ 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', () {
@@ -55,16 +71,23 @@ void main() {
expect(() => jsonDecode(json), returnsNormally);
});
- test('legacy hierarchyJSON returns the current hierarchy JSON', () async {
+ test('legacy hierarchyJSON aliases return the current hierarchy JSON',
+ () async {
final mod = SimpleModule(Logic());
await mod.build();
expect(
- // This verifies that the deprecated compatibility alias still works.
+ // Exercise the legacy API to preserve its compatibility coverage.
// ignore: deprecated_member_use_from_same_package
ModuleTree.instance.hierarchyJSON,
equals(ModuleTree.instance.hierarchyJson),
);
+ expect(
+ // Exercise the legacy API to preserve its compatibility coverage.
+ // ignore: deprecated_member_use_from_same_package
+ ModuleServices.instance.hierarchyJSON,
+ equals(ModuleServices.instance.hierarchyJson),
+ );
});
test('register and lookup round-trips a service', () async {
@@ -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);
diff --git a/test/waveform_service_test.dart b/test/waveform_service_test.dart
index b0ce33c3ce..9ed1eadd0b 100644
--- a/test/waveform_service_test.dart
+++ b/test/waveform_service_test.dart
@@ -1,52 +1,94 @@
-// 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 {
+class _WindowWaveModule extends Module {
late final Logic a;
- WindowWaveModule() {
+ _WindowWaveModule() {
a = addOutput('a');
}
}
-class HistoryWaveformService extends WaveformService {
+class _FanoutWaveModule extends Module {
+ _FanoutWaveModule(Logic a) {
+ a = addInput('a', a, width: a.width);
+ for (var i = 0; i < 16; i++) {
+ addOutput('out$i', width: a.width) <= a;
+ }
+ }
+}
+
+class _LiveFstQueryWaveformService extends WaveformService {
+ Map>? liveValuesAtWindowEntry;
+
+ _LiveFstQueryWaveformService(super.module, {required String outputPath})
+ : super(
+ outputDirectory: Directory(outputPath).parent.path,
+ outputFileName: File(outputPath).uri.pathSegments.last,
+ format: WaveOutputFormat.fst,
+ startTime: 10,
+ register: false,
+ writeToFile: true,
+ );
+
+ @override
+ void onTimestampCapture(int timestamp, Set changed) {
+ if (timestamp != 10) {
+ return;
+ }
+ final query = createWaveformQuery();
+ liveValuesAtWindowEntry = {
+ for (final signal in query.signals)
+ signal.name: [
+ for (final change in query.changes(
+ signal,
+ startTime: 10,
+ endTime: 10,
+ ))
+ (change.time, change.value),
+ ],
+ };
+ }
+}
+
+class _HistoryWaveformService extends WaveformService {
final Map> history = {};
final List capturedTimestamps = [];
- HistoryWaveformService(super.module, {super.startTime});
+ _HistoryWaveformService(super.module, {super.startTime, super.register});
@override
void onSignalCollected(Logic signal) {
- history[signal] = [(Simulator.time, binaryValue(signal))];
+ history[signal] = [(Simulator.time, _binaryValue(signal))];
}
@override
void onValueChange(Logic signal, int timestamp) {
- history[signal]!.add((timestamp, binaryValue(signal)));
+ history[signal]!.add((timestamp, _binaryValue(signal)));
}
@override
@@ -55,22 +97,26 @@ class HistoryWaveformService extends WaveformService {
}
}
-String binaryValue(Logic signal) => signal.value.reversed
+String _binaryValue(Logic signal) => signal.value.reversed
.toList()
.map((value) => value.toString(includeWidth: false))
.join();
-class SimpleModWithSeq extends Module {
- Logic get val => output('val');
- SimpleModWithSeq(Logic asyncReset, Logic clk) {
- clk = addInput('clk', clk);
- asyncReset = addInput('asyncReset', asyncReset);
- addOutput('val');
+const _tempDumpDir = 'tmp_test';
- val <= flop(clk, Const(1), reset: asyncReset, asyncReset: true);
- }
+String _temporaryVcdPath(String name) => '$_tempDumpDir/temp_wave_$name.vcd';
+
+String _temporaryFstPath(String name) => '$_tempDumpDir/temp_wave_$name.fst';
+
+int _readFstTimescaleExponent(Uint8List data) {
+ const timescaleExponentOffset = 73;
+ final unsignedExponent = data[timescaleExponentOffset];
+ return unsignedExponent < 0x80 ? unsignedExponent : unsignedExponent - 0x100;
}
+// ─── 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.
@@ -87,19 +133,7 @@ void createTemporaryDump(Module module, String name) {
);
}
-// 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,241 +142,54 @@ void deleteTemporaryDump(String name) {
void main() {
tearDown(() async {
await Simulator.reset();
+ ModuleServices.instance.reset();
});
- test('attach dumper after put', () async {
- final a = Logic(name: 'a');
- final mod = SimpleModule(a);
+ test('file-backed debugging capture can retain history', () async {
+ final mod = _SimpleWaveModule(Logic());
await mod.build();
- const dumpName = 'dumpAfterPut';
-
- a.put(1);
- createTemporaryDump(mod, dumpName);
-
- 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('attach deprecated wave dumper after put', () async {
- final a = Logic(name: 'a');
- final mod = SimpleModule(a);
- await mod.build();
-
- const dumpName = 'deprecatedDumpAfterPut';
-
- a.put(1);
- createTemporaryWaveDumperDump(mod, dumpName);
-
- 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());
-
- 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,
- );
-
- 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(
+ final service = WaveformService(
mod,
outputDirectory: tempDumpDir,
- outputBaseName: 'retained_file_capture',
+ outputBaseName: 'temp_dump_debugDump',
writeToFile: true,
retainInMemory: true,
);
- expect(waveformService.canSendWaveforms(), isTrue);
+ expect(service.retainInMemory, isTrue);
+ expect(service.canSendWaveforms(), isTrue);
+ expect(service.inMemoryOutput, isNotNull);
await Simulator.run();
- File(waveformService.outputFilePath).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);
+ deleteTemporaryDump('debugDump');
});
- test('recording window snapshots stable signal values at its start',
- () async {
+ test('registers with ModuleServices by default', () 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,
- );
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final dumpPath = _temporaryVcdPath('serviceRegistration');
- Simulator.registerAction(5, () => a.put(1));
- Simulator.registerAction(15, () {});
- Simulator.registerAction(20, () {});
- await Simulator.run();
+ WaveformService.fromOutputPath(mod, outputPath: dumpPath);
- 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',
- );
+ final service = ModuleServices.instance.lookup();
+ expect(service, isNotNull);
+ final waveformJson = jsonEncode(service!.toJson());
+ expect(waveformJson, contains('"format":"vcd"'));
- deleteTemporaryDump(dumpName);
+ File(dumpPath).deleteSync();
});
test('window-entry snapshot reaches waveform hooks', () async {
- final mod = WindowWaveModule();
+ final mod = _WindowWaveModule();
await mod.build();
mod.a.put(0);
- final service = HistoryWaveformService(mod, startTime: 10);
+
+ final service =
+ _HistoryWaveformService(mod, startTime: 10, register: false);
Simulator.registerAction(5, () => mod.a.put(1));
Simulator.registerAction(15, () {});
@@ -356,12 +203,7 @@ void main() {
.toList(),
);
expect(
- VcdParser.confirmValue(
- vcdContents,
- 'a',
- 10,
- LogicValue.one,
- ),
+ VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.one),
isTrue,
);
expect(service.history[mod.a], equals([(0, '0'), (10, '1')]));
@@ -369,10 +211,11 @@ void main() {
});
test('window entry and boundary change are separate hook batches', () async {
- final mod = WindowWaveModule();
+ final mod = _WindowWaveModule();
await mod.build();
mod.a.put(0);
- final service = HistoryWaveformService(mod, startTime: 10);
+ final service =
+ _HistoryWaveformService(mod, startTime: 10, register: false);
Simulator.registerAction(5, () => mod.a.put(1));
Simulator.registerAction(10, () => mod.a.put(0));
@@ -385,226 +228,679 @@ void main() {
expect(service.capturedTimestamps, equals([10, 10]));
});
- test('rejects formats without a matching waveform writer', () async {
- final mod = SimpleModule(Logic());
+ test('window-entry FST values agree live, finalized, and in fst2vcd',
+ () async {
+ final a = Logic(name: 'a', width: 8);
+ final mod = _FanoutWaveModule(a);
await mod.build();
+ a.put(0);
+
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final fstPath = _temporaryFstPath('windowEntrySameTimestamp');
+ final fstFile = File(fstPath);
+ addTearDown(() {
+ if (fstFile.existsSync()) {
+ fstFile.deleteSync();
+ }
+ });
+ final service = _LiveFstQueryWaveformService(mod, outputPath: fstPath);
+ Simulator.registerAction(5, () => a.put(1));
+ Simulator.registerAction(10, () => a.put(0));
+ Simulator.registerAction(20, () => a.put(1));
+ await Simulator.run();
+
+ final query = service.createWaveformQuery();
+ final finalizedValues = {
+ for (final signal in query.signals)
+ signal.name: [
+ for (final change in query.changes(
+ signal,
+ startTime: 10,
+ endTime: 10,
+ ))
+ (change.time, change.value),
+ ],
+ };
+ expect(service.liveValuesAtWindowEntry, isNotNull);
+ expect(finalizedValues, equals(service.liveValuesAtWindowEntry));
expect(
- () => WaveformService(mod, format: WaveOutputFormat.fst),
- throwsUnsupportedError,
+ finalizedValues.values,
+ everyElement(equals([(10, '00000000')])),
);
+
+ if (Process.runSync('which', ['fst2vcd']).exitCode == 0) {
+ 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;
+ for (final name in finalizedValues.keys) {
+ expect(
+ VcdParser.confirmValue(
+ vcdContents,
+ name,
+ 10,
+ LogicValue.ofString('00000000'),
+ ),
+ isTrue,
+ reason: '$name should be zero at the window-entry timestamp',
+ );
+ }
+ }
});
- test('attach dumper before 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 = 'dumpBeforePut';
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final dumpPath = _temporaryVcdPath('serviceCapture');
- createTemporaryDump(mod, dumpName);
- a.inject(1);
+ final service = WaveformService.fromOutputPath(
+ mod,
+ outputPath: dumpPath,
+ register: false,
+ );
+ expect(service.outputFilePath, dumpPath);
+ 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 vcdContents = File(dumpPath).readAsStringSync();
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);
+ File(dumpPath).deleteSync();
});
- test('multiple injects in the same timestamp', () async {
- final clk = SimpleClockGenerator(10).clk;
+ test('captures waveform to FST format', () async {
final a = Logic(name: 'a');
- final mod = SimpleModule(a);
- a <= clk;
-
+ final mod = _SimpleWaveModule(a);
await mod.build();
- const dumpName = 'multiInject';
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final dumpPath = _temporaryFstPath('fstCapture');
- createTemporaryDump(mod, dumpName);
+ WaveformService.fromOutputPath(mod,
+ outputPath: dumpPath, format: WaveOutputFormat.fst, register: false);
- Simulator.setMaxSimTime(100);
- unawaited(Simulator.run());
+ a.inject(1);
+ Simulator.registerAction(10, () => a.put(0));
+ await Simulator.run();
- await clk.nextPosedge;
- await clk.nextPosedge;
- await clk.nextPosedge;
+ final fstFile = File(dumpPath);
+ expect(fstFile.existsSync(), isTrue);
+ expect(fstFile.lengthSync(), greaterThan(100));
- // inject a 0 on a when it should be 1 already from the clock
- a.inject(0);
+ fstFile.deleteSync();
+ });
- await Simulator.simulationEnded;
+ test('FST writer tuning uses the backend-specific constructor', () async {
+ final mod = _SimpleWaveModule(Logic(name: 'a'));
+ await mod.build();
- final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync();
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final fstFile = File(_temporaryFstPath('fstWriterConfig'));
+ addTearDown(() {
+ if (fstFile.existsSync()) {
+ fstFile.deleteSync();
+ }
+ });
+ final service = WaveformService.fst(
+ mod,
+ outputDirectory: _tempDumpDir,
+ outputBaseName: 'temp_wave_fstWriterConfig',
+ register: false,
+ config: const FstWriterConfig(maxChangesPerBlock: 2),
+ );
- 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));
+ await Simulator.run();
- deleteTemporaryDump(dumpName);
+ final writerMetadata = service.toJson()['writer']! as Map;
+ expect(writerMetadata['maxChangesPerBlock'], equals(2));
});
- test('multi-bit value', () async {
- final a = Logic(name: 'a', width: 8);
- final mod = SimpleModule(a);
+ test('VCD query capability reports unsupported explicitly', () async {
+ final mod = _SimpleWaveModule(Logic(name: 'a'));
await mod.build();
- const dumpName = 'multiBit';
+ final service = WaveformService(mod, register: false);
- createTemporaryDump(mod, dumpName);
- a.inject(0x5a);
+ expect(service.createWaveformQuery, throwsUnsupportedError);
+ });
- Simulator.registerAction(10, () => a.put(0xa5));
- await Simulator.run();
+ test('rejects unsupported waveform timescale exponents', () {
+ expect(
+ () => WaveformTimeScale.fromExponent(-16),
+ throwsArgumentError,
+ );
+ expect(
+ () => WaveformTimeScale.fromExponent(3),
+ throwsArgumentError,
+ );
+ });
- final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync();
+ for (final format in WaveOutputFormat.values) {
+ test(
+ '${format.name} preserves existing output with failIfExists and '
+ 'overwrites by default', () async {
+ final a = Logic(name: 'a');
+ final mod = _SimpleWaveModule(a);
+ await mod.build();
+
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final outputFile = File(
+ '$_tempDumpDir/temp_wave_overwritePolicy.${format.fileExtension}',
+ );
+ addTearDown(() {
+ if (outputFile.existsSync()) {
+ outputFile.deleteSync();
+ }
+ });
+
+ final originalContents = utf8.encode('existing waveform contents');
+ outputFile.writeAsBytesSync(originalContents);
+
+ expect(
+ () => WaveformService.fromOutputPath(
+ mod,
+ outputPath: outputFile.path,
+ format: format,
+ overwritePolicy: OverwritePolicy.failIfExists,
+ register: false,
+ ),
+ throwsA(isA()),
+ );
+ expect(outputFile.readAsBytesSync(), equals(originalContents));
+
+ a.put(0);
+ WaveformService.fromOutputPath(
+ mod,
+ outputPath: outputFile.path,
+ format: format,
+ register: false,
+ );
+ await Simulator.run();
+
+ expect(outputFile.readAsBytesSync(), isNot(equals(originalContents)));
+ expect(outputFile.lengthSync(), greaterThan(originalContents.length));
+ });
+ }
+ test('writes a shared non-default timescale to VCD and FST', () async {
+ const timescale = WaveformTimeScale.oneNanosecond;
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final vcdFile = File(_temporaryVcdPath('timescale'));
+ final fstFile = File(_temporaryFstPath('timescale'));
+ addTearDown(() {
+ for (final outputFile in [vcdFile, fstFile]) {
+ if (outputFile.existsSync()) {
+ outputFile.deleteSync();
+ }
+ }
+ });
+
+ final vcdService = await _captureWaveformWithTimescale(
+ vcdFile.path,
+ WaveOutputFormat.vcd,
+ timescale,
+ );
+ final vcdMetadata = vcdService.toJson();
+ final vcdWriterMetadata = vcdMetadata['writer']! as Map;
+ final vcdContents = vcdFile.readAsStringSync();
+ expect(vcdService.timescale, same(timescale));
+ expect(vcdMetadata['timescale'], equals(timescale.vcdValue));
+ expect(vcdWriterMetadata['timescale'], equals(timescale.vcdValue));
+ expect(vcdContents, contains(r'$timescale 1ns $end'));
expect(
- VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofInt(0x5a, 8)),
- equals(true));
- expect(
- VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofInt(0xa5, 8)),
- equals(true));
+ VcdParser.confirmValue(vcdContents, 'a', 100, LogicValue.one),
+ isTrue,
+ );
+
+ await Simulator.reset();
+ ModuleServices.instance.reset();
- deleteTemporaryDump(dumpName);
+ final fstService = await _captureWaveformWithTimescale(
+ fstFile.path,
+ WaveOutputFormat.fst,
+ timescale,
+ );
+ final fstMetadata = fstService.toJson();
+ final fstWriterMetadata = fstMetadata['writer']! as Map;
+ final fstEvents = _readFstEvents(
+ fstFile.path,
+ signalNames: const ['a', 'b'],
+ signalWidths: const [1, 1],
+ );
+ expect(fstService.timescale, same(timescale));
+ expect(fstMetadata['timescale'], equals(timescale.vcdValue));
+ expect(fstWriterMetadata['timescale'], equals(timescale.vcdValue));
+ expect(
+ fstWriterMetadata['maxChangesPerBlock'],
+ equals(FstWriterConfig.defaultMaxChangesPerBlock),
+ );
+ expect(
+ _readFstTimescaleExponent(fstFile.readAsBytesSync()),
+ equals(timescale.exponent),
+ );
+ expect(fstEvents['a']![100], equals('1'));
});
- test('multi-bit value mixed invalid', () async {
- final a = Logic(name: 'a', width: 8);
- final mod = SimpleModule(a);
+ 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);
- const dumpName = 'multiBitInvalid';
+ Directory(_tempDumpDir).createSync(recursive: true);
+ final fstPath = _temporaryFstPath('convertedValues');
+ WaveformService.fromOutputPath(mod,
+ outputPath: fstPath,
+ format: WaveOutputFormat.fst,
+ startTime: 10,
+ register: false);
- createTemporaryDump(mod, dumpName);
- a.inject(LogicValue.ofString('01xzzx10'));
-
- Simulator.registerAction(10, () => a.put(LogicValue.ofString('0x0x1z1z')));
+ Simulator.registerAction(5, () => a.put(1));
+ Simulator.registerAction(15, () => a.put(0));
+ Simulator.registerAction(20, () {});
await Simulator.run();
- final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync();
-
+ 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', 0, LogicValue.ofString('01xzzx10')),
- equals(true));
+ VcdParser.confirmValue(vcdContents, 'a', 15, LogicValue.zero), isTrue,
+ reason: 'the later input transition must retain its timestamp');
expect(
- VcdParser.confirmValue(
- vcdContents, 'a', 10, LogicValue.ofString('0x0x1z1z')),
- equals(true));
+ 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');
+
+ await _dumpParityWaveform(vcdPath, WaveOutputFormat.vcd);
+ final vcdEvents = _readVcdEvents(vcdPath, const {'a', 'b'});
+
+ await Simulator.reset();
+ ModuleServices.instance.reset();
+
+ await _dumpParityWaveform(fstPath, WaveOutputFormat.fst);
+ final fstEvents = _readFstEvents(fstPath,
+ signalNames: const ['a', 'b'], signalWidths: const [4, 4]);
+
+ expect(fstEvents, equals(vcdEvents));
- deleteTemporaryDump(dumpName);
+ File(vcdPath).deleteSync();
+ File(fstPath).deleteSync();
});
+}
- test('dump after max sim time works', () async {
- final a = SimpleClockGenerator(10).clk;
- final mod = SimpleModule(a);
- await mod.build();
+Future _captureWaveformWithTimescale(
+ String outputPath,
+ WaveOutputFormat format,
+ WaveformTimeScale timescale,
+) async {
+ final a = Logic(name: 'a');
+ final mod = _SimpleWaveModule(a);
+ await mod.build();
+ a.put(0);
+
+ final service = WaveformService.fromOutputPath(
+ mod,
+ outputPath: outputPath,
+ format: format,
+ timescale: timescale,
+ register: false,
+ );
+ Simulator.registerAction(100, () => a.put(1));
+ await Simulator.run();
+ return service;
+}
- const dumpName = 'maxSimTime';
+Future _dumpParityWaveform(
+ String outputPath, WaveOutputFormat format) async {
+ Directory(_tempDumpDir).createSync(recursive: true);
- createTemporaryDump(mod, dumpName);
+ final a = Logic(name: 'a', width: 4);
+ final mod = _SimpleWaveModule(a);
+ await mod.build();
- Simulator.setMaxSimTime(100);
+ a.put(0x1);
+ WaveformService.fromOutputPath(mod,
+ outputPath: outputPath, format: format, register: false);
- await Simulator.run();
+ Simulator.registerAction(10, () => a.put(0x2));
+ Simulator.registerAction(20, () => a.put(0xf));
+ await Simulator.run();
+}
- final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync();
+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;
+ }
- expect(
- VcdParser.confirmValue(vcdContents, 'a', 99, LogicValue.one),
- equals(true),
- );
+ 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;
+ }
- deleteTemporaryDump(dumpName);
- });
+ final parsed = _parseVcdValueUpdate(line, markerToWidth);
+ if (parsed == null) {
+ continue;
+ }
- test('create non-existent output directories', () async {
- final mod = SimpleModule(Logic());
- await mod.build();
+ final signalName = markerToSignal[parsed.marker];
+ if (signalName != null) {
+ events[signalName]![currentTime] = parsed.value;
+ }
+ }
- const dir1Path = '$tempDumpDir/dir1';
+ return events;
+}
- final waveformService = WaveformService(
- mod,
- outputDirectory: '$dir1Path/dir2',
- outputBaseName: 'waves',
- writeToFile: true,
- );
+({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));
+ }
+
+ for (final marker in markerToWidth.keys) {
+ if (line.endsWith(marker)) {
+ return (marker: marker, value: line[0]);
+ }
+ }
+ return null;
+}
- expect(File(waveformService.outputFilePath).existsSync(), equals(true));
+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);
+ }
- // Let the service close its asynchronous file sink before cleanup.
- await Simulator.run();
+ blockOffset = blockEnd;
+ }
- if (File(waveformService.outputFilePath).existsSync()) {
- File(dir1Path).deleteSync(recursive: true);
+ return 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;
+
+ 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;
+
+ 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;
+ }
+
+ 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);
+
+ final chainLength = _readU64(data, timeDataStart - 8);
+ final chainStart = timeDataStart - 8 - chainLength;
+ final signalOffsets = _decodeFstOffsetChain(
+ data.sublist(chainStart, timeDataStart - 8), valueMaxHandle.value);
+
+ for (var signalIndex = 0; signalIndex < signalNames.length; signalIndex++) {
+ final signalOffset = signalOffsets[signalIndex];
+ if (signalOffset == null) {
+ continue;
}
- });
- test('async reset shown in waves correctly', () async {
- final reset = Logic();
- final clk = SimpleClockGenerator(10).clk;
- final mod = SimpleModWithSeq(reset, clk);
+ 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);
+ }
+}
- await mod.build();
+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;
+}
+
+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++;
+ }
+ }
- const dumpName = 'asyncReset';
+ return offsets;
+}
+
+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');
+
+ 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;
}
diff --git a/tool/gh_actions/install_gtkwave.sh b/tool/gh_actions/install_gtkwave.sh
new file mode 100644
index 0000000000..e77ce91942
--- /dev/null
+++ b/tool/gh_actions/install_gtkwave.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+
+# Copyright (C) 2026 Intel Corporation
+# SPDX-License-Identifier: BSD-3-Clause
+#
+# install_gtkwave.sh
+# GitHub Actions step: Install GTKWave and its fst2vcd converter.
+#
+# 2026 October 12
+# Author: ROHD Contributors
+
+set -euo pipefail
+
+sudo -n apt-get update
+sudo -n apt-get install --yes gtkwave
+command -v fst2vcd
diff --git a/tool/gh_actions/pana_source.sh b/tool/gh_actions/pana_source.sh
index ddec29cb1f..55754e818c 100755
--- a/tool/gh_actions/pana_source.sh
+++ b/tool/gh_actions/pana_source.sh
@@ -33,11 +33,18 @@ sdk="$2"
analyze_arguments=(analyze --fatal-infos)
pana_arguments=()
pana_score_threshold=0
-if [[ "$package" == rohd_source_navigator ]]; then
- # TODO(desmonddak): https://github.com/intel/rohd/pull/718 Remove this
- # exception after the package move is on main and Pana can verify its URL.
- pana_score_threshold=10
-fi
+case "$package" in
+ rohd_source_navigator)
+ # TODO(desmonddak): https://github.com/intel/rohd/pull/718 Remove this
+ # exception after the package move is on main and Pana can verify its URL.
+ pana_score_threshold=10
+ ;;
+ rohd_devtools_widgets|rohd_waveform)
+ # These packages intentionally retain dependency bounds for their
+ # supported SDK ranges. Allow the corresponding 10-point Pana deduction.
+ pana_score_threshold=10
+ ;;
+esac
if [[ "$sdk" == flutter ]]; then
flutter_root="${FLUTTER_ROOT:-}"
if [[ -z "$flutter_root" ]]; then
diff --git a/tool/test/pana_source_test.sh b/tool/test/pana_source_test.sh
index dca4a09f9e..d103e0c3cf 100644
--- a/tool/test/pana_source_test.sh
+++ b/tool/test/pana_source_test.sh
@@ -13,6 +13,8 @@ readonly FIXTURE="$(mktemp -d)"
trap 'rm -rf "$FIXTURE"' EXIT
export SOURCE="$FIXTURE/source package" SDK_LOG="$FIXTURE/sdk.log"
export NAVIGATOR="$FIXTURE/rohd_source_navigator"
+export WIDGETS="$FIXTURE/rohd_devtools_widgets"
+export WAVEFORM="$FIXTURE/rohd_waveform"
export PUB_CACHE="$FIXTURE/pub cache" TMPDIR="$FIXTURE/temp space"
export FLUTTER_ROOT="$FIXTURE/flutter sdk" PANA_ANALYSIS_INCLUDES=1
@@ -30,6 +32,10 @@ done
cp -R "$SOURCE" "$FIXTURE/original"
cp -R "$SOURCE" "$NAVIGATOR"
cp -R "$NAVIGATOR" "$FIXTURE/original-navigator"
+cp -R "$SOURCE" "$WIDGETS"
+cp -R "$WIDGETS" "$FIXTURE/original-widgets"
+cp -R "$SOURCE" "$WAVEFORM"
+cp -R "$WAVEFORM" "$FIXTURE/original-waveform"
cat > "$FIXTURE/bin/dart" <<'EOF'
#!/bin/bash
@@ -97,6 +103,8 @@ run_case() {
fi
diff -ru "$FIXTURE/original" "$SOURCE"
diff -ru "$FIXTURE/original-navigator" "$NAVIGATOR"
+ diff -ru "$FIXTURE/original-widgets" "$WIDGETS"
+ diff -ru "$FIXTURE/original-waveform" "$WAVEFORM"
[[ -z "$(find "$TMPDIR" -mindepth 1 -print -quit)" ]]
passed=$((passed + 1))
}
@@ -129,6 +137,14 @@ export EXPECTED_SDK=dart EXPECTED_THRESHOLD=10
run_case 0 "$NAVIGATOR" dart
[[ "$(cat "$SDK_LOG")" == $'dart|pub get\ndart|analyze --fatal-infos lib\ndart|pub downgrade\ndart|analyze --fatal-infos lib\npana' ]]
+export EXPECTED_SDK=flutter EXPECTED_THRESHOLD=10
+run_case 0 "$WIDGETS" flutter
+[[ "$(cat "$SDK_LOG")" == $'flutter|pub get\nflutter|analyze --fatal-infos --no-pub lib\nflutter|pub downgrade\nflutter|analyze --fatal-infos --no-pub lib\npana' ]]
+
+export EXPECTED_SDK=dart EXPECTED_THRESHOLD=10
+run_case 0 "$WAVEFORM" dart
+[[ "$(cat "$SDK_LOG")" == $'dart|pub get\ndart|analyze --fatal-infos lib\ndart|pub downgrade\ndart|analyze --fatal-infos lib\npana' ]]
+
mv "$PUB_CACHE/bin/pana" "$FIXTURE/pana"
export EXPECTED_THRESHOLD=0
run_case 2 "$SOURCE" dart