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