From 82e900e48a66704cf22c385959e5ee1d6a56be7b Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Apr 2026 08:30:50 -0700 Subject: [PATCH 01/20] move synthesis naming to a common naming utility so all synthesizers agree on names --- lib/src/module.dart | 39 +- lib/src/synthesizers/synth_builder.dart | 5 +- lib/src/synthesizers/synthesizer.dart | 22 +- .../systemverilog_synthesizer.dart | 6 +- .../synthesizers/utilities/synth_logic.dart | 81 +-- .../utilities/synth_module_definition.dart | 60 +- .../synth_sub_module_instantiation.dart | 14 +- lib/src/utilities/signal_namer.dart | 271 ++++++++ test/naming_cases_test.dart | 583 ++++++++++++++++++ test/naming_consistency_test.dart | 247 ++++++++ 10 files changed, 1215 insertions(+), 113 deletions(-) create mode 100644 lib/src/utilities/signal_namer.dart create mode 100644 test/naming_cases_test.dart create mode 100644 test/naming_consistency_test.dart diff --git a/lib/src/module.dart b/lib/src/module.dart index 0fd51eac7..09e11fdc7 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // module.dart @@ -11,12 +11,12 @@ import 'dart:async'; import 'dart:collection'; import 'package:meta/meta.dart'; - import 'package:rohd/rohd.dart'; import 'package:rohd/src/collections/traverseable_collection.dart'; import 'package:rohd/src/diagnostics/inspector_service.dart'; import 'package:rohd/src/utilities/config.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; +import 'package:rohd/src/utilities/signal_namer.dart'; import 'package:rohd/src/utilities/timestamper.dart'; import 'package:rohd/src/utilities/uniquifier.dart'; @@ -52,6 +52,41 @@ abstract class Module { /// An internal mapping of input names to their sources to this [Module]. late final Map _inputSources = {}; + // ─── Canonical naming (SignalNamer) ───────────────────────────── + + /// Lazily-constructed namer that owns the [Uniquifier] and the + /// sparse Logic→String cache. Initialized on first access. + @internal + late final SignalNamer signalNamer = _createSignalNamer(); + + SignalNamer _createSignalNamer() { + assert(hasBuilt, 'Module must be built before canonical names are bound.'); + return SignalNamer.forModule( + inputs: _inputs, + outputs: _outputs, + inOuts: _inOuts, + ); + } + + /// Returns the collision-free signal name for [logic] within this module. + String signalName(Logic logic) => signalNamer.nameOf(logic); + + /// Allocates a collision-free signal name in this module's namespace. + /// + /// Used by synthesizers to name connection nets, submodule instances, + /// intermediate wires, and other artifacts that have no user-created + /// [Logic] object. The returned name is guaranteed not to collide with + /// any signal name or any previously allocated name. + /// + /// When [reserved] is `true`, the exact [baseName] (after sanitization) is + /// claimed without modification; an exception is thrown if it collides. + String allocateSignalName(String baseName, {bool reserved = false}) => + signalNamer.allocate(baseName, reserved: reserved); + + /// Returns `true` if [name] has not yet been claimed as a signal name in + /// this module's namespace. + bool isSignalNameAvailable(String name) => signalNamer.isAvailable(name); + /// An internal mapping of inOut names to their sources to this [Module]. late final Map _inOutSources = {}; diff --git a/lib/src/synthesizers/synth_builder.dart b/lib/src/synthesizers/synth_builder.dart index 54e312ab3..3b3a6011c 100644 --- a/lib/src/synthesizers/synth_builder.dart +++ b/lib/src/synthesizers/synth_builder.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // synth_builder.dart @@ -56,6 +56,9 @@ class SynthBuilder { } } + // Allow the synthesizer to prepare with knowledge of top module(s) + synthesizer.prepare(this.tops); + final modulesToParse = [...tops]; for (var i = 0; i < modulesToParse.length; i++) { final moduleI = modulesToParse[i]; diff --git a/lib/src/synthesizers/synthesizer.dart b/lib/src/synthesizers/synthesizer.dart index b70c9338e..2d7730208 100644 --- a/lib/src/synthesizers/synthesizer.dart +++ b/lib/src/synthesizers/synthesizer.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2023 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // synthesizer.dart @@ -6,18 +6,34 @@ // // 2021 August 26 // Author: Max Korbel -// import 'package:rohd/rohd.dart'; /// An object capable of converting a module into some new output format abstract class Synthesizer { + /// Called by [SynthBuilder] before synthesis begins, with the top-level + /// module(s) being synthesized. + /// + /// Override this method to perform any initialization that requires + /// knowledge of the top module, such as resolving port names to [Logic] + /// objects, or computing global signal sets. + /// + /// The default implementation does nothing. + void prepare(List tops) {} + /// Determines whether [module] needs a separate definition or can just be /// described in-line. bool generatesDefinition(Module module); /// Synthesizes [module] into a [SynthesisResult], given the mapping provided /// by [getInstanceTypeOfModule]. + /// + /// Optionally a [lookupExistingResult] callback may be supplied which + /// allows the synthesizer to query already-generated `SynthesisResult`s + /// for child modules (useful when building parent output that needs + /// information from children). SynthesisResult synthesize( - Module module, String Function(Module module) getInstanceTypeOfModule); + Module module, String Function(Module module) getInstanceTypeOfModule, + {SynthesisResult? Function(Module module)? lookupExistingResult, + Map? existingResults}); } diff --git a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart index d8b5bae36..b83acb9cc 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // systemverilog_synthesizer.dart @@ -137,7 +137,9 @@ class SystemVerilogSynthesizer extends Synthesizer { @override SynthesisResult synthesize( - Module module, String Function(Module module) getInstanceTypeOfModule) { + Module module, String Function(Module module) getInstanceTypeOfModule, + {SynthesisResult? Function(Module module)? lookupExistingResult, + Map? existingResults}) { assert( module is! SystemVerilog || module.generatedDefinitionType != DefinitionGenerationType.none, diff --git a/lib/src/synthesizers/utilities/synth_logic.dart b/lib/src/synthesizers/utilities/synth_logic.dart index 64ed3bed1..4a9c0e20a 100644 --- a/lib/src/synthesizers/utilities/synth_logic.dart +++ b/lib/src/synthesizers/utilities/synth_logic.dart @@ -12,7 +12,6 @@ import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; -import 'package:rohd/src/utilities/uniquifier.dart'; /// Represents a logic signal in the generated code within a module. @internal @@ -196,81 +195,25 @@ class SynthLogic { /// The name of this, if it has been picked. String? _name; - /// Picks a [name]. + /// Picks a [name] using the module's signal namer. /// /// Must be called exactly once. - void pickName(Uniquifier uniquifier) { + void pickName() { assert(_name == null, 'Should only pick a name once.'); - _name = _findName(uniquifier); + _name = _findName(); } /// Finds the best name from the collection of [Logic]s. - String _findName(Uniquifier uniquifier) { - // check for const - if (_constLogic != null) { - if (!_constNameDisallowed) { - return _constLogic!.value.toString(); - } else { - assert( - logics.length > 1, - 'If there is a constant, but the const name is not allowed, ' - 'there needs to be another option'); - } - } - - // check for reserved - if (_reservedLogic != null) { - return uniquifier.getUniqueName( - initialName: _reservedLogic!.name, reserved: true); - } - - // check for renameable - if (_renameableLogic != null) { - return uniquifier.getUniqueName( - initialName: _renameableLogic!.preferredSynthName); - } - - // pick a preferred, available, mergeable name, if one exists - final unpreferredMergeableLogics = []; - final uniquifiableMergeableLogics = []; - for (final mergeableLogic in _mergeableLogics) { - if (Naming.isUnpreferred(mergeableLogic.name)) { - unpreferredMergeableLogics.add(mergeableLogic); - } else if (!uniquifier.isAvailable(mergeableLogic.preferredSynthName)) { - uniquifiableMergeableLogics.add(mergeableLogic); - } else { - return uniquifier.getUniqueName( - initialName: mergeableLogic.preferredSynthName); - } - } - - // uniquify a preferred, mergeable name, if one exists - if (uniquifiableMergeableLogics.isNotEmpty) { - return uniquifier.getUniqueName( - initialName: uniquifiableMergeableLogics.first.preferredSynthName); - } - - // pick an available unpreferred mergeable name, if one exists, otherwise - // uniquify an unpreferred mergeable name - if (unpreferredMergeableLogics.isNotEmpty) { - return uniquifier.getUniqueName( - initialName: unpreferredMergeableLogics - .firstWhereOrNull((element) => - uniquifier.isAvailable(element.preferredSynthName)) - ?.preferredSynthName ?? - unpreferredMergeableLogics.first.preferredSynthName); - } - - // pick anything (unnamed) and uniquify as necessary (considering preferred) - // no need to prefer an available one here, since it's all unnamed - return uniquifier.getUniqueName( - initialName: _unnamedLogics - .firstWhereOrNull((element) => - !Naming.isUnpreferred(element.preferredSynthName)) - ?.preferredSynthName ?? - _unnamedLogics.first.preferredSynthName); - } + /// + /// Delegates to signal namer which handles constant value naming, priority + /// selection, and uniquification via the module's shared namespace. + String _findName() => + parentSynthModuleDefinition.module.signalNamer.nameOfBest( + logics, + constValue: _constLogic, + constNameDisallowed: _constNameDisallowed, + ); /// Creates an instance to represent [initialLogic] and any that merge /// into it. diff --git a/lib/src/synthesizers/utilities/synth_module_definition.dart b/lib/src/synthesizers/utilities/synth_module_definition.dart index b8b78476a..dac9075e8 100644 --- a/lib/src/synthesizers/utilities/synth_module_definition.dart +++ b/lib/src/synthesizers/utilities/synth_module_definition.dart @@ -14,7 +14,6 @@ import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/collections/traverseable_collection.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; -import 'package:rohd/src/utilities/uniquifier.dart'; /// A version of [BusSubset] that can be used for slicing on [LogicStructure] /// ports. @@ -110,10 +109,6 @@ class SynthModuleDefinition { @override String toString() => "module name: '${module.name}'"; - /// Used to uniquify any identifiers, including signal names - /// and module instances. - final Uniquifier _synthInstantiationNameUniquifier; - /// Indicates whether [logic] has a corresponding present [SynthLogic] in /// this definition. @internal @@ -289,14 +284,7 @@ class SynthModuleDefinition { /// Creates a new definition representation for this [module]. SynthModuleDefinition(this.module) - : _synthInstantiationNameUniquifier = Uniquifier( - reservedNames: { - ...module.inputs.keys, - ...module.outputs.keys, - ...module.inOuts.keys, - }, - ), - assert( + : assert( !(module is SystemVerilog && module.generatedDefinitionType == DefinitionGenerationType.none), @@ -465,6 +453,7 @@ class SynthModuleDefinition { final receiverIsSubModuleOutput = receiver.isOutput && (receiver.parentModule?.parent == module); + if (receiverIsSubModuleOutput) { final subModule = receiver.parentModule!; @@ -513,6 +502,7 @@ class SynthModuleDefinition { _collapseArrays(); _collapseAssignments(); _assignSubmodulePortMapping(); + _pruneUnused(); process(); _pickNames(); @@ -752,49 +742,59 @@ class SynthModuleDefinition { } /// Picks names of signals and sub-modules. + /// + /// Signal names are read from [Module.signalName] (for user-created + /// [Logic] objects) or kept as literal constants. Submodule instance + /// names and synthesizer artifacts are allocated from the shared + /// [Module] namespace via [Module.allocateSignalName], guaranteeing no + /// collisions across synthesizers. void _pickNames() { - // first ports get priority + // Name allocation order matters — earlier claims get the unsuffixed name + // when there are collisions. This matches production ROHD priority: + // 1. Ports (reserved by _initNamespace, claimed via signalName) + // 2. Reserved submodule instances + // 3. Reserved internal signals + // 4. Non-reserved submodule instances + // 5. Non-reserved internal signals for (final input in inputs) { - input.pickName(_synthInstantiationNameUniquifier); + input.pickName(); } for (final output in outputs) { - output.pickName(_synthInstantiationNameUniquifier); + output.pickName(); } for (final inOut in inOuts) { - inOut.pickName(_synthInstantiationNameUniquifier); + inOut.pickName(); } - // pick names of *reserved* submodule instances - final nonReservedSubmodules = []; + // Reserved submodule instances first (they assert their exact name). for (final submodule in subModuleInstantiations) { if (submodule.module.reserveName) { - submodule.pickName(_synthInstantiationNameUniquifier); + submodule.pickName(module); assert(submodule.module.name == submodule.name, 'Expect reserved names to retain their name.'); - } else { - nonReservedSubmodules.add(submodule); } } - // then *reserved* internal signals get priority + // Reserved internal signals next. final nonReservedSignals = []; for (final signal in internalSignals) { if (signal.isReserved) { - signal.pickName(_synthInstantiationNameUniquifier); + signal.pickName(); } else { nonReservedSignals.add(signal); } } - // then submodule instances - for (final submodule in nonReservedSubmodules - .where((element) => element.needsInstantiation)) { - submodule.pickName(_synthInstantiationNameUniquifier); + // Then non-reserved submodule instances. + for (final submodule in subModuleInstantiations) { + if (!submodule.module.reserveName && submodule.needsInstantiation) { + submodule.pickName(module); + } } - // then the rest of the internal signals + // Then the rest of the internal signals. for (final signal in nonReservedSignals) { - signal.pickName(_synthInstantiationNameUniquifier); + signal.pickName(); } } diff --git a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart index 80a415a09..4f1c3e4f2 100644 --- a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart +++ b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // synth_sub_module_instantiation.dart @@ -11,7 +11,6 @@ import 'dart:collection'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; -import 'package:rohd/src/utilities/uniquifier.dart'; /// Represents an instantiation of a module within another module. class SynthSubModuleInstantiation { @@ -25,13 +24,16 @@ class SynthSubModuleInstantiation { String get name => _name!; /// Selects a name for this module instance. Must be called exactly once. - void pickName(Uniquifier uniquifier) { + /// + /// Names are allocated from [parentModule]'s shared namespace via + /// [Module.allocateSignalName], ensuring no collision with signal names or + /// other submodule instances — even across multiple synthesizers. + void pickName(Module parentModule) { assert(_name == null, 'Should only pick a name once.'); - _name = uniquifier.getUniqueName( - initialName: module.uniqueInstanceName, + _name = parentModule.allocateSignalName( + module.uniqueInstanceName, reserved: module.reserveName, - nullStarter: 'm', ); } diff --git a/lib/src/utilities/signal_namer.dart b/lib/src/utilities/signal_namer.dart new file mode 100644 index 000000000..b7d9dc090 --- /dev/null +++ b/lib/src/utilities/signal_namer.dart @@ -0,0 +1,271 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_namer.dart +// Collision-free signal naming within a module scope. +// +// 2026 April 10 +// Author: Desmond Kirkpatrick + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/sanitizer.dart'; +import 'package:rohd/src/utilities/uniquifier.dart'; + +/// Assigns collision-free names to [Logic] signals within a single module. +/// +/// Wraps a [Uniquifier] with a sparse Logic→String cache so that each +/// signal is named exactly once and every subsequent lookup is O(1). +/// +/// Port names are reserved at construction time. Internal signals are +/// named lazily on the first [nameOf] call. +@internal +class SignalNamer { + final Uniquifier _uniquifier; + + /// Sparse cache: only entries where the canonical name has been resolved. + /// Ports whose sanitized name == logic.name may be absent (fast-path + /// through [_portLogics] check). + final Map _names = {}; + + /// The set of port [Logic] objects, for O(1) port membership tests. + final Set _portLogics; + + SignalNamer._({ + required Uniquifier uniquifier, + required Map portRenames, + required Set portLogics, + }) : _uniquifier = uniquifier, + _portLogics = portLogics { + _names.addAll(portRenames); + } + + /// Creates a [SignalNamer] for the given module ports. + /// + /// Sanitized port names are reserved in the namespace. Ports whose + /// sanitized name differs from [Logic.name] are cached immediately. + factory SignalNamer.forModule({ + required Map inputs, + required Map outputs, + required Map inOuts, + }) { + final portRenames = {}; + final portLogics = {}; + final portNames = []; + + void collectPort(String rawName, Logic logic) { + final sanitized = Sanitizer.sanitizeSV(rawName); + portNames.add(sanitized); + portLogics.add(logic); + if (sanitized != logic.name) { + portRenames[logic] = sanitized; + } + } + + for (final entry in inputs.entries) { + collectPort(entry.key, entry.value); + } + for (final entry in outputs.entries) { + collectPort(entry.key, entry.value); + } + for (final entry in inOuts.entries) { + collectPort(entry.key, entry.value); + } + + // Claim each port name as reserved so that: + // (a) non-reserved signals can't steal them, and + // (b) a second reserved signal with the same name throws. + final uniquifier = Uniquifier(); + for (final name in portNames) { + uniquifier.getUniqueName(initialName: name, reserved: true); + } + + return SignalNamer._( + uniquifier: uniquifier, + portRenames: portRenames, + portLogics: portLogics, + ); + } + + /// Returns the canonical name for [logic]. + /// + /// The first call for a given [logic] allocates a collision-free name + /// via the underlying [Uniquifier]. Subsequent calls return the cached + /// result in O(1). + String nameOf(Logic logic) { + // Fast path: already named (port rename or previously-queried signal). + final cached = _names[logic]; + if (cached != null) { + return cached; + } + + // Port whose sanitized name == logic.name — already reserved. + if (_portLogics.contains(logic)) { + return logic.name; + } + + // First time seeing this internal signal — derive base name. + String baseName; + // Only treat as reserved for Uniquifier purposes if this is a true + // reserved internal signal (not a submodule port that happens to have + // Naming.reserved). + final isReservedInternal = logic.naming == Naming.reserved && !logic.isPort; + if (logic.naming == Naming.reserved || logic.isArrayMember) { + baseName = logic.name; + } else { + baseName = Sanitizer.sanitizeSV(logic.structureName); + } + + final name = _uniquifier.getUniqueName( + initialName: baseName, + reserved: isReservedInternal, + ); + _names[logic] = name; + return name; + } + + /// The base name that would be used for [logic] before uniquification. + static String baseName(Logic logic) => + (logic.naming == Naming.reserved || logic.isArrayMember) + ? logic.name + : Sanitizer.sanitizeSV(logic.structureName); + + /// Chooses the best name from a pool of merged [Logic] signals. + /// + /// When [constValue] is provided and [constNameDisallowed] is `false`, + /// the constant's value string is used directly as the name (no + /// uniquification). When [constNameDisallowed] is `true`, the constant + /// is excluded from the candidate pool and the normal priority applies. + /// + /// Priority (after constant handling): + /// 1. Port of this module (always wins — its name is already reserved). + /// 2. Reserved internal signal (exact name, throws on collision). + /// 3. Renameable signal. + /// 4. Preferred-available mergeable (base name not yet taken). + /// 5. Preferred-uniquifiable mergeable. + /// 6. Available-unpreferred mergeable. + /// 7. First unpreferred mergeable. + /// 8. Unnamed (prefer non-unpreferred base name). + /// + /// The winning name is allocated once and cached for the chosen [Logic]. + /// All other non-port [Logic]s in [candidates] are also cached to the + /// same name. + String nameOfBest( + Iterable candidates, { + Const? constValue, + bool constNameDisallowed = false, + }) { + // Constant whose literal value string is the name. + if (constValue != null && !constNameDisallowed) { + return constValue.value.toString(); + } + + // Classify using _portLogics membership (context-aware) rather than + // Logic.naming (context-independent), because submodule ports have + // Naming.reserved but should NOT be treated as reserved here. + Logic? port; + Logic? reserved; + Logic? renameable; + final preferredMergeable = []; + final unpreferredMergeable = []; + final unnamed = []; + + for (final logic in candidates) { + if (_portLogics.contains(logic)) { + port = logic; + } else if (logic.isPort) { + // Submodule port — treat as mergeable regardless of intrinsic naming, + // matching SynthModuleDefinition's namingOverride convention. + if (Naming.isUnpreferred(baseName(logic))) { + unpreferredMergeable.add(logic); + } else { + preferredMergeable.add(logic); + } + } else if (logic.naming == Naming.reserved) { + reserved = logic; + } else if (logic.naming == Naming.renameable) { + renameable = logic; + } else if (logic.naming == Naming.mergeable) { + if (Naming.isUnpreferred(baseName(logic))) { + unpreferredMergeable.add(logic); + } else { + preferredMergeable.add(logic); + } + } else { + unnamed.add(logic); + } + } + + // Port of this module — name already reserved in namespace. + if (port != null) { + return _nameAndCacheAll(port, candidates); + } + + // Reserved internal — must keep exact name (throws on collision). + if (reserved != null) { + return _nameAndCacheAll(reserved, candidates); + } + + // Renameable — preferred base, uniquified if needed. + if (renameable != null) { + return _nameAndCacheAll(renameable, candidates); + } + + // Preferred-available mergeable. + for (final logic in preferredMergeable) { + if (_uniquifier.isAvailable(baseName(logic))) { + return _nameAndCacheAll(logic, candidates); + } + } + + // Preferred-uniquifiable mergeable. + if (preferredMergeable.isNotEmpty) { + return _nameAndCacheAll(preferredMergeable.first, candidates); + } + + // Unpreferred mergeable — prefer available. + if (unpreferredMergeable.isNotEmpty) { + final best = unpreferredMergeable + .firstWhereOrNull((e) => _uniquifier.isAvailable(baseName(e))) ?? + unpreferredMergeable.first; + return _nameAndCacheAll(best, candidates); + } + + // Unnamed — prefer non-unpreferred base name. + if (unnamed.isNotEmpty) { + final best = + unnamed.firstWhereOrNull((e) => !Naming.isUnpreferred(baseName(e))) ?? + unnamed.first; + return _nameAndCacheAll(best, candidates); + } + + throw StateError('No Logic candidates to name.'); + } + + /// Names [chosen] via [nameOf], then caches the same name for all other + /// non-port [Logic]s in [all]. + String _nameAndCacheAll(Logic chosen, Iterable all) { + final name = nameOf(chosen); + for (final logic in all) { + if (!identical(logic, chosen) && !_portLogics.contains(logic)) { + _names[logic] = name; + } + } + return name; + } + + /// Allocates a collision-free name for a non-signal artifact (wire, + /// instance, etc.). + /// + /// When [reserved] is `true`, the exact [baseName] (after sanitization) + /// is claimed without modification; an exception is thrown if it collides. + String allocate(String baseName, {bool reserved = false}) => + _uniquifier.getUniqueName( + initialName: Sanitizer.sanitizeSV(baseName), + reserved: reserved, + ); + + /// Returns `true` if [name] has not yet been claimed in this namespace. + bool isAvailable(String name) => _uniquifier.isAvailable(name); +} diff --git a/test/naming_cases_test.dart b/test/naming_cases_test.dart new file mode 100644 index 000000000..fbc1d9536 --- /dev/null +++ b/test/naming_cases_test.dart @@ -0,0 +1,583 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// naming_cases_test.dart +// Systematic test of all signal-naming cases in the synthesis pipeline. +// +// 2026 April 10 +// Author: Desmond Kirkpatrick + +// ════════════════════════════════════════════════════════ +// NAMING CROSS-PRODUCT TABLE +// ════════════════════════════════════════════════════════ +// +// Axis 1 — Naming enum (set at Logic construction time): +// reserved Exact name required; collision → exception. +// renameable Keeps name, uniquified on collision; never merged. +// mergeable May merge with equivalent signals; any merged name chosen. +// unnamed No user name; system generates one. +// +// Axis 2 — Context role (per SynthModuleDefinition): +// this-port Port of module being synthesized +// (namingOverride → reserved). +// sub-port Port of a child submodule +// (namingOverride → mergeable). +// internal Non-port signal inside the module (no override). +// const Const object (separate path via constValue). +// +// Axis 3 — Name preference: +// preferred baseName does NOT start with '_' +// unpreferred baseName starts with '_' +// +// Axis 4 — Constant context (only for Const): +// allowed Literal value string used as name. +// disallowed Feeding expressionlessInput; +// must use a wire name. +// +// ────────────────────────────────────────────────────── +// Row Naming Context Pref? Test Valid? +// Effective class → Outcome +// ────────────────────────────────────────────────────── +// 1 reserved this-port pref T1 ✓ +// port (in _portLogics) → exact sanitized name +// 2 reserved this-port unpref T2 ✓ unusual +// port → exact _-prefixed port name +// 3 reserved sub-port pref T3 ✓ +// preferred mergeable → merged, uniquified +// 4 reserved sub-port unpref T4 ✓ +// unpreferred mergeable → low-priority merge +// 5 reserved internal pref T5 ✓ +// reserved internal → exact name, throw on clash +// 6 reserved internal unpref T6 ✓ unusual +// reserved internal → exact _-prefixed name +// 7 renameable this-port pref — can't happen* +// port → exact port name +// 8 renameable sub-port pref — can't happen* +// preferred mergeable → merged +// 9 renameable internal pref T9 ✓ +// renameable → base name, uniquified +// 10 renameable internal unpref T10 ✓ unusual +// renameable → uniquified _-prefixed +// 11 mergeable this-port pref T11 ✓ +// port → exact port name (Logic.port()) +// 12 mergeable this-port unpref T12 ✓ unusual +// port → exact _-prefixed port name +// 13 mergeable sub-port pref T3 ✓ (=row 3) +// preferred mergeable → best-available merge +// 14 mergeable sub-port unpref T4 ✓ (=row 4) +// unpreferred mergeable → low-priority merge +// 15 mergeable internal pref T15 ✓ +// preferred mergeable → prefer available name +// 16 mergeable internal unpref T16 ✓ +// unpreferred mergeable → low-priority merge +// 17 unnamed this-port — — ✗ impossible** +// port → exact port name +// 18 unnamed sub-port — — ✗ impossible** +// mergeable → merged +// 19 unnamed internal (unpf) T19 ✓ +// unnamed → generated _s name +// 20 —(Const) — — T20 ✓ +// const allowed → literal value e.g. 8'h42 +// 21 —(Const) — — T21 ✓ +// const disallowed → wire name (not literal) +// ────────────────────────────────────────────────────── +// +// * Rows 7-8: addInput/addOutput always create +// Logic with Naming.reserved, so a port can +// never have intrinsic Naming.renameable. +// The namingOverride makes it moot anyway. +// +// ** Rows 17-18: addInput/addOutput require a +// non-null, non-empty name. chooseName() only +// yields Naming.unnamed for null/empty names, +// so a port can never be unnamed. +// +// ✗ unnamed + reserved: Logic(naming: reserved) +// with null/empty name throws +// NullReservedNameException / +// EmptyReservedNameException at construction +// time. Never reaches synthesizer. +// +// Additional cross-cutting concerns: +// COL Collision between mergeables +// → uniquified suffix (_0) +// MG Merge: directly-connected signals +// share SynthLogic +// INST Submodule instance names: unique, +// don't collide with ports +// ST Structure element: structureName +// = "parent.field" → sanitized ("_") +// AR Array element: isArrayMember +// → uses logic.name (index-based) +// +// ════════════════════════════════════════════════════════ + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:test/test.dart'; + +// ── Leaf sub-modules ────────────────────────────── + +/// A leaf module whose `in0` is an "expressionless input" — +/// meaning any constant driving it must get a real wire name, not a literal. +class _ExpressionlessSub extends Module with SystemVerilog { + @override + List get expressionlessInputs => const ['in0']; + + _ExpressionlessSub(Logic a, Logic b) : super(name: 'exprsub') { + a = addInput('in0', a, width: a.width); + b = addInput('in1', b, width: b.width); + addOutput('out', width: a.width) <= a & b; + } +} + +/// A simple sub-module with preferred-name ports. +class _SimpleSub extends Module { + _SimpleSub(Logic x) : super(name: 'simplesub') { + x = addInput('x', x, width: x.width); + addOutput('y', width: x.width) <= ~x; + } +} + +/// A sub-module with an unpreferred-name port. +class _UnprefSub extends Module { + _UnprefSub(Logic a) : super(name: 'unprefsub') { + a = addInput('_uport', a, width: a.width); + addOutput('uout', width: a.width) <= ~a; + } +} + +// ── Main test module ────────────────────────────── +// One module that exercises every valid naming case in a minimal design. +// Each signal is tagged with the row number from the table above. + +class _AllNamingCases extends Module { + // Exposed for test inspection. + // Row 1 / Row 2: ports (accessed via mod.input / mod.output). + // Row 5: + late final Logic reservedInternal; + // Row 6: + late final Logic reservedInternalUnpref; + // Row 9: + late final Logic renameableInternal; + // Row 10: + late final Logic renameableInternalUnpref; + // Row 15: + late final Logic mergeablePref; + // Row 15 collision partner: + late final Logic mergeablePrefCollide; + // Row 16: + late final Logic mergeableUnpref; + // Row 19: + late final Logic unnamed; + // Row 20: + late final Logic constAllowed; + // Row 21: + late final Logic constDisallowed; + // MG: + late final Logic mergeTarget; + + // Structure/array elements (ST, AR): + late final LogicStructure structPort; + late final LogicArray arrayPort; + + _AllNamingCases() : super(name: 'allcases') { + // ── Row 1: reserved + this-port + preferred ────────────────── + final inp = addInput('inp', Logic(width: 8), width: 8); + final out = addOutput('out', width: 8); + + // ── Row 2: reserved + this-port + unpreferred ──────────────── + final uInp = addInput('_uinp', Logic(width: 8), width: 8); + + // ── Row 11: mergeable + this-port + preferred ──────────────── + // (This is the Logic.port() → connectIO path. addInput forces + // Naming.reserved regardless of the source's naming, so intrinsic + // mergeable is overridden to reserved. We test the port keeps its + // exact name.) + final mPortInp = addInput('mport', Logic(width: 8), width: 8); + + // ── Row 12: mergeable + this-port + unpreferred ────────────── + final mPortUnpref = addInput('_muprt', Logic(width: 8), width: 8); + + // ── Row 5: reserved + internal + preferred ─────────────────── + reservedInternal = Logic(name: 'resv', width: 8, naming: Naming.reserved) + ..gets(inp ^ Const(0x01, width: 8)); + + // ── Row 6: reserved + internal + unpreferred ───────────────── + reservedInternalUnpref = + Logic(name: '_resvu', width: 8, naming: Naming.reserved) + ..gets(inp ^ Const(0x02, width: 8)); + + // ── Row 9: renameable + internal + preferred ───────────────── + renameableInternal = Logic(name: 'ren', width: 8, naming: Naming.renameable) + ..gets(inp ^ Const(0x03, width: 8)); + + // ── Row 10: renameable + internal + unpreferred ────────────── + renameableInternalUnpref = + Logic(name: '_renu', width: 8, naming: Naming.renameable) + ..gets(inp ^ Const(0x04, width: 8)); + + // ── Row 15: mergeable + internal + preferred ───────────────── + mergeablePref = Logic(name: 'mname', width: 8, naming: Naming.mergeable) + ..gets(inp ^ Const(0x05, width: 8)); + + // ── COL: collision partner — same base name 'mname' ────────── + mergeablePrefCollide = + Logic(name: 'mname', width: 8, naming: Naming.mergeable) + ..gets(inp ^ Const(0x06, width: 8)); + + // ── Row 16: mergeable + internal + unpreferred ─────────────── + mergeableUnpref = Logic(name: '_hidden', width: 8, naming: Naming.mergeable) + ..gets(inp ^ Const(0x07, width: 8)); + + // ── Row 19: unnamed + internal ─────────────────────────────── + unnamed = Logic(width: 8)..gets(inp ^ Const(0x08, width: 8)); + + // ── Rows 3/13: sub-port preferred (via _SimpleSub.x / .y) ─── + // ── Row 4/14: sub-port unpreferred (via _UnprefSub._uport) ── + final sub = _SimpleSub(renameableInternal); + final subOut = sub.output('y'); + // Use a distinct expression so the submodule port doesn't merge with + // renameableInternal (which is renameable and would win). + final unpSub = _UnprefSub(inp ^ Const(0x0a, width: 8)); + + // ── MG: merge behavior — mergeTarget merges with subOut ────── + mergeTarget = Logic(name: 'mmerge', width: 8, naming: Naming.mergeable) + ..gets(subOut); + + // ── Row 20: constant with name allowed ─────────────────────── + constAllowed = + Const(0x42, width: 8).named('const_ok', naming: Naming.mergeable); + + // ── Row 21: constant with name disallowed (expressionlessInput) + constDisallowed = + Const(0x09, width: 8).named('const_wire', naming: Naming.mergeable); + // ignore: unused_local_variable + final exprSub = _ExpressionlessSub(constDisallowed, inp); + + // ── ST: structure element (structureName = "parent.field") ──── + structPort = _SimpleStruct(); + addInput('stIn', structPort, width: structPort.width); + + // ── AR: array element (isArrayMember, uses logic.name) ─────── + arrayPort = LogicArray([3], 8, name: 'arIn'); + addInputArray('arIn', arrayPort, dimensions: [3], elementWidth: 8); + + // Drive output to use all signals (prevents pruning). + out <= + mergeTarget | + mergeablePrefCollide | + mergeableUnpref | + unnamed | + constAllowed | + uInp | + mPortInp | + mPortUnpref | + reservedInternalUnpref | + renameableInternalUnpref | + unpSub.output('uout'); + } +} + +/// A minimal LogicStructure for testing structureName sanitization. +class _SimpleStruct extends LogicStructure { + final Logic field1; + final Logic field2; + + factory _SimpleStruct({String name = 'st'}) => _SimpleStruct._( + Logic(name: 'a', width: 4), + Logic(name: 'b', width: 4), + name: name, + ); + + _SimpleStruct._(this.field1, this.field2, {required super.name}) + : super([field1, field2]); + + @override + LogicStructure clone({String? name}) => + _SimpleStruct(name: name ?? this.name); +} + +// ── Helpers ─────────────────────────────────────── + +/// Collects a map from Logic → picked name for all SynthLogics. +Map _collectNames(SynthModuleDefinition def) { + final names = {}; + for (final sl in [ + ...def.inputs, + ...def.outputs, + ...def.inOuts, + ...def.internalSignals, + ]) { + try { + final n = sl.name; + for (final logic in sl.logics) { + names[logic] = n; + } + // ignore: avoid_catches_without_on_clauses + } catch (_) { + // name not picked (pruned/replaced) + } + } + return names; +} + +/// Finds a SynthLogic that contains [logic]. +SynthLogic? _findSynthLogic(SynthModuleDefinition def, Logic logic) { + for (final sl in [ + ...def.inputs, + ...def.outputs, + ...def.inOuts, + ...def.internalSignals, + ]) { + if (sl.logics.contains(logic)) { + return sl; + } + } + return null; +} + +// ── Tests ──────────────────────────────────────── + +void main() { + late _AllNamingCases mod; + late SynthModuleDefinition def; + late Map names; + + setUp(() async { + mod = _AllNamingCases(); + await mod.build(); + def = SynthModuleDefinition(mod); + names = _collectNames(def); + }); + + group('naming cases', () { + // ── Row 1: reserved + this-port + preferred ──────────────── + + test('T1: reserved preferred port keeps exact name', () { + expect(names[mod.input('inp')], 'inp'); + expect(names[mod.output('out')], 'out'); + }); + + // ── Row 2: reserved + this-port + unpreferred ────────────── + + test('T2: reserved unpreferred port keeps exact _-prefixed name', () { + expect(names[mod.input('_uinp')], '_uinp'); + }); + + // ── Rows 3/13: sub-port + preferred (reserved or mergeable) ─ + + test('T3: submodule preferred port gets a name in parent', () { + final subX = mod.subModules.whereType<_SimpleSub>().first.input('x'); + final n = names[subX]; + expect(n, isNotNull, reason: 'Submodule port must be named'); + // Treated as preferred mergeable — name should not start with _. + expect(n, isNot(startsWith('_')), + reason: 'Preferred submodule port name should not be unpreferred'); + }); + + // ── Row 4/14: sub-port + unpreferred ──────────────────────── + + test('T4: submodule unpreferred port gets an unpreferred name', () { + final subUPort = + mod.subModules.whereType<_UnprefSub>().first.input('_uport'); + final n = names[subUPort]; + expect(n, isNotNull, reason: 'Submodule port must be named'); + expect(n, startsWith('_'), + reason: 'Unpreferred submodule port should keep _-prefix'); + }); + + // ── Row 5: reserved + internal + preferred ────────────────── + + test('T5: reserved preferred internal keeps exact name', () { + expect(names[mod.reservedInternal], 'resv'); + }); + + // ── Row 6: reserved + internal + unpreferred ──────────────── + + test('T6: reserved unpreferred internal keeps exact _-prefixed name', () { + expect(names[mod.reservedInternalUnpref], '_resvu'); + }); + + // ── Row 9: renameable + internal + preferred ──────────────── + + test('T9: renameable preferred internal gets its name', () { + final n = names[mod.renameableInternal]; + expect(n, isNotNull); + expect(n, contains('ren')); + }); + + // ── Row 10: renameable + internal + unpreferred ───────────── + + test('T10: renameable unpreferred internal keeps _-prefix', () { + final n = names[mod.renameableInternalUnpref]; + expect(n, isNotNull); + expect(n, startsWith('_'), + reason: 'Unpreferred renameable should keep _-prefix'); + expect(n, contains('renu')); + }); + + // ── Row 11: mergeable + this-port + preferred ─────────────── + + test('T11: mergeable-origin port (Logic.port) keeps exact port name', () { + // addInput overrides naming to reserved; the port name is exact. + expect(names[mod.input('mport')], 'mport'); + }); + + // ── Row 12: mergeable + this-port + unpreferred ───────────── + + test('T12: mergeable-origin unpreferred port keeps exact name', () { + expect(names[mod.input('_muprt')], '_muprt'); + }); + + // ── Row 15: mergeable + internal + preferred ──────────────── + + test('T15: mergeable preferred internal gets its name', () { + final n = names[mod.mergeablePref]; + expect(n, isNotNull); + expect(n, contains('mname')); + }); + + // ── COL: name collision → uniquified suffix ───────────────── + + test('COL: collision between two mergeables gets uniquified', () { + final n1 = names[mod.mergeablePref]; + final n2 = names[mod.mergeablePrefCollide]; + expect(n1, isNot(n2), reason: 'Colliding names must be uniquified'); + expect({n1, n2}, containsAll(['mname', 'mname_0'])); + }); + + // ── Row 16: mergeable + internal + unpreferred ────────────── + + test('T16: mergeable unpreferred internal keeps _-prefix', () { + final n = names[mod.mergeableUnpref]; + expect(n, isNotNull); + expect(n, startsWith('_'), + reason: 'Unpreferred mergeable should keep _-prefix'); + }); + + // ── Row 19: unnamed + internal ────────────────────────────── + + test('T19: unnamed signal gets a generated name', () { + final n = names[mod.unnamed]; + expect(n, isNotNull, reason: 'Unnamed signal must still get a name'); + // chooseName() gives unnamed signals a name starting with '_s'. + expect(n, startsWith('_'), + reason: 'Unnamed signals get unpreferred generated names'); + }); + + // ── Row 20: constant with name allowed ────────────────────── + + test('T20: constant with name allowed uses literal value', () { + final sl = _findSynthLogic(def, mod.constAllowed); + expect(sl, isNotNull); + if (sl != null && !sl.constNameDisallowed) { + expect(sl.name, contains("8'h42"), + reason: 'Allowed constant should use value literal'); + } + }); + + // ── Row 21: constant with name disallowed ─────────────────── + + test('T21: constant with name disallowed uses wire name', () { + final sl = _findSynthLogic(def, mod.constDisallowed); + expect(sl, isNotNull); + if (sl != null) { + if (sl.constNameDisallowed) { + expect(sl.name, isNot(contains("8'h09")), + reason: 'Disallowed constant should not use value literal'); + expect(sl.name, isNotEmpty); + } + } + }); + + // ── MG: merge behavior ────────────────────────────────────── + + test('MG: merged signals share the same SynthLogic', () { + final sl = _findSynthLogic(def, mod.mergeTarget); + expect(sl, isNotNull); + if (sl != null && sl.logics.length > 1) { + expect(sl.name, isNotEmpty); + } + }); + + // ── INST: submodule instance naming ───────────────────────── + + test('INST: submodule instances get collision-free names', () { + final instNames = def.subModuleInstantiations + .where((s) => s.needsInstantiation) + .map((s) => s.name) + .toList(); + expect(instNames.toSet().length, instNames.length, + reason: 'Instance names must be unique'); + final portNames = {...mod.inputs.keys, ...mod.outputs.keys}; + for (final name in instNames) { + expect(portNames, isNot(contains(name)), + reason: 'Instance "$name" should not collide with a port'); + } + }); + + // ── ST: structure element naming ──────────────────────────── + + test('ST: structure element structureName is sanitized', () { + // structureName for field1 is "st.a" → sanitized to "st_a". + final stIn = mod.input('stIn'); + final n = names[stIn]; + expect(n, isNotNull); + // The port itself should keep its reserved name 'stIn'. + expect(n, 'stIn'); + }); + + // ── AR: array element naming ──────────────────────────────── + + test('AR: array port keeps its name', () { + // Array ports are registered via addInputArray with Naming.reserved. + final arIn = mod.input('arIn'); + final n = names[arIn]; + expect(n, isNotNull); + expect(n, 'arIn'); + }); + + // ── Impossible cases ──────────────────────────────────────── + + test('unnamed + reserved throws at construction time', () { + expect( + () => Logic(naming: Naming.reserved), + throwsA(isA()), + ); + expect( + () => Logic(name: '', naming: Naming.reserved), + throwsA(isA()), + ); + }); + + // ── Golden SV snapshot ────────────────────────────────────── + + test('golden SV output snapshot', () { + final sv = mod.generateSynth(); + + // Port declarations. + expect(sv, contains('input logic [7:0] inp')); + expect(sv, contains('output logic [7:0] out')); + expect(sv, contains('_uinp')); + expect(sv, contains('mport')); + expect(sv, contains('_muprt')); + + // Reserved internals. + expect(sv, contains('resv')); + expect(sv, contains('_resvu')); + + // Renameable internals. + expect(sv, contains('ren')); + expect(sv, contains('_renu')); + + // Constant literal (T20). + expect(sv, contains("8'h42")); + + // Submodule instantiations. + expect(sv, contains('simplesub')); + expect(sv, contains('exprsub')); + expect(sv, contains('unprefsub')); + }); + }); +} diff --git a/test/naming_consistency_test.dart b/test/naming_consistency_test.dart new file mode 100644 index 000000000..53f95e6d8 --- /dev/null +++ b/test/naming_consistency_test.dart @@ -0,0 +1,247 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// naming_consistency_test.dart +// Validates that both the SystemVerilog synthesizer and a base +// SynthModuleDefinition (used by the netlist synthesizer) produce +// consistent signal names via the shared Module.signalNamer. +// +// 2026 April 10 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/synthesizers/systemverilog/systemverilog_synth_module_definition.dart'; +import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:test/test.dart'; + +// ── Helper modules ────────────────────────────────────────────────── + +/// A simple module with ports, internal wires, and a sub-module. +class _Inner extends Module { + _Inner(Logic a, Logic b) : super(name: 'inner') { + a = addInput('a', a, width: a.width); + b = addInput('b', b, width: b.width); + addOutput('y', width: a.width) <= a & b; + } +} + +class _Outer extends Module { + _Outer(Logic a, Logic b) : super(name: 'outer') { + a = addInput('a', a, width: a.width); + b = addInput('b', b, width: b.width); + final inner = _Inner(a, b); + addOutput('y', width: a.width) <= inner.output('y'); + } +} + +/// A module with a constant assignment (exercises const naming). +class _ConstModule extends Module { + _ConstModule(Logic a) : super(name: 'constmod') { + a = addInput('a', a, width: 8); + final c = Const(0x42, width: 8).named('myConst', naming: Naming.mergeable); + addOutput('y', width: 8) <= a + c; + } +} + +/// A module with Naming.renameable and Naming.mergeable signals. +class _MixedNaming extends Module { + _MixedNaming(Logic a) : super(name: 'mixednaming') { + a = addInput('a', a, width: 8); + final r = Logic(name: 'renamed', width: 8, naming: Naming.renameable) + ..gets(a); + final m = Logic(name: 'merged', width: 8, naming: Naming.mergeable) + ..gets(r); + addOutput('y', width: 8) <= m; + } +} + +/// A module with a FlipFlop sub-module. +class _FlopOuter extends Module { + _FlopOuter(Logic clk, Logic d) : super(name: 'flopouter') { + clk = addInput('clk', clk); + d = addInput('d', d, width: 8); + addOutput('q', width: 8) <= flop(clk, d); + } +} + +/// Builds [SynthModuleDefinition]s from both bases and collects a +/// Logic→name mapping for all present SynthLogics. +/// +/// Returns maps from Logic to its resolved signal name. +Map _collectNames(SynthModuleDefinition def) { + final names = {}; + for (final sl in [ + ...def.inputs, + ...def.outputs, + ...def.inOuts, + ...def.internalSignals, + ]) { + // Skip SynthLogics whose name was never picked (replaced/pruned). + try { + final n = sl.name; + for (final logic in sl.logics) { + names[logic] = n; + } + // ignore: avoid_catches_without_on_clauses + } catch (_) { + // name not picked — skip + } + } + return names; +} + +void main() { + group('naming consistency', () { + test('SV and base SynthModuleDefinition agree on port names', () async { + final mod = _Outer(Logic(width: 8), Logic(width: 8)); + await mod.build(); + + // SV synthesizer path + final svDef = SystemVerilogSynthModuleDefinition(mod); + + // Base path (same as netlist synthesizer uses) + // Since signalNamer is late final, the second constructor reuses + // the same naming state — names must be consistent. + final baseDef = SynthModuleDefinition(mod); + + final svNames = _collectNames(svDef); + final baseNames = _collectNames(baseDef); + + // Every Logic present in both must have the same name. + for (final logic in svNames.keys) { + if (baseNames.containsKey(logic)) { + expect(baseNames[logic], svNames[logic], + reason: 'Name mismatch for ${logic.name} ' + '(${logic.runtimeType}, naming=${logic.naming})'); + } + } + + // Port names specifically must match. + for (final port in [...mod.inputs.values, ...mod.outputs.values]) { + expect(svNames[port], isNotNull, + reason: 'SV def should have port ${port.name}'); + expect(baseNames[port], isNotNull, + reason: 'Base def should have port ${port.name}'); + expect(svNames[port], baseNames[port], + reason: 'Port name must match for ${port.name}'); + } + }); + + test('constant naming is consistent', () async { + final mod = _ConstModule(Logic(width: 8)); + await mod.build(); + + final svDef = SystemVerilogSynthModuleDefinition(mod); + final baseDef = SynthModuleDefinition(mod); + + final svNames = _collectNames(svDef); + final baseNames = _collectNames(baseDef); + + for (final logic in svNames.keys) { + if (baseNames.containsKey(logic)) { + expect(baseNames[logic], svNames[logic], + reason: 'Name mismatch for ${logic.name}'); + } + } + }); + + test('mixed naming (renameable + mergeable) is consistent', () async { + final mod = _MixedNaming(Logic(width: 8)); + await mod.build(); + + final svDef = SystemVerilogSynthModuleDefinition(mod); + final baseDef = SynthModuleDefinition(mod); + + final svNames = _collectNames(svDef); + final baseNames = _collectNames(baseDef); + + for (final logic in svNames.keys) { + if (baseNames.containsKey(logic)) { + expect(baseNames[logic], svNames[logic], + reason: 'Name mismatch for ${logic.name}'); + } + } + }); + + test('flop module naming is consistent', () async { + final mod = _FlopOuter(Logic(), Logic(width: 8)); + await mod.build(); + + final svDef = SystemVerilogSynthModuleDefinition(mod); + final baseDef = SynthModuleDefinition(mod); + + final svNames = _collectNames(svDef); + final baseNames = _collectNames(baseDef); + + for (final logic in svNames.keys) { + if (baseNames.containsKey(logic)) { + expect(baseNames[logic], svNames[logic], + reason: 'Name mismatch for ${logic.name}'); + } + } + }); + + test('signalNamer is shared across multiple SynthModuleDefinitions', + () async { + final mod = _Outer(Logic(width: 8), Logic(width: 8)); + await mod.build(); + + // Build one def, then build another — same signalNamer instance. + final def1 = SynthModuleDefinition(mod); + final def2 = SynthModuleDefinition(mod); + + final names1 = _collectNames(def1); + final names2 = _collectNames(def2); + + for (final logic in names1.keys) { + if (names2.containsKey(logic)) { + expect(names2[logic], names1[logic], + reason: 'Shared namer should produce same name for ' + '${logic.name}'); + } + } + }); + + test('Module.signalName matches SynthLogic.name for ports', () async { + final mod = _Outer(Logic(width: 8), Logic(width: 8)); + await mod.build(); + + final def = SynthModuleDefinition(mod); + final synthNames = _collectNames(def); + + // Module.signalName uses SignalNamer.nameOf directly + for (final port in [...mod.inputs.values, ...mod.outputs.values]) { + final moduleName = mod.signalName(port); + final synthName = synthNames[port]; + expect(synthName, moduleName, + reason: 'SynthLogic.name and Module.signalName must agree ' + 'for port ${port.name}'); + } + }); + + test('submodule instance names are allocated from shared namespace', + () async { + // When building a single SynthModuleDefinition (as each synthesizer + // does), submodule instance names come from Module.allocateSignalName. + final mod = _Outer(Logic(width: 8), Logic(width: 8)); + await mod.build(); + + final def = SynthModuleDefinition(mod); + + final instNames = def.subModuleInstantiations + .where((s) => s.needsInstantiation) + .map((s) => s.name) + .toSet(); + + // The inner module instance should have a name + expect(instNames, isNotEmpty, + reason: 'Should have at least one submodule instance'); + + // All instance names should be obtainable from the module namespace + for (final name in instNames) { + expect(mod.isSignalNameAvailable(name), isFalse, + reason: 'Instance name "$name" should be claimed in namespace'); + } + }); + }); +} From 85f88cef0f472794689c9965b1be768fc5682b59 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Apr 2026 08:36:09 -0700 Subject: [PATCH 02/20] dart 3.11 parameter_assignments pickiness --- analysis_options.yaml | 4 +++- lib/src/module.dart | 3 --- lib/src/signals/logic.dart | 1 - lib/src/signals/wire_net.dart | 1 - lib/src/utilities/simcompare.dart | 1 - lib/src/values/logic_value.dart | 3 --- 6 files changed, 3 insertions(+), 10 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 65d475023..2b2098177 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -129,7 +129,9 @@ linter: - overridden_fields - package_names - package_prefixed_library_names - - parameter_assignments + # parameter_assignments - disabled; ROHD idiomatically reassigns + # constructor parameters via addInput/addOutput. + # - parameter_assignments - prefer_adjacent_string_concatenation - prefer_asserts_in_initializer_lists - prefer_asserts_with_message diff --git a/lib/src/module.dart b/lib/src/module.dart index 09e11fdc7..188b78890 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -702,7 +702,6 @@ abstract class Module { } if (source is LogicStructure) { - // ignore: parameter_assignments source = source.packed; } @@ -739,7 +738,6 @@ abstract class Module { String name, LogicType source) { _checkForSafePortName(name); - // ignore: parameter_assignments source = _validateType(source, isOutput: false, name: name); if (source.isNet || (source is LogicStructure && source.hasNets)) { @@ -848,7 +846,6 @@ abstract class Module { throw PortTypeException(source, 'Typed inOuts must be nets.'); } - // ignore: parameter_assignments source = _validateType(source, isOutput: false, name: name); _inOutDrivers.add(source); diff --git a/lib/src/signals/logic.dart b/lib/src/signals/logic.dart index 88afba0d6..4c5f99e5e 100644 --- a/lib/src/signals/logic.dart +++ b/lib/src/signals/logic.dart @@ -377,7 +377,6 @@ class Logic { // If we are connecting a `LogicStructure` to this simple `Logic`, // then pack it first. if (other is LogicStructure) { - // ignore: parameter_assignments other = other.packed; } diff --git a/lib/src/signals/wire_net.dart b/lib/src/signals/wire_net.dart index 78e8b1beb..f93529b0f 100644 --- a/lib/src/signals/wire_net.dart +++ b/lib/src/signals/wire_net.dart @@ -189,7 +189,6 @@ class _WireNetBlasted extends _Wire implements _WireNet { other as _WireNet; if (other is! _WireNetBlasted) { - // ignore: parameter_assignments other = other.toBlasted(); } diff --git a/lib/src/utilities/simcompare.dart b/lib/src/utilities/simcompare.dart index 3a25f4074..d7850df4e 100644 --- a/lib/src/utilities/simcompare.dart +++ b/lib/src/utilities/simcompare.dart @@ -282,7 +282,6 @@ abstract class SimCompare { : 'logic'); if (adjust != null) { - // ignore: parameter_assignments signalName = adjust(signalName); } diff --git a/lib/src/values/logic_value.dart b/lib/src/values/logic_value.dart index 0cdc3c1df..81fc7304b 100644 --- a/lib/src/values/logic_value.dart +++ b/lib/src/values/logic_value.dart @@ -218,7 +218,6 @@ abstract class LogicValue implements Comparable { if (val.width == 1 && (!val.isValid || fill)) { if (!val.isValid) { - // ignore: parameter_assignments width ??= 1; } if (width == null) { @@ -243,7 +242,6 @@ abstract class LogicValue implements Comparable { if (val.length == 1 && (val == 'x' || val == 'z' || fill)) { if (val == 'x' || val == 'z') { - // ignore: parameter_assignments width ??= 1; } if (width == null) { @@ -269,7 +267,6 @@ abstract class LogicValue implements Comparable { if (val.length == 1 && (val.first == LogicValue.x || val.first == LogicValue.z || fill)) { if (!val.first.isValid) { - // ignore: parameter_assignments width ??= 1; } if (width == null) { From b7087c40467389ae38be40e2d4c599c0d532ebe7 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Apr 2026 13:03:20 -0700 Subject: [PATCH 03/20] conflict resolved and dart format . works --- .../synthesizers/utilities/synth_logic.dart | 79 ------------------- 1 file changed, 79 deletions(-) diff --git a/lib/src/synthesizers/utilities/synth_logic.dart b/lib/src/synthesizers/utilities/synth_logic.dart index d0a5e5d5a..b5827295b 100644 --- a/lib/src/synthesizers/utilities/synth_logic.dart +++ b/lib/src/synthesizers/utilities/synth_logic.dart @@ -221,7 +221,6 @@ class SynthLogic { } /// Finds the best name from the collection of [Logic]s. -<<<<<<< central_naming /// /// Delegates to signal namer which handles constant value naming, priority /// selection, and uniquification via the module's shared namespace. @@ -231,84 +230,6 @@ class SynthLogic { constValue: _constLogic, constNameDisallowed: _constNameDisallowed, ); -======= - String _findName(Uniquifier uniquifier) { - // check for const - if (_constLogic != null) { - if (!_constNameDisallowed) { - return _constLogic!.value.toString(); - } else { - assert( - logics.length > 1, - 'If there is a constant, but the const name is not allowed, ' - 'there needs to be another option', - ); - } - } - - // check for reserved - if (_reservedLogic != null) { - return uniquifier.getUniqueName( - initialName: _reservedLogic!.name, - reserved: true, - ); - } - - // check for renameable - if (_renameableLogic != null) { - return uniquifier.getUniqueName( - initialName: _renameableLogic!.preferredSynthName, - ); - } - - // pick a preferred, available, mergeable name, if one exists - final unpreferredMergeableLogics = []; - final uniquifiableMergeableLogics = []; - for (final mergeableLogic in _mergeableLogics) { - if (Naming.isUnpreferred(mergeableLogic.preferredSynthName)) { - unpreferredMergeableLogics.add(mergeableLogic); - } else if (!uniquifier.isAvailable(mergeableLogic.preferredSynthName)) { - uniquifiableMergeableLogics.add(mergeableLogic); - } else { - return uniquifier.getUniqueName( - initialName: mergeableLogic.preferredSynthName, - ); - } - } - - // uniquify a preferred, mergeable name, if one exists - if (uniquifiableMergeableLogics.isNotEmpty) { - return uniquifier.getUniqueName( - initialName: uniquifiableMergeableLogics.first.preferredSynthName, - ); - } - - // pick an available unpreferred mergeable name, if one exists, otherwise - // uniquify an unpreferred mergeable name - if (unpreferredMergeableLogics.isNotEmpty) { - return uniquifier.getUniqueName( - initialName: unpreferredMergeableLogics - .firstWhereOrNull( - (element) => - uniquifier.isAvailable(element.preferredSynthName), - ) - ?.preferredSynthName ?? - unpreferredMergeableLogics.first.preferredSynthName, - ); - } - - // pick anything (unnamed) and uniquify as necessary (considering preferred) - // no need to prefer an available one here, since it's all unnamed - return uniquifier.getUniqueName( - initialName: _unnamedLogics - .firstWhereOrNull( - (element) => !Naming.isUnpreferred(element.preferredSynthName), - ) - ?.preferredSynthName ?? - _unnamedLogics.first.preferredSynthName, - ); - } ->>>>>>> main /// Creates an instance to represent [initialLogic] and any that merge /// into it. From 4a55214d9448376d8900a9348422f04f0985cd06 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sat, 18 Apr 2026 14:18:31 -0700 Subject: [PATCH 04/20] properly assign naming spaces for instances vs signals --- lib/src/module.dart | 41 ++++++- lib/src/synthesizers/synthesizer.dart | 8 +- .../systemverilog_synthesizer.dart | 3 +- .../utilities/synth_module_definition.dart | 9 +- .../synth_sub_module_instantiation.dart | 10 +- test/instance_signal_name_collision_test.dart | 108 ++++++++++++++++++ test/naming_consistency_test.dart | 16 ++- 7 files changed, 166 insertions(+), 29 deletions(-) create mode 100644 test/instance_signal_name_collision_test.dart diff --git a/lib/src/module.dart b/lib/src/module.dart index 188b78890..8a6cd037b 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -68,25 +68,54 @@ abstract class Module { ); } + /// Separate namespace for submodule instance names. + /// + /// Instance names and signal names occupy different namespaces in + /// SystemVerilog (and most other HDLs), so they must be uniquified + /// independently to avoid false collisions. + @internal + late final Uniquifier instanceNameUniquifier = Uniquifier(); + /// Returns the collision-free signal name for [logic] within this module. String signalName(Logic logic) => signalNamer.nameOf(logic); - /// Allocates a collision-free signal name in this module's namespace. + /// Allocates a collision-free signal name in this module's signal namespace. /// - /// Used by synthesizers to name connection nets, submodule instances, - /// intermediate wires, and other artifacts that have no user-created - /// [Logic] object. The returned name is guaranteed not to collide with - /// any signal name or any previously allocated name. + /// Used by synthesizers to name connection nets, intermediate wires, and + /// other signal artifacts. The returned name is guaranteed not to collide + /// with any other signal name previously allocated in this module. /// /// When [reserved] is `true`, the exact [baseName] (after sanitization) is /// claimed without modification; an exception is thrown if it collides. String allocateSignalName(String baseName, {bool reserved = false}) => signalNamer.allocate(baseName, reserved: reserved); + /// Allocates a collision-free instance name in this module's instance + /// namespace. + /// + /// Instance names are kept separate from signal names because in + /// SystemVerilog (and other HDLs) they occupy distinct namespaces — a + /// signal and a submodule instance may legally share the same identifier + /// without collision. Mixing them into one uniquifier causes spurious + /// suffixing. + /// + /// When [reserved] is `true`, the exact [baseName] (after sanitization) is + /// claimed without modification; an exception is thrown if it collides. + String allocateInstanceName(String baseName, {bool reserved = false}) => + instanceNameUniquifier.getUniqueName( + initialName: Sanitizer.sanitizeSV(baseName), + reserved: reserved, + ); + /// Returns `true` if [name] has not yet been claimed as a signal name in - /// this module's namespace. + /// this module's signal namespace. bool isSignalNameAvailable(String name) => signalNamer.isAvailable(name); + /// Returns `true` if [name] has not yet been claimed as an instance name in + /// this module's instance namespace. + bool isInstanceNameAvailable(String name) => + instanceNameUniquifier.isAvailable(name); + /// An internal mapping of inOut names to their sources to this [Module]. late final Map _inOutSources = {}; diff --git a/lib/src/synthesizers/synthesizer.dart b/lib/src/synthesizers/synthesizer.dart index 2d7730208..ce3d2c900 100644 --- a/lib/src/synthesizers/synthesizer.dart +++ b/lib/src/synthesizers/synthesizer.dart @@ -27,13 +27,7 @@ abstract class Synthesizer { /// Synthesizes [module] into a [SynthesisResult], given the mapping provided /// by [getInstanceTypeOfModule]. - /// - /// Optionally a [lookupExistingResult] callback may be supplied which - /// allows the synthesizer to query already-generated `SynthesisResult`s - /// for child modules (useful when building parent output that needs - /// information from children). SynthesisResult synthesize( Module module, String Function(Module module) getInstanceTypeOfModule, - {SynthesisResult? Function(Module module)? lookupExistingResult, - Map? existingResults}); + {Map? existingResults}); } diff --git a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart index b83acb9cc..d50daf45a 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart @@ -138,8 +138,7 @@ class SystemVerilogSynthesizer extends Synthesizer { @override SynthesisResult synthesize( Module module, String Function(Module module) getInstanceTypeOfModule, - {SynthesisResult? Function(Module module)? lookupExistingResult, - Map? existingResults}) { + {Map? existingResults}) { assert( module is! SystemVerilog || module.generatedDefinitionType != DefinitionGenerationType.none, diff --git a/lib/src/synthesizers/utilities/synth_module_definition.dart b/lib/src/synthesizers/utilities/synth_module_definition.dart index dac9075e8..97722a629 100644 --- a/lib/src/synthesizers/utilities/synth_module_definition.dart +++ b/lib/src/synthesizers/utilities/synth_module_definition.dart @@ -744,10 +744,11 @@ class SynthModuleDefinition { /// Picks names of signals and sub-modules. /// /// Signal names are read from [Module.signalName] (for user-created - /// [Logic] objects) or kept as literal constants. Submodule instance - /// names and synthesizer artifacts are allocated from the shared - /// [Module] namespace via [Module.allocateSignalName], guaranteeing no - /// collisions across synthesizers. + /// [Logic] objects) or kept as literal constants and are allocated from + /// [Module.allocateSignalName] (signal namespace). Submodule instance + /// names are allocated from [Module.allocateInstanceName] (instance + /// namespace). The two namespaces are independent, matching SystemVerilog + /// semantics where signal and instance identifiers do not collide. void _pickNames() { // Name allocation order matters — earlier claims get the unsuffixed name // when there are collisions. This matches production ROHD priority: diff --git a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart index 4f1c3e4f2..4eaf83f57 100644 --- a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart +++ b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart @@ -25,13 +25,15 @@ class SynthSubModuleInstantiation { /// Selects a name for this module instance. Must be called exactly once. /// - /// Names are allocated from [parentModule]'s shared namespace via - /// [Module.allocateSignalName], ensuring no collision with signal names or - /// other submodule instances — even across multiple synthesizers. + /// Names are allocated from [parentModule]'s instance namespace via + /// [Module.allocateInstanceName], which is kept separate from the signal + /// namespace. In SystemVerilog (and other HDLs) instance names and signal + /// names occupy distinct namespaces, so they must be uniquified + /// independently to avoid spurious suffixing. void pickName(Module parentModule) { assert(_name == null, 'Should only pick a name once.'); - _name = parentModule.allocateSignalName( + _name = parentModule.allocateInstanceName( module.uniqueInstanceName, reserved: module.reserveName, ); diff --git a/test/instance_signal_name_collision_test.dart b/test/instance_signal_name_collision_test.dart new file mode 100644 index 000000000..0673e3522 --- /dev/null +++ b/test/instance_signal_name_collision_test.dart @@ -0,0 +1,108 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// instance_signal_name_collision_test.dart +// Regression test that demonstrates the bug present in the main branch where +// submodule instance names and signal names share a single Uniquifier. +// +// In SystemVerilog, signal identifiers and instance identifiers live in +// *separate* namespaces, so it is perfectly legal to have a signal called +// "inner" and a module instance also called "inner" in the same scope. +// +// When a single shared Uniquifier is used (main-branch behaviour), the second +// name to be allocated gets spuriously suffixed (e.g. "inner_0"), which +// produces incorrect generated SV. +// +// 2026 April 18 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:test/test.dart'; + +// ── Minimal repro modules ──────────────────────────────────────────────────── + +/// Leaf module whose default instance name is "inner". +class _Inner extends Module { + _Inner(Logic a) : super(name: 'inner') { + a = addInput('a', a, width: a.width); + addOutput('y', width: a.width) <= a; + } +} + +/// Parent module that: +/// • instantiates [_Inner] (default instance name: "inner") +/// • names an internal wire "inner" as well +/// +/// In SV the two identifiers live in different namespaces, so both should +/// be emitted as "inner" without any suffix. +class _CollidingParent extends Module { + _CollidingParent(Logic a) : super(name: 'colliding_parent') { + a = addInput('a', a, width: a.width); + + // Internal wire explicitly named "inner". + final inner = Logic(name: 'inner', width: a.width, naming: Naming.reserved) + ..gets(a); + + // Submodule whose uniqueInstanceName will also be "inner". + final sub = _Inner(inner); + + addOutput('y', width: a.width) <= sub.output('y'); + } +} + +// ── Test ───────────────────────────────────────────────────────────────────── + +void main() { + group('instance / signal name collision (main-branch bug)', () { + late _CollidingParent mod; + late SynthModuleDefinition def; + + setUpAll(() async { + mod = _CollidingParent(Logic(width: 8)); + await mod.build(); + def = SynthModuleDefinition(mod); + }); + + test('internal signal named "inner" retains its exact name', () { + // Find the SynthLogic for the reserved "inner" wire. + final sl = def.internalSignals.cast().firstWhere( + (s) => s!.logics.any((l) => l.name == 'inner'), + orElse: () => null, + ); + expect(sl, isNotNull, reason: 'Expected to find SynthLogic for "inner"'); + expect(sl!.name, 'inner', + reason: 'Signal "inner" must not be suffixed to "inner_0"'); + }); + + test('submodule instance named "inner" retains its exact name', () { + final inst = def.subModuleInstantiations + .where((s) => s.needsInstantiation) + .cast() + .firstWhere( + (s) => s!.module.name == 'inner', + orElse: () => null, + ); + expect(inst, isNotNull, reason: 'Expected submodule instance for inner'); + expect(inst!.name, 'inner', + reason: 'Instance "inner" must not be suffixed to "inner_0"'); + }); + + test('signal and instance may share the name "inner" without collision', () { + // Both should be "inner", not one of them "inner_0". + final sl = def.internalSignals.cast().firstWhere( + (s) => s!.logics.any((l) => l.name == 'inner'), + orElse: () => null, + ); + final inst = def.subModuleInstantiations + .where((s) => s.needsInstantiation) + .cast() + .firstWhere( + (s) => s!.module.name == 'inner', + orElse: () => null, + ); + expect(sl?.name, 'inner'); + expect(inst?.name, 'inner'); + }); + }); +} diff --git a/test/naming_consistency_test.dart b/test/naming_consistency_test.dart index 53f95e6d8..b569bd4d6 100644 --- a/test/naming_consistency_test.dart +++ b/test/naming_consistency_test.dart @@ -219,10 +219,12 @@ void main() { } }); - test('submodule instance names are allocated from shared namespace', + test('submodule instance names are allocated from the instance namespace', () async { - // When building a single SynthModuleDefinition (as each synthesizer - // does), submodule instance names come from Module.allocateSignalName. + // Instance names come from Module.allocateInstanceName, which is + // separate from the signal namespace (Module.allocateSignalName). + // A signal and a submodule instance may therefore share the same + // identifier without collision — matching SystemVerilog semantics. final mod = _Outer(Logic(width: 8), Logic(width: 8)); await mod.build(); @@ -237,10 +239,12 @@ void main() { expect(instNames, isNotEmpty, reason: 'Should have at least one submodule instance'); - // All instance names should be obtainable from the module namespace + // Instance names are claimed in the *instance* namespace, NOT the + // signal namespace. for (final name in instNames) { - expect(mod.isSignalNameAvailable(name), isFalse, - reason: 'Instance name "$name" should be claimed in namespace'); + expect(mod.isInstanceNameAvailable(name), isFalse, + reason: 'Instance name "$name" should be claimed in instance ' + 'namespace'); } }); }); From ed7be3696082ded32f01ccabaeb5e63b8efb1a02 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sat, 18 Apr 2026 15:32:50 -0700 Subject: [PATCH 05/20] format issue --- test/instance_signal_name_collision_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/instance_signal_name_collision_test.dart b/test/instance_signal_name_collision_test.dart index 0673e3522..2cdfb2e3e 100644 --- a/test/instance_signal_name_collision_test.dart +++ b/test/instance_signal_name_collision_test.dart @@ -88,7 +88,8 @@ void main() { reason: 'Instance "inner" must not be suffixed to "inner_0"'); }); - test('signal and instance may share the name "inner" without collision', () { + test('signal and instance may share the name "inner" without collision', + () { // Both should be "inner", not one of them "inner_0". final sl = def.internalSignals.cast().firstWhere( (s) => s!.logics.any((l) => l.name == 'inner'), From ab09aed656059ee777755293f176bb354f417a84 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 19 Apr 2026 05:27:52 -0700 Subject: [PATCH 06/20] Controllable enforcement of signal vs instance name uniqueness. --- lib/src/module.dart | 37 +++++++++++++-- lib/src/synthesizers/synth_builder.dart | 3 -- lib/src/synthesizers/synthesizer.dart | 10 ---- lib/src/utilities/config.dart | 9 ++++ lib/src/utilities/signal_namer.dart | 47 +++++++++++++++---- test/instance_signal_name_collision_test.dart | 9 ++++ test/name_test.dart | 5 ++ 7 files changed, 96 insertions(+), 24 deletions(-) diff --git a/lib/src/module.dart b/lib/src/module.dart index 8a6cd037b..475f48c68 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -65,6 +65,9 @@ abstract class Module { inputs: _inputs, outputs: _outputs, inOuts: _inOuts, + isAvailableInOtherNamespace: (name) => + !Config.ensureUniqueSignalAndInstanceNames || + instanceNameUniquifier.isAvailable(name), ); } @@ -101,11 +104,39 @@ abstract class Module { /// /// When [reserved] is `true`, the exact [baseName] (after sanitization) is /// claimed without modification; an exception is thrown if it collides. - String allocateInstanceName(String baseName, {bool reserved = false}) => - instanceNameUniquifier.getUniqueName( - initialName: Sanitizer.sanitizeSV(baseName), + String allocateInstanceName(String baseName, {bool reserved = false}) { + final sanitizedBaseName = Sanitizer.sanitizeSV(baseName); + + if (!Config.ensureUniqueSignalAndInstanceNames) { + return instanceNameUniquifier.getUniqueName( + initialName: sanitizedBaseName, reserved: reserved, ); + } + + if (reserved) { + if (!instanceNameUniquifier.isAvailable(sanitizedBaseName, + reserved: true) || + !signalNamer.isAvailable(sanitizedBaseName)) { + throw UnavailableReservedNameException(sanitizedBaseName); + } + + return instanceNameUniquifier.getUniqueName( + initialName: sanitizedBaseName, + reserved: true, + ); + } + + var candidate = sanitizedBaseName; + var suffix = 0; + while (!instanceNameUniquifier.isAvailable(candidate) || + !signalNamer.isAvailable(candidate)) { + candidate = '${sanitizedBaseName}_$suffix'; + suffix++; + } + + return instanceNameUniquifier.getUniqueName(initialName: candidate); + } /// Returns `true` if [name] has not yet been claimed as a signal name in /// this module's signal namespace. diff --git a/lib/src/synthesizers/synth_builder.dart b/lib/src/synthesizers/synth_builder.dart index 3b3a6011c..f9d0a0d08 100644 --- a/lib/src/synthesizers/synth_builder.dart +++ b/lib/src/synthesizers/synth_builder.dart @@ -56,9 +56,6 @@ class SynthBuilder { } } - // Allow the synthesizer to prepare with knowledge of top module(s) - synthesizer.prepare(this.tops); - final modulesToParse = [...tops]; for (var i = 0; i < modulesToParse.length; i++) { final moduleI = modulesToParse[i]; diff --git a/lib/src/synthesizers/synthesizer.dart b/lib/src/synthesizers/synthesizer.dart index ce3d2c900..7b350e8b4 100644 --- a/lib/src/synthesizers/synthesizer.dart +++ b/lib/src/synthesizers/synthesizer.dart @@ -11,16 +11,6 @@ import 'package:rohd/rohd.dart'; /// An object capable of converting a module into some new output format abstract class Synthesizer { - /// Called by [SynthBuilder] before synthesis begins, with the top-level - /// module(s) being synthesized. - /// - /// Override this method to perform any initialization that requires - /// knowledge of the top module, such as resolving port names to [Logic] - /// objects, or computing global signal sets. - /// - /// The default implementation does nothing. - void prepare(List tops) {} - /// Determines whether [module] needs a separate definition or can just be /// described in-line. bool generatesDefinition(Module module); diff --git a/lib/src/utilities/config.dart b/lib/src/utilities/config.dart index 4aa2ca8c6..89eda836a 100644 --- a/lib/src/utilities/config.dart +++ b/lib/src/utilities/config.dart @@ -11,4 +11,13 @@ class Config { /// The version of the ROHD framework. static const String version = '0.6.8'; + + /// Controls whether synthesized signal names and instance names must be + /// unique across both namespaces. + /// + /// When `true`, central naming cross-checks both namespaces during + /// allocation to avoid collisions in generated output. + /// + /// When `false`, signal and instance names are uniquified independently. + static bool ensureUniqueSignalAndInstanceNames = true; } diff --git a/lib/src/utilities/signal_namer.dart b/lib/src/utilities/signal_namer.dart index b7d9dc090..7f98fdff3 100644 --- a/lib/src/utilities/signal_namer.dart +++ b/lib/src/utilities/signal_namer.dart @@ -23,6 +23,7 @@ import 'package:rohd/src/utilities/uniquifier.dart'; @internal class SignalNamer { final Uniquifier _uniquifier; + final bool Function(String name) _isAvailableInOtherNamespace; /// Sparse cache: only entries where the canonical name has been resolved. /// Ports whose sanitized name == logic.name may be absent (fast-path @@ -36,8 +37,10 @@ class SignalNamer { required Uniquifier uniquifier, required Map portRenames, required Set portLogics, + required bool Function(String name) isAvailableInOtherNamespace, }) : _uniquifier = uniquifier, - _portLogics = portLogics { + _portLogics = portLogics, + _isAvailableInOtherNamespace = isAvailableInOtherNamespace { _names.addAll(portRenames); } @@ -49,6 +52,7 @@ class SignalNamer { required Map inputs, required Map outputs, required Map inOuts, + bool Function(String name)? isAvailableInOtherNamespace, }) { final portRenames = {}; final portLogics = {}; @@ -85,9 +89,36 @@ class SignalNamer { uniquifier: uniquifier, portRenames: portRenames, portLogics: portLogics, + isAvailableInOtherNamespace: + isAvailableInOtherNamespace ?? ((_) => true), ); } + bool _isAvailable(String name, {bool reserved = false}) => + _uniquifier.isAvailable(name, reserved: reserved) && + _isAvailableInOtherNamespace(name); + + String _allocateUniqueName(String baseName, {bool reserved = false}) { + if (reserved) { + if (!_isAvailable(baseName, reserved: true)) { + throw UnavailableReservedNameException(baseName); + } + + _uniquifier.getUniqueName(initialName: baseName, reserved: true); + return baseName; + } + + var candidate = baseName; + var suffix = 0; + while (!_isAvailable(candidate)) { + candidate = '${baseName}_$suffix'; + suffix++; + } + + _uniquifier.getUniqueName(initialName: candidate); + return candidate; + } + /// Returns the canonical name for [logic]. /// /// The first call for a given [logic] allocates a collision-free name @@ -117,8 +148,8 @@ class SignalNamer { baseName = Sanitizer.sanitizeSV(logic.structureName); } - final name = _uniquifier.getUniqueName( - initialName: baseName, + final name = _allocateUniqueName( + baseName, reserved: isReservedInternal, ); _names[logic] = name; @@ -214,7 +245,7 @@ class SignalNamer { // Preferred-available mergeable. for (final logic in preferredMergeable) { - if (_uniquifier.isAvailable(baseName(logic))) { + if (_isAvailable(baseName(logic))) { return _nameAndCacheAll(logic, candidates); } } @@ -227,7 +258,7 @@ class SignalNamer { // Unpreferred mergeable — prefer available. if (unpreferredMergeable.isNotEmpty) { final best = unpreferredMergeable - .firstWhereOrNull((e) => _uniquifier.isAvailable(baseName(e))) ?? + .firstWhereOrNull((e) => _isAvailable(baseName(e))) ?? unpreferredMergeable.first; return _nameAndCacheAll(best, candidates); } @@ -261,11 +292,11 @@ class SignalNamer { /// When [reserved] is `true`, the exact [baseName] (after sanitization) /// is claimed without modification; an exception is thrown if it collides. String allocate(String baseName, {bool reserved = false}) => - _uniquifier.getUniqueName( - initialName: Sanitizer.sanitizeSV(baseName), + _allocateUniqueName( + Sanitizer.sanitizeSV(baseName), reserved: reserved, ); /// Returns `true` if [name] has not yet been claimed in this namespace. - bool isAvailable(String name) => _uniquifier.isAvailable(name); + bool isAvailable(String name) => _isAvailable(name); } diff --git a/test/instance_signal_name_collision_test.dart b/test/instance_signal_name_collision_test.dart index 2cdfb2e3e..6ee10de92 100644 --- a/test/instance_signal_name_collision_test.dart +++ b/test/instance_signal_name_collision_test.dart @@ -18,6 +18,7 @@ import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:rohd/src/utilities/config.dart'; import 'package:test/test.dart'; // ── Minimal repro modules ──────────────────────────────────────────────────── @@ -57,13 +58,21 @@ void main() { group('instance / signal name collision (main-branch bug)', () { late _CollidingParent mod; late SynthModuleDefinition def; + late bool previousSetting; setUpAll(() async { + previousSetting = Config.ensureUniqueSignalAndInstanceNames; + Config.ensureUniqueSignalAndInstanceNames = false; + mod = _CollidingParent(Logic(width: 8)); await mod.build(); def = SynthModuleDefinition(mod); }); + tearDownAll(() { + Config.ensureUniqueSignalAndInstanceNames = previousSetting; + }); + test('internal signal named "inner" retains its exact name', () { // Find the SynthLogic for the reserved "inner" wire. final sl = def.internalSignals.cast().firstWhere( diff --git a/test/name_test.dart b/test/name_test.dart index 2742c0ec8..c863c04f5 100644 --- a/test/name_test.dart +++ b/test/name_test.dart @@ -136,6 +136,11 @@ void main() { final nameTypes = [nameType1, nameType2]; // skip ones that actually *should* cause a failure + // + // Note: SystemVerilog allows using the same identifier for a signal + // and an instance because they are different namespaces. However, + // Icarus Verilog rejects that pattern, so ROHD treats those as + // conflicts for simulator compatibility. final shouldConflict = [ { NameType.internalModuleDefinition, From 520d2809fdfa844260aa7614bdf55d8655330b09 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 19 Apr 2026 06:58:00 -0700 Subject: [PATCH 07/20] Refactored to Namer class. No external API changes for ROHD --- lib/src/module.dart | 93 +---- lib/src/synthesizers/synthesizer.dart | 3 +- .../systemverilog_synthesizer.dart | 3 +- .../synthesizers/utilities/synth_logic.dart | 37 +- .../utilities/synth_module_definition.dart | 9 +- .../synth_sub_module_instantiation.dart | 6 +- lib/src/utilities/config.dart | 9 - lib/src/utilities/namer.dart | 349 ++++++++++++++++++ lib/src/utilities/signal_namer.dart | 18 +- test/instance_signal_name_collision_test.dart | 8 +- test/naming_consistency_test.dart | 23 +- test/naming_namespace_test.dart | 180 +++++++++ 12 files changed, 596 insertions(+), 142 deletions(-) create mode 100644 lib/src/utilities/namer.dart create mode 100644 test/naming_namespace_test.dart diff --git a/lib/src/module.dart b/lib/src/module.dart index 475f48c68..02e02ad63 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -15,8 +15,8 @@ import 'package:rohd/rohd.dart'; import 'package:rohd/src/collections/traverseable_collection.dart'; import 'package:rohd/src/diagnostics/inspector_service.dart'; import 'package:rohd/src/utilities/config.dart'; +import 'package:rohd/src/utilities/namer.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; -import 'package:rohd/src/utilities/signal_namer.dart'; import 'package:rohd/src/utilities/timestamper.dart'; import 'package:rohd/src/utilities/uniquifier.dart'; @@ -52,101 +52,22 @@ abstract class Module { /// An internal mapping of input names to their sources to this [Module]. late final Map _inputSources = {}; - // ─── Canonical naming (SignalNamer) ───────────────────────────── + // ─── Central naming (Namer) ───────────────────────────────────── - /// Lazily-constructed namer that owns the [Uniquifier] and the - /// sparse Logic→String cache. Initialized on first access. + /// Central namer that owns both the signal and instance namespaces. + /// Initialized lazily on first access (after build). @internal - late final SignalNamer signalNamer = _createSignalNamer(); + late final Namer namer = _createNamer(); - SignalNamer _createSignalNamer() { + Namer _createNamer() { assert(hasBuilt, 'Module must be built before canonical names are bound.'); - return SignalNamer.forModule( + return Namer.forModule( inputs: _inputs, outputs: _outputs, inOuts: _inOuts, - isAvailableInOtherNamespace: (name) => - !Config.ensureUniqueSignalAndInstanceNames || - instanceNameUniquifier.isAvailable(name), ); } - /// Separate namespace for submodule instance names. - /// - /// Instance names and signal names occupy different namespaces in - /// SystemVerilog (and most other HDLs), so they must be uniquified - /// independently to avoid false collisions. - @internal - late final Uniquifier instanceNameUniquifier = Uniquifier(); - - /// Returns the collision-free signal name for [logic] within this module. - String signalName(Logic logic) => signalNamer.nameOf(logic); - - /// Allocates a collision-free signal name in this module's signal namespace. - /// - /// Used by synthesizers to name connection nets, intermediate wires, and - /// other signal artifacts. The returned name is guaranteed not to collide - /// with any other signal name previously allocated in this module. - /// - /// When [reserved] is `true`, the exact [baseName] (after sanitization) is - /// claimed without modification; an exception is thrown if it collides. - String allocateSignalName(String baseName, {bool reserved = false}) => - signalNamer.allocate(baseName, reserved: reserved); - - /// Allocates a collision-free instance name in this module's instance - /// namespace. - /// - /// Instance names are kept separate from signal names because in - /// SystemVerilog (and other HDLs) they occupy distinct namespaces — a - /// signal and a submodule instance may legally share the same identifier - /// without collision. Mixing them into one uniquifier causes spurious - /// suffixing. - /// - /// When [reserved] is `true`, the exact [baseName] (after sanitization) is - /// claimed without modification; an exception is thrown if it collides. - String allocateInstanceName(String baseName, {bool reserved = false}) { - final sanitizedBaseName = Sanitizer.sanitizeSV(baseName); - - if (!Config.ensureUniqueSignalAndInstanceNames) { - return instanceNameUniquifier.getUniqueName( - initialName: sanitizedBaseName, - reserved: reserved, - ); - } - - if (reserved) { - if (!instanceNameUniquifier.isAvailable(sanitizedBaseName, - reserved: true) || - !signalNamer.isAvailable(sanitizedBaseName)) { - throw UnavailableReservedNameException(sanitizedBaseName); - } - - return instanceNameUniquifier.getUniqueName( - initialName: sanitizedBaseName, - reserved: true, - ); - } - - var candidate = sanitizedBaseName; - var suffix = 0; - while (!instanceNameUniquifier.isAvailable(candidate) || - !signalNamer.isAvailable(candidate)) { - candidate = '${sanitizedBaseName}_$suffix'; - suffix++; - } - - return instanceNameUniquifier.getUniqueName(initialName: candidate); - } - - /// Returns `true` if [name] has not yet been claimed as a signal name in - /// this module's signal namespace. - bool isSignalNameAvailable(String name) => signalNamer.isAvailable(name); - - /// Returns `true` if [name] has not yet been claimed as an instance name in - /// this module's instance namespace. - bool isInstanceNameAvailable(String name) => - instanceNameUniquifier.isAvailable(name); - /// An internal mapping of inOut names to their sources to this [Module]. late final Map _inOutSources = {}; diff --git a/lib/src/synthesizers/synthesizer.dart b/lib/src/synthesizers/synthesizer.dart index 7b350e8b4..687bbab03 100644 --- a/lib/src/synthesizers/synthesizer.dart +++ b/lib/src/synthesizers/synthesizer.dart @@ -18,6 +18,5 @@ abstract class Synthesizer { /// Synthesizes [module] into a [SynthesisResult], given the mapping provided /// by [getInstanceTypeOfModule]. SynthesisResult synthesize( - Module module, String Function(Module module) getInstanceTypeOfModule, - {Map? existingResults}); + Module module, String Function(Module module) getInstanceTypeOfModule); } diff --git a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart index d50daf45a..062647ac3 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart @@ -137,8 +137,7 @@ class SystemVerilogSynthesizer extends Synthesizer { @override SynthesisResult synthesize( - Module module, String Function(Module module) getInstanceTypeOfModule, - {Map? existingResults}) { + Module module, String Function(Module module) getInstanceTypeOfModule) { assert( module is! SystemVerilog || module.generatedDefinitionType != DefinitionGenerationType.none, diff --git a/lib/src/synthesizers/utilities/synth_logic.dart b/lib/src/synthesizers/utilities/synth_logic.dart index b5827295b..ad88bd6cc 100644 --- a/lib/src/synthesizers/utilities/synth_logic.dart +++ b/lib/src/synthesizers/utilities/synth_logic.dart @@ -11,11 +11,25 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:rohd/src/utilities/namer.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; /// Represents a logic signal in the generated code within a module. @internal class SynthLogic { + /// Controls whether two constants with the same value driving separate + /// module inputs are merged into a single signal declaration. + /// + /// When `true` (the default), identical constants are collapsed to one + /// declaration — desirable for simulation-oriented output such as + /// SystemVerilog, where a single `assign wire = VALUE;` feeds all + /// downstream consumers. + /// + /// When `false`, each constant input keeps its own declaration. This is + /// useful for netlist/visualization outputs where seeing every individual + /// constant connection is more informative than an optimized fan-out net. + static bool mergeConstantInputs = true; + /// All [Logic]s represented, regardless of type. List get logics => UnmodifiableListView([ if (_reservedLogic != null) _reservedLogic!, @@ -225,7 +239,7 @@ class SynthLogic { /// Delegates to signal namer which handles constant value naming, priority /// selection, and uniquification via the module's shared namespace. String _findName() => - parentSynthModuleDefinition.module.signalNamer.nameOfBest( + parentSynthModuleDefinition.module.namer.signalNameOfBest( logics, constValue: _constLogic, constNameDisallowed: _constNameDisallowed, @@ -274,7 +288,12 @@ class SynthLogic { } /// Indicates whether two constants can be merged. + /// + /// Merging is only performed when [SynthLogic.mergeConstantInputs] is + /// `true`. Set it to `false` to keep each constant input as its own + /// declaration (e.g. for netlist/visualization output). static bool _constantsMergeable(SynthLogic a, SynthLogic b) => + SynthLogic.mergeConstantInputs && a.isConstant && b.isConstant && a._constLogic!.value == b._constLogic!.value && @@ -336,7 +355,7 @@ class SynthLogic { @override String toString() => '${_name == null ? 'null' : '"$name"'}, ' - 'logics contained: ${logics.map((e) => e.preferredSynthName).toList()}'; + 'logics contained: ${logics.map(Namer.baseName).toList()}'; /// Provides a definition for a range in SV from a width. static String _widthToRangeDef(int width, {bool forceRange = false}) { @@ -483,17 +502,3 @@ class SynthLogicArrayElement extends SynthLogic { ' parentArray=($parentArray), element ${logic.arrayIndex}, logic: $logic' ' logics contained: ${logics.map((e) => e.name).toList()}'; } - -extension on Logic { - /// Returns the preferred name for this [Logic] while generating in the synth - /// stack. - String get preferredSynthName => naming == Naming.reserved - // if reserved, keep the exact name - ? name - : isArrayMember - // arrays nicely name their elements already - ? name - // sanitize to remove any `.` in struct names - // the base `name` will be returned if not a structure. - : Sanitizer.sanitizeSV(structureName); -} diff --git a/lib/src/synthesizers/utilities/synth_module_definition.dart b/lib/src/synthesizers/utilities/synth_module_definition.dart index 97722a629..73b4e95c3 100644 --- a/lib/src/synthesizers/utilities/synth_module_definition.dart +++ b/lib/src/synthesizers/utilities/synth_module_definition.dart @@ -743,12 +743,11 @@ class SynthModuleDefinition { /// Picks names of signals and sub-modules. /// - /// Signal names are read from [Module.signalName] (for user-created + /// Signal names are read from `Namer.signalNameOf `(for user-created /// [Logic] objects) or kept as literal constants and are allocated from - /// [Module.allocateSignalName] (signal namespace). Submodule instance - /// names are allocated from [Module.allocateInstanceName] (instance - /// namespace). The two namespaces are independent, matching SystemVerilog - /// semantics where signal and instance identifiers do not collide. + /// `Namer.allocateSignalName` (signal namespace). Submodule instance + /// names are allocated from `Namer.allocateInstanceName` (instance + /// namespace). Both namespaces are managed by the module's `Namer`. void _pickNames() { // Name allocation order matters — earlier claims get the unsuffixed name // when there are collisions. This matches production ROHD priority: diff --git a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart index 4eaf83f57..0cee7f1c9 100644 --- a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart +++ b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart @@ -25,15 +25,15 @@ class SynthSubModuleInstantiation { /// Selects a name for this module instance. Must be called exactly once. /// - /// Names are allocated from [parentModule]'s instance namespace via - /// [Module.allocateInstanceName], which is kept separate from the signal + /// Names are allocated from [parentModule]'s `Namer`'s instance namespace + /// via `Namer.allocateInstanceName`], which is kept separate from the signal /// namespace. In SystemVerilog (and other HDLs) instance names and signal /// names occupy distinct namespaces, so they must be uniquified /// independently to avoid spurious suffixing. void pickName(Module parentModule) { assert(_name == null, 'Should only pick a name once.'); - _name = parentModule.allocateInstanceName( + _name = parentModule.namer.allocateInstanceName( module.uniqueInstanceName, reserved: module.reserveName, ); diff --git a/lib/src/utilities/config.dart b/lib/src/utilities/config.dart index 89eda836a..4aa2ca8c6 100644 --- a/lib/src/utilities/config.dart +++ b/lib/src/utilities/config.dart @@ -11,13 +11,4 @@ class Config { /// The version of the ROHD framework. static const String version = '0.6.8'; - - /// Controls whether synthesized signal names and instance names must be - /// unique across both namespaces. - /// - /// When `true`, central naming cross-checks both namespaces during - /// allocation to avoid collisions in generated output. - /// - /// When `false`, signal and instance names are uniquified independently. - static bool ensureUniqueSignalAndInstanceNames = true; } diff --git a/lib/src/utilities/namer.dart b/lib/src/utilities/namer.dart new file mode 100644 index 000000000..f03f708fa --- /dev/null +++ b/lib/src/utilities/namer.dart @@ -0,0 +1,349 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// namer.dart +// Central collision-free naming for signals and instances within a module. +// +// 2026 April 10 +// Author: Desmond Kirkpatrick + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/sanitizer.dart'; +import 'package:rohd/src/utilities/uniquifier.dart'; + +/// Central namer that manages collision-free names for both signals and +/// submodule instances within a single module scope. +/// +/// Signal names and instance names occupy separate namespaces (matching +/// SystemVerilog semantics), but can optionally be cross-checked via +/// [uniquifySignalAndInstanceNames] for simulator compatibility. +/// +/// Port names are reserved at construction time. Internal signal names +/// are assigned lazily on the first [signalNameOf] call. Instance names +/// are allocated explicitly via [allocateInstanceName]. +@internal +class Namer { + /// Controls whether signal names and instance names must be unique + /// across both namespaces. + /// + /// When `true` (the default), allocations cross-check both namespaces + /// so that no identifier appears as both a signal and an instance name. + /// This is necessary for simulators like Icarus Verilog that reject + /// duplicate identifiers even across namespace boundaries. + /// + /// When `false`, signal and instance names are uniquified independently, + /// matching strict SystemVerilog semantics where instance and signal + /// identifiers occupy separate namespaces. + static bool uniquifySignalAndInstanceNames = true; + + // ─── Signal namespace ─────────────────────────────────────────── + + final Uniquifier _signalUniquifier; + + /// Sparse cache: only entries where the canonical name has been resolved. + /// Ports whose sanitized name == logic.name may be absent (fast-path + /// through [_portLogics] check). + final Map _signalNames = {}; + + /// The set of port [Logic] objects, for O(1) port membership tests. + final Set _portLogics; + + // ─── Instance namespace ───────────────────────────────────────── + + final Uniquifier _instanceUniquifier = Uniquifier(); + + // ─── Construction ─────────────────────────────────────────────── + + Namer._({ + required Uniquifier signalUniquifier, + required Map portRenames, + required Set portLogics, + }) : _signalUniquifier = signalUniquifier, + _portLogics = portLogics { + _signalNames.addAll(portRenames); + } + + /// Creates a [Namer] for the given module ports. + /// + /// Sanitized port names are reserved in the signal namespace. Ports + /// whose sanitized name differs from [Logic.name] are cached immediately. + factory Namer.forModule({ + required Map inputs, + required Map outputs, + required Map inOuts, + }) { + final portRenames = {}; + final portLogics = {}; + final portNames = []; + + void collectPort(String rawName, Logic logic) { + final sanitized = Sanitizer.sanitizeSV(rawName); + portNames.add(sanitized); + portLogics.add(logic); + if (sanitized != logic.name) { + portRenames[logic] = sanitized; + } + } + + for (final entry in inputs.entries) { + collectPort(entry.key, entry.value); + } + for (final entry in outputs.entries) { + collectPort(entry.key, entry.value); + } + for (final entry in inOuts.entries) { + collectPort(entry.key, entry.value); + } + + final uniquifier = Uniquifier(); + for (final name in portNames) { + uniquifier.getUniqueName(initialName: name, reserved: true); + } + + return Namer._( + signalUniquifier: uniquifier, + portRenames: portRenames, + portLogics: portLogics, + ); + } + + // ─── Signal availability / allocation ─────────────────────────── + + bool _isSignalAvailable(String name, {bool reserved = false}) => + _signalUniquifier.isAvailable(name, reserved: reserved) && + (!uniquifySignalAndInstanceNames || + _instanceUniquifier.isAvailable(name)); + + String _allocateUniqueSignalName(String baseName, {bool reserved = false}) { + if (reserved) { + if (!_isSignalAvailable(baseName, reserved: true)) { + throw UnavailableReservedNameException(baseName); + } + + _signalUniquifier.getUniqueName(initialName: baseName, reserved: true); + return baseName; + } + + var candidate = baseName; + var suffix = 0; + while (!_isSignalAvailable(candidate)) { + candidate = '${baseName}_$suffix'; + suffix++; + } + + _signalUniquifier.getUniqueName(initialName: candidate); + return candidate; + } + + /// Returns `true` if [name] has not yet been claimed in the signal + /// namespace. + bool isSignalNameAvailable(String name) => _isSignalAvailable(name); + + /// Allocates a collision-free name in the signal namespace. + /// + /// When [reserved] is `true`, the exact [baseName] (after sanitization) + /// is claimed without modification; an exception is thrown if it collides. + String allocateSignalName(String baseName, {bool reserved = false}) => + _allocateUniqueSignalName( + Sanitizer.sanitizeSV(baseName), + reserved: reserved, + ); + + // ─── Instance availability / allocation ───────────────────────── + + bool _isInstanceAvailable(String name, {bool reserved = false}) => + _instanceUniquifier.isAvailable(name, reserved: reserved) && + (!uniquifySignalAndInstanceNames || _signalUniquifier.isAvailable(name)); + + /// Returns `true` if [name] has not yet been claimed in the instance + /// namespace. + bool isInstanceNameAvailable(String name) => + _instanceUniquifier.isAvailable(name); + + /// Allocates a collision-free instance name. + /// + /// When [reserved] is `true`, the exact [baseName] (after sanitization) + /// is claimed without modification; an exception is thrown if it collides. + String allocateInstanceName(String baseName, {bool reserved = false}) { + final sanitizedBaseName = Sanitizer.sanitizeSV(baseName); + + if (!uniquifySignalAndInstanceNames) { + return _instanceUniquifier.getUniqueName( + initialName: sanitizedBaseName, + reserved: reserved, + ); + } + + if (reserved) { + if (!_isInstanceAvailable(sanitizedBaseName, reserved: true)) { + throw UnavailableReservedNameException(sanitizedBaseName); + } + + return _instanceUniquifier.getUniqueName( + initialName: sanitizedBaseName, + reserved: true, + ); + } + + var candidate = sanitizedBaseName; + var suffix = 0; + while (!_isInstanceAvailable(candidate)) { + candidate = '${sanitizedBaseName}_$suffix'; + suffix++; + } + + return _instanceUniquifier.getUniqueName(initialName: candidate); + } + + // ─── Signal naming (Logic → String) ───────────────────────────── + + /// Returns the canonical name for [logic]. + /// + /// The first call for a given [logic] allocates a collision-free name + /// via the underlying [Uniquifier]. Subsequent calls return the cached + /// result in O(1). + String signalNameOf(Logic logic) { + final cached = _signalNames[logic]; + if (cached != null) { + return cached; + } + + if (_portLogics.contains(logic)) { + return logic.name; + } + + String base; + final isReservedInternal = logic.naming == Naming.reserved && !logic.isPort; + if (logic.naming == Naming.reserved || logic.isArrayMember) { + base = logic.name; + } else { + base = Sanitizer.sanitizeSV(logic.structureName); + } + + final name = _allocateUniqueSignalName( + base, + reserved: isReservedInternal, + ); + _signalNames[logic] = name; + return name; + } + + /// The base name that would be used for [logic] before uniquification. + static String baseName(Logic logic) => + (logic.naming == Naming.reserved || logic.isArrayMember) + ? logic.name + : Sanitizer.sanitizeSV(logic.structureName); + + /// Chooses the best name from a pool of merged [Logic] signals. + /// + /// When [constValue] is provided and [constNameDisallowed] is `false`, + /// the constant's value string is used directly as the name (no + /// uniquification). When [constNameDisallowed] is `true`, the constant + /// is excluded from the candidate pool and the normal priority applies. + /// + /// Priority (after constant handling): + /// 1. Port of this module (always wins — its name is already reserved). + /// 2. Reserved internal signal (exact name, throws on collision). + /// 3. Renameable signal. + /// 4. Preferred-available mergeable (base name not yet taken). + /// 5. Preferred-uniquifiable mergeable. + /// 6. Available-unpreferred mergeable. + /// 7. First unpreferred mergeable. + /// 8. Unnamed (prefer non-unpreferred base name). + /// + /// The winning name is allocated once and cached for the chosen [Logic]. + /// All other non-port [Logic]s in [candidates] are also cached to the + /// same name. + String signalNameOfBest( + Iterable candidates, { + Const? constValue, + bool constNameDisallowed = false, + }) { + if (constValue != null && !constNameDisallowed) { + return constValue.value.toString(); + } + + Logic? port; + Logic? reserved; + Logic? renameable; + final preferredMergeable = []; + final unpreferredMergeable = []; + final unnamed = []; + + for (final logic in candidates) { + if (_portLogics.contains(logic)) { + port = logic; + } else if (logic.isPort) { + if (Naming.isUnpreferred(baseName(logic))) { + unpreferredMergeable.add(logic); + } else { + preferredMergeable.add(logic); + } + } else if (logic.naming == Naming.reserved) { + reserved = logic; + } else if (logic.naming == Naming.renameable) { + renameable = logic; + } else if (logic.naming == Naming.mergeable) { + if (Naming.isUnpreferred(baseName(logic))) { + unpreferredMergeable.add(logic); + } else { + preferredMergeable.add(logic); + } + } else { + unnamed.add(logic); + } + } + + if (port != null) { + return _nameAndCacheAll(port, candidates); + } + + if (reserved != null) { + return _nameAndCacheAll(reserved, candidates); + } + + if (renameable != null) { + return _nameAndCacheAll(renameable, candidates); + } + + for (final logic in preferredMergeable) { + if (_isSignalAvailable(baseName(logic))) { + return _nameAndCacheAll(logic, candidates); + } + } + + if (preferredMergeable.isNotEmpty) { + return _nameAndCacheAll(preferredMergeable.first, candidates); + } + + if (unpreferredMergeable.isNotEmpty) { + final best = unpreferredMergeable + .firstWhereOrNull((e) => _isSignalAvailable(baseName(e))) ?? + unpreferredMergeable.first; + return _nameAndCacheAll(best, candidates); + } + + if (unnamed.isNotEmpty) { + final best = + unnamed.firstWhereOrNull((e) => !Naming.isUnpreferred(baseName(e))) ?? + unnamed.first; + return _nameAndCacheAll(best, candidates); + } + + throw StateError('No Logic candidates to name.'); + } + + /// Names [chosen] via [signalNameOf], then caches the same name for all + /// other non-port [Logic]s in [all]. + String _nameAndCacheAll(Logic chosen, Iterable all) { + final name = signalNameOf(chosen); + for (final logic in all) { + if (!identical(logic, chosen) && !_portLogics.contains(logic)) { + _signalNames[logic] = name; + } + } + return name; + } +} diff --git a/lib/src/utilities/signal_namer.dart b/lib/src/utilities/signal_namer.dart index 7f98fdff3..1f217489c 100644 --- a/lib/src/utilities/signal_namer.dart +++ b/lib/src/utilities/signal_namer.dart @@ -22,6 +22,19 @@ import 'package:rohd/src/utilities/uniquifier.dart'; /// named lazily on the first [nameOf] call. @internal class SignalNamer { + /// Controls whether synthesized signal names and instance names must be + /// unique across both namespaces. + /// + /// When `true` (the default), central naming cross-checks both namespaces + /// during allocation so that no identifier appears as both a signal and an + /// instance name. This is necessary for simulators like Icarus Verilog + /// that reject duplicate identifiers even across namespace boundaries. + /// + /// When `false`, signal and instance names are uniquified independently, + /// matching strict SystemVerilog semantics where instance and signal + /// identifiers occupy separate namespaces. + static bool uniquifySignalAndInstanceNames = true; + final Uniquifier _uniquifier; final bool Function(String name) _isAvailableInOtherNamespace; @@ -89,14 +102,13 @@ class SignalNamer { uniquifier: uniquifier, portRenames: portRenames, portLogics: portLogics, - isAvailableInOtherNamespace: - isAvailableInOtherNamespace ?? ((_) => true), + isAvailableInOtherNamespace: isAvailableInOtherNamespace ?? (_) => true, ); } bool _isAvailable(String name, {bool reserved = false}) => _uniquifier.isAvailable(name, reserved: reserved) && - _isAvailableInOtherNamespace(name); + (!uniquifySignalAndInstanceNames || _isAvailableInOtherNamespace(name)); String _allocateUniqueName(String baseName, {bool reserved = false}) { if (reserved) { diff --git a/test/instance_signal_name_collision_test.dart b/test/instance_signal_name_collision_test.dart index 6ee10de92..c369f83e4 100644 --- a/test/instance_signal_name_collision_test.dart +++ b/test/instance_signal_name_collision_test.dart @@ -18,7 +18,7 @@ import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; -import 'package:rohd/src/utilities/config.dart'; +import 'package:rohd/src/utilities/namer.dart'; import 'package:test/test.dart'; // ── Minimal repro modules ──────────────────────────────────────────────────── @@ -61,8 +61,8 @@ void main() { late bool previousSetting; setUpAll(() async { - previousSetting = Config.ensureUniqueSignalAndInstanceNames; - Config.ensureUniqueSignalAndInstanceNames = false; + previousSetting = Namer.uniquifySignalAndInstanceNames; + Namer.uniquifySignalAndInstanceNames = false; mod = _CollidingParent(Logic(width: 8)); await mod.build(); @@ -70,7 +70,7 @@ void main() { }); tearDownAll(() { - Config.ensureUniqueSignalAndInstanceNames = previousSetting; + Namer.uniquifySignalAndInstanceNames = previousSetting; }); test('internal signal named "inner" retains its exact name', () { diff --git a/test/naming_consistency_test.dart b/test/naming_consistency_test.dart index b569bd4d6..c79221baa 100644 --- a/test/naming_consistency_test.dart +++ b/test/naming_consistency_test.dart @@ -4,7 +4,7 @@ // naming_consistency_test.dart // Validates that both the SystemVerilog synthesizer and a base // SynthModuleDefinition (used by the netlist synthesizer) produce -// consistent signal names via the shared Module.signalNamer. +// consistent signal names via the shared Module.namer. // // 2026 April 10 // Author: Desmond Kirkpatrick @@ -100,7 +100,7 @@ void main() { final svDef = SystemVerilogSynthModuleDefinition(mod); // Base path (same as netlist synthesizer uses) - // Since signalNamer is late final, the second constructor reuses + // Since namer is late final, the second constructor reuses // the same naming state — names must be consistent. final baseDef = SynthModuleDefinition(mod); @@ -181,12 +181,11 @@ void main() { } }); - test('signalNamer is shared across multiple SynthModuleDefinitions', - () async { + test('namer is shared across multiple SynthModuleDefinitions', () async { final mod = _Outer(Logic(width: 8), Logic(width: 8)); await mod.build(); - // Build one def, then build another — same signalNamer instance. + // Build one def, then build another — same namer instance. final def1 = SynthModuleDefinition(mod); final def2 = SynthModuleDefinition(mod); @@ -202,27 +201,27 @@ void main() { } }); - test('Module.signalName matches SynthLogic.name for ports', () async { + test('Namer.signalNameOf matches SynthLogic.name for ports', () async { final mod = _Outer(Logic(width: 8), Logic(width: 8)); await mod.build(); final def = SynthModuleDefinition(mod); final synthNames = _collectNames(def); - // Module.signalName uses SignalNamer.nameOf directly + // Module.namer.signalNameOf uses Namer directly for (final port in [...mod.inputs.values, ...mod.outputs.values]) { - final moduleName = mod.signalName(port); + final moduleName = mod.namer.signalNameOf(port); final synthName = synthNames[port]; expect(synthName, moduleName, - reason: 'SynthLogic.name and Module.signalName must agree ' + reason: 'SynthLogic.name and Module.namer.signalNameOf must agree ' 'for port ${port.name}'); } }); test('submodule instance names are allocated from the instance namespace', () async { - // Instance names come from Module.allocateInstanceName, which is - // separate from the signal namespace (Module.allocateSignalName). + // Instance names come from Module.namer.allocateInstanceName, which is + // separate from the signal namespace (Module.namer.allocateSignalName). // A signal and a submodule instance may therefore share the same // identifier without collision — matching SystemVerilog semantics. final mod = _Outer(Logic(width: 8), Logic(width: 8)); @@ -242,7 +241,7 @@ void main() { // Instance names are claimed in the *instance* namespace, NOT the // signal namespace. for (final name in instNames) { - expect(mod.isInstanceNameAvailable(name), isFalse, + expect(mod.namer.isInstanceNameAvailable(name), isFalse, reason: 'Instance name "$name" should be claimed in instance ' 'namespace'); } diff --git a/test/naming_namespace_test.dart b/test/naming_namespace_test.dart new file mode 100644 index 000000000..32e55629d --- /dev/null +++ b/test/naming_namespace_test.dart @@ -0,0 +1,180 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// naming_namespace_test.dart +// Tests for constant naming via nameOfBest, the tryMerge guard for +// constNameDisallowed, and separate instance/signal namespaces. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/namer.dart'; +import 'package:test/test.dart'; + +/// A simple submodule whose instance name can collide with a signal name. +class _Inner extends Module { + _Inner(Logic a, {super.name = 'inner'}) { + a = addInput('a', a); + addOutput('b') <= ~a; + } +} + +/// Top module that has a signal named the same as a submodule instance. +class _InstanceSignalCollision extends Module { + _InstanceSignalCollision({String instanceName = 'inner'}) + : super(name: 'top') { + final a = addInput('a', Logic()); + final o = addOutput('o'); + + // Create a signal whose name matches the submodule instance name. + final sig = Logic(name: instanceName); + sig <= ~a; + + final sub = _Inner(sig, name: instanceName); + o <= sub.output('b'); + } +} + +/// Top module with two submodule instances that have the same name. +class _DuplicateInstances extends Module { + _DuplicateInstances() : super(name: 'top') { + final a = addInput('a', Logic()); + final o = addOutput('o'); + + final sub1 = _Inner(a, name: 'blk'); + final sub2 = _Inner(sub1.output('b'), name: 'blk'); + o <= sub2.output('b'); + } +} + +/// Module that uses a constant in a connection chain, exercising constant +/// naming through nameOfBest. +class _ConstantNamingModule extends Module { + _ConstantNamingModule() : super(name: 'const_mod') { + final a = addInput('a', Logic()); + final o = addOutput('o'); + + // A constant "1" drives one input of the AND gate. + o <= a & Const(1); + } +} + +/// Module with a mux where one input is a constant, exercising the +/// constNameDisallowed path — the mux output cannot use the constant's +/// literal as its name because it also carries non-constant values. +class _ConstNameDisallowedModule extends Module { + _ConstNameDisallowedModule() : super(name: 'const_disallow') { + final a = addInput('a', Logic()); + final sel = addInput('sel', Logic()); + final o = addOutput('o'); + + // mux output can be the constant OR a, so the constant name is disallowed. + o <= mux(sel, Const(1), a); + } +} + +void main() { + tearDown(() async { + await Simulator.reset(); + // Restore default. + Namer.uniquifySignalAndInstanceNames = true; + }); + + group('constant naming via nameOfBest', () { + test('constant value appears as literal in SV output', () async { + final dut = _ConstantNamingModule(); + await dut.build(); + final sv = dut.generateSynth(); + + // The constant "1" should appear as a literal 1'h1 in the output, + // not as a declared signal. + expect(sv, contains("1'h1")); + }); + + test('constNameDisallowed falls through to signal naming', () async { + final dut = _ConstNameDisallowedModule(); + await dut.build(); + final sv = dut.generateSynth(); + + // The output assignment should NOT use the raw constant literal + // as a wire name; a proper signal name should be used instead. + // The constant still appears as a literal in the mux expression. + expect(sv, contains("1'h1")); + // The output 'o' should be assigned from something. + expect(sv, contains('o')); + }); + }); + + group('separate instance and signal namespaces', () { + test( + 'signal and instance with same name do not collide ' + 'when namespaces are independent', () async { + Namer.uniquifySignalAndInstanceNames = false; + final dut = _InstanceSignalCollision(); + await dut.build(); + final sv = dut.generateSynth(); + + // With independent namespaces, the signal keeps its name 'inner' + // and the instance also keeps 'inner' — no spurious _0 suffix. + expect(sv, contains(RegExp(r'logic\s+inner[,;\s]'))); + expect(sv, isNot(contains('inner_0'))); + }); + + test( + 'signal and instance get suffixed when ' + 'ensureUniqueSignalAndInstanceNames is true', () async { + Namer.uniquifySignalAndInstanceNames = true; + final dut = _InstanceSignalCollision(); + await dut.build(); + final sv = dut.generateSynth(); + + // With cross-namespace checking enabled, the signal 'inner' is + // allocated first (during signal naming); when the instance tries + // to claim 'inner', it sees the signal namespace has it, so the + // instance OR signal gets a suffix. + expect(sv, contains('inner_0')); + }); + + test( + 'signal and instance do not spuriously suffix when ' + 'ensureUniqueSignalAndInstanceNames is false', () async { + Namer.uniquifySignalAndInstanceNames = false; + final dut = _InstanceSignalCollision(); + await dut.build(); + final sv = dut.generateSynth(); + + // With independent namespaces, no spurious suffixing. + expect(sv, isNot(contains('inner_0'))); + }); + + test('duplicate instance names get uniquified', () async { + final dut = _DuplicateInstances(); + await dut.build(); + final sv = dut.generateSynth(); + + // Two instances named 'blk' — one should be 'blk', the other 'blk_0'. + expect(sv, contains('blk')); + expect(sv, contains(RegExp(r'blk_\d'))); + }); + }); + + group('instance namespace independence', () { + test('allocateInstanceName is independent from allocateSignalName', + () async { + final dut = _InstanceSignalCollision(); + await dut.build(); + + // After build, the signal namer has 'inner' claimed. + // With independent namespaces, instance namespace should also accept + // 'inner' without conflict. + Namer.uniquifySignalAndInstanceNames = false; + + // The instance namespace should show 'inner' as available before + // any instance allocation. + // (After synthesis, names are already allocated, so we just verify + // the module built without error.) + expect(dut.generateSynth(), isNotEmpty); + }); + }); +} From 61d031928feb5156f1ab8bf697571bc9077b665e Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 19 Apr 2026 20:14:20 -0700 Subject: [PATCH 08/20] signal registry --- test/signal_registry_test.dart | 142 +++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 test/signal_registry_test.dart diff --git a/test/signal_registry_test.dart b/test/signal_registry_test.dart new file mode 100644 index 000000000..152b6091a --- /dev/null +++ b/test/signal_registry_test.dart @@ -0,0 +1,142 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_registry_test.dart +// Tests for Module canonical naming (SynthesisNameRegistry). +// +// 2026 April 14 + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +// ──────────────────────────────────────────────────────────────── +// Simple test modules +// ──────────────────────────────────────────────────────────────── + +class _GateMod extends Module { + _GateMod(Logic a, Logic b) : super(name: 'gatetestmodule') { + a = addInput('a', a); + b = addInput('b', b); + final aBar = addOutput('a_bar'); + final aAndB = addOutput('a_and_b'); + aBar <= ~a; + aAndB <= a & b; + } +} + +class _Counter extends Module { + _Counter(Logic en, Logic reset, {int width = 8}) : super(name: 'counter') { + en = addInput('en', en); + reset = addInput('reset', reset); + final val = addOutput('val', width: width); + final nextVal = Logic(name: 'nextVal', width: width); + nextVal <= val + 1; + Sequential.multi([ + SimpleClockGenerator(10).clk, + reset, + ], [ + If(reset, then: [ + val < 0, + ], orElse: [ + If(en, then: [val < nextVal]), + ]), + ]); + } +} + +void main() { + tearDown(() async { + await Simulator.reset(); + }); + + group('signalName basics', () { + test('returns port names after build', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + expect(mod.namer.signalNameOf(mod.input('a')), equals('a')); + expect(mod.namer.signalNameOf(mod.input('b')), equals('b')); + expect(mod.namer.signalNameOf(mod.output('a_bar')), equals('a_bar')); + expect(mod.namer.signalNameOf(mod.output('a_and_b')), equals('a_and_b')); + }); + + test('returns internal signal names', () async { + final mod = _Counter(Logic(), Logic()); + await mod.build(); + + expect(mod.namer.signalNameOf(mod.input('en')), equals('en')); + expect(mod.namer.signalNameOf(mod.input('reset')), equals('reset')); + expect(mod.namer.signalNameOf(mod.output('val')), equals('val')); + }); + + test('agrees with signalName after synth', () async { + final mod = _Counter(Logic(), Logic()); + await mod.build(); + + for (final entry in mod.inputs.entries) { + expect( + mod.namer.signalNameOf(entry.value), + isNotNull, + reason: 'signalName should work for input ${entry.key}', + ); + } + for (final entry in mod.outputs.entries) { + expect( + mod.namer.signalNameOf(entry.value), + isNotNull, + reason: 'signalName should work for output ${entry.key}', + ); + } + }); + }); + + group('allocateSignalName', () { + test('avoids collision with existing names', () async { + final mod = _Counter(Logic(), Logic()); + await mod.build(); + + final allocated = mod.namer.allocateSignalName('en'); + expect(allocated, isNot(equals('en')), + reason: 'Should not collide with existing port name'); + expect(allocated, contains('en'), + reason: 'Should be based on the requested name'); + }); + + test('successive allocations are unique', () async { + final mod = _Counter(Logic(), Logic()); + await mod.build(); + + final a = mod.namer.allocateSignalName('wire'); + final b = mod.namer.allocateSignalName('wire'); + expect(a, isNot(equals(b)), reason: 'Each allocation should be unique'); + }); + }); + + group('sparse storage', () { + test('identity names not stored in renames', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + expect(mod.namer.signalNameOf(mod.input('a')), equals('a')); + expect(mod.input('a').name, equals('a')); + }); + }); + + group('determinism', () { + test('same module produces identical canonical names', () async { + Future> buildAndGetNames() async { + final mod = _Counter(Logic(), Logic()); + await mod.build(); + return { + for (final sig in mod.signals) sig.name: mod.namer.signalNameOf(sig), + }; + } + + final names1 = await buildAndGetNames(); + await Simulator.reset(); + final names2 = await buildAndGetNames(); + + expect(names1, equals(names2)); + }); + }); +} From becdb369f6715cf29bd8f66050dfbd6cfe83c79a Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 1 May 2026 13:02:53 -0700 Subject: [PATCH 09/20] module context name uniquification instead of signal/instance split --- .../utilities/synth_module_definition.dart | 8 +- .../synth_sub_module_instantiation.dart | 7 +- lib/src/utilities/namer.dart | 106 ++---- lib/src/utilities/signal_namer.dart | 314 ------------------ test/instance_signal_name_collision_test.dart | 59 +--- test/naming_consistency_test.dart | 13 +- test/naming_namespace_test.dart | 65 +--- 7 files changed, 59 insertions(+), 513 deletions(-) delete mode 100644 lib/src/utilities/signal_namer.dart diff --git a/lib/src/synthesizers/utilities/synth_module_definition.dart b/lib/src/synthesizers/utilities/synth_module_definition.dart index 73b4e95c3..1a4c97393 100644 --- a/lib/src/synthesizers/utilities/synth_module_definition.dart +++ b/lib/src/synthesizers/utilities/synth_module_definition.dart @@ -743,11 +743,11 @@ class SynthModuleDefinition { /// Picks names of signals and sub-modules. /// - /// Signal names are read from `Namer.signalNameOf `(for user-created + /// Signal names are read from `Namer.signalNameOf` (for user-created /// [Logic] objects) or kept as literal constants and are allocated from - /// `Namer.allocateSignalName` (signal namespace). Submodule instance - /// names are allocated from `Namer.allocateInstanceName` (instance - /// namespace). Both namespaces are managed by the module's `Namer`. + /// `Namer.allocateSignalName`. Submodule instance names are allocated + /// from `Namer.allocateInstanceName`. All names share a single + /// namespace managed by the module's `Namer`. void _pickNames() { // Name allocation order matters — earlier claims get the unsuffixed name // when there are collisions. This matches production ROHD priority: diff --git a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart index 0cee7f1c9..67f9e2832 100644 --- a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart +++ b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart @@ -25,11 +25,8 @@ class SynthSubModuleInstantiation { /// Selects a name for this module instance. Must be called exactly once. /// - /// Names are allocated from [parentModule]'s `Namer`'s instance namespace - /// via `Namer.allocateInstanceName`], which is kept separate from the signal - /// namespace. In SystemVerilog (and other HDLs) instance names and signal - /// names occupy distinct namespaces, so they must be uniquified - /// independently to avoid spurious suffixing. + /// Names are allocated from [parentModule]'s `Namer`'s shared namespace + /// via `Namer.allocateInstanceName`. void pickName(Module parentModule) { assert(_name == null, 'Should only pick a name once.'); diff --git a/lib/src/utilities/namer.dart b/lib/src/utilities/namer.dart index f03f708fa..481dc64e3 100644 --- a/lib/src/utilities/namer.dart +++ b/lib/src/utilities/namer.dart @@ -16,31 +16,17 @@ import 'package:rohd/src/utilities/uniquifier.dart'; /// Central namer that manages collision-free names for both signals and /// submodule instances within a single module scope. /// -/// Signal names and instance names occupy separate namespaces (matching -/// SystemVerilog semantics), but can optionally be cross-checked via -/// [uniquifySignalAndInstanceNames] for simulator compatibility. +/// All identifiers (signals and instances) share a single namespace, +/// ensuring no name collisions in the generated SystemVerilog. /// /// Port names are reserved at construction time. Internal signal names /// are assigned lazily on the first [signalNameOf] call. Instance names /// are allocated explicitly via [allocateInstanceName]. @internal class Namer { - /// Controls whether signal names and instance names must be unique - /// across both namespaces. - /// - /// When `true` (the default), allocations cross-check both namespaces - /// so that no identifier appears as both a signal and an instance name. - /// This is necessary for simulators like Icarus Verilog that reject - /// duplicate identifiers even across namespace boundaries. - /// - /// When `false`, signal and instance names are uniquified independently, - /// matching strict SystemVerilog semantics where instance and signal - /// identifiers occupy separate namespaces. - static bool uniquifySignalAndInstanceNames = true; - - // ─── Signal namespace ─────────────────────────────────────────── + // ─── Shared namespace ─────────────────────────────────────────── - final Uniquifier _signalUniquifier; + final Uniquifier _uniquifier; /// Sparse cache: only entries where the canonical name has been resolved. /// Ports whose sanitized name == logic.name may be absent (fast-path @@ -50,17 +36,13 @@ class Namer { /// The set of port [Logic] objects, for O(1) port membership tests. final Set _portLogics; - // ─── Instance namespace ───────────────────────────────────────── - - final Uniquifier _instanceUniquifier = Uniquifier(); - // ─── Construction ─────────────────────────────────────────────── Namer._({ - required Uniquifier signalUniquifier, + required Uniquifier uniquifier, required Map portRenames, required Set portLogics, - }) : _signalUniquifier = signalUniquifier, + }) : _uniquifier = uniquifier, _portLogics = portLogics { _signalNames.addAll(portRenames); } @@ -103,99 +85,65 @@ class Namer { } return Namer._( - signalUniquifier: uniquifier, + uniquifier: uniquifier, portRenames: portRenames, portLogics: portLogics, ); } - // ─── Signal availability / allocation ─────────────────────────── + // ─── Name availability / allocation ───────────────────────────── - bool _isSignalAvailable(String name, {bool reserved = false}) => - _signalUniquifier.isAvailable(name, reserved: reserved) && - (!uniquifySignalAndInstanceNames || - _instanceUniquifier.isAvailable(name)); + bool _isAvailable(String name, {bool reserved = false}) => + _uniquifier.isAvailable(name, reserved: reserved); - String _allocateUniqueSignalName(String baseName, {bool reserved = false}) { + String _allocateUniqueName(String baseName, {bool reserved = false}) { if (reserved) { - if (!_isSignalAvailable(baseName, reserved: true)) { + if (!_isAvailable(baseName, reserved: true)) { throw UnavailableReservedNameException(baseName); } - _signalUniquifier.getUniqueName(initialName: baseName, reserved: true); + _uniquifier.getUniqueName(initialName: baseName, reserved: true); return baseName; } var candidate = baseName; var suffix = 0; - while (!_isSignalAvailable(candidate)) { + while (!_isAvailable(candidate)) { candidate = '${baseName}_$suffix'; suffix++; } - _signalUniquifier.getUniqueName(initialName: candidate); + _uniquifier.getUniqueName(initialName: candidate); return candidate; } - /// Returns `true` if [name] has not yet been claimed in the signal - /// namespace. - bool isSignalNameAvailable(String name) => _isSignalAvailable(name); + /// Returns `true` if [name] has not yet been claimed in the namespace. + bool isNameAvailable(String name) => _isAvailable(name); /// Allocates a collision-free name in the signal namespace. /// /// When [reserved] is `true`, the exact [baseName] (after sanitization) /// is claimed without modification; an exception is thrown if it collides. String allocateSignalName(String baseName, {bool reserved = false}) => - _allocateUniqueSignalName( + _allocateUniqueName( Sanitizer.sanitizeSV(baseName), reserved: reserved, ); - // ─── Instance availability / allocation ───────────────────────── - - bool _isInstanceAvailable(String name, {bool reserved = false}) => - _instanceUniquifier.isAvailable(name, reserved: reserved) && - (!uniquifySignalAndInstanceNames || _signalUniquifier.isAvailable(name)); + // ─── Instance allocation ──────────────────────────────────────── - /// Returns `true` if [name] has not yet been claimed in the instance - /// namespace. - bool isInstanceNameAvailable(String name) => - _instanceUniquifier.isAvailable(name); + /// Returns `true` if [name] has not yet been claimed in the namespace. + bool isInstanceNameAvailable(String name) => _isAvailable(name); /// Allocates a collision-free instance name. /// /// When [reserved] is `true`, the exact [baseName] (after sanitization) /// is claimed without modification; an exception is thrown if it collides. - String allocateInstanceName(String baseName, {bool reserved = false}) { - final sanitizedBaseName = Sanitizer.sanitizeSV(baseName); - - if (!uniquifySignalAndInstanceNames) { - return _instanceUniquifier.getUniqueName( - initialName: sanitizedBaseName, + String allocateInstanceName(String baseName, {bool reserved = false}) => + _allocateUniqueName( + Sanitizer.sanitizeSV(baseName), reserved: reserved, ); - } - - if (reserved) { - if (!_isInstanceAvailable(sanitizedBaseName, reserved: true)) { - throw UnavailableReservedNameException(sanitizedBaseName); - } - - return _instanceUniquifier.getUniqueName( - initialName: sanitizedBaseName, - reserved: true, - ); - } - - var candidate = sanitizedBaseName; - var suffix = 0; - while (!_isInstanceAvailable(candidate)) { - candidate = '${sanitizedBaseName}_$suffix'; - suffix++; - } - - return _instanceUniquifier.getUniqueName(initialName: candidate); - } // ─── Signal naming (Logic → String) ───────────────────────────── @@ -222,7 +170,7 @@ class Namer { base = Sanitizer.sanitizeSV(logic.structureName); } - final name = _allocateUniqueSignalName( + final name = _allocateUniqueName( base, reserved: isReservedInternal, ); @@ -309,7 +257,7 @@ class Namer { } for (final logic in preferredMergeable) { - if (_isSignalAvailable(baseName(logic))) { + if (_isAvailable(baseName(logic))) { return _nameAndCacheAll(logic, candidates); } } @@ -320,7 +268,7 @@ class Namer { if (unpreferredMergeable.isNotEmpty) { final best = unpreferredMergeable - .firstWhereOrNull((e) => _isSignalAvailable(baseName(e))) ?? + .firstWhereOrNull((e) => _isAvailable(baseName(e))) ?? unpreferredMergeable.first; return _nameAndCacheAll(best, candidates); } diff --git a/lib/src/utilities/signal_namer.dart b/lib/src/utilities/signal_namer.dart deleted file mode 100644 index 1f217489c..000000000 --- a/lib/src/utilities/signal_namer.dart +++ /dev/null @@ -1,314 +0,0 @@ -// Copyright (C) 2026 Intel Corporation -// SPDX-License-Identifier: BSD-3-Clause -// -// signal_namer.dart -// Collision-free signal naming within a module scope. -// -// 2026 April 10 -// Author: Desmond Kirkpatrick - -import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; -import 'package:rohd/rohd.dart'; -import 'package:rohd/src/utilities/sanitizer.dart'; -import 'package:rohd/src/utilities/uniquifier.dart'; - -/// Assigns collision-free names to [Logic] signals within a single module. -/// -/// Wraps a [Uniquifier] with a sparse Logic→String cache so that each -/// signal is named exactly once and every subsequent lookup is O(1). -/// -/// Port names are reserved at construction time. Internal signals are -/// named lazily on the first [nameOf] call. -@internal -class SignalNamer { - /// Controls whether synthesized signal names and instance names must be - /// unique across both namespaces. - /// - /// When `true` (the default), central naming cross-checks both namespaces - /// during allocation so that no identifier appears as both a signal and an - /// instance name. This is necessary for simulators like Icarus Verilog - /// that reject duplicate identifiers even across namespace boundaries. - /// - /// When `false`, signal and instance names are uniquified independently, - /// matching strict SystemVerilog semantics where instance and signal - /// identifiers occupy separate namespaces. - static bool uniquifySignalAndInstanceNames = true; - - final Uniquifier _uniquifier; - final bool Function(String name) _isAvailableInOtherNamespace; - - /// Sparse cache: only entries where the canonical name has been resolved. - /// Ports whose sanitized name == logic.name may be absent (fast-path - /// through [_portLogics] check). - final Map _names = {}; - - /// The set of port [Logic] objects, for O(1) port membership tests. - final Set _portLogics; - - SignalNamer._({ - required Uniquifier uniquifier, - required Map portRenames, - required Set portLogics, - required bool Function(String name) isAvailableInOtherNamespace, - }) : _uniquifier = uniquifier, - _portLogics = portLogics, - _isAvailableInOtherNamespace = isAvailableInOtherNamespace { - _names.addAll(portRenames); - } - - /// Creates a [SignalNamer] for the given module ports. - /// - /// Sanitized port names are reserved in the namespace. Ports whose - /// sanitized name differs from [Logic.name] are cached immediately. - factory SignalNamer.forModule({ - required Map inputs, - required Map outputs, - required Map inOuts, - bool Function(String name)? isAvailableInOtherNamespace, - }) { - final portRenames = {}; - final portLogics = {}; - final portNames = []; - - void collectPort(String rawName, Logic logic) { - final sanitized = Sanitizer.sanitizeSV(rawName); - portNames.add(sanitized); - portLogics.add(logic); - if (sanitized != logic.name) { - portRenames[logic] = sanitized; - } - } - - for (final entry in inputs.entries) { - collectPort(entry.key, entry.value); - } - for (final entry in outputs.entries) { - collectPort(entry.key, entry.value); - } - for (final entry in inOuts.entries) { - collectPort(entry.key, entry.value); - } - - // Claim each port name as reserved so that: - // (a) non-reserved signals can't steal them, and - // (b) a second reserved signal with the same name throws. - final uniquifier = Uniquifier(); - for (final name in portNames) { - uniquifier.getUniqueName(initialName: name, reserved: true); - } - - return SignalNamer._( - uniquifier: uniquifier, - portRenames: portRenames, - portLogics: portLogics, - isAvailableInOtherNamespace: isAvailableInOtherNamespace ?? (_) => true, - ); - } - - bool _isAvailable(String name, {bool reserved = false}) => - _uniquifier.isAvailable(name, reserved: reserved) && - (!uniquifySignalAndInstanceNames || _isAvailableInOtherNamespace(name)); - - String _allocateUniqueName(String baseName, {bool reserved = false}) { - if (reserved) { - if (!_isAvailable(baseName, reserved: true)) { - throw UnavailableReservedNameException(baseName); - } - - _uniquifier.getUniqueName(initialName: baseName, reserved: true); - return baseName; - } - - var candidate = baseName; - var suffix = 0; - while (!_isAvailable(candidate)) { - candidate = '${baseName}_$suffix'; - suffix++; - } - - _uniquifier.getUniqueName(initialName: candidate); - return candidate; - } - - /// Returns the canonical name for [logic]. - /// - /// The first call for a given [logic] allocates a collision-free name - /// via the underlying [Uniquifier]. Subsequent calls return the cached - /// result in O(1). - String nameOf(Logic logic) { - // Fast path: already named (port rename or previously-queried signal). - final cached = _names[logic]; - if (cached != null) { - return cached; - } - - // Port whose sanitized name == logic.name — already reserved. - if (_portLogics.contains(logic)) { - return logic.name; - } - - // First time seeing this internal signal — derive base name. - String baseName; - // Only treat as reserved for Uniquifier purposes if this is a true - // reserved internal signal (not a submodule port that happens to have - // Naming.reserved). - final isReservedInternal = logic.naming == Naming.reserved && !logic.isPort; - if (logic.naming == Naming.reserved || logic.isArrayMember) { - baseName = logic.name; - } else { - baseName = Sanitizer.sanitizeSV(logic.structureName); - } - - final name = _allocateUniqueName( - baseName, - reserved: isReservedInternal, - ); - _names[logic] = name; - return name; - } - - /// The base name that would be used for [logic] before uniquification. - static String baseName(Logic logic) => - (logic.naming == Naming.reserved || logic.isArrayMember) - ? logic.name - : Sanitizer.sanitizeSV(logic.structureName); - - /// Chooses the best name from a pool of merged [Logic] signals. - /// - /// When [constValue] is provided and [constNameDisallowed] is `false`, - /// the constant's value string is used directly as the name (no - /// uniquification). When [constNameDisallowed] is `true`, the constant - /// is excluded from the candidate pool and the normal priority applies. - /// - /// Priority (after constant handling): - /// 1. Port of this module (always wins — its name is already reserved). - /// 2. Reserved internal signal (exact name, throws on collision). - /// 3. Renameable signal. - /// 4. Preferred-available mergeable (base name not yet taken). - /// 5. Preferred-uniquifiable mergeable. - /// 6. Available-unpreferred mergeable. - /// 7. First unpreferred mergeable. - /// 8. Unnamed (prefer non-unpreferred base name). - /// - /// The winning name is allocated once and cached for the chosen [Logic]. - /// All other non-port [Logic]s in [candidates] are also cached to the - /// same name. - String nameOfBest( - Iterable candidates, { - Const? constValue, - bool constNameDisallowed = false, - }) { - // Constant whose literal value string is the name. - if (constValue != null && !constNameDisallowed) { - return constValue.value.toString(); - } - - // Classify using _portLogics membership (context-aware) rather than - // Logic.naming (context-independent), because submodule ports have - // Naming.reserved but should NOT be treated as reserved here. - Logic? port; - Logic? reserved; - Logic? renameable; - final preferredMergeable = []; - final unpreferredMergeable = []; - final unnamed = []; - - for (final logic in candidates) { - if (_portLogics.contains(logic)) { - port = logic; - } else if (logic.isPort) { - // Submodule port — treat as mergeable regardless of intrinsic naming, - // matching SynthModuleDefinition's namingOverride convention. - if (Naming.isUnpreferred(baseName(logic))) { - unpreferredMergeable.add(logic); - } else { - preferredMergeable.add(logic); - } - } else if (logic.naming == Naming.reserved) { - reserved = logic; - } else if (logic.naming == Naming.renameable) { - renameable = logic; - } else if (logic.naming == Naming.mergeable) { - if (Naming.isUnpreferred(baseName(logic))) { - unpreferredMergeable.add(logic); - } else { - preferredMergeable.add(logic); - } - } else { - unnamed.add(logic); - } - } - - // Port of this module — name already reserved in namespace. - if (port != null) { - return _nameAndCacheAll(port, candidates); - } - - // Reserved internal — must keep exact name (throws on collision). - if (reserved != null) { - return _nameAndCacheAll(reserved, candidates); - } - - // Renameable — preferred base, uniquified if needed. - if (renameable != null) { - return _nameAndCacheAll(renameable, candidates); - } - - // Preferred-available mergeable. - for (final logic in preferredMergeable) { - if (_isAvailable(baseName(logic))) { - return _nameAndCacheAll(logic, candidates); - } - } - - // Preferred-uniquifiable mergeable. - if (preferredMergeable.isNotEmpty) { - return _nameAndCacheAll(preferredMergeable.first, candidates); - } - - // Unpreferred mergeable — prefer available. - if (unpreferredMergeable.isNotEmpty) { - final best = unpreferredMergeable - .firstWhereOrNull((e) => _isAvailable(baseName(e))) ?? - unpreferredMergeable.first; - return _nameAndCacheAll(best, candidates); - } - - // Unnamed — prefer non-unpreferred base name. - if (unnamed.isNotEmpty) { - final best = - unnamed.firstWhereOrNull((e) => !Naming.isUnpreferred(baseName(e))) ?? - unnamed.first; - return _nameAndCacheAll(best, candidates); - } - - throw StateError('No Logic candidates to name.'); - } - - /// Names [chosen] via [nameOf], then caches the same name for all other - /// non-port [Logic]s in [all]. - String _nameAndCacheAll(Logic chosen, Iterable all) { - final name = nameOf(chosen); - for (final logic in all) { - if (!identical(logic, chosen) && !_portLogics.contains(logic)) { - _names[logic] = name; - } - } - return name; - } - - /// Allocates a collision-free name for a non-signal artifact (wire, - /// instance, etc.). - /// - /// When [reserved] is `true`, the exact [baseName] (after sanitization) - /// is claimed without modification; an exception is thrown if it collides. - String allocate(String baseName, {bool reserved = false}) => - _allocateUniqueName( - Sanitizer.sanitizeSV(baseName), - reserved: reserved, - ); - - /// Returns `true` if [name] has not yet been claimed in this namespace. - bool isAvailable(String name) => _isAvailable(name); -} diff --git a/test/instance_signal_name_collision_test.dart b/test/instance_signal_name_collision_test.dart index c369f83e4..65747204a 100644 --- a/test/instance_signal_name_collision_test.dart +++ b/test/instance_signal_name_collision_test.dart @@ -2,23 +2,14 @@ // SPDX-License-Identifier: BSD-3-Clause // // instance_signal_name_collision_test.dart -// Regression test that demonstrates the bug present in the main branch where -// submodule instance names and signal names share a single Uniquifier. -// -// In SystemVerilog, signal identifiers and instance identifiers live in -// *separate* namespaces, so it is perfectly legal to have a signal called -// "inner" and a module instance also called "inner" in the same scope. -// -// When a single shared Uniquifier is used (main-branch behaviour), the second -// name to be allocated gets spuriously suffixed (e.g. "inner_0"), which -// produces incorrect generated SV. +// Tests that submodule instance names and signal names share a single +// namespace, so a collision between them results in uniquification. // // 2026 April 18 // Author: Desmond Kirkpatrick import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; -import 'package:rohd/src/utilities/namer.dart'; import 'package:test/test.dart'; // ── Minimal repro modules ──────────────────────────────────────────────────── @@ -35,8 +26,8 @@ class _Inner extends Module { /// • instantiates [_Inner] (default instance name: "inner") /// • names an internal wire "inner" as well /// -/// In SV the two identifiers live in different namespaces, so both should -/// be emitted as "inner" without any suffix. +/// Because both identifiers live in a single shared namespace, one of them +/// will be suffixed to avoid collision. class _CollidingParent extends Module { _CollidingParent(Logic a) : super(name: 'colliding_parent') { a = addInput('a', a, width: a.width); @@ -55,36 +46,30 @@ class _CollidingParent extends Module { // ── Test ───────────────────────────────────────────────────────────────────── void main() { - group('instance / signal name collision (main-branch bug)', () { + group('instance / signal name collision (shared namespace)', () { late _CollidingParent mod; late SynthModuleDefinition def; - late bool previousSetting; setUpAll(() async { - previousSetting = Namer.uniquifySignalAndInstanceNames; - Namer.uniquifySignalAndInstanceNames = false; - mod = _CollidingParent(Logic(width: 8)); await mod.build(); def = SynthModuleDefinition(mod); }); - tearDownAll(() { - Namer.uniquifySignalAndInstanceNames = previousSetting; - }); - test('internal signal named "inner" retains its exact name', () { - // Find the SynthLogic for the reserved "inner" wire. + // The reserved signal should keep its exact name. final sl = def.internalSignals.cast().firstWhere( (s) => s!.logics.any((l) => l.name == 'inner'), orElse: () => null, ); expect(sl, isNotNull, reason: 'Expected to find SynthLogic for "inner"'); expect(sl!.name, 'inner', - reason: 'Signal "inner" must not be suffixed to "inner_0"'); + reason: 'Reserved signal "inner" must keep its exact name'); }); - test('submodule instance named "inner" retains its exact name', () { + test( + 'submodule instance is uniquified because signal ' + '"inner" already claimed the name', () { final inst = def.subModuleInstantiations .where((s) => s.needsInstantiation) .cast() @@ -93,26 +78,10 @@ void main() { orElse: () => null, ); expect(inst, isNotNull, reason: 'Expected submodule instance for inner'); - expect(inst!.name, 'inner', - reason: 'Instance "inner" must not be suffixed to "inner_0"'); - }); - - test('signal and instance may share the name "inner" without collision', - () { - // Both should be "inner", not one of them "inner_0". - final sl = def.internalSignals.cast().firstWhere( - (s) => s!.logics.any((l) => l.name == 'inner'), - orElse: () => null, - ); - final inst = def.subModuleInstantiations - .where((s) => s.needsInstantiation) - .cast() - .firstWhere( - (s) => s!.module.name == 'inner', - orElse: () => null, - ); - expect(sl?.name, 'inner'); - expect(inst?.name, 'inner'); + // The instance should be suffixed since the signal took "inner" first. + expect(inst!.name, isNot('inner'), + reason: 'Instance should be uniquified when signal already ' + 'claims "inner"'); }); }); } diff --git a/test/naming_consistency_test.dart b/test/naming_consistency_test.dart index c79221baa..8c9397082 100644 --- a/test/naming_consistency_test.dart +++ b/test/naming_consistency_test.dart @@ -218,12 +218,10 @@ void main() { } }); - test('submodule instance names are allocated from the instance namespace', + test('submodule instance names are allocated from the shared namespace', () async { - // Instance names come from Module.namer.allocateInstanceName, which is - // separate from the signal namespace (Module.namer.allocateSignalName). - // A signal and a submodule instance may therefore share the same - // identifier without collision — matching SystemVerilog semantics. + // Instance names come from Module.namer.allocateInstanceName, which + // shares the same namespace as signal names. final mod = _Outer(Logic(width: 8), Logic(width: 8)); await mod.build(); @@ -238,11 +236,10 @@ void main() { expect(instNames, isNotEmpty, reason: 'Should have at least one submodule instance'); - // Instance names are claimed in the *instance* namespace, NOT the - // signal namespace. + // Instance names are claimed in the shared namespace. for (final name in instNames) { expect(mod.namer.isInstanceNameAvailable(name), isFalse, - reason: 'Instance name "$name" should be claimed in instance ' + reason: 'Instance name "$name" should be claimed in the ' 'namespace'); } }); diff --git a/test/naming_namespace_test.dart b/test/naming_namespace_test.dart index 32e55629d..a5263a998 100644 --- a/test/naming_namespace_test.dart +++ b/test/naming_namespace_test.dart @@ -2,14 +2,13 @@ // SPDX-License-Identifier: BSD-3-Clause // // naming_namespace_test.dart -// Tests for constant naming via nameOfBest, the tryMerge guard for -// constNameDisallowed, and separate instance/signal namespaces. +// Tests for constant naming via nameOfBest and shared instance/signal +// namespace uniquification. // // 2026 April // Author: Desmond Kirkpatrick import 'package:rohd/rohd.dart'; -import 'package:rohd/src/utilities/namer.dart'; import 'package:test/test.dart'; /// A simple submodule whose instance name can collide with a signal name. @@ -77,8 +76,6 @@ class _ConstNameDisallowedModule extends Module { void main() { tearDown(() async { await Simulator.reset(); - // Restore default. - Namer.uniquifySignalAndInstanceNames = true; }); group('constant naming via nameOfBest', () { @@ -106,48 +103,19 @@ void main() { }); }); - group('separate instance and signal namespaces', () { + group('shared instance and signal namespace', () { test( - 'signal and instance with same name do not collide ' - 'when namespaces are independent', () async { - Namer.uniquifySignalAndInstanceNames = false; + 'signal and instance with same name get uniquified ' + 'in the shared namespace', () async { final dut = _InstanceSignalCollision(); await dut.build(); final sv = dut.generateSynth(); - // With independent namespaces, the signal keeps its name 'inner' - // and the instance also keeps 'inner' — no spurious _0 suffix. - expect(sv, contains(RegExp(r'logic\s+inner[,;\s]'))); - expect(sv, isNot(contains('inner_0'))); - }); - - test( - 'signal and instance get suffixed when ' - 'ensureUniqueSignalAndInstanceNames is true', () async { - Namer.uniquifySignalAndInstanceNames = true; - final dut = _InstanceSignalCollision(); - await dut.build(); - final sv = dut.generateSynth(); - - // With cross-namespace checking enabled, the signal 'inner' is - // allocated first (during signal naming); when the instance tries - // to claim 'inner', it sees the signal namespace has it, so the - // instance OR signal gets a suffix. + // With a single shared namespace, one of the two "inner" identifiers + // must be suffixed to avoid collision. expect(sv, contains('inner_0')); }); - test( - 'signal and instance do not spuriously suffix when ' - 'ensureUniqueSignalAndInstanceNames is false', () async { - Namer.uniquifySignalAndInstanceNames = false; - final dut = _InstanceSignalCollision(); - await dut.build(); - final sv = dut.generateSynth(); - - // With independent namespaces, no spurious suffixing. - expect(sv, isNot(contains('inner_0'))); - }); - test('duplicate instance names get uniquified', () async { final dut = _DuplicateInstances(); await dut.build(); @@ -158,23 +126,4 @@ void main() { expect(sv, contains(RegExp(r'blk_\d'))); }); }); - - group('instance namespace independence', () { - test('allocateInstanceName is independent from allocateSignalName', - () async { - final dut = _InstanceSignalCollision(); - await dut.build(); - - // After build, the signal namer has 'inner' claimed. - // With independent namespaces, instance namespace should also accept - // 'inner' without conflict. - Namer.uniquifySignalAndInstanceNames = false; - - // The instance namespace should show 'inner' as available before - // any instance allocation. - // (After synthesis, names are already allocated, so we just verify - // the module built without error.) - expect(dut.generateSynth(), isNotEmpty); - }); - }); } From d5904a6d83601318ee0efee98b7ec7a4b8fa5c93 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 3 May 2026 12:23:27 -0700 Subject: [PATCH 10/20] cleanup of port vs signal name assumptions, constant merging and signal/instance naming routine names --- .../synthesizers/utilities/synth_logic.dart | 18 --- .../utilities/synth_module_definition.dart | 4 +- .../synth_sub_module_instantiation.dart | 4 +- lib/src/utilities/namer.dart | 114 ++++-------------- test/name_test.dart | 4 +- test/naming_consistency_test.dart | 4 +- test/signal_registry_test.dart | 11 +- 7 files changed, 39 insertions(+), 120 deletions(-) diff --git a/lib/src/synthesizers/utilities/synth_logic.dart b/lib/src/synthesizers/utilities/synth_logic.dart index ad88bd6cc..8fcbc014a 100644 --- a/lib/src/synthesizers/utilities/synth_logic.dart +++ b/lib/src/synthesizers/utilities/synth_logic.dart @@ -17,19 +17,6 @@ import 'package:rohd/src/utilities/sanitizer.dart'; /// Represents a logic signal in the generated code within a module. @internal class SynthLogic { - /// Controls whether two constants with the same value driving separate - /// module inputs are merged into a single signal declaration. - /// - /// When `true` (the default), identical constants are collapsed to one - /// declaration — desirable for simulation-oriented output such as - /// SystemVerilog, where a single `assign wire = VALUE;` feeds all - /// downstream consumers. - /// - /// When `false`, each constant input keeps its own declaration. This is - /// useful for netlist/visualization outputs where seeing every individual - /// constant connection is more informative than an optimized fan-out net. - static bool mergeConstantInputs = true; - /// All [Logic]s represented, regardless of type. List get logics => UnmodifiableListView([ if (_reservedLogic != null) _reservedLogic!, @@ -288,12 +275,7 @@ class SynthLogic { } /// Indicates whether two constants can be merged. - /// - /// Merging is only performed when [SynthLogic.mergeConstantInputs] is - /// `true`. Set it to `false` to keep each constant input as its own - /// declaration (e.g. for netlist/visualization output). static bool _constantsMergeable(SynthLogic a, SynthLogic b) => - SynthLogic.mergeConstantInputs && a.isConstant && b.isConstant && a._constLogic!.value == b._constLogic!.value && diff --git a/lib/src/synthesizers/utilities/synth_module_definition.dart b/lib/src/synthesizers/utilities/synth_module_definition.dart index 9b7a6e42c..9ea120646 100644 --- a/lib/src/synthesizers/utilities/synth_module_definition.dart +++ b/lib/src/synthesizers/utilities/synth_module_definition.dart @@ -760,8 +760,8 @@ class SynthModuleDefinition { /// /// Signal names are read from `Namer.signalNameOf` (for user-created /// [Logic] objects) or kept as literal constants and are allocated from - /// `Namer.allocateSignalName`. Submodule instance names are allocated - /// from `Namer.allocateInstanceName`. All names share a single + /// `Namer.signalNameOf`. Submodule instance names are allocated + /// from `Namer.allocateRawName`. All names share a single /// namespace managed by the module's `Namer`. void _pickNames() { // Name allocation order matters — earlier claims get the unsuffixed name diff --git a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart index 67f9e2832..cf7da28e8 100644 --- a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart +++ b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart @@ -26,11 +26,11 @@ class SynthSubModuleInstantiation { /// Selects a name for this module instance. Must be called exactly once. /// /// Names are allocated from [parentModule]'s `Namer`'s shared namespace - /// via `Namer.allocateInstanceName`. + /// via `Namer.allocateName`. void pickName(Module parentModule) { assert(_name == null, 'Should only pick a name once.'); - _name = parentModule.namer.allocateInstanceName( + _name = parentModule.namer.allocateRawName( module.uniqueInstanceName, reserved: module.reserveName, ); diff --git a/lib/src/utilities/namer.dart b/lib/src/utilities/namer.dart index 481dc64e3..efbe8e3e4 100644 --- a/lib/src/utilities/namer.dart +++ b/lib/src/utilities/namer.dart @@ -21,16 +21,15 @@ import 'package:rohd/src/utilities/uniquifier.dart'; /// /// Port names are reserved at construction time. Internal signal names /// are assigned lazily on the first [signalNameOf] call. Instance names -/// are allocated explicitly via [allocateInstanceName]. +/// are allocated explicitly via [allocateRawName]. @internal class Namer { // ─── Shared namespace ─────────────────────────────────────────── final Uniquifier _uniquifier; - /// Sparse cache: only entries where the canonical name has been resolved. - /// Ports whose sanitized name == logic.name may be absent (fast-path - /// through [_portLogics] check). + /// Cache of resolved names for internal (non-port) signals only. + /// Port names are returned directly from [_portLogics] and never cached here. final Map _signalNames = {}; /// The set of port [Logic] objects, for O(1) port membership tests. @@ -40,108 +39,48 @@ class Namer { Namer._({ required Uniquifier uniquifier, - required Map portRenames, required Set portLogics, }) : _uniquifier = uniquifier, - _portLogics = portLogics { - _signalNames.addAll(portRenames); - } + _portLogics = portLogics; /// Creates a [Namer] for the given module ports. /// - /// Sanitized port names are reserved in the signal namespace. Ports - /// whose sanitized name differs from [Logic.name] are cached immediately. + /// Port names are reserved in the shared namespace. Port names are + /// guaranteed sanitary by [Module]'s `_checkForSafePortName`. factory Namer.forModule({ required Map inputs, required Map outputs, required Map inOuts, }) { - final portRenames = {}; - final portLogics = {}; - final portNames = []; - - void collectPort(String rawName, Logic logic) { - final sanitized = Sanitizer.sanitizeSV(rawName); - portNames.add(sanitized); - portLogics.add(logic); - if (sanitized != logic.name) { - portRenames[logic] = sanitized; - } - } - - for (final entry in inputs.entries) { - collectPort(entry.key, entry.value); - } - for (final entry in outputs.entries) { - collectPort(entry.key, entry.value); - } - for (final entry in inOuts.entries) { - collectPort(entry.key, entry.value); - } + final portLogics = { + ...inputs.values, + ...outputs.values, + ...inOuts.values, + }; final uniquifier = Uniquifier(); - for (final name in portNames) { - uniquifier.getUniqueName(initialName: name, reserved: true); + for (final logic in portLogics) { + uniquifier.getUniqueName(initialName: logic.name, reserved: true); } return Namer._( uniquifier: uniquifier, - portRenames: portRenames, portLogics: portLogics, ); } // ─── Name availability / allocation ───────────────────────────── - bool _isAvailable(String name, {bool reserved = false}) => - _uniquifier.isAvailable(name, reserved: reserved); - - String _allocateUniqueName(String baseName, {bool reserved = false}) { - if (reserved) { - if (!_isAvailable(baseName, reserved: true)) { - throw UnavailableReservedNameException(baseName); - } - - _uniquifier.getUniqueName(initialName: baseName, reserved: true); - return baseName; - } - - var candidate = baseName; - var suffix = 0; - while (!_isAvailable(candidate)) { - candidate = '${baseName}_$suffix'; - suffix++; - } - - _uniquifier.getUniqueName(initialName: candidate); - return candidate; - } - /// Returns `true` if [name] has not yet been claimed in the namespace. - bool isNameAvailable(String name) => _isAvailable(name); + bool isAvailable(String name) => _uniquifier.isAvailable(name); - /// Allocates a collision-free name in the signal namespace. + /// Allocates a collision-free name in the shared namespace. /// /// When [reserved] is `true`, the exact [baseName] (after sanitization) /// is claimed without modification; an exception is thrown if it collides. - String allocateSignalName(String baseName, {bool reserved = false}) => - _allocateUniqueName( - Sanitizer.sanitizeSV(baseName), - reserved: reserved, - ); - - // ─── Instance allocation ──────────────────────────────────────── - - /// Returns `true` if [name] has not yet been claimed in the namespace. - bool isInstanceNameAvailable(String name) => _isAvailable(name); - - /// Allocates a collision-free instance name. - /// - /// When [reserved] is `true`, the exact [baseName] (after sanitization) - /// is claimed without modification; an exception is thrown if it collides. - String allocateInstanceName(String baseName, {bool reserved = false}) => - _allocateUniqueName( - Sanitizer.sanitizeSV(baseName), + String allocateRawName(String baseName, {bool reserved = false}) => + _uniquifier.getUniqueName( + initialName: Sanitizer.sanitizeSV(baseName), reserved: reserved, ); @@ -170,8 +109,8 @@ class Namer { base = Sanitizer.sanitizeSV(logic.structureName); } - final name = _allocateUniqueName( - base, + final name = _uniquifier.getUniqueName( + initialName: base, reserved: isReservedInternal, ); _signalNames[logic] = name; @@ -256,19 +195,16 @@ class Namer { return _nameAndCacheAll(renameable, candidates); } - for (final logic in preferredMergeable) { - if (_isAvailable(baseName(logic))) { - return _nameAndCacheAll(logic, candidates); - } - } - if (preferredMergeable.isNotEmpty) { - return _nameAndCacheAll(preferredMergeable.first, candidates); + final best = preferredMergeable + .firstWhereOrNull((e) => isAvailable(baseName(e))) ?? + preferredMergeable.first; + return _nameAndCacheAll(best, candidates); } if (unpreferredMergeable.isNotEmpty) { final best = unpreferredMergeable - .firstWhereOrNull((e) => _isAvailable(baseName(e))) ?? + .firstWhereOrNull((e) => isAvailable(baseName(e))) ?? unpreferredMergeable.first; return _nameAndCacheAll(best, candidates); } diff --git a/test/name_test.dart b/test/name_test.dart index c863c04f5..bde8a9c9f 100644 --- a/test/name_test.dart +++ b/test/name_test.dart @@ -1,7 +1,7 @@ -// Copyright (C) 2023-2024 Intel Corporation +// Copyright (C) 2023-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // -// definition_name_test.dart +// name_test.dart // Tests for definition names (including reserving them) of Modules. // // 2022 March 7 diff --git a/test/naming_consistency_test.dart b/test/naming_consistency_test.dart index 8c9397082..f0d7b2d31 100644 --- a/test/naming_consistency_test.dart +++ b/test/naming_consistency_test.dart @@ -220,7 +220,7 @@ void main() { test('submodule instance names are allocated from the shared namespace', () async { - // Instance names come from Module.namer.allocateInstanceName, which + // Instance names come from Module.namer.allocateName, which // shares the same namespace as signal names. final mod = _Outer(Logic(width: 8), Logic(width: 8)); await mod.build(); @@ -238,7 +238,7 @@ void main() { // Instance names are claimed in the shared namespace. for (final name in instNames) { - expect(mod.namer.isInstanceNameAvailable(name), isFalse, + expect(mod.namer.isAvailable(name), isFalse, reason: 'Instance name "$name" should be claimed in the ' 'namespace'); } diff --git a/test/signal_registry_test.dart b/test/signal_registry_test.dart index 152b6091a..d1719c85e 100644 --- a/test/signal_registry_test.dart +++ b/test/signal_registry_test.dart @@ -2,9 +2,10 @@ // SPDX-License-Identifier: BSD-3-Clause // // signal_registry_test.dart -// Tests for Module canonical naming (SynthesisNameRegistry). +// Tests for Module canonical naming (Namer). // // 2026 April 14 +// Author: Desmond Kirkpatrick import 'package:rohd/rohd.dart'; import 'package:test/test.dart'; @@ -90,12 +91,12 @@ void main() { }); }); - group('allocateSignalName', () { + group('allocateName', () { test('avoids collision with existing names', () async { final mod = _Counter(Logic(), Logic()); await mod.build(); - final allocated = mod.namer.allocateSignalName('en'); + final allocated = mod.namer.allocateRawName('en'); expect(allocated, isNot(equals('en')), reason: 'Should not collide with existing port name'); expect(allocated, contains('en'), @@ -106,8 +107,8 @@ void main() { final mod = _Counter(Logic(), Logic()); await mod.build(); - final a = mod.namer.allocateSignalName('wire'); - final b = mod.namer.allocateSignalName('wire'); + final a = mod.namer.allocateRawName('wire'); + final b = mod.namer.allocateRawName('wire'); expect(a, isNot(equals(b)), reason: 'Each allocation should be unique'); }); }); From 364529353eaeb08601fd1a51763f74755210169b Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 6 May 2026 12:32:17 -0700 Subject: [PATCH 11/20] Add ModuleServices singleton and SvService Introduces a singleton service registry (ModuleServices) that provides a unified query surface for DevTools and inspection tools. Module.build() now registers the root module with ModuleServices.instance. Also adds SvService which wraps SystemVerilog synthesis and registers with ModuleServices for DevTools access to SV metadata. This is a clean separation: no netlist code is included. The netlist branch will later extend ModuleServices with a netlistService field. --- lib/rohd.dart | 1 + lib/src/diagnostics/module_services.dart | 79 +++++++++++ lib/src/module.dart | 3 +- .../systemverilog/sv_service.dart | 116 +++++++++++++++ .../systemverilog/systemverilog.dart | 1 + test/module_services_test.dart | 134 ++++++++++++++++++ 6 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 lib/src/diagnostics/module_services.dart create mode 100644 lib/src/synthesizers/systemverilog/sv_service.dart create mode 100644 test/module_services_test.dart diff --git a/lib/rohd.dart b/lib/rohd.dart index 841505590..d0ea2a266 100644 --- a/lib/rohd.dart +++ b/lib/rohd.dart @@ -1,6 +1,7 @@ // Copyright (C) 2021-2023 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'src/diagnostics/module_services.dart'; export 'src/exceptions/exceptions.dart'; export 'src/external.dart'; export 'src/finite_state_machine.dart'; diff --git a/lib/src/diagnostics/module_services.dart b/lib/src/diagnostics/module_services.dart new file mode 100644 index 000000000..3bdb6d53c --- /dev/null +++ b/lib/src/diagnostics/module_services.dart @@ -0,0 +1,79 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_services.dart +// Singleton service registry for DevTools integration. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/diagnostics/inspector_service.dart'; + +/// Singleton service registry that provides a unified query surface for +/// DevTools and other inspection tools. +/// +/// Services register themselves here on construction; DevTools evaluates +/// getters on [instance] via `EvalOnDartLibrary` to pull data. +/// +/// **Auto-registered:** +/// - [rootModule] / [hierarchyJSON] — set by [Module.build]. +/// +/// **Opt-in (registered by service constructors):** +/// - [svService] — SystemVerilog synthesis results. +/// +/// Additional services (netlist, trace, waveform) can be added by setting +/// the corresponding field after construction. +class ModuleServices { + ModuleServices._(); + + /// The singleton instance. + static final ModuleServices instance = ModuleServices._(); + + // ─── Hierarchy (auto-registered by Module.build) ────────────── + + /// The most recently built top-level [Module]. + /// + /// Set automatically at the end of [Module.build]. + Module? rootModule; + + /// Returns the module hierarchy as a JSON string. + /// + /// DevTools evaluates this via `EvalOnDartLibrary` to display + /// the module hierarchy. + String get hierarchyJSON { + ModuleTree.rootModuleInstance = rootModule; + return ModuleTree.instance.hierarchyJSON; + } + + /// Returns the primary inspector JSON for DevTools. + /// + /// Returns the hierarchy JSON. Downstream branches (e.g. netlist) may + /// override this to return richer data when available. + String get inspectorJSON => hierarchyJSON; + + // ─── SystemVerilog service (opt-in) ─────────────────────────── + + /// The active [SvService], if one has been registered. + SvService? svService; + + /// Returns SV synthesis metadata as JSON, or an unavailable status. + String get svJSON => svService != null + ? jsonEncode(svService!.toJson()) + : _unavailable('sv'); + + // ─── Helpers ────────────────────────────────────────────────── + + static String _unavailable(String service) => jsonEncode({ + 'status': 'unavailable', + 'reason': '$service service not registered', + }); + + /// Resets all services. Intended for test teardown. + void reset() { + rootModule = null; + svService = null; + } +} diff --git a/lib/src/module.dart b/lib/src/module.dart index 02e02ad63..9f6ec634e 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -13,7 +13,6 @@ import 'dart:collection'; import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/collections/traverseable_collection.dart'; -import 'package:rohd/src/diagnostics/inspector_service.dart'; import 'package:rohd/src/utilities/config.dart'; import 'package:rohd/src/utilities/namer.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; @@ -333,7 +332,7 @@ abstract class Module { _hasBuilt = true; - ModuleTree.rootModuleInstance = this; + ModuleServices.instance.rootModule = this; } /// Confirms that the post-[build] hierarchy is valid. diff --git a/lib/src/synthesizers/systemverilog/sv_service.dart b/lib/src/synthesizers/systemverilog/sv_service.dart new file mode 100644 index 000000000..65dc12ab2 --- /dev/null +++ b/lib/src/synthesizers/systemverilog/sv_service.dart @@ -0,0 +1,116 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// sv_service.dart +// Service wrapper for SystemVerilog synthesis. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:rohd/rohd.dart'; + +/// A service that wraps SystemVerilog synthesis of a [Module] hierarchy. +/// +/// Provides access to the generated SV file contents and per-module +/// synthesis results, and optionally registers with [ModuleServices] +/// for DevTools inspection. +/// +/// Example: +/// ```dart +/// final dut = MyModule(...); +/// await dut.build(); +/// final sv = SvService(dut); +/// +/// // Write individual .sv files: +/// sv.writeFiles('build/'); +/// +/// // Or get the concatenated output (like generateSynth): +/// print(sv.allContents); +/// ``` +class SvService { + /// The top-level [Module] being synthesized. + final Module module; + + /// The underlying [SynthBuilder] that drove synthesis. + late final SynthBuilder synthBuilder; + + /// The generated file contents (one per unique module definition). + late final List fileContents; + + /// Creates an [SvService] for [module]. + /// + /// [module] must already be built. Set [register] to `true` (the + /// default) to register this service with [ModuleServices] for + /// DevTools access. + SvService(this.module, {bool register = true}) { + if (!module.hasBuilt) { + throw Exception( + 'Module must be built before creating SvService. ' + 'Call build() first.'); + } + + synthBuilder = SynthBuilder(module, SystemVerilogSynthesizer()); + fileContents = synthBuilder.getSynthFileContents(); + + if (register) { + ModuleServices.instance.svService = this; + } + } + + /// All [SynthesisResult]s produced by synthesis. + Set get synthesisResults => synthBuilder.synthesisResults; + + /// Returns the concatenated SystemVerilog output as a single string, + /// matching the format of [Module.generateSynth]. + String get allContents => + fileContents.map((fc) => fc.contents).join('\n\n'); + + /// Returns a map from module definition name to its SV file contents. + /// + /// Keys are [SynthesisResult.instanceTypeName] (the uniquified definition + /// name used in the generated SV). + Map get contentsByName => { + for (final fc in fileContents) fc.name: fc.contents, + }; + + /// Returns a map from module definition name + /// ([Module.definitionName]) to its SV file contents. + /// + /// This uses the original definition name (not uniquified), matching + /// the keys used by FLC trace data. + Map get contentsByDefinitionName { + final result = {}; + for (final sr in synthesisResults) { + final defName = sr.module.definitionName; + final instanceName = sr.instanceTypeName; + // Find the file content matching this instance type name. + final fc = fileContents.firstWhereOrNull((f) => f.name == instanceName); + if (fc != null) { + result[defName] = fc.contents; + } + } + return result; + } + + /// Writes each module's SV to a separate file in [directory]. + /// + /// Files are named `.sv`. + void writeFiles(String directory) { + final dir = Directory(directory)..createSync(recursive: true); + for (final fc in fileContents) { + File('${dir.path}/${fc.name}.sv').writeAsStringSync(fc.contents); + } + } + + /// Returns a JSON-serialisable summary of the SV synthesis. + /// + /// Contains the list of generated module definition names. + Map toJson() => { + 'modules': [ + for (final fc in fileContents) fc.name, + ], + }; +} diff --git a/lib/src/synthesizers/systemverilog/systemverilog.dart b/lib/src/synthesizers/systemverilog/systemverilog.dart index 281b05df9..e5f772e44 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog.dart @@ -1,5 +1,6 @@ // Copyright (C) 2021-2024 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'sv_service.dart'; export 'systemverilog_mixins.dart'; export 'systemverilog_synthesizer.dart'; diff --git a/test/module_services_test.dart b/test/module_services_test.dart new file mode 100644 index 000000000..f9b0ac075 --- /dev/null +++ b/test/module_services_test.dart @@ -0,0 +1,134 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_services_test.dart +// Unit tests for ModuleServices and SvService. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; +import 'dart:io'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +class SimpleModule extends Module { + SimpleModule(Logic a) : super(name: 'simple') { + a = addInput('a', a); + addOutput('b') <= ~a; + } +} + +void main() { + tearDown(() { + ModuleServices.instance.reset(); + }); + + group('ModuleServices', () { + test('rootModule is set after build', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + expect(ModuleServices.instance.rootModule, equals(mod)); + }); + + test('hierarchyJSON returns valid JSON', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final json = ModuleServices.instance.hierarchyJSON; + expect(() => jsonDecode(json), returnsNormally); + }); + + test('inspectorJSON matches hierarchyJSON', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + expect(ModuleServices.instance.inspectorJSON, + equals(ModuleServices.instance.hierarchyJSON)); + }); + + test('svJSON returns unavailable when no service registered', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final result = + jsonDecode(ModuleServices.instance.svJSON) as Map; + expect(result['status'], equals('unavailable')); + }); + + test('reset clears all services', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + expect(ModuleServices.instance.rootModule, isNotNull); + ModuleServices.instance.reset(); + expect(ModuleServices.instance.rootModule, isNull); + expect(ModuleServices.instance.svService, isNull); + }); + }); + + group('SvService', () { + test('registers with ModuleServices on creation', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final sv = SvService(mod); + expect(ModuleServices.instance.svService, equals(sv)); + }); + + test('allContents is non-empty', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final sv = SvService(mod); + expect(sv.allContents, isNotEmpty); + }); + + test('contentsByName has entries', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final sv = SvService(mod); + expect(sv.contentsByName, isNotEmpty); + }); + + test('contentsByDefinitionName has entries', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final sv = SvService(mod); + expect(sv.contentsByDefinitionName, isNotEmpty); + expect(sv.contentsByDefinitionName.containsKey('SimpleModule'), isTrue); + }); + + test('svJSON returns valid JSON after registration', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + SvService(mod); + final result = + jsonDecode(ModuleServices.instance.svJSON) as Map; + expect(result['modules'], isList); + }); + + test('writeFiles creates SV files', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final sv = SvService(mod); + final dir = Directory.systemTemp.createTempSync('sv_test_'); + try { + sv.writeFiles(dir.path); + final files = dir.listSync().whereType().toList(); + expect(files, isNotEmpty); + expect(files.any((f) => f.path.endsWith('.sv')), isTrue); + } finally { + dir.deleteSync(recursive: true); + } + }); + + test('register false does not register', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + ModuleServices.instance.reset(); + SvService(mod, register: false); + expect(ModuleServices.instance.svService, isNull); + }); + + test('throws if module not built', () { + final mod = SimpleModule(Logic()); + expect(() => SvService(mod), throwsException); + }); + }); +} From 510ee0103a2bab72de48260565b340cc15bb8f99 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 6 May 2026 12:58:52 -0700 Subject: [PATCH 12/20] Add netlist synthesizer, NetlistService, and supporting infrastructure Adds a complete netlist synthesis pipeline that converts ROHD module hierarchies into Yosys-compatible JSON netlists. Key additions: - NetlistSynthesizer: traverses module hierarchy producing cell/wire JSON - NetlistService: async factory that registers with ModuleServices - NetlistOptions: configurable post-processing passes (DCE, struct grouping, concat collapsing, etc.) - NetlistPasses: optimization and structural transformations - LeafCellMapper: maps ROHD gates to standard cell definitions - NetlistUtils: wire naming, bit-range formatting, JSON helpers Also extends Module.build() with optional netlistOptions parameter, updates ModuleServices with netlistService field, and refactors examples into lib/src/examples/ for reuse in tests. Comprehensive test coverage across 11 new/updated test files (146 tests). --- example/example.dart | 40 +- example/filter_bank.dart | 117 + example/oven_fsm.dart | 175 +- example/tree.dart | 39 +- lib/rohd.dart | 3 +- lib/src/diagnostics/module_services.dart | 54 +- lib/src/examples/filter_bank_modules.dart | 954 +++++++ lib/src/examples/oven_fsm_modules.dart | 211 ++ lib/src/examples/tree_modules.dart | 63 + lib/src/module.dart | 25 +- .../netlist/leaf_cell_mapper.dart | 486 ++++ lib/src/synthesizers/netlist/netlist.dart | 16 + .../synthesizers/netlist/netlist_options.dart | 138 + .../synthesizers/netlist/netlist_passes.dart | 2541 +++++++++++++++++ .../synthesizers/netlist/netlist_service.dart | 224 ++ .../netlist/netlist_synthesis_result.dart | 84 + .../netlist/netlist_synthesizer.dart | 1788 ++++++++++++ .../synthesizers/netlist/netlist_utils.dart | 531 ++++ lib/src/synthesizers/synthesizers.dart | 3 +- .../systemverilog/sv_service.dart | 48 +- test/incremental_expansion_test.dart | 116 + test/module_services_test.dart | 324 ++- test/netlist_example_test.dart | 285 ++ test/netlist_synthesizer_test.dart | 1437 ++++++++++ test/netlist_test.dart | 706 +++++ test/signal_registry_test.dart | 40 + test/slim_connected_port_test.dart | 77 + test/slim_full_canonical_test.dart | 180 ++ test/slim_incremental_equivalence_test.dart | 310 ++ test/struct_port_pruning_test.dart | 143 + test/synth_name_parity_test.dart | 125 + 31 files changed, 10953 insertions(+), 330 deletions(-) create mode 100644 example/filter_bank.dart create mode 100644 lib/src/examples/filter_bank_modules.dart create mode 100644 lib/src/examples/oven_fsm_modules.dart create mode 100644 lib/src/examples/tree_modules.dart create mode 100644 lib/src/synthesizers/netlist/leaf_cell_mapper.dart create mode 100644 lib/src/synthesizers/netlist/netlist.dart create mode 100644 lib/src/synthesizers/netlist/netlist_options.dart create mode 100644 lib/src/synthesizers/netlist/netlist_passes.dart create mode 100644 lib/src/synthesizers/netlist/netlist_service.dart create mode 100644 lib/src/synthesizers/netlist/netlist_synthesis_result.dart create mode 100644 lib/src/synthesizers/netlist/netlist_synthesizer.dart create mode 100644 lib/src/synthesizers/netlist/netlist_utils.dart create mode 100644 test/incremental_expansion_test.dart create mode 100644 test/netlist_example_test.dart create mode 100644 test/netlist_synthesizer_test.dart create mode 100644 test/netlist_test.dart create mode 100644 test/slim_connected_port_test.dart create mode 100644 test/slim_full_canonical_test.dart create mode 100644 test/slim_incremental_equivalence_test.dart create mode 100644 test/struct_port_pruning_test.dart create mode 100644 test/synth_name_parity_test.dart diff --git a/example/example.dart b/example/example.dart index 2ddbfc738..7715ffb34 100644 --- a/example/example.dart +++ b/example/example.dart @@ -11,35 +11,16 @@ // allow `print` messages (disable lint): // ignore_for_file: avoid_print -// Import necessary dart packages for this file. +// Import necessary dart pacakges for this file. import 'dart:async'; // Import the ROHD package. import 'package:rohd/rohd.dart'; -// Define a class Counter that extends ROHD's abstract Module class. -class Counter extends Module { - // For convenience, map interesting outputs to short variable names for - // consumers of this module. - Logic get val => output('val'); - - // This counter supports any width, determined at run-time. - final int width; - - Counter(Logic en, Logic reset, Logic clk, - {this.width = 8, super.name = 'counter'}) { - // Register inputs and outputs of the module in the constructor. - // Module logic must consume registered inputs and output to registered - // outputs. - en = addInput('en', en); - reset = addInput('reset', reset); - clk = addInput('clk', clk); - addOutput('val', width: width); - - // We can use the `flop` function to automate creation of a `Sequential`. - val <= flop(clk, reset: reset, en: en, val + 1); - } -} +// Re-export the Counter module from the library examples so that +// existing tests that `import 'example/example.dart'` still see it. +import 'package:rohd/src/examples/oven_fsm_modules.dart' show Counter; +export 'package:rohd/src/examples/oven_fsm_modules.dart' show Counter; // Let's simulate with this counter a little, generate a waveform, and take a // look at generated SystemVerilog. @@ -76,8 +57,9 @@ Future main({bool noPrint = false}) async { // Let's also print a message every time the value on the counter changes, // just for this example to make it easier to see before we look at waves. if (!noPrint) { - counter.val.changed - .listen((e) => print('@${Simulator.time}: Value changed: $e')); + counter.val.changed.listen( + (e) => print('@${Simulator.time}: Value changed: $e'), + ); } // Start off with a disabled counter and asserting reset at the start. @@ -115,7 +97,9 @@ Future main({bool noPrint = false}) async { // We can take a look at the waves now. if (!noPrint) { - print('To view waves, check out waves.vcd with a waveform viewer' - ' (e.g. `gtkwave waves.vcd`).'); + print( + 'To view waves, check out waves.vcd with a waveform viewer' + ' (e.g. `gtkwave waves.vcd`).', + ); } } diff --git a/example/filter_bank.dart b/example/filter_bank.dart new file mode 100644 index 000000000..b1d6f6eb3 --- /dev/null +++ b/example/filter_bank.dart @@ -0,0 +1,117 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// filter_bank.dart +// A polyphase FIR filter bank design example exercising: +// - Deep hierarchy with shared sub-module definitions +// - Interface (FilterDataInterface) +// - LogicStructure (FilterSample) +// - LogicArray (coefficient storage) +// - Pipeline (pipelined MAC accumulation) +// - FiniteStateMachine (FilterController) +// +// The filter bank has two channels that share an identical MacUnit definition. +// A controller FSM sequences: idle → loading → running → draining → done. +// +// 2026 March 26 +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:rohd/rohd.dart'; + +// Import module definitions. +import 'package:rohd/src/examples/filter_bank_modules.dart'; + +// Re-export so downstream consumers (e.g. devtools loopback) can use. +export 'package:rohd/src/examples/filter_bank_modules.dart'; + +// ────────────────────────────────────────────────────────────────── +// Standalone simulation entry point +// ────────────────────────────────────────────────────────────────── + +Future main({bool noPrint = false}) async { + const dataWidth = 16; + const numTaps = 3; + + // Low-pass-ish coefficients (scaled integers) + const coeffs0 = [1, 2, 1]; // channel 0: symmetric LPF kernel + const coeffs1 = [1, -2, 1]; // channel 1: high-pass kernel + + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + final dut = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: [coeffs0, coeffs1], + ); + + // Before we can simulate or generate code, we need to build it. + await dut.build(); + + // Set a maximum time for the simulation so it doesn't keep running forever. + Simulator.setMaxSimTime(500); + + // Attach a waveform dumper so we can see what happens. + if (!noPrint) { + WaveDumper(dut, outputPath: 'filter_bank.vcd'); + } + + // Kick off the simulation. + unawaited(Simulator.run()); + + // ── Reset ── + reset.inject(1); + start.inject(0); + samplesIn.elements[0].inject(0); + samplesIn.elements[1].inject(0); + validIn.inject(0); + inputDone.inject(0); + + await clk.nextPosedge; + await clk.nextPosedge; + reset.inject(0); + + // ── Start filtering ── + await clk.nextPosedge; + start.inject(1); + await clk.nextPosedge; + start.inject(0); + validIn.inject(1); + + // ── Feed sample stream: impulse response test ── + // Send a single '1' followed by zeros to get the impulse response + samplesIn.elements[0].inject(1); + samplesIn.elements[1].inject(1); + await clk.nextPosedge; + + for (var i = 0; i < 8; i++) { + samplesIn.elements[0].inject(0); + samplesIn.elements[1].inject(0); + await clk.nextPosedge; + } + + // ── Signal end of input ── + validIn.inject(0); + inputDone.inject(1); + await clk.nextPosedge; + inputDone.inject(0); + + // ── Wait for drain ── + for (var i = 0; i < 15; i++) { + await clk.nextPosedge; + } + + await Simulator.endSimulation(); +} diff --git a/example/oven_fsm.dart b/example/oven_fsm.dart index 2788baa55..8f69f697b 100644 --- a/example/oven_fsm.dart +++ b/example/oven_fsm.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Intel Corporation +// Copyright (C) 2023-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // oven_fsm.dart @@ -14,175 +14,12 @@ import 'dart:async'; import 'package:rohd/rohd.dart'; -// Import the counter module implement in example.dart. -import './example.dart'; +// Import module definitions (Counter, OvenModule, enums). +import 'package:rohd/src/examples/oven_fsm_modules.dart'; -// Enumerated type named `OvenState` with four possible states: -// `standby`, `cooking`,`paused`, and `completed`. -enum OvenState { standby, cooking, paused, completed } - -// One-hot encoded `Button` using dart enhanced enums. -// Represent start, pause, and resume as integer value 0, 1, -// and 2 respectively. -enum Button { - start(value: 0), - pause(value: 1), - resume(value: 2); - - const Button({required this.value}); - - final int value; -} - -// One-hot encoded `LEDLight` using dart enhanced enums. -// Represent yellow, blue, red, and green as integer value 0, 1, -// 2, and 3 respectively. -enum LEDLight { - yellow(value: 0), - blue(value: 1), - red(value: 2), - green(value: 3); - - const LEDLight({required this.value}); - - final int value; -} - -// Define a class OvenModule that extends ROHD's abstract Module class. -class OvenModule extends Module { - // A private variable with type FiniteStateMachine `_oven`. - // - // Use `late` to indicate that the value will not be null - // and will be assign in the later section. - late FiniteStateMachine _oven; - - // We can expose an LED light output as a getter to retrieve it value. - Logic get led => output('led'); - - // This oven module receives a `button` and a `reset` input from runtime. - OvenModule(Logic button, Logic reset, Logic clk) : super(name: 'OvenModule') { - // Register inputs and outputs of the module in the constructor. - // Module logic must consume registered inputs and output to registered - // outputs. `led` output also added as the output port. - button = addInput('button', button, width: button.width); - reset = addInput('reset', reset); - clk = addInput('clk', clk); - final led = addOutput('led', width: button.width); - - // Register local signals, `counterReset` and `en` - // for Counter module. - final counterReset = Logic(name: 'counter_reset'); - final en = Logic(name: 'counter_en'); - - // An internal counter module that will be used to time the cooking state. - // Receive `en`, `counterReset` and `clk` as input. - final counter = Counter(en, counterReset, clk, name: 'counter_module'); - - // A list of `OvenState` that describe the FSM. Note that - // `OvenState` consists of identifier, events and actions. We - // can think of `identifier` as the state name, `events` is a map of event - // that trigger next state. `actions` is the behaviour of current state, - // like what is the actions need to be shown separate current state with - // other state. Represented as List of conditionals to be executed. - final states = [ - // identifier: standby state, represent by `OvenState.standby`. - State(OvenState.standby, - // events: - // When the button `start` is pressed during standby state, - // OvenState will changed to `OvenState.cooking` state. - events: { - Logic(name: 'button_start') - ..gets(button - .eq(Const(Button.start.value, width: button.width))): - OvenState.cooking, - }, - // actions: - // During the standby state, `led` is change to blue; timer's - // `counterReset` is set to 1 (Reset the timer); - // timer's `en` is set to 0 (Disable value update). - actions: [ - led < LEDLight.blue.value, - counterReset < 1, - en < 0, - ]), - - // identifier: cooking state, represent by `OvenState.cooking`. - State(OvenState.cooking, - // events: - // When the button `paused` is pressed during cooking state, - // OvenState will changed to `OvenState.paused` state. - // - // When the button `counter` time is elapsed during cooking state, - // OvenState will changed to `OvenState.completed` state. - events: { - Logic(name: 'button_pause') - ..gets(button - .eq(Const(Button.pause.value, width: button.width))): - OvenState.paused, - Logic(name: 'counter_time_complete')..gets(counter.val.eq(4)): - OvenState.completed - }, - // actions: - // During the cooking state, `led` is change to yellow; timer's - // `counterReset` is set to 0 (Do not reset); - // timer's `en` is set to 1 (Enable value update). - actions: [ - led < LEDLight.yellow.value, - counterReset < 0, - en < 1, - ]), - - // identifier: paused state, represent by `OvenState.paused`. - State(OvenState.paused, - // events: - // When the button `resume` is pressed during paused state, - // OvenState will changed to `OvenState.cooking` state. - events: { - Logic(name: 'button_resume') - ..gets(button - .eq(Const(Button.resume.value, width: button.width))): - OvenState.cooking - }, - // actions: - // During the paused state, `led` is change to red; timer's - // `counterReset` is set to 0 (Do not reset); - // timer's `en` is set to 0 (Disable value update). - actions: [ - led < LEDLight.red.value, - counterReset < 0, - en < 0, - ]), - - // identifier: completed state, represent by `OvenState.completed`. - State(OvenState.completed, - // events: - // When the button `start` is pressed during completed state, - // OvenState will changed to `OvenState.cooking` state. - events: { - Logic(name: 'button_start') - ..gets(button - .eq(Const(Button.start.value, width: button.width))): - OvenState.cooking - }, - // actions: - // During the start state, `led` is change to green; timer's - // `counterReset` is set to 1 (Reset value); - // timer's `en` is set to 0 (Disable value update). - actions: [ - led < LEDLight.green.value, - counterReset < 1, - en < 0, - ]) - ]; - - // Assign the _oven FiniteStateMachine object to private variable declared. - _oven = - FiniteStateMachine(clk, reset, OvenState.standby, states); - } - - // An oven FiniteStateMachine that represent in getter. - FiniteStateMachine get ovenStateMachine => _oven; -} +// Re-export module definitions so test files that import this file +// get access to OvenModule, OvenState, Button, LEDLight, etc. +export 'package:rohd/src/examples/oven_fsm_modules.dart' hide Counter; /// A helper function to wait for a number of cycles. Future waitCycles(Logic clk, int numCycles) async { diff --git a/example/tree.dart b/example/tree.dart index f5c30a979..8f5b2f96a 100644 --- a/example/tree.dart +++ b/example/tree.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2023 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // tree.dart @@ -13,6 +13,13 @@ import 'package:rohd/rohd.dart'; +// Import module definition. +import 'package:rohd/src/examples/tree_modules.dart'; + +// Re-export module definition so test files that import this file +// get access to TreeOfTwoInputModules. +export 'package:rohd/src/examples/tree_modules.dart'; + /// The below example demonstrates some aspects of the power of ROHD where /// writing equivalent design code in SystemVerilog can be challenging or /// impossible. The example is a port from an example used by Chisel. @@ -35,36 +42,6 @@ import 'package:rohd/rohd.dart'; /// number of inputs and different logic without any explicit /// parameterization. -class TreeOfTwoInputModules extends Module { - final Logic Function(Logic a, Logic b) _op; - final List _seq = []; - Logic get out => output('out'); - - TreeOfTwoInputModules(List seq, this._op) - : super(name: 'tree_of_two_input_modules') { - if (seq.isEmpty) { - throw Exception("Don't use TreeOfTwoInputModules with an empty sequence"); - } - - for (var i = 0; i < seq.length; i++) { - _seq.add(addInput('seq$i', seq[i], width: seq[i].width)); - } - addOutput('out', width: seq[0].width); - - if (_seq.length == 1) { - out <= _seq[0]; - } else { - final a = TreeOfTwoInputModules( - _seq.getRange(0, _seq.length ~/ 2).toList(), _op) - .out; - final b = TreeOfTwoInputModules( - _seq.getRange(_seq.length ~/ 2, _seq.length).toList(), _op) - .out; - out <= _op(a, b); - } - } -} - Future main({bool noPrint = false}) async { // You could instantiate this module with some code such as: final tree = TreeOfTwoInputModules( diff --git a/lib/rohd.dart b/lib/rohd.dart index d0ea2a266..075bda975 100644 --- a/lib/rohd.dart +++ b/lib/rohd.dart @@ -1,6 +1,7 @@ -// Copyright (C) 2021-2023 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'src/diagnostics/inspector_service.dart'; export 'src/diagnostics/module_services.dart'; export 'src/exceptions/exceptions.dart'; export 'src/external.dart'; diff --git a/lib/src/diagnostics/module_services.dart b/lib/src/diagnostics/module_services.dart index 3bdb6d53c..30a51a410 100644 --- a/lib/src/diagnostics/module_services.dart +++ b/lib/src/diagnostics/module_services.dart @@ -10,7 +10,6 @@ import 'dart:convert'; import 'package:rohd/rohd.dart'; -import 'package:rohd/src/diagnostics/inspector_service.dart'; /// Singleton service registry that provides a unified query surface for /// DevTools and other inspection tools. @@ -23,9 +22,10 @@ import 'package:rohd/src/diagnostics/inspector_service.dart'; /// /// **Opt-in (registered by service constructors):** /// - [svService] — SystemVerilog synthesis results. +/// - [netlistService] — Yosys-format netlist JSON. /// -/// Additional services (netlist, trace, waveform) can be added by setting -/// the corresponding field after construction. +/// Additional services (trace, waveform) can be added by setting the +/// corresponding field after construction. class ModuleServices { ModuleServices._(); @@ -48,11 +48,30 @@ class ModuleServices { return ModuleTree.instance.hierarchyJSON; } - /// Returns the primary inspector JSON for DevTools. + /// Returns the unified inspector JSON — the primary entry point for + /// DevTools to load the design. /// - /// Returns the hierarchy JSON. Downstream branches (e.g. netlist) may - /// override this to return richer data when available. - String get inspectorJSON => hierarchyJSON; + /// When a [NetlistService] is registered, this returns the slim netlist + /// (hierarchy + ports + cells without connectivity). + /// + /// Falls back to the hierarchy JSON when no netlist service is available. + String get inspectorJSON { + if (netlistService != null) { + return netlistService!.slimJson; + } + return hierarchyJSON; + } + + /// Returns the full netlist JSON for a single module definition. + /// + /// When a [NetlistService] is registered, returns the per-module netlist + /// (with full connectivity). + String inspectorModuleJSON(String definitionName) { + if (netlistService != null) { + return netlistService!.moduleJson(definitionName); + } + return _unavailable('netlist'); + } // ─── SystemVerilog service (opt-in) ─────────────────────────── @@ -60,9 +79,23 @@ class ModuleServices { SvService? svService; /// Returns SV synthesis metadata as JSON, or an unavailable status. - String get svJSON => svService != null - ? jsonEncode(svService!.toJson()) - : _unavailable('sv'); + String get svJSON => + svService != null ? jsonEncode(svService!.toJson()) : _unavailable('sv'); + + // ─── Netlist service (opt-in) ───────────────────────────────── + + /// The active [NetlistService], if one has been registered. + NetlistService? netlistService; + + /// Returns the full netlist hierarchy as JSON, or an unavailable status. + String get netlistJSON => netlistService != null + ? netlistService!.toJson() + : _unavailable('netlist'); + + /// Returns the netlist for a single module definition, or unavailable. + String netlistModuleJSON(String definitionName) => netlistService != null + ? netlistService!.moduleJson(definitionName) + : _unavailable('netlist'); // ─── Helpers ────────────────────────────────────────────────── @@ -75,5 +108,6 @@ class ModuleServices { void reset() { rootModule = null; svService = null; + netlistService = null; } } diff --git a/lib/src/examples/filter_bank_modules.dart b/lib/src/examples/filter_bank_modules.dart new file mode 100644 index 000000000..21a0f1ccd --- /dev/null +++ b/lib/src/examples/filter_bank_modules.dart @@ -0,0 +1,954 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// filter_bank_modules.dart +// Module class definitions for the polyphase FIR filter bank example. +// +// 2025 March 26 +// Author: Desmond Kirkpatrick +// +// Architecture: each FilterChannel uses a single MacUnit that is +// time-multiplexed across taps. A tap counter sequences CoeffBank +// and a delay-line mux so the MAC accumulates one tap per clock cycle. +// After numTaps cycles the accumulated result is latched as the output +// sample and the accumulator resets for the next input sample. +// +// ROHD features exercised: +// - LogicStructure (FilterSample) +// - Interface (FilterDataInterface) +// - LogicArray (CoeffBank coefficient ROM, delay line) +// - Pipeline (MacUnit multiply-accumulate) +// - FiniteStateMachine (FilterController) +// - Multiple instantiation (two FilterChannels share one definition) +// +// Separated from filter_bank.dart so these classes can be imported +// in web-targeted code (no dart:io dependency). +// +// 2026 March 26 +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; +import 'package:rohd/rohd.dart'; + +// ────────────────────────────────────────────────────────────────── +// LogicStructure: a typed sample word carrying data + valid + channel +// ────────────────────────────────────────────────────────────────── + +/// A structured signal bundling a data sample with metadata. +/// +/// Packs three fields — [data], [valid], and [channel] — into a single +/// bus that can be driven and sampled as a unit. Used throughout the +/// [FilterBank] to carry tagged samples between modules. +class FilterSample extends LogicStructure { + /// The sample data word. + late final Logic data; + + /// Whether this sample is valid. + late final Logic valid; + + /// The channel index this sample belongs to. + late final Logic channel; + + /// Creates a [FilterSample] with the given [dataWidth] (default 16) + /// and optional [name]. + FilterSample({int dataWidth = 16, String? name}) + : super( + [ + Logic(name: 'data', width: dataWidth), + Logic(name: 'valid'), + Logic(name: 'channel'), + ], + name: name ?? 'filter_sample', + ) { + data = elements[0]; + valid = elements[1]; + channel = elements[2]; + } + + // Private constructor for clone to share element structure. + FilterSample._clone(super.elements, {required super.name}) { + data = elements[0]; + valid = elements[1]; + channel = elements[2]; + } + + @override + + /// Returns a structural clone of this sample, preserving element names. + FilterSample clone({String? name}) => FilterSample._clone( + elements.map((e) => e.clone(name: e.name)), + name: name ?? this.name, + ); +} + +// ────────────────────────────────────────────────────────────────── +// Interface: tagged port bundle for filter data I/O +// ────────────────────────────────────────────────────────────────── + +/// Tags for grouping port directions in [FilterDataInterface]. +enum FilterPortTag { + /// Ports carrying data into the filter (`sampleIn`, `validIn`). + inputPorts, + + /// Ports carrying data out of the filter (`dataOut`, `validOut`). + outputPorts, +} + +/// An interface carrying sample data and control into/out of filter modules. +/// +/// Groups ports by [FilterPortTag] so that [connectIO] can wire +/// inputs and outputs in a single call. +class FilterDataInterface extends Interface { + /// Input sample data bus. + Logic get sampleIn => port('sampleIn'); + + /// Input valid strobe. + Logic get validIn => port('validIn'); + + /// Output filtered data bus. + Logic get dataOut => port('dataOut'); + + /// Output valid strobe. + Logic get validOut => port('validOut'); + + /// The data width used by this interface. + final int _dataWidth; + + /// Creates a [FilterDataInterface] with the given [dataWidth] + /// (default 16 bits). + FilterDataInterface({int dataWidth = 16}) : _dataWidth = dataWidth { + setPorts([ + Logic.port('sampleIn', dataWidth), + Logic.port('validIn'), + ], [ + FilterPortTag.inputPorts + ]); + + setPorts([ + Logic.port('dataOut', dataWidth), + Logic.port('validOut'), + ], [ + FilterPortTag.outputPorts + ]); + } + + @override + + /// Returns a new interface with the same data width. + FilterDataInterface clone() => FilterDataInterface(dataWidth: _dataWidth); +} + +// ────────────────────────────────────────────────────────────────── +// CoeffBank: stores FIR tap coefficients in a LogicArray +// ────────────────────────────────────────────────────────────────── + +/// A coefficient storage module backed by a [LogicArray] input port. +/// +/// Accepts a [LogicArray] of per-tap coefficients via [addInputArray] +/// and a tap index, then mux-selects the corresponding coefficient. +class CoeffBank extends Module { + /// The coefficient value at the selected index. + Logic get coeffOut => output('coeffOut'); + + /// The per-tap coefficient array (registered input port). + @protected + LogicArray get coeffArray => input('coeffArray') as LogicArray; + + /// The tap index input. + @protected + Logic get tapIndex => input('tapIndex'); + + /// Number of taps. + final int numTaps; + + /// Data width. + final int dataWidth; + + /// Creates a [CoeffBank] with [numTaps] taps at [dataWidth] bits. + /// + /// [coefficients] is a [LogicArray] with one element per tap — + /// registered as an input port via [addInputArray]. + /// [tapIndex] selects the active coefficient. + CoeffBank(Logic tapIndex, LogicArray coefficients, + {required this.numTaps, + required this.dataWidth, + super.name = 'CoeffBank'}) + : super(definitionName: 'CoeffBank_T${numTaps}_W$dataWidth') { + // Register ports + tapIndex = addInput('tapIndex', tapIndex, width: tapIndex.width); + final coeffArray = addInputArray('coeffArray', coefficients, + dimensions: [numTaps], elementWidth: dataWidth); + final coeffOut = addOutput('coeffOut', width: dataWidth); + + // Mux-chain ROM: priority-select coefficient by tap index. + Logic selected = Const(0, width: dataWidth); + for (var i = numTaps - 1; i >= 0; i--) { + selected = mux( + tapIndex.eq(Const(i, width: tapIndex.width)).named('tapMatch$i'), + coeffArray.elements[i], + selected, + ); + } + coeffOut <= selected; + } +} + +// ────────────────────────────────────────────────────────────────── +// MacUnit: a single multiply-accumulate pipeline stage +// ────────────────────────────────────────────────────────────────── + +/// A pipelined multiply-accumulate unit. +/// +/// Pipeline stage 0: multiply sample × coefficient +/// Pipeline stage 1: add product to running accumulator +class MacUnit extends Module { + /// Accumulated result. + Logic get result => output('result'); + + /// Sample data input. + @protected + Logic get sampleInPin => input('sampleIn'); + + /// Coefficient input. + @protected + Logic get coeffInPin => input('coeffIn'); + + /// Accumulator input. + @protected + Logic get accumInPin => input('accumIn'); + + /// Clock input. + @protected + Logic get clkPin => input('clk'); + + /// Reset input. + @protected + Logic get resetPin => input('reset'); + + /// Enable input. + @protected + Logic get enablePin => input('enable'); + + /// Data width. + final int dataWidth; + + /// Creates a [MacUnit] that multiplies [sampleIn] by [coeffIn] in + /// stage 0 and adds the product to [accumIn] in stage 1. + /// + /// [clk], [reset], and [enable] control the pipeline registers. + MacUnit(Logic sampleIn, Logic coeffIn, Logic accumIn, Logic clk, Logic reset, + Logic enable, + {required this.dataWidth, super.name = 'MacUnit'}) + : super(definitionName: 'MacUnit_W$dataWidth') { + sampleIn = addInput('sampleIn', sampleIn, width: dataWidth); + coeffIn = addInput('coeffIn', coeffIn, width: dataWidth); + accumIn = addInput('accumIn', accumIn, width: dataWidth); + clk = addInput('clk', clk); + reset = addInput('reset', reset); + enable = addInput('enable', enable); + final result = addOutput('result', width: dataWidth); + + // A 2-stage pipeline: multiply, then accumulate + final pipe = Pipeline( + clk, + reset: reset, + stages: [ + // Stage 0: multiply + (p) => [ + // Product = sample * coefficient (truncated to dataWidth) + p.get(sampleIn) < + (p.get(sampleIn) * p.get(coeffIn)).named('product'), + ], + // Stage 1: accumulate + (p) => [ + p.get(sampleIn) < + (p.get(sampleIn) + p.get(accumIn)).named('macSum'), + ], + ], + signals: [sampleIn, coeffIn, accumIn], + ); + + result <= pipe.get(sampleIn); + } +} + +// ────────────────────────────────────────────────────────────────── +// FilterChannel: one polyphase FIR channel with time-multiplexed MAC +// ────────────────────────────────────────────────────────────────── + +/// A single polyphase FIR filter channel with [numTaps] taps. +/// +/// Uses a [FilterDataInterface] for its sample I/O ports. +/// +/// Architecture: +/// - A delay line (shift register) captures incoming samples. +/// - A tap counter cycles 0 … numTaps-1 each sample period. +/// - [CoeffBank] provides the coefficient for the current tap. +/// - A mux selects the delay-line sample for the current tap. +/// - A single [MacUnit] multiplies the selected sample by the +/// coefficient and adds it to a running accumulator. +/// - After all taps are processed the accumulator is latched as +/// the output and the accumulator resets for the next sample. +class FilterChannel extends Module { + /// The data interface for this channel (internal use only). + @protected + late final FilterDataInterface intf; + + /// Filtered output. + Logic get dataOut => intf.dataOut; + + /// Output valid. + Logic get validOut => intf.validOut; + + /// Number of FIR taps in this channel. + final int numTaps; + + /// Bit width of each data sample. + final int dataWidth; + + /// Clock input. + @protected + Logic get clkPin => input('clk'); + + /// Reset input. + @protected + Logic get resetPin => input('reset'); + + /// Enable input. + @protected + Logic get enablePin => input('enable'); + + /// Creates a [FilterChannel] with [numTaps] taps at [dataWidth] bits. + /// + /// [srcIntf] provides the sample/valid input ports. [coefficients] + /// supplies per-tap constant coefficients. + FilterChannel( + FilterDataInterface srcIntf, + Logic clk, + Logic reset, + Logic enable, { + required this.numTaps, + required this.dataWidth, + required List coefficients, + super.name = 'FilterChannel', + }) : super(definitionName: 'FilterChannel_T${numTaps}_W$dataWidth') { + // Connect the Interface — creates module input/output ports + intf = FilterDataInterface(dataWidth: dataWidth) + ..connectIO(this, srcIntf, + inputTags: [FilterPortTag.inputPorts], + outputTags: [FilterPortTag.outputPorts]); + + final sampleIn = intf.sampleIn; + final validIn = intf.validIn; + clk = addInput('clk', clk); + reset = addInput('reset', reset); + enable = addInput('enable', enable); + + final tapIdxWidth = _bitsFor(numTaps); + + // ── Delay line (shift register via explicit flop bank + gates) ── + // AND gate: shift enable = enable & validIn & tapCounter==0 + // Samples shift in only when starting a new accumulation cycle. + final tapCounter = Logic(width: tapIdxWidth, name: 'tapCounter'); + final atFirstTap = + tapCounter.eq(Const(0, width: tapIdxWidth)).named('atFirstTap'); + final shiftEn = Logic(name: 'shiftEn'); + shiftEn <= (enable & validIn).named('enableAndValid') & atFirstTap; + + // LogicArray-backed delay line: one element per tap register. + final delayLine = LogicArray([numTaps], dataWidth, name: 'delayLine'); + for (var i = 0; i < numTaps; i++) { + final tapInput = (i == 0) ? sampleIn : delayLine.elements[i - 1]; + // Mux: hold current value or shift in new sample + final tapNext = Logic(width: dataWidth, name: 'nextTap$i'); + tapNext <= mux(shiftEn, tapInput, delayLine.elements[i]); + // Flop: register the next-state value + delayLine.elements[i] <= flop(clk, reset: reset, tapNext); + } + + // ── Coefficient bank — driven by tapCounter ── + // Build a LogicArray of constants from the coefficient list and + // pass it as an input port to CoeffBank (demonstrates addInputArray + // on a sub-module). + final coeffArray = LogicArray([numTaps], dataWidth, name: 'coeffArray'); + for (var i = 0; i < numTaps; i++) { + coeffArray.elements[i] <= Const(coefficients[i], width: dataWidth); + } + + final coeffBank = CoeffBank( + tapCounter, + coeffArray, + numTaps: numTaps, + dataWidth: dataWidth, + name: 'coeffBank', + ); + + // ── Delay-line mux — select sample for current tap ── + var selectedSample = delayLine.elements[0]; + for (var i = 1; i < numTaps; i++) { + final tapSelect = + tapCounter.eq(Const(i, width: tapIdxWidth)).named('tapSelect$i'); + selectedSample = mux(tapSelect, delayLine.elements[i], selectedSample) + .named('tapMux$i'); + } + + // ── Running accumulator (feedback register) ── + final accumReg = Logic(width: dataWidth, name: 'accumReg'); + // Reset accumulator at the start of each new sample (tap 0). + // Combinational block: equivalent to `always_comb` in SystemVerilog. + final accumFeedback = Logic(width: dataWidth, name: 'accumFeedback'); + Combinational([ + If(atFirstTap, then: [ + accumFeedback < Const(0, width: dataWidth), + ], orElse: [ + accumFeedback < accumReg, + ]), + ]); + + // ── Single MAC unit — time-multiplexed across taps ── + final mac = MacUnit( + selectedSample, + coeffBank.coeffOut, + accumFeedback, + clk, + reset, + enable, + dataWidth: dataWidth, + name: 'mac', + ); + + // Register the MAC result for accumulator feedback. + accumReg <= flop(clk, reset: reset, mac.result); + + // ── Tap counter: cycles 0 … numTaps-1 while enabled ── + // Sequential block: equivalent to `always_ff @(posedge clk)` in SV. + // When enabled, the counter increments and wraps at numTaps-1. + // When disabled, it resets to 0. + final lastTap = + tapCounter.eq(Const(numTaps - 1, width: tapIdxWidth)).named('lastTap'); + Sequential(clk, reset: reset, [ + If(enable, then: [ + If(lastTap, then: [ + tapCounter < Const(0, width: tapIdxWidth), + ], orElse: [ + tapCounter < tapCounter + Const(1, width: tapIdxWidth), + ]), + ], orElse: [ + tapCounter < Const(0, width: tapIdxWidth), + ]), + ]); + + // ── Output latch: capture accumulator when all taps processed ── + // The MAC pipeline has 2 stages, so the result is ready 2 cycles + // after the last tap enters. A 2-stage shift register of lastTap + // creates the latch strobe. + final lastTapD1 = Logic(name: 'lastTapD1'); + final lastTapD2 = Logic(name: 'lastTapD2'); + final outputReg = Logic(width: dataWidth, name: 'outputReg'); + + // Sequential block with If: latch strobe delay and output register. + Sequential(clk, reset: reset, [ + lastTapD1 < lastTap, + lastTapD2 < lastTapD1, + If(lastTapD2, then: [ + outputReg < accumReg, + ]), + ]); + + // ── Valid pipeline: track whether we have a valid output ── + // validIn is high during data injection. After the MAC pipeline + // latency (numTaps + 2 cycles), outputs become valid. + final validPipe = Logic(name: 'validPipe'); + final outputReady = (lastTapD2 & enable).named('outputReady'); + + // Sequential block: register the valid strobe and hold it. + Sequential(clk, reset: reset, [ + If(enable, then: [ + validPipe < outputReady, + ]), + ]); + + // Combinational block: gate the output to zero when not valid. + final dataOut = intf.dataOut; + final validOut = intf.validOut; + Combinational([ + If(validPipe, then: [ + dataOut < outputReg, + ], orElse: [ + dataOut < Const(0, width: dataWidth), + ]), + validOut < validPipe, + ]); + } + + /// Minimum bits needed to represent [n] values. + static int _bitsFor(int n) { + if (n <= 1) { + return 1; + } + var bits = 0; + var v = n - 1; + while (v > 0) { + bits++; + v >>= 1; + } + return bits; + } +} + +// ────────────────────────────────────────────────────────────────── +// FilterController: FSM sequencing the filter bank +// ────────────────────────────────────────────────────────────────── + +/// States for the [FilterController] finite state machine. +enum FilterState { + /// Waiting for the start signal. + idle, + + /// Accepting initial samples into the delay line. + loading, + + /// Normal filtering operation. + running, + + /// Flushing the pipeline after the input stream ends. + draining, + + /// Processing complete. + done, +} + +/// Controls the filter bank operation via a [FiniteStateMachine]. +/// +/// - idle: waiting for start signal +/// - loading: accepting initial samples into delay line +/// - running: normal filtering +/// - draining: flushing pipeline after input stream ends +/// - done: processing complete +class FilterController extends Module { + /// Encoded FSM state (3 bits). + Logic get state => output('state'); + + /// High while the filter channels should be processing. + Logic get filterEnable => output('filterEnable'); + + /// High during the initial sample-loading phase. + Logic get loadingPhase => output('loadingPhase'); + + /// Asserted when the filter bank has finished processing. + Logic get doneFlag => output('doneFlag'); + + /// Clock input. + @protected + Logic get clkPin => input('clk'); + + /// Reset input. + @protected + Logic get resetPin => input('reset'); + + /// Start input. + @protected + Logic get startPin => input('start'); + + /// Input valid. + @protected + Logic get inputValidPin => input('inputValid'); + + /// Input done. + @protected + Logic get inputDonePin => input('inputDone'); + + late final FiniteStateMachine _fsm; + + /// Returns the FSM's current state index for a given [FilterState]. + int? getStateIndex(FilterState s) => _fsm.getStateIndex(s); + + /// Creates a [FilterController] that sequences the filter bank. + /// + /// After [start] is asserted the FSM moves through loading → running + /// → draining (for [drainCycles] cycles) → done. + FilterController( + Logic clk, Logic reset, Logic start, Logic inputValid, Logic inputDone, + {required int drainCycles, super.name = 'FilterController'}) + : super(definitionName: 'FilterController') { + clk = addInput('clk', clk); + reset = addInput('reset', reset); + start = addInput('start', start); + inputValid = addInput('inputValid', inputValid); + inputDone = addInput('inputDone', inputDone); + + final filterEnable = addOutput('filterEnable'); + final loadingPhase = addOutput('loadingPhase'); + final doneFlag = addOutput('doneFlag'); + final state = addOutput('state', width: 3); + + // Drain counter + final drainCount = Logic(width: 8, name: 'drainCount'); + final drainDone = + drainCount.eq(Const(drainCycles, width: 8)).named('drainDone'); + + _fsm = FiniteStateMachine( + clk, + reset, + FilterState.idle, + [ + State( + FilterState.idle, + events: { + start: FilterState.loading, + }, + actions: [ + filterEnable < 0, + loadingPhase < 0, + doneFlag < 0, + ], + ), + State( + FilterState.loading, + events: { + inputValid: FilterState.running, + }, + actions: [ + filterEnable < 1, + loadingPhase < 1, + doneFlag < 0, + ], + ), + State( + FilterState.running, + events: { + inputDone: FilterState.draining, + }, + actions: [ + filterEnable < 1, + loadingPhase < 0, + doneFlag < 0, + ], + ), + State( + FilterState.draining, + events: { + drainDone: FilterState.done, + }, + actions: [ + filterEnable < 1, + loadingPhase < 0, + doneFlag < 0, + ], + ), + State( + FilterState.done, + events: {}, + actions: [ + filterEnable < 0, + loadingPhase < 0, + doneFlag < 1, + ], + ), + ], + ); + + state <= _fsm.currentState.zeroExtend(state.width); + + // Drain counter: Sequential block increments while draining, + // resets to zero otherwise. + final drainIdx = _fsm.getStateIndex(FilterState.draining)!; + final isDraining = Logic(name: 'isDraining'); + isDraining <= _fsm.currentState.eq(Const(drainIdx, width: _fsm.stateWidth)); + + Sequential(clk, reset: reset, [ + If(isDraining, then: [ + drainCount < drainCount + Const(1, width: 8), + ], orElse: [ + drainCount < Const(0, width: 8), + ]), + ]); + } +} + +// ────────────────────────────────────────────────────────────────── +// FilterBank: top-level 2-channel polyphase FIR filter +// ────────────────────────────────────────────────────────────────── + +/// A 2-channel polyphase FIR filter bank. +/// +/// Hierarchy: +/// ```text +/// FilterBank (top) +/// ├── FilterController (FSM) +/// ├── FilterChannel 'ch0' +/// │ ├── CoeffBank (coefficient ROM via LogicArray + mux chain) +/// │ └── MacUnit 'mac' (pipelined multiply-accumulate) +/// └── FilterChannel 'ch1' +/// ├── CoeffBank +/// └── MacUnit 'mac' +/// ``` +/// +/// Each channel time-multiplexes a single MacUnit across all taps, +/// sequenced by a tap counter that drives the CoeffBank tap index +/// and a delay-line sample mux. +/// +/// Uses: +/// - [FilterDataInterface] for I/O port bundles +/// - [FilterSample] LogicStructure for structured sample signals +/// - [LogicArray] in CoeffBank for coefficient storage +/// - [Pipeline] in MacUnit for pipelined MAC +/// - [FiniteStateMachine] in FilterController for sequencing +/// - Multiple instantiation: two [FilterChannel]s share one definition +/// - [LogicNet] / [addInOut] for bidirectional shared data bus + +// ────────────────────────────────────────────────────────────────── +// SharedDataBus: bidirectional port for coefficient/status I/O +// ────────────────────────────────────────────────────────────────── + +/// A module with a bidirectional data bus for loading/reading data. +/// +/// In real hardware, a shared data bus is common for: +/// - Loading filter coefficients from external memory +/// - Reading diagnostic status or filter output snapshots +/// +/// Direction is controlled by `writeEnable`: when high, the module's +/// internal [TriStateBuffer] drives `storedValue` onto `dataBus`; +/// when low, the external driver owns the bus and the module latches +/// the incoming value into a register. +/// +/// Exercises `addInOut` / `LogicNet` / [TriStateBuffer] / inout port +/// direction through the full ROHD stack: synthesis, hierarchy, +/// waveform capture, and DevTools rendering. +class SharedDataBus extends Module { + /// The bidirectional data bus port. + Logic get dataBus => inOut('dataBus'); + + /// The stored value (latched when the bus is driven externally). + Logic get storedValue => output('storedValue'); + + /// Write-enable input. + @protected + Logic get writeEnablePin => input('writeEnable'); + + /// Clock input. + @protected + Logic get clkPin => input('clk'); + + /// Reset input. + @protected + Logic get resetPin => input('reset'); + + /// Data width in bits. + final int dataWidth; + + /// Creates a [SharedDataBus] with a [dataWidth]-bit bidirectional port. + /// + /// [dataBusNet] is the external [LogicNet] to connect. + /// [writeEnable] controls bus direction: 1 = module drives bus, + /// 0 = external drives bus (module reads). + /// [clk] and [reset] provide synchronous storage. + SharedDataBus( + LogicNet dataBusNet, + Logic writeEnable, + Logic clk, + Logic reset, { + required this.dataWidth, + super.name = 'SharedDataBus', + }) : super(definitionName: 'SharedDataBus') { + final bus = addInOut('dataBus', dataBusNet, width: dataWidth); + writeEnable = addInput('writeEnable', writeEnable); + clk = addInput('clk', clk); + reset = addInput('reset', reset); + + final storedValue = addOutput('storedValue', width: dataWidth); + + // Latch the bus value on clock edge when the external side is driving. + storedValue <= + flop( + clk, + bus, + reset: reset, + en: ~writeEnable, + resetValue: Const(0, width: dataWidth), + ); + + // Drive the latched value back onto the bus when writeEnable is high. + // TriStateBuffer drives its out (a LogicNet) with storedValue when + // enabled; otherwise it outputs high-Z. Joining out↔bus makes the + // two nets share the same wire. + TriStateBuffer(storedValue, enable: writeEnable, name: 'busDriver') + .out + .gets(bus); + } +} + +/// The top-level polyphase FIR filter bank. +class FilterBank extends Module { + /// Per-channel filtered outputs as a [LogicArray]. + /// + /// `channelOut.elements[i]` is the filtered output of channel `i`. + LogicArray get channelOut => output('channelOut') as LogicArray; + + /// Channel 0 filtered output (convenience getter). + Logic get out0 => channelOut.elements[0]; + + /// Channel 1 filtered output (convenience getter). + Logic get out1 => channelOut.elements[1]; + + /// Output valid (aligned with filtered outputs). + Logic get validOut => output('validOut'); + + /// Done signal from the controller FSM. + Logic get done => output('done'); + + /// Controller state (for debug visibility). + Logic get state => output('state'); + + /// Clock input. + @protected + Logic get clkPin => input('clk'); + + /// Reset input. + @protected + Logic get resetPin => input('reset'); + + /// Start input. + @protected + Logic get startPin => input('start'); + + /// Per-channel sample input array. + @protected + LogicArray get samplesInPin => input('samplesIn') as LogicArray; + + /// Input valid strobe. + @protected + Logic get validInPin => input('validIn'); + + /// Input-done strobe. + @protected + Logic get inputDonePin => input('inputDone'); + + /// Number of FIR taps per channel. + final int numTaps; + + /// Bit width of each data sample. + final int dataWidth; + + /// Number of filter channels. + final int numChannels; + + /// Creates a [FilterBank] with [numChannels] channels (default 2). + /// + /// Each channel has [numTaps] FIR taps at [dataWidth] bits. + /// [coefficients] is a list of per-channel coefficient lists — + /// `coefficients[i]` supplies the tap weights for channel `i`. + /// [samplesIn] is a [LogicArray] with one element per channel. + /// [validIn] qualifies the sample data. Assert [start] to begin + /// and [inputDone] when the input stream is complete. + /// + /// Optionally pass [dataBus] (a `LogicNet`) and [writeEnable] to + /// attach a bidirectional shared data bus via [SharedDataBus]. + /// The bus latches external data when [writeEnable] is low and + /// drives `storedValue` output. + FilterBank( + Logic clk, + Logic reset, + Logic start, + LogicArray samplesIn, + Logic validIn, + Logic inputDone, { + required this.numTaps, + required this.dataWidth, + required List> coefficients, + this.numChannels = 2, + LogicNet? dataBus, + Logic? writeEnable, + super.name = 'FilterBank', + String? definitionName, + }) : super(definitionName: definitionName ?? 'FilterBank') { + if (coefficients.length != numChannels) { + throw Exception( + 'coefficients must have $numChannels entries (one per channel).'); + } + + // ── Register ports ── + clk = addInput('clk', clk); + reset = addInput('reset', reset); + start = addInput('start', start); + samplesIn = addInputArray('samplesIn', samplesIn, + dimensions: [numChannels], elementWidth: dataWidth); + validIn = addInput('validIn', validIn); + inputDone = addInput('inputDone', inputDone); + + final channelOut = addOutputArray('channelOut', + dimensions: [numChannels], elementWidth: dataWidth); + final validOut = addOutput('validOut'); + final done = addOutput('done'); + final state = addOutput('state', width: 3); + + // ── FilterSample LogicStructure for input bundling ── + final samples = []; + for (var ch = 0; ch < numChannels; ch++) { + final sample = FilterSample(dataWidth: dataWidth, name: 'sample$ch'); + sample.data <= samplesIn.elements[ch]; + sample.valid <= validIn; + sample.channel <= Const(ch); + samples.add(sample); + } + + // ── Controller FSM ── + // Drain cycles: numTaps cycles per accumulation + pipeline depth (2) + 1 + final controller = FilterController( + clk, + reset, + start, + validIn, + inputDone, + drainCycles: numTaps + 3, + name: 'controller', + ); + + final filterEnable = controller.filterEnable; + + // ── Per-channel filter instantiation ── + final srcIntfs = []; + for (var ch = 0; ch < numChannels; ch++) { + final srcIntf = FilterDataInterface(dataWidth: dataWidth); + srcIntf.sampleIn <= samples[ch].data; + srcIntf.validIn <= samples[ch].valid; + + FilterChannel( + srcIntf, + clk, + reset, + filterEnable, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: coefficients[ch], + name: 'ch$ch', + ); + + srcIntfs.add(srcIntf); + } + + // ── Connect outputs ── + for (var ch = 0; ch < numChannels; ch++) { + channelOut.elements[ch] <= srcIntfs[ch].dataOut; + } + validOut <= srcIntfs[0].validOut; + done <= controller.doneFlag; + state <= controller.state; + + // ── Optional shared data bus (inOut port) ── + if (dataBus != null && writeEnable != null) { + final busPort = addInOut('dataBus', dataBus, width: dataWidth); + writeEnable = addInput('writeEnable', writeEnable); + final storedValue = addOutput('storedValue', width: dataWidth); + + final sharedBus = SharedDataBus( + LogicNet(name: 'busNet', width: dataWidth)..gets(busPort), + writeEnable, + clk, + reset, + dataWidth: dataWidth, + ); + storedValue <= sharedBus.storedValue; + } + } +} diff --git a/lib/src/examples/oven_fsm_modules.dart b/lib/src/examples/oven_fsm_modules.dart new file mode 100644 index 000000000..b1f18a3f5 --- /dev/null +++ b/lib/src/examples/oven_fsm_modules.dart @@ -0,0 +1,211 @@ +// Copyright (C) 2023-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// oven_fsm_modules.dart +// Web-safe module class definitions for the Oven FSM example. +// +// Extracted from example/oven_fsm.dart and example/example.dart so these +// classes can be imported in web-targeted code (no dart:io dependency). +// +// 2026 April +// Original authors: Yao Jing Quek, Max Korbel + +import 'package:meta/meta.dart'; +import 'package:rohd/rohd.dart'; + +// ────────────────────────────────────────────────────────────────── +// Counter (from example/example.dart) +// ────────────────────────────────────────────────────────────────── + +/// A simple 8-bit counter with enable and synchronous reset. +class Counter extends Module { + /// The current counter value. + Logic get val => output('val'); + + /// The enable input. + @protected + Logic get en => input('en'); + + /// The reset input. + @protected + Logic get resetPin => input('reset'); + + /// The clock input. + @protected + Logic get clkPin => input('clk'); + + /// Bit width of the counter (default 8). + final int width; + + /// Creates a [Counter] of [width] bits driven by [clk]. + /// + /// Increments on each rising edge when [en] is high. + /// [reset] synchronously clears the count to zero. + Counter( + Logic en, + Logic reset, + Logic clk, { + this.width = 8, + super.name = 'counter', + }) : super(definitionName: 'Counter_W$width') { + en = addInput('en', en); + reset = addInput('reset', reset); + clk = addInput('clk', clk); + addOutput('val', width: width); + + val <= flop(clk, reset: reset, en: en, val + 1); + } +} + +// ────────────────────────────────────────────────────────────────── +// Oven FSM enums +// ────────────────────────────────────────────────────────────────── + +/// Oven states: standby → cooking → paused → completed. +enum OvenState { + /// Waiting for the start button. + standby, + + /// Actively cooking (timer running). + cooking, + + /// Cooking paused (timer held). + paused, + + /// Cooking finished (timer expired). + completed, +} + +/// One-hot encoded button inputs. +enum Button { + /// Start or restart cooking. + start(value: 0), + + /// Pause cooking. + pause(value: 1), + + /// Resume from pause. + resume(value: 2); + + /// Creates a button with the given encoded [value]. + const Button({required this.value}); + + /// The encoded value for this button. + final int value; +} + +/// One-hot encoded LED output colors. +enum LEDLight { + /// Yellow — cooking in progress. + yellow(value: 0), + + /// Blue — standby. + blue(value: 1), + + /// Red — paused. + red(value: 2), + + /// Green — cooking complete. + green(value: 3); + + /// Creates an LED color with the given encoded [value]. + const LEDLight({required this.value}); + + /// The encoded value for this LED color. + final int value; +} + +// ────────────────────────────────────────────────────────────────── +// OvenModule +// ────────────────────────────────────────────────────────────────── + +/// A microwave oven FSM with 4 states and an internal timer counter. +/// +/// Inputs: +/// - `button` (2-bit): start / pause / resume +/// - `reset`: active-high synchronous reset +/// - `clk`: clock +/// +/// Outputs: +/// - `led` (2-bit): blue (standby), yellow (cooking), +/// red (paused), green (completed) +class OvenModule extends Module { + late final FiniteStateMachine _oven; + + /// The LED output encoding the current state. + Logic get led => output('led'); + + /// The button input. + @protected + Logic get button => input('button'); + + /// The reset input. + @protected + Logic get resetPin => input('reset'); + + /// The clock input. + @protected + Logic get clkPin => input('clk'); + + /// Creates an [OvenModule] controlled by [button] with [clk] and [reset]. + OvenModule(Logic button, Logic reset, Logic clk) + : super(name: 'oven', definitionName: 'OvenModule') { + button = addInput('button', button, width: button.width); + reset = addInput('reset', reset); + clk = addInput('clk', clk); + final led = addOutput('led', width: button.width); + + final counterReset = Logic(name: 'counter_reset'); + final en = Logic(name: 'counter_en'); + + final counter = Counter(en, counterReset, clk, name: 'counter_module'); + + final states = [ + State(OvenState.standby, events: { + Logic(name: 'button_start') + ..gets(button.eq(Const(Button.start.value, width: button.width))): + OvenState.cooking, + }, actions: [ + led < LEDLight.blue.value, + counterReset < 1, + en < 0, + ]), + State(OvenState.cooking, events: { + Logic(name: 'button_pause') + ..gets(button.eq(Const(Button.pause.value, width: button.width))): + OvenState.paused, + Logic(name: 'counter_time_complete')..gets(counter.val.eq(4)): + OvenState.completed, + }, actions: [ + led < LEDLight.yellow.value, + counterReset < 0, + en < 1, + ]), + State(OvenState.paused, events: { + Logic(name: 'button_resume') + ..gets( + button.eq(Const(Button.resume.value, width: button.width))): + OvenState.cooking, + }, actions: [ + led < LEDLight.red.value, + counterReset < 0, + en < 0, + ]), + State(OvenState.completed, events: { + Logic(name: 'button_start') + ..gets(button.eq(Const(Button.start.value, width: button.width))): + OvenState.cooking, + }, actions: [ + led < LEDLight.green.value, + counterReset < 1, + en < 0, + ]), + ]; + + _oven = + FiniteStateMachine(clk, reset, OvenState.standby, states); + } + + /// The internal [FiniteStateMachine] driving the oven states. + FiniteStateMachine get ovenStateMachine => _oven; +} diff --git a/lib/src/examples/tree_modules.dart b/lib/src/examples/tree_modules.dart new file mode 100644 index 000000000..96fc7d283 --- /dev/null +++ b/lib/src/examples/tree_modules.dart @@ -0,0 +1,63 @@ +// Copyright (C) 2021-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// tree_modules.dart +// Web-safe module class definition for the Tree of Two-Input Modules example. +// +// Extracted from example/tree.dart so it can be imported in web-targeted code. +// +// 2026 April +// Original author: Max Korbel + +import 'package:meta/meta.dart'; +import 'package:rohd/rohd.dart'; + +// ────────────────────────────────────────────────────────────────── +// TreeOfTwoInputModules +// ────────────────────────────────────────────────────────────────── + +/// A logarithmic-height tree of arbitrary two-input/one-output modules. +/// +/// Recursively instantiates itself, splitting the input list in half at each +/// level. The operation [op] is applied to combine pairs of results. +class TreeOfTwoInputModules extends Module { + /// The combining operation (internal use only). + @protected + final Logic Function(Logic a, Logic b) op; + + final List _seq = []; + + /// The combined output of the tree. + Logic get out => output('out'); + + /// Creates a tree that reduces [seq] using [op]. + /// + /// Recursively splits [seq] in half until single elements remain, + /// then combines them pair-wise with the supplied operation. + TreeOfTwoInputModules(List seq, this.op) + : super( + name: 'tree_of_two_input_modules', + definitionName: 'TreeMax_N${seq.length}', + ) { + if (seq.isEmpty) { + throw Exception("Don't use TreeOfTwoInputModules with an empty sequence"); + } + + for (var i = 0; i < seq.length; i++) { + _seq.add(addInput('seq$i', seq[i], width: seq[i].width)); + } + addOutput('out', width: seq[0].width); + + if (_seq.length == 1) { + out <= _seq[0]; + } else { + final a = + TreeOfTwoInputModules(_seq.getRange(0, _seq.length ~/ 2).toList(), op) + .out; + final b = TreeOfTwoInputModules( + _seq.getRange(_seq.length ~/ 2, _seq.length).toList(), op) + .out; + out <= op(a, b); + } + } +} diff --git a/lib/src/module.dart b/lib/src/module.dart index 9f6ec634e..db0690871 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -13,10 +13,8 @@ import 'dart:collection'; import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/collections/traverseable_collection.dart'; -import 'package:rohd/src/utilities/config.dart'; import 'package:rohd/src/utilities/namer.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; -import 'package:rohd/src/utilities/timestamper.dart'; import 'package:rohd/src/utilities/uniquifier.dart'; /// Represents a synthesizable hardware entity with clearly defined interface @@ -303,8 +301,11 @@ abstract class Module { /// /// The hierarchy is built "bottom-up", so leaf-level [Module]s are built /// before the [Module]s which contain them. + /// + /// If [netlistOptions] is provided, a [NetlistService] is automatically + /// created and registered after the module hierarchy is constructed. @mustCallSuper - Future build() async { + Future build({NetlistOptions? netlistOptions}) async { if (hasBuilt) { throw Exception( 'This Module has already been built, and can only be built once.'); @@ -333,6 +334,11 @@ abstract class Module { _hasBuilt = true; ModuleServices.instance.rootModule = this; + + // Optionally synthesize a netlist and register the service. + if (netlistOptions != null) { + await NetlistService.create(this, options: netlistOptions); + } } /// Confirms that the post-[build] hierarchy is valid. @@ -1133,18 +1139,7 @@ abstract class Module { throw ModuleNotBuiltException(this); } - final synthHeader = ''' -/** - * Generated by ROHD - www.github.com/intel/rohd - * Generation time: ${Timestamper.stamp()} - * ROHD Version: ${Config.version} - */ - -'''; - return synthHeader + - SynthBuilder(this, SystemVerilogSynthesizer()) - .getSynthFileContents() - .join('\n\n////////////////////\n\n'); + return SvService(this, register: false).synthOutput; } } diff --git a/lib/src/synthesizers/netlist/leaf_cell_mapper.dart b/lib/src/synthesizers/netlist/leaf_cell_mapper.dart new file mode 100644 index 000000000..606747dee --- /dev/null +++ b/lib/src/synthesizers/netlist/leaf_cell_mapper.dart @@ -0,0 +1,486 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// leaf_cell_mapper.dart +// Maps ROHD leaf modules to Yosys-primitive cell representations. +// +// 2026 February 11 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; + +/// The result of mapping a leaf ROHD module to a Yosys-style cell. +typedef LeafCellMapping = ({ + String cellType, + Map portDirs, + Map> connections, + Map parameters, +}); + +/// Context provided to each leaf-cell mapping handler. +/// +/// Contains the module instance plus the raw ROHD port directions and +/// connections built by the synthesizer, so handlers can remap them to +/// Yosys-primitive port names. +class LeafCellContext { + /// The ROHD [Module] being mapped. + final Module module; + + /// Raw ROHD port-direction map (`{'portName': 'input'|'output'|'inout'}`). + final Map rawPortDirs; + + /// Raw ROHD connection map (`{'portName': [wireId, ...]}`). + final Map> rawConns; + + /// Creates a [LeafCellContext]. + const LeafCellContext(this.module, this.rawPortDirs, this.rawConns); + + // ── Shared helper methods ─────────────────────────────────────────── + + /// Find the first input port name matching [prefix]. + String? findInput(String prefix) { + for (final k in module.inputs.keys) { + if (k.startsWith(prefix)) { + return k; + } + } + return null; + } + + /// The first output port name, or `null` if there are none. + String? get firstOutput => + module.outputs.keys.isEmpty ? null : module.outputs.keys.first; + + /// The first input port name, or `null` if there are none. + String? get firstInput => + module.inputs.keys.isEmpty ? null : module.inputs.keys.first; + + /// Width (number of wire IDs) for a given ROHD port name. + int width(String portName) => rawConns[portName]?.length ?? 0; + + /// Build new port-direction and connection maps from a + /// `{rohdPortName: yosysPortName}` mapping. + ({ + Map portDirs, + Map> connections, + }) remap(Map nameMap) { + final pd = {}; + final cn = >{}; + for (final e in nameMap.entries) { + final rohdName = e.key; + final netlistPortName = e.value; + pd[netlistPortName] = rawPortDirs[rohdName] ?? 'output'; + cn[netlistPortName] = rawConns[rohdName] ?? []; + } + return (portDirs: pd, connections: cn); + } +} + +/// Signature for a leaf-cell mapping handler. +/// +/// Returns a [LeafCellMapping] if the handler recognises the module, +/// or `null` to let the next handler try. +typedef LeafCellHandler = LeafCellMapping? Function(LeafCellContext ctx); + +/// Maps ROHD leaf [Module]s to Yosys-primitive cell representations. +/// +/// Handlers are registered via [register] and tried in registration order. +/// A singleton instance with all built-in ROHD types pre-registered is +/// available via [LeafCellMapper.defaultMapper]. +/// +/// ```dart +/// final mapper = LeafCellMapper.defaultMapper; +/// final result = mapper.map(sub, rawPortDirs, rawConns); +/// ``` +class LeafCellMapper { + /// Ordered list of registered handlers. + final _handlers = []; + + /// Creates an empty [LeafCellMapper] with no registered handlers. + LeafCellMapper(); + + /// The default mapper with all built-in ROHD leaf types registered. + static final defaultMapper = LeafCellMapper._withDefaults(); + + /// Register a mapping [handler]. + /// + /// Handlers are tried in registration order; the first non-null result + /// wins. Register more-specific handlers before less-specific ones. + void register(LeafCellHandler handler) { + _handlers.add(handler); + } + + /// Try to map [module] to a Yosys-primitive cell. + /// + /// Returns `null` if no registered handler matches. + LeafCellMapping? map( + Module module, + Map rawPortDirs, + Map> rawConns, + ) { + final ctx = LeafCellContext(module, rawPortDirs, rawConns); + for (final handler in _handlers) { + final result = handler(ctx); + if (result != null) { + return result; + } + } + return null; + } + + // ══════════════════════════════════════════════════════════════════════ + // Reusable mapping patterns + // ══════════════════════════════════════════════════════════════════════ + + /// Map a single-input, single-output gate (e.g. `$not`, `$reduce_and`). + static LeafCellMapping? unaryAY( + LeafCellContext ctx, + String cellType, + ) { + final inN = ctx.firstInput; + final out = ctx.firstOutput; + if (inN == null || out == null) { + return null; + } + final r = ctx.remap({inN: 'A', out: 'Y'}); + return ( + cellType: cellType, + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'A_WIDTH': ctx.width(inN), + 'Y_WIDTH': ctx.width(out), + }, + ); + } + + /// Map a two-input gate with ports A, B, Y (e.g. `$and`, `$eq`, `$shl`). + static LeafCellMapping? binaryABY( + LeafCellContext ctx, + String cellType, { + required String inAPrefix, + required String inBPrefix, + }) { + final a = ctx.findInput(inAPrefix); + final b = ctx.findInput(inBPrefix); + final out = ctx.firstOutput; + if (a == null || b == null || out == null) { + return null; + } + final r = ctx.remap({a: 'A', b: 'B', out: 'Y'}); + return ( + cellType: cellType, + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'A_WIDTH': ctx.width(a), + 'B_WIDTH': ctx.width(b), + 'Y_WIDTH': ctx.width(out), + }, + ); + } + + // ══════════════════════════════════════════════════════════════════════ + // Built-in handler registration + // ══════════════════════════════════════════════════════════════════════ + + /// Creates a [LeafCellMapper] with built-in handlers for common ROHD leaf + /// types. + factory LeafCellMapper._withDefaults() { + final m = LeafCellMapper(); + + // Helper to reduce boilerplate for type-map-based handlers. + void registerByTypeMap( + Map typeMap, + LeafCellMapping? Function(LeafCellContext ctx, String cellType) handler, + ) { + m.register((ctx) { + final cellType = typeMap[ctx.module.runtimeType]; + return cellType == null ? null : handler(ctx, cellType); + }); + } + + m + // ── BusSubset → $slice ──────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! BusSubset) { + return null; + } + final sub = ctx.module as BusSubset; + final inName = sub.inputs.keys.first; + final outName = sub.outputs.keys.first; + final r = ctx.remap({inName: 'A', outName: 'Y'}); + return ( + cellType: r'$slice', + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'OFFSET': sub.startIndex, + 'A_WIDTH': ctx.width(inName), + 'Y_WIDTH': ctx.width(outName), + }, + ); + }) + + // ── Swizzle → $concat ───────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! Swizzle) { + return null; + } + final outName = ctx.firstOutput; + final inputKeys = ctx.module.inputs.keys.toList(); + + // Filter out zero-width inputs (degenerate concat operands). + final nonZeroKeys = inputKeys.where((k) => ctx.width(k) > 0).toList(); + + if (nonZeroKeys.length == 2 && outName != null) { + final r = ctx + .remap({nonZeroKeys[0]: 'A', nonZeroKeys[1]: 'B', outName: 'Y'}); + return ( + cellType: r'$concat', + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'A_WIDTH': ctx.width(nonZeroKeys[0]), + 'B_WIDTH': ctx.width(nonZeroKeys[1]), + }, + ); + } + + // Single non-zero input ⇒ emit as $buf. + if (nonZeroKeys.length == 1 && outName != null) { + final r = ctx.remap({nonZeroKeys[0]: 'A', outName: 'Y'}); + return ( + cellType: r'$buf', + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'WIDTH': ctx.width(nonZeroKeys[0]), + }, + ); + } + + if (nonZeroKeys.isEmpty) { + return null; + } + + // N-input concat: per-input range labels, output is Y. + final pd = {}; + final cn = >{}; + final params = {}; + var bitOffset = 0; + for (var i = 0; i < nonZeroKeys.length; i++) { + final ik = nonZeroKeys[i]; + final w = ctx.width(ik); + final label = + w == 1 ? '[$bitOffset]' : '[${bitOffset + w - 1}:$bitOffset]'; + pd[label] = 'input'; + cn[label] = ctx.rawConns[ik] ?? []; + params['IN${i}_WIDTH'] = w; + bitOffset += w; + } + if (outName != null) { + pd['Y'] = 'output'; + cn['Y'] = ctx.rawConns[outName] ?? []; + } + return ( + cellType: r'$concat', + portDirs: pd, + connections: cn, + parameters: params, + ); + }) + + // ── NOT gate ────────────────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! NotGate) { + return null; + } + return unaryAY(ctx, r'$not'); + }) + + // ── Mux ─────────────────────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! Mux) { + return null; + } + final ctrl = ctx.findInput('_control') ?? ctx.findInput('control'); + final d0 = ctx.findInput('_d0') ?? ctx.findInput('d0'); + final d1 = ctx.findInput('_d1') ?? ctx.findInput('d1'); + final out = ctx.firstOutput; + if (ctrl == null || d0 == null || d1 == null || out == null) { + return null; + } + // Yosys: S=select, A=d0 (when S=0), B=d1 (when S=1). + final r = ctx.remap({ctrl: 'S', d0: 'A', d1: 'B', out: 'Y'}); + return ( + cellType: r'$mux', + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'WIDTH': ctx.width(d0), + }, + ); + }) + + // ── Add ─────────────────────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! Add) { + return null; + } + final in0 = ctx.findInput('_in0') ?? ctx.findInput('in0'); + final in1 = ctx.findInput('_in1') ?? ctx.findInput('in1'); + final sumName = ctx.module.outputs.keys + .firstWhere((k) => !k.contains('carry'), orElse: () => ''); + final carryName = ctx.module.outputs.keys + .firstWhere((k) => k.contains('carry'), orElse: () => ''); + if (in0 == null || in1 == null || sumName.isEmpty) { + return null; + } + final pd = { + 'A': 'input', + 'B': 'input', + 'Y': 'output', + }; + final cn = >{ + 'A': ctx.rawConns[in0] ?? [], + 'B': ctx.rawConns[in1] ?? [], + 'Y': ctx.rawConns[sumName] ?? [], + }; + if (carryName.isNotEmpty) { + pd['CO'] = 'output'; + cn['CO'] = ctx.rawConns[carryName] ?? []; + } + return ( + cellType: r'$add', + portDirs: pd, + connections: cn, + parameters: { + 'A_WIDTH': ctx.width(in0), + 'B_WIDTH': ctx.width(in1), + 'Y_WIDTH': ctx.width(sumName), + }, + ); + }) + + // ── FlipFlop → $dff ─────────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! FlipFlop) { + return null; + } + final clk = ctx.findInput('_clk') ?? ctx.findInput('clk'); + final d = ctx.findInput('_d') ?? ctx.findInput('d'); + final en = ctx.findInput('_en') ?? ctx.findInput('en'); + final rst = ctx.findInput('_reset') ?? ctx.findInput('reset'); + final q = ctx.firstOutput; + if (clk == null || d == null || q == null) { + return null; + } + final pd = { + '_clk': 'input', + '_d': 'input', + '_q': 'output', + }; + final cn = >{ + '_clk': ctx.rawConns[clk] ?? [], + '_d': ctx.rawConns[d] ?? [], + '_q': ctx.rawConns[q] ?? [], + }; + if (en != null && ctx.rawConns.containsKey(en)) { + pd['_en'] = 'input'; + cn['_en'] = ctx.rawConns[en] ?? []; + } + if (rst != null && ctx.rawConns.containsKey(rst)) { + pd['_reset'] = 'input'; + cn['_reset'] = ctx.rawConns[rst] ?? []; + } + final rstVal = + ctx.findInput('_resetValue') ?? ctx.findInput('resetValue'); + if (rstVal != null && ctx.rawConns.containsKey(rstVal)) { + pd['_resetValue'] = 'input'; + cn['_resetValue'] = ctx.rawConns[rstVal] ?? []; + } + return ( + cellType: r'$dff', + portDirs: pd, + connections: cn, + parameters: { + 'WIDTH': ctx.width(d), + 'CLK_POLARITY': 1, + }, + ); + }); + + // ── Type-map-based gates ─────────────────────────────────────────── + final gateRegistrations = <( + Map, + LeafCellMapping? Function(LeafCellContext, String), + )>[ + ( + const { + And2Gate: r'$and', + Or2Gate: r'$or', + Xor2Gate: r'$xor', + }, + (ctx, type) => + binaryABY(ctx, type, inAPrefix: '_in0', inBPrefix: '_in1'), + ), + ( + const { + AndUnary: r'$reduce_and', + OrUnary: r'$reduce_or', + XorUnary: r'$reduce_xor', + }, + unaryAY, + ), + ( + const { + Multiply: r'$mul', + Subtract: r'$sub', + Equals: r'$eq', + NotEquals: r'$ne', + LessThan: r'$lt', + GreaterThan: r'$gt', + LessThanOrEqual: r'$le', + GreaterThanOrEqual: r'$ge', + }, + (ctx, type) => + binaryABY(ctx, type, inAPrefix: '_in0', inBPrefix: '_in1'), + ), + ( + const { + LShift: r'$shl', + RShift: r'$shr', + ARShift: r'$shiftx', + }, + (ctx, type) => + binaryABY(ctx, type, inAPrefix: '_in', inBPrefix: '_shiftAmount'), + ), + ]; + for (final (typeMap, handler) in gateRegistrations) { + registerByTypeMap(typeMap, handler); + } + + // ── TriStateBuffer → $tribuf ────────────────────────────────────── + m.register((ctx) { + if (ctx.module is! TriStateBuffer) { + return null; + } + final tsb = ctx.module as TriStateBuffer; + final inName = tsb.inputs.keys.first; // data input + final enName = tsb.inputs.keys.last; // enable + final outName = tsb.inOuts.keys.first; // inout output + final r = ctx.remap({inName: 'A', enName: 'EN', outName: 'Y'}); + return ( + cellType: r'$tribuf', + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'WIDTH': ctx.width(inName), + }, + ); + }); + + return m; + } +} diff --git a/lib/src/synthesizers/netlist/netlist.dart b/lib/src/synthesizers/netlist/netlist.dart new file mode 100644 index 000000000..8d335e812 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist.dart @@ -0,0 +1,16 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist.dart +// Barrel file for netlist synthesis library. +// +// 2026 February 11 +// Author: Desmond Kirkpatrick + +export 'leaf_cell_mapper.dart'; +export 'netlist_options.dart'; +export 'netlist_passes.dart'; +export 'netlist_service.dart'; +export 'netlist_synthesis_result.dart'; +export 'netlist_synthesizer.dart'; +export 'netlist_utils.dart'; diff --git a/lib/src/synthesizers/netlist/netlist_options.dart b/lib/src/synthesizers/netlist/netlist_options.dart new file mode 100644 index 000000000..95d0856c1 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_options.dart @@ -0,0 +1,138 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_options.dart +// Configuration for netlist synthesis. +// +// 2026 March 12 +// Author: Desmond Kirkpatrick + +import 'package:rohd/src/synthesizers/netlist/leaf_cell_mapper.dart'; + +/// Configuration options for netlist synthesis. +/// +/// The netlist synthesizer serves two main consumer flows, both configured +/// through these options: +/// +/// **Flow 1 — Slim JSON** (`NetlistService.slimJson`): +/// Batch synthesis of the entire design, producing a lightweight +/// representation with ports, signals, and cell stubs but **no cell +/// connections**. Used for the initial DevTools hierarchy load. +/// +/// **Flow 2 — Full JSON, incremental** (`NetlistService.moduleJson`): +/// Returns the complete netlist (with cell connections) for a single +/// module definition on demand. Results are cached; the first call +/// may trigger a lazy `SynthBuilder` run on the requested subtree. +/// +/// Both flows run the identical pipeline: `SynthBuilder` → +/// `collectModuleEntries` → `applyPostProcessingPasses`. Flow 1 +/// then strips cell connections from the cached data; Flow 2 returns +/// it verbatim. This guarantees cell keys and wire IDs are stable +/// across both flows. +/// +/// Bundles all parameters that control netlist generation into a single +/// object, making it easier to pass through call chains and to store +/// for incremental synthesis. +/// +/// Example usage: +/// ```dart +/// const options = NetlistOptions( +/// groupStructConversions: true, +/// collapseStructGroups: true, +/// ); +/// final synth = NetlistSynthesizer(options: options); +/// ``` +class NetlistOptions { + /// The leaf-cell mapper used to convert ROHD leaf modules to Yosys + /// primitive cell types. When `null`, [LeafCellMapper.defaultMapper] + /// is used. + final LeafCellMapper? leafCellMapper; + + /// When `true`, groups of `$slice` + `$concat` cells that represent + /// structure-to-structure signal conversions are collapsed into + /// synthetic child modules, reducing visual clutter in the netlist. + final bool groupStructConversions; + + /// When `true` (requires [groupStructConversions] to also be `true`), + /// the synthetic child modules created for struct conversions will have + /// all their internal `$slice`/`$concat` cells and intermediate nets + /// removed, leaving only a single `$buf` cell that directly connects + /// each input port to the corresponding output port. + final bool collapseStructGroups; + + /// When `true` (requires [groupStructConversions] to also be `true`), + /// enables an additional grouping pass that finds `$concat` cells whose + /// input bits all trace back through `$buf`/`$slice` chains to a + /// contiguous sub-range of a single source bus. + final bool groupMaximalSubsets; + + /// When `true` (requires [groupStructConversions] to also be `true`), + /// enables an additional pass that finds `$concat` cells where a + /// contiguous run of input ports trace back through `$buf`/`$slice` + /// chains to a contiguous sub-range of a single source bus. + final bool collapseConcats; + + /// When `true`, `$slice` cells whose outputs feed directly into a + /// `$struct_pack` input port are absorbed into the pack cell, which + /// already describes the field decomposition. The redundant slices + /// are removed. + final bool collapseSelectsIntoPack; + + /// When `true`, `$struct_unpack` output ports that feed directly into + /// `$concat` input ports are collapsed: the concat is replaced by a + /// `$buf` or `$slice` from the unpack's source bus when all inputs + /// trace back to it contiguously. + final bool collapseUnpackToConcat; + + /// When `true`, `$struct_unpack` output ports that feed (possibly + /// through `$buf`/`$slice` chains) into `$struct_pack` input ports + /// are collapsed: the intermediate cells are removed and the pack + /// input wires are rewired to the unpack output wires directly. + final bool collapseUnpackToPack; + + /// When `true`, dead-cell elimination is performed after aliasing to + /// remove cells whose inputs are entirely undriven or whose outputs + /// are entirely unconsumed. + final bool enableDCE; + + /// When `true`, the synthesizer produces "slim" output: the full + /// synthesis pipeline runs (including all post-processing passes), + /// but cell connection maps are stripped from the result. + /// Netnames and ports are still emitted with full wire-ID fidelity, + /// so a subsequent full-mode synthesis of the same module will + /// produce compatible wire IDs. + final bool slimMode; + + /// When `true`, contiguous ascending runs of ≥3 integer bit IDs in + /// `bits` arrays and cell `connections` arrays are replaced with + /// `"start:end"` range strings (e.g. `[52, 53, 54, 55]` → `["52:55"]`). + /// + /// This is backward-compatible: Yosys-format arrays already mix + /// integers with constant strings `"0"` and `"1"`. Parsers can + /// detect range strings by the presence of `:`. + final bool compressBitRanges; + + /// When `true`, the JSON output uses no indentation (compact form). + /// When `false` (default), the JSON is pretty-printed with two-space + /// indentation. + final bool compactJson; + + /// Creates a [NetlistOptions] with the given configuration. + /// + /// All parameters have sensible defaults matching the current + /// netlist synthesizer behaviour. + const NetlistOptions({ + this.leafCellMapper, + this.groupStructConversions = false, + this.collapseStructGroups = false, + this.groupMaximalSubsets = false, + this.collapseConcats = false, + this.collapseSelectsIntoPack = false, + this.collapseUnpackToConcat = false, + this.collapseUnpackToPack = false, + this.enableDCE = true, + this.slimMode = false, + this.compressBitRanges = false, + this.compactJson = false, + }); +} diff --git a/lib/src/synthesizers/netlist/netlist_passes.dart b/lib/src/synthesizers/netlist/netlist_passes.dart new file mode 100644 index 000000000..7fdcf57fb --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_passes.dart @@ -0,0 +1,2541 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_passes.dart +// Post-processing optimization passes for netlist synthesis. +// +// These passes operate on the modules map (definition name → module data) +// produced by [NetlistSynthesizer.synthesize]. They simplify the netlist +// by grouping struct conversions, collapsing redundant cells, and inserting +// buffer cells for cleaner schematic rendering. +// +// 2025 February 11 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; + +/// Post-processing optimization passes for netlist synthesis. +/// +/// All methods are static — no instances are created. +class NetlistPasses { + NetlistPasses._(); + + /// Collects a combined modules map from [SynthesisResult]s suitable for + /// JSON emission. + static Map> collectModuleEntries( + Iterable results, { + Module? topModule, + }) { + final allModules = >{}; + for (final result in results) { + if (result is NetlistSynthesisResult) { + final typeName = result.instanceTypeName; + final attrs = Map.from(result.attributes); + if (topModule != null && result.module == topModule) { + attrs['top'] = 1; + } + allModules[typeName] = { + 'attributes': attrs, + 'ports': result.ports, + 'cells': result.cells, + 'netnames': result.netnames, + }; + } + } + return allModules; + } + + // -- Maximal-subset grouping ------------------------------------------- + + /// Finds `$concat` cells whose input bits all trace back through + /// `$buf`/`$slice` chains to a contiguous sub-range of a single source + /// bus. Replaces the entire concat-tree (the concat itself plus the + /// intermediate `$buf` and `$slice` cells that exclusively serve it) + /// with a single `$slice` (or `$buf` when the sub-range covers the + /// full source width). + /// + /// This pass runs *before* the connected-component grouping so that + /// the simplified cells can be picked up by the standard struct-assign + /// grouping and collapse passes. + static void applyMaximalSubsetGrouping( + Map> allModules, + ) { + for (final moduleDef in allModules.values) { + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + // Build wire-driver, wire-consumer, and bit-to-net maps. + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); + + final cellsToRemove = {}; + final cellsToAdd = >{}; + var replIdx = 0; + + // Process each $concat cell. + for (final concatEntry in cells.entries.toList()) { + final concatName = concatEntry.key; + final concatCell = concatEntry.value; + if ((concatCell['type'] as String?) != r'$concat') { + continue; + } + if (cellsToRemove.contains(concatName)) { + continue; + } + + final conns = concatCell['connections'] as Map? ?? {}; + + // Gather the concat's input bits in LSB-first order. + final inputBits = []; + if (conns.containsKey('A')) { + // Standard 2-input concat: A (LSB), B (MSB). + for (final b in conns['A'] as List) { + if (b is int) { + inputBits.add(b); + } + } + for (final b in conns['B'] as List) { + if (b is int) { + inputBits.add(b); + } + } + } else { + // Multi-input concat: range-named ports [lo:hi]. + final rangePorts = >{}; + for (final portName in conns.keys) { + if (portName == 'Y') { + continue; + } + final m = NetlistUtils.rangePortRe.firstMatch(portName); + if (m != null) { + final hi = int.parse(m.group(1)!); + final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; + rangePorts[lo] = [ + for (final b in conns[portName] as List) + if (b is int) b, + ]; + } + } + for (final k in rangePorts.keys.toList()..sort()) { + inputBits.addAll(rangePorts[k]!); + } + } + + if (inputBits.isEmpty) { + continue; + } + + final outputBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; + + // Trace each input bit backward through $buf and $slice cells + // to find its ultimate source bit. Record the chain of + // intermediate cells visited. + final sourceBits = []; + final intermediateCells = {}; + var allFromOneBus = true; + String? sourceBusNet; + List? sourceBusBits; + + for (final inputBit in inputBits) { + final (traced, chain) = + NetlistUtils.traceBackward(inputBit, wireDriverCell, cells); + sourceBits.add(traced); + intermediateCells.addAll(chain); + + // Identify which named bus this bit belongs to. + final info = bitToNetInfo[traced]; + if (info == null) { + allFromOneBus = false; + break; + } + if (sourceBusNet == null) { + sourceBusNet = info.$1; + sourceBusBits = info.$2; + } else if (sourceBusNet != info.$1) { + allFromOneBus = false; + break; + } + } + + if (!allFromOneBus || sourceBusNet == null || sourceBusBits == null) { + continue; + } + + // Verify the traced source bits form a contiguous sub-range + // of the source bus. + if (sourceBits.length != inputBits.length) { + continue; + } + + // Find each source bit's index within the source bus. + final indices = []; + var contiguous = true; + for (final sb in sourceBits) { + final idx = sourceBusBits.indexOf(sb); + if (idx < 0) { + contiguous = false; + break; + } + indices.add(idx); + } + if (!contiguous || indices.isEmpty) { + continue; + } + + // Check that indices are sequential (contiguous ascending). + for (var i = 1; i < indices.length; i++) { + if (indices[i] != indices[i - 1] + 1) { + contiguous = false; + break; + } + } + if (!contiguous) { + continue; + } + + // Verify that every intermediate cell is used exclusively + // by this concat chain (no fanout to other consumers). + if (!NetlistUtils.isExclusiveChain( + intermediates: intermediateCells, + ownerCell: concatName, + cells: cells, + wireConsumerCells: wireConsumerCells, + allowPortConsumers: true, + )) { + continue; + } + + // Build the source bus bits list (the full bus from the module). + // We need the A connection to be the full source bus. + final sourceBusParentBits = sourceBusBits.cast().toList(); + + final offset = indices.first; + final yWidth = outputBits.length; + final aWidth = sourceBusBits.length; + + // Mark intermediate cells and the concat for removal. + cellsToRemove + ..addAll(intermediateCells) + ..add(concatName); + + if (yWidth == aWidth) { + cellsToAdd['maxsub_buf_$replIdx'] = NetlistUtils.makeBufCell( + aWidth, sourceBusParentBits, outputBits.cast()); + } else { + cellsToAdd['maxsub_slice_$replIdx'] = NetlistUtils.makeSliceCell( + offset, + aWidth, + yWidth, + sourceBusParentBits, + outputBits.cast()); + } + replIdx++; + } + + // Apply removals and additions. + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); + } + } + + // -- Partial concat collapsing ----------------------------------------- + + /// Scans every module in [allModules] for `$concat` cells where a + /// contiguous run of input ports (≥ 2) all trace back through + /// `$buf`/`$slice` chains to a contiguous sub-range of a single source + /// bus with exclusive fan-out. Each such run is replaced by a single + /// `$slice` and the concat is rebuilt with fewer input ports. + /// + /// If *all* ports of a concat qualify as a single run, the concat is + /// eliminated entirely and replaced with a `$slice` (or `$buf` for + /// full-width). + static void applyCollapseConcats( + Map> allModules, + ) { + for (final entry in allModules.entries) { + final moduleDef = entry.value; + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + final concatCount = cells.values + .where((c) => (c['type'] as String?) == r'$concat') + .length; + if (concatCount == 0) { + continue; + } + + // Iterate until no more collapses are possible. Nested concat chains + // (e.g. swizzle_4 feeding swizzle_3) require multiple passes because + // traceBackward only traces through $slice/$buf, not $concat. + var changed = true; + var replIdx = 0; + var iteration = 0; + while (changed && iteration < 20) { + changed = false; + iteration++; + + // --- Build wire-driver, wire-consumer, and bit-to-net maps ------- + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); + + final cellsToRemove = {}; + final cellsToAdd = >{}; + + // --- Process each $concat cell ------------------------------------ + for (final concatEntry in cells.entries.toList()) { + final concatName = concatEntry.key; + final concatCell = concatEntry.value; + if ((concatCell['type'] as String?) != r'$concat') { + continue; + } + if (cellsToRemove.contains(concatName)) { + continue; + } + + final conns = + concatCell['connections'] as Map? ?? {}; + + // Parse input ports into an ordered list. + // Supports both range-named ports [hi:lo] and A/B form. + final inputPorts = <(int lo, String portName, List bits)>[]; + var hasRangePorts = false; + for (final portName in conns.keys) { + if (portName == 'Y') { + continue; + } + final m = NetlistUtils.rangePortRe.firstMatch(portName); + if (m != null) { + hasRangePorts = true; + final hi = int.parse(m.group(1)!); + final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; + inputPorts.add(( + lo, + portName, + [ + for (final b in conns[portName] as List) + if (b is int) b, + ], + )); + } + } + if (!hasRangePorts) { + // A/B form: convert to ordered list. + if (conns.containsKey('A') && conns.containsKey('B')) { + final aBits = [ + for (final b in conns['A'] as List) + if (b is int) b, + ]; + final bBits = [ + for (final b in conns['B'] as List) + if (b is int) b, + ]; + inputPorts + ..add((0, 'A', aBits)) + ..add((aBits.length, 'B', bBits)); + } + } + inputPorts.sort((a, b) => a.$1.compareTo(b.$1)); + + if (inputPorts.length < 2) { + continue; + } + + // --- Trace each port's bits back to a source bus ---------------- + final portTraces = <({ + String? busName, + List? busBits, + List sourceIndices, + Set intermediates, + bool valid, + })>[]; + + for (final (_, _, bits) in inputPorts) { + final sourceIndices = []; + final intermediates = {}; + String? busName; + List? busBits; + var valid = true; + + for (final bit in bits) { + final (traced, chain) = + NetlistUtils.traceBackward(bit, wireDriverCell, cells); + intermediates.addAll(chain); + + // Identify source net. + final info = bitToNetInfo[traced]; + if (info == null) { + valid = false; + break; + } + if (busName == null) { + busName = info.$1; + busBits = info.$2; + } else if (busName != info.$1) { + valid = false; + break; + } + final idx = busBits!.indexOf(traced); + if (idx < 0) { + valid = false; + break; + } + sourceIndices.add(idx); + } + + // Check contiguous within this port. + if (valid && sourceIndices.length == bits.length) { + for (var i = 1; i < sourceIndices.length; i++) { + if (sourceIndices[i] != sourceIndices[i - 1] + 1) { + valid = false; + break; + } + } + } else { + valid = false; + } + + portTraces.add(( + busName: busName, + busBits: busBits, + sourceIndices: sourceIndices, + intermediates: intermediates, + valid: valid, + )); + } + + // --- Find maximal runs of consecutive traceable ports ----------- + final runs = <(int startIdx, int endIdx)>[]; + var runStart = 0; + while (runStart < inputPorts.length) { + final t = portTraces[runStart]; + if (!t.valid || t.busName == null) { + runStart++; + continue; + } + var runEnd = runStart; + while (runEnd + 1 < inputPorts.length) { + final nextT = portTraces[runEnd + 1]; + if (!nextT.valid) { + break; + } + if (nextT.busName != t.busName) { + break; + } + // Check contiguity across port boundary. + final curLast = portTraces[runEnd].sourceIndices.last; + final nextFirst = nextT.sourceIndices.first; + if (nextFirst != curLast + 1) { + break; + } + runEnd++; + } + if (runEnd > runStart) { + runs.add((runStart, runEnd)); + } + runStart = runEnd + 1; + } + + if (runs.isEmpty) { + continue; + } + + // --- Verify exclusivity of intermediate cells for each run ------ + final validRuns = + <(int startIdx, int endIdx, Set intermediates)>[]; + for (final (startIdx, endIdx) in runs) { + final allIntermediates = {}; + for (var i = startIdx; i <= endIdx; i++) { + allIntermediates.addAll(portTraces[i].intermediates); + } + if (NetlistUtils.isExclusiveChain( + intermediates: allIntermediates, + ownerCell: concatName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + validRuns.add((startIdx, endIdx, allIntermediates)); + } + } + + if (validRuns.isEmpty) { + continue; + } + + // --- Check whether ALL ports form a single valid run ------------ + final allCollapsed = validRuns.length == 1 && + validRuns.first.$1 == 0 && + validRuns.first.$2 == inputPorts.length - 1; + + // Remove exclusive intermediate cells for all valid runs. + for (final (_, _, intermediates) in validRuns) { + cellsToRemove.addAll(intermediates); + } + + if (allCollapsed) { + // Full collapse — replace concat with a single $slice or $buf. + final t0 = portTraces.first; + final srcOffset = t0.sourceIndices.first; + final yWidth = (conns['Y'] as List).whereType().length; + final aWidth = t0.busBits!.length; + final sourceBusParentBits = t0.busBits!.cast().toList(); + final outputBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; + + cellsToRemove.add(concatName); + if (yWidth == aWidth) { + cellsToAdd['collapse_buf_$replIdx'] = NetlistUtils.makeBufCell( + aWidth, sourceBusParentBits, outputBits); + } else { + cellsToAdd['collapse_slice_$replIdx'] = + NetlistUtils.makeSliceCell(srcOffset, aWidth, yWidth, + sourceBusParentBits, outputBits); + } + replIdx++; + continue; + } + + // --- Partial collapse — rebuild concat with fewer ports --------- + cellsToRemove.add(concatName); + + final newConns = >{}; + final newDirs = {}; + var outBitOffset = 0; + + var portIdx = 0; + while (portIdx < inputPorts.length) { + // Check if this port starts a valid run. + (int, int, Set)? activeRun; + for (final run in validRuns) { + if (run.$1 == portIdx) { + activeRun = run; + break; + } + } + + if (activeRun != null) { + final (startIdx, endIdx, _) = activeRun; + // Compute combined width and collect original input wire bits. + final originalBits = []; + for (var i = startIdx; i <= endIdx; i++) { + originalBits.addAll(inputPorts[i].$3.cast()); + } + final width = originalBits.length; + final t0 = portTraces[startIdx]; + final srcOffset = t0.sourceIndices.first; + final sourceBusBits = t0.busBits!.cast().toList(); + + // Reuse the original concat-input wire bits as the $slice + // output so that existing netname associations are preserved. + cellsToAdd['collapse_slice_$replIdx'] = + NetlistUtils.makeSliceCell(srcOffset, t0.busBits!.length, + width, sourceBusBits, originalBits); + replIdx++; + + // Add the combined port to the rebuilt concat. + final hi = outBitOffset + width - 1; + final portName = + hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; + newConns[portName] = originalBits; + newDirs[portName] = 'input'; + outBitOffset += width; + + portIdx = endIdx + 1; + } else { + // Keep this port as-is. + final port = inputPorts[portIdx]; + final width = port.$3.length; + final hi = outBitOffset + width - 1; + final portName = + hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; + newConns[portName] = port.$3.cast(); + newDirs[portName] = 'input'; + outBitOffset += width; + portIdx++; + } + } + + // Preserve Y. + newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; + newDirs['Y'] = 'output'; + + // Remove original concat, add collapsed replacement. + cellsToRemove.add(concatName); + cellsToAdd['${concatName}_collapsed'] = { + 'hide_name': concatCell['hide_name'], + 'type': r'$concat', + 'parameters': {}, + 'attributes': concatCell['attributes'] ?? {}, + 'port_directions': newDirs, + 'connections': newConns, + }; + } + + // Apply removals and additions. + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); + if (cellsToRemove.isNotEmpty || cellsToAdd.isNotEmpty) { + changed = true; + } + } // end while (changed) + } + } + + // -- Struct-conversion grouping ---------------------------------------- + + /// Scans every module in [allModules] for connected components of `$slice` + /// and `$concat` cells that form reconvergent struct-conversion trees. + /// Such trees arise from `LogicStructure.gets()` when a flat bus is + /// assigned to a struct (or vice-versa): leaf fields are sliced out and + /// re-packed through potentially multiple levels of concats. + /// + /// Each connected component is extracted into a new synthetic module + /// definition (added to [allModules]) and replaced in the parent with a + /// single hierarchical cell. This collapses the visual noise in the + /// netlist into a tidy "struct_assign_*" box. + static void applyStructConversionGrouping( + Map> allModules, + ) { + // Collect new module definitions to add (avoid modifying map during + // iteration). + final newModuleDefs = >{}; + + // Process each existing module definition. + for (final moduleName in allModules.keys.toList()) { + final moduleDef = allModules[moduleName]!; + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + // Identify all $slice and $concat cells. + final sliceConcat = {}; + for (final entry in cells.entries) { + final type = entry.value['type'] as String?; + if (type == r'$slice' || type == r'$concat') { + sliceConcat.add(entry.key); + } + } + if (sliceConcat.length < 2) { + continue; + } + + // Build wire-ID → driver cell and wire-ID → consumer cells maps. + final ( + :wireDriverCell, + wireConsumerCells: wireConsumerSets, + :bitToNetInfo, + ) = NetlistUtils.buildWireMaps(cells, moduleDef); + // Convert Set consumers to List for iteration. + final wireConsumerCells = >{ + for (final e in wireConsumerSets.entries) e.key: e.value.toList(), + }; + final modPorts = moduleDef['ports'] as Map>?; + + // Build adjacency among sliceConcat cells: two are adjacent if one's + // output feeds the other's input. + final adj = >{ + for (final cn in sliceConcat) cn: {}, + }; + for (final cn in sliceConcat) { + final cell = cells[cn]!; + final conns = cell['connections'] as Map? ?? {}; + final pdirs = cell['port_directions'] as Map? ?? {}; + for (final pe in conns.entries) { + final d = pdirs[pe.key] as String? ?? ''; + for (final b in pe.value as List) { + if (b is! int) { + continue; + } + if (d == 'output') { + // Find consumers in sliceConcat. + for (final consumer in wireConsumerCells[b] ?? []) { + if (consumer != cn && sliceConcat.contains(consumer)) { + adj[cn]!.add(consumer); + adj[consumer]!.add(cn); + } + } + } else if (d == 'input') { + // Find driver in sliceConcat. + final drv = wireDriverCell[b]; + if (drv != null && drv != cn && sliceConcat.contains(drv)) { + adj[cn]!.add(drv); + adj[drv]!.add(cn); + } + } + } + } + } + + // Find connected components via BFS. + final visited = {}; + final components = >[]; + for (final start in sliceConcat) { + if (visited.contains(start)) { + continue; + } + final comp = {}; + final queue = [start]; + while (queue.isNotEmpty) { + final node = queue.removeLast(); + if (!comp.add(node)) { + continue; + } + visited.add(node); + for (final nb in adj[node]!) { + if (!comp.contains(nb)) { + queue.add(nb); + } + } + } + if (comp.length >= 2) { + components.add(comp); + } + } + + // For each connected component, extract it into a synthetic module. + var groupIdx = 0; + final groupQueue = [...components]; + var gqi = 0; + final claimedCells = {}; + while (gqi < groupQueue.length) { + final comp = groupQueue[gqi++]..removeAll(claimedCells); + if (comp.length < 2) { + continue; + } + + // Collect all wire IDs used inside the component and classify them + // as internal-only (driven AND consumed within comp) or external + // (boundary ports of the synthetic module). + // + // External inputs = wire IDs consumed by comp cells but driven + // outside the component. + // External outputs = wire IDs produced by comp cells but consumed + // outside the component (or by module ports). + final compOutputIds = {}; // driven by comp + final compInputIds = {}; // consumed by comp + + for (final cn in comp) { + final cell = cells[cn]!; + final conns = cell['connections'] as Map? ?? {}; + final pdirs = cell['port_directions'] as Map? ?? {}; + for (final pe in conns.entries) { + final d = pdirs[pe.key] as String? ?? ''; + for (final b in pe.value as List) { + if (b is! int) { + continue; + } + if (d == 'output') { + compOutputIds.add(b); + } else if (d == 'input') { + compInputIds.add(b); + } + } + } + } + + // External input bits: consumed by comp but NOT driven by comp. + final extInputBits = compInputIds.difference(compOutputIds); + // External output bits: driven by comp but consumed outside comp + // (by non-comp cells or by module output ports). + final extOutputBits = {}; + for (final b in compOutputIds) { + // Check non-comp cell consumers. + for (final consumer in wireConsumerCells[b] ?? []) { + if (!comp.contains(consumer)) { + extOutputBits.add(b); + break; + } + } + // Check module output ports. + if (!extOutputBits.contains(b) && modPorts != null) { + for (final portEntry in modPorts.values) { + final dir = portEntry['direction'] as String?; + if (dir != 'output') { + continue; + } + final bits = portEntry['bits'] as List?; + if (bits != null && bits.contains(b)) { + extOutputBits.add(b); + break; + } + } + } + } + + if (extInputBits.isEmpty || extOutputBits.isEmpty) { + continue; // degenerate component, skip + } + + // Group external bits by netname to form named ports. + // Build a net-name → sorted bit IDs mapping for inputs and outputs. + final netnames = moduleDef['netnames'] as Map? ?? {}; + + // Wire → netname map (for bits in this component). + final wireToNet = {}; + for (final nnEntry in netnames.entries) { + final nd = nnEntry.value! as Map; + final bits = nd['bits'] as List? ?? []; + for (final b in bits) { + if (b is int) { + wireToNet[b] = nnEntry.key; + } + } + } + + // Group external input bits by their netname, preserving order. + final inputGroups = >{}; + for (final b in extInputBits) { + final nn = wireToNet[b] ?? 'in_$b'; + (inputGroups[nn] ??= []).add(b); + } + for (final v in inputGroups.values) { + v.sort(); + } + + // Group external output bits by their netname, preserving order. + final outputGroups = >{}; + for (final b in extOutputBits) { + final nn = wireToNet[b] ?? 'out_$b'; + (outputGroups[nn] ??= []).add(b); + } + for (final v in outputGroups.values) { + v.sort(); + } + + // Guard: only group when the component is a true struct + // assignment — one signal split into selections then re-assembled + // into one signal. The input may be wider than the output when + // fields are dropped (e.g. a nonCacheable bit unused in the + // destination struct). Multi-source concats (e.g. swizzles + // combining independent signals) and simple bit-range selections + // must remain as standalone cells. + if (inputGroups.length != 1 || + outputGroups.length != 1 || + extInputBits.length < extOutputBits.length) { + // Try sub-component extraction: for each $concat cell in the + // component, backward-BFS to find the subset of cells that + // transitively feed it. If that subset is strictly smaller + // than the full component it may pass the guard on its own. + for (final cn in comp.toList()) { + final cell = cells[cn]; + if (cell == null) { + continue; + } + if ((cell['type'] as String?) != r'$concat') { + continue; + } + + final subComp = {cn}; + final bfsQueue = [cn]; + while (bfsQueue.isNotEmpty) { + final cur = bfsQueue.removeLast(); + final curCell = cells[cur]; + if (curCell == null) { + continue; + } + final cConns = + curCell['connections'] as Map? ?? {}; + final cDirs = + curCell['port_directions'] as Map? ?? {}; + for (final pe in cConns.entries) { + if ((cDirs[pe.key] as String?) != 'input') { + continue; + } + for (final b in pe.value as List) { + if (b is! int) { + continue; + } + final drv = wireDriverCell[b]; + if (drv != null && + comp.contains(drv) && + !subComp.contains(drv)) { + subComp.add(drv); + bfsQueue.add(drv); + } + } + } + } + + if (subComp.length >= 2 && subComp.length < comp.length) { + groupQueue.add(subComp); + } + } + continue; + } + + // Build the synthetic module's internal wire-ID space. + final usedIds = {}; + for (final cn in comp) { + final cell = cells[cn]; + if (cell == null) { + continue; + } + final conns = cell['connections'] as Map? ?? {}; + for (final bits in conns.values) { + for (final b in bits as List) { + if (b is int) { + usedIds.add(b); + } + } + } + } + + var nextLocalId = 2; + final idRemap = {}; + for (final id in usedIds) { + idRemap[id] = nextLocalId++; + } + + List remapBits(List bits) => + bits.map((b) => b is int ? (idRemap[b] ?? b) : b).toList(); + + // Build ports: one input port per input group, one output port per + // output group. + final childPorts = >{}; + final instanceConns = >{}; + final instancePortDirs = {}; + + for (final entry in inputGroups.entries) { + final portName = 'in_${entry.key}'; + final parentBits = entry.value.cast(); + childPorts[portName] = { + 'direction': 'input', + 'bits': remapBits(parentBits), + }; + instanceConns[portName] = parentBits; + instancePortDirs[portName] = 'input'; + } + + for (final entry in outputGroups.entries) { + final portName = 'out_${entry.key}'; + final parentBits = entry.value.cast(); + childPorts[portName] = { + 'direction': 'output', + 'bits': remapBits(parentBits), + }; + instanceConns[portName] = parentBits; + instancePortDirs[portName] = 'output'; + } + + // Re-map cells into the child's local ID space. + final childCells = >{}; + for (final cn in comp) { + final cell = Map.from(cells[cn]!); + final conns = Map.from( + cell['connections']! as Map); + for (final key in conns.keys.toList()) { + conns[key] = remapBits((conns[key] as List).cast()); + } + cell['connections'] = conns; + childCells[cn] = cell; + } + + // Build netnames for the child module. + final childNetnames = {}; + for (final pe in childPorts.entries) { + childNetnames[pe.key] = { + 'bits': pe.value['bits'], + 'attributes': {}, + }; + } + + final coveredIds = {}; + for (final nn in childNetnames.values) { + final bits = (nn! as Map)['bits']! as List; + for (final b in bits) { + if (b is int) { + coveredIds.add(b); + } + } + } + for (final cellEntry in childCells.entries) { + final cellName = cellEntry.key; + final conns = + cellEntry.value['connections'] as Map? ?? {}; + for (final connEntry in conns.entries) { + final portName = connEntry.key; + final bits = connEntry.value as List; + final missingBits = []; + for (final b in bits) { + if (b is int && !coveredIds.contains(b)) { + missingBits.add(b); + coveredIds.add(b); + } + } + if (missingBits.isNotEmpty) { + childNetnames['${cellName}_$portName'] = { + 'bits': missingBits, + 'hide_name': 1, + 'attributes': {}, + }; + } + } + } + + // Choose a name for the synthetic module type. + final syntheticTypeName = 'struct_assign_${moduleName}_$groupIdx'; + final syntheticInstanceName = 'struct_assign_$groupIdx'; + groupIdx++; + + // Register the synthetic module definition. + newModuleDefs[syntheticTypeName] = { + 'attributes': {'src': 'generated'}, + 'ports': childPorts, + 'cells': childCells, + 'netnames': childNetnames, + }; + + // Remove the grouped cells from the parent. + claimedCells.addAll(comp); + comp.forEach(cells.remove); + + // Add a hierarchical cell referencing the synthetic module. + cells[syntheticInstanceName] = { + 'hide_name': 0, + 'type': syntheticTypeName, + 'parameters': {}, + 'attributes': {}, + 'port_directions': instancePortDirs, + 'connections': instanceConns, + }; + } + } + + // Add all new synthetic module definitions. + allModules.addAll(newModuleDefs); + } + + /// Replace groups of `$slice` cells that share the same input bus and + /// whose outputs all feed into the same destination cell+port with a + /// single `$buf` cell. + /// + /// This eliminates visual noise from struct-to-flat-bus decomposition + /// when the destination consumes the full struct value unchanged. + /// Both signal names (source struct and destination port) are preserved + /// as separate netnames connected through the buffer. + static void applyStructBufferInsertion( + Map> allModules, + ) { + for (final moduleName in allModules.keys.toList()) { + final moduleDef = allModules[moduleName]!; + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + // Group $slice cells by their input bus (A bits). + final slicesByInput = >{}; + for (final entry in cells.entries) { + final cell = entry.value; + if (cell['type'] != r'$slice') { + continue; + } + final conns = cell['connections'] as Map?; + if (conns == null) { + continue; + } + final aBits = conns['A'] as List?; + if (aBits == null) { + continue; + } + final key = aBits.join(','); + (slicesByInput[key] ??= []).add(entry.key); + } + + var bufIdx = 0; + for (final sliceGroup in slicesByInput.values) { + if (sliceGroup.length < 2) { + continue; + } + + // Collect all Y output bit IDs from the group. + final allYBitIds = {}; + for (final sliceName in sliceGroup) { + final cell = cells[sliceName]!; + final conns = cell['connections']! as Map; + for (final b in conns['Y']! as List) { + if (b is int) { + allYBitIds.add(b); + } + } + } + + // Check: do all Y bits go to the same destination cell+port + // (or a single module output port)? + String? destId; // unique identifier for the destination + var allSameDest = true; + + // Check cell port destinations. + for (final otherEntry in cells.entries) { + if (sliceGroup.contains(otherEntry.key)) { + continue; + } + final otherConns = + otherEntry.value['connections'] as Map? ?? {}; + for (final portEntry in otherConns.entries) { + final bits = portEntry.value as List; + if (bits.any((b) => b is int && allYBitIds.contains(b))) { + final id = '${otherEntry.key}.${portEntry.key}'; + if (destId == null) { + destId = id; + } else if (destId != id) { + allSameDest = false; + break; + } + } + } + if (!allSameDest) { + break; + } + } + + // Also check module output ports as potential destinations. + final modPorts = + moduleDef['ports'] as Map>?; + if (allSameDest && modPorts != null) { + for (final portEntry in modPorts.entries) { + final port = portEntry.value; + final dir = port['direction'] as String?; + if (dir != 'output') { + continue; + } + final bits = port['bits'] as List?; + if (bits != null && + bits.any((b) => b is int && allYBitIds.contains(b))) { + final id = '__port_${portEntry.key}'; + if (destId == null) { + destId = id; + } else if (destId != id) { + allSameDest = false; + break; + } + } + } + } + + if (!allSameDest || destId == null) { + continue; + } + + // Verify slices contiguously cover the full A bus. + final firstSlice = cells[sliceGroup.first]!; + final params0 = firstSlice['parameters'] as Map?; + final aWidth = params0?['A_WIDTH'] as int?; + if (aWidth == null) { + continue; + } + + // Map offset → Y bits list, and validate. + final coverageYBits = >{}; + var totalYBits = 0; + var valid = true; + for (final sliceName in sliceGroup) { + final cell = cells[sliceName]!; + final params = cell['parameters'] as Map?; + final offset = params?['OFFSET'] as int?; + final yWidth = params?['Y_WIDTH'] as int?; + if (offset == null || yWidth == null) { + valid = false; + break; + } + final conns = cell['connections']! as Map; + final yBits = (conns['Y']! as List).cast(); + if (yBits.length != yWidth) { + valid = false; + break; + } + coverageYBits[offset] = yBits; + totalYBits += yWidth; + } + if (!valid || totalYBits != aWidth) { + continue; + } + + // Verify contiguous coverage (no gaps or overlaps). + final sortedOffsets = coverageYBits.keys.toList()..sort(); + var expectedOffset = 0; + for (final off in sortedOffsets) { + if (off != expectedOffset) { + valid = false; + break; + } + expectedOffset += coverageYBits[off]!.length; + } + if (!valid || expectedOffset != aWidth) { + continue; + } + + // Build the buffer cell. + final firstConns = firstSlice['connections']! as Map; + final aBus = (firstConns['A']! as List).cast(); + + // Construct Y by concatenating slice outputs in offset order. + final yBus = []; + for (final off in sortedOffsets) { + yBus.addAll(coverageYBits[off]!); + } + + // Remove slice cells. + sliceGroup.forEach(cells.remove); + + // Insert $buf cell. + cells['struct_buf_$bufIdx'] = + NetlistUtils.makeBufCell(aWidth, aBus, yBus); + bufIdx++; + } + } + } + + /// Replaces each `struct_assign_*` hierarchical instance in parent modules + /// with one `$buf` cell per output port and removes the synthetic module + /// definition. + /// + /// For each output port the internal `$slice`/`$concat` routing is traced + /// back to the corresponding input-port bits so that each `$buf` connects + /// only the bits belonging to that specific net. This keeps distinct + /// signal paths (e.g. `sum_0 → sumRpath` vs `sumP1 → sumPlusOneRpath`) + /// as separate cells so the schematic viewer can route them independently. + static void collapseStructGroupModules( + Map> allModules, + ) { + // Collect the names of all struct_assign module definitions to remove. + final structAssignTypes = { + for (final name in allModules.keys) + if (name.startsWith('struct_assign_')) name, + }; + + if (structAssignTypes.isEmpty) { + return; + } + + // Track which struct_assign types were fully collapsed (all instances + // replaced). Only those will have their definitions removed. + final collapsedTypes = {}; + final keptTypes = {}; + + // In each module, replace cells that instantiate a struct_assign type + // with a $buf cell. + for (final moduleDef in allModules.values) { + final cells = + moduleDef['cells'] as Map>? ?? {}; + + final replacements = >{}; + final removals = []; + + for (final entry in cells.entries) { + final cellName = entry.key; + final cell = entry.value; + final type = cell['type'] as String?; + if (type == null || !structAssignTypes.contains(type)) { + continue; + } + + final conns = cell['connections'] as Map? ?? {}; + + // Look up the synthetic module definition so we can trace the + // actual per-bit routing through its internal $slice/$concat cells. + final synthDef = allModules[type]; + if (synthDef == null) { + continue; + } + + final synthPorts = + synthDef['ports'] as Map>? ?? {}; + final synthCells = + synthDef['cells'] as Map>? ?? {}; + + // Map local (module-internal) input port bits → parent bit IDs, + // and also record which input port name each local bit belongs to + // plus its index within that port. + final localToParent = {}; + final localBitToInputPort = {}; + final localBitToIndex = {}; + final inputPortWidths = {}; + for (final pEntry in synthPorts.entries) { + final dir = pEntry.value['direction'] as String?; + if (dir != 'input' && dir != 'inout') { + continue; + } + final localBits = + (pEntry.value['bits'] as List?)?.cast() ?? []; + final parentBits = (conns[pEntry.key] as List?)?.cast() ?? []; + inputPortWidths[pEntry.key] = localBits.length; + for (var i = 0; i < localBits.length && i < parentBits.length; i++) { + if (localBits[i] is int) { + localToParent[localBits[i] as int] = parentBits[i]; + localBitToInputPort[localBits[i] as int] = pEntry.key; + localBitToIndex[localBits[i] as int] = i; + } + } + } + + final inputPortBits = localToParent.keys.toSet(); + + // Build a net-driver map inside the synthetic module by + // processing its $slice, $concat, and $buf cells. + final driver = {}; + + for (final sc in synthCells.values) { + final ct = sc['type'] as String?; + final cc = sc['connections'] as Map? ?? {}; + final cp = sc['parameters'] as Map? ?? {}; + + if (ct == r'$slice') { + final aBits = (cc['A'] as List?)?.cast() ?? []; + final yBits = (cc['Y'] as List?)?.cast() ?? []; + final offset = cp['OFFSET'] as int? ?? 0; + final yWidth = yBits.length; + final aWidth = aBits.length; + final reversed = offset + yWidth > aWidth; + for (var i = 0; i < yBits.length; i++) { + if (yBits[i] is int) { + final srcIdx = reversed ? (offset - i) : (offset + i); + if (srcIdx >= 0 && srcIdx < aBits.length) { + driver[yBits[i] as int] = aBits[srcIdx]; + } + } + } + } else if (ct == r'$concat') { + final yBits = (cc['Y'] as List?)?.cast() ?? []; + + // Gather input bits in LSB-first order. Two formats: + // 1. Standard 2-input: ports A (LSB) and B (MSB). + // 2. Multi-input: range-named ports [lo:hi] with + // INx_WIDTH parameters — ordered by range start. + final inputBits = []; + if (cc.containsKey('A')) { + inputBits + ..addAll((cc['A'] as List?)?.cast() ?? []) + ..addAll((cc['B'] as List?)?.cast() ?? []); + } else { + // Multi-input concat: collect range-named ports ordered + // by their starting bit position (LSB first). + final rangePorts = >{}; + for (final portName in cc.keys) { + if (portName == 'Y') { + continue; + } + final m = NetlistUtils.rangePortRe.firstMatch(portName); + if (m != null) { + final hi = int.parse(m.group(1)!); + final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; + rangePorts[lo] = + (cc[portName] as List?)?.cast() ?? []; + } + } + final sortedKeys = rangePorts.keys.toList()..sort(); + for (final k in sortedKeys) { + inputBits.addAll(rangePorts[k]!); + } + } + + for (var i = 0; i < yBits.length; i++) { + if (yBits[i] is int && i < inputBits.length) { + driver[yBits[i] as int] = inputBits[i]; + } + } + } else if (ct == r'$buf') { + final aBits = (cc['A'] as List?)?.cast() ?? []; + final yBits = (cc['Y'] as List?)?.cast() ?? []; + for (var i = 0; i < yBits.length && i < aBits.length; i++) { + if (yBits[i] is int) { + driver[yBits[i] as int] = aBits[i]; + } + } + } + } + + // Trace a local bit backwards through the driver map until we + // reach an input port bit or a string constant. + Object traceToSource(Object bit) { + final visited = {}; + var current = bit; + while (current is int && !inputPortBits.contains(current)) { + if (visited.contains(current)) { + break; + } + visited.add(current); + final next = driver[current]; + if (next == null) { + break; + } + current = next; + } + return current; + } + + // For each output port, trace its bits to their source and build + // the appropriate cell type: + // $buf – output has same width as its single source input port + // $slice – output is a contiguous sub-range of one input port + // $concat – output combines bits from multiple input ports + final perPortCells = >{}; + var anyUnresolved = false; + + for (final pEntry in synthPorts.entries) { + final dir = pEntry.value['direction'] as String?; + if (dir != 'output') { + continue; + } + final localBits = + (pEntry.value['bits'] as List?)?.cast() ?? []; + final parentBits = (conns[pEntry.key] as List?)?.cast() ?? []; + + final portOutputBits = []; + final portInputBits = []; + // Track per-bit source: local input-port bit ID (int) or null. + final sourceBitIds = []; + + for (var i = 0; i < parentBits.length; i++) { + portOutputBits.add(parentBits[i]); + if (i < localBits.length) { + final source = traceToSource(localBits[i]); + if (source is int && localToParent.containsKey(source)) { + portInputBits.add(localToParent[source]!); + sourceBitIds.add(source); + } else if (source is String) { + portInputBits.add(source); + sourceBitIds.add(null); + } else { + portInputBits.add('x'); + sourceBitIds.add(null); + } + } else { + portInputBits.add('x'); + sourceBitIds.add(null); + } + } + + if (portInputBits.contains('x')) { + anyUnresolved = true; + break; + } + + if (portOutputBits.isEmpty) { + continue; + } + + // Determine which input port(s) source this output port. + final sourcePortNames = {}; + for (final sid in sourceBitIds) { + if (sid != null && localBitToInputPort.containsKey(sid)) { + sourcePortNames.add(localBitToInputPort[sid]!); + } + } + + final cellKey = '${cellName}_${pEntry.key}'; + + if (sourcePortNames.length == 1) { + final srcPort = sourcePortNames.first; + final srcWidth = inputPortWidths[srcPort] ?? 0; + if (portOutputBits.length == srcWidth) { + // Same width → $buf + perPortCells['${cellKey}_buf'] = NetlistUtils.makeBufCell( + portOutputBits.length, portInputBits, portOutputBits); + } else { + // Subset of one input port → $slice. Determine the offset + // from the first traced bit's index within its input port. + final firstIdx = sourceBitIds.first; + final offset = + firstIdx != null ? (localBitToIndex[firstIdx] ?? 0) : 0; + perPortCells['${cellKey}_slice'] = NetlistUtils.makeSliceCell( + offset, + srcWidth, + portOutputBits.length, + (conns[srcPort] as List?)?.cast() ?? [], + portOutputBits); + } + } else { + // Multiple source ports – should be rare after the grouping + // guard excludes multi-source concats. Fall back to $buf. + perPortCells['${cellKey}_buf'] = NetlistUtils.makeBufCell( + portOutputBits.length, portInputBits, portOutputBits); + } + } + + if (perPortCells.isEmpty) { + continue; + } + + // Only collapse pure passthroughs: every output bit must trace + // back to an input-port bit or a string constant. If any bit + // fell through as 'x' the module is doing real computation + // (e.g. addition, muxing) and should be kept as a hierarchy. + if (anyUnresolved) { + keptTypes.add(type); + continue; + } + + collapsedTypes.add(type); + removals.add(cellName); + replacements.addAll(perPortCells); + } + + removals.forEach(cells.remove); + cells.addAll(replacements); + } + + // Remove only the synthetic module definitions whose instances were all + // successfully collapsed. Types that had at least one non-passthrough + // instance must keep their definition so the hierarchy is preserved. + collapsedTypes.difference(keptTypes).forEach(allModules.remove); + } + + /// Replace standalone `$concat` cells whose input bits all originate + /// from a single module input (or inout) port and cover its full width + /// with a simple `$buf` cell. + /// + /// This eliminates the visual noise of struct-to-bitvector reassembly + /// when an input [LogicStructure] port is decomposed into fields and + /// immediately re-packed via a [Swizzle]. + static void applyConcatToBufferReplacement( + Map> allModules, + ) { + for (final moduleDef in allModules.values) { + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + final modPorts = moduleDef['ports'] as Map>?; + if (modPorts == null) { + continue; + } + + // Build bit → port-name map for input / inout ports. + final bitToPort = {}; + for (final portEntry in modPorts.entries) { + final dir = portEntry.value['direction'] as String?; + if (dir != 'input' && dir != 'inout') { + continue; + } + final bits = portEntry.value['bits'] as List? ?? []; + for (final b in bits) { + if (b is int) { + bitToPort[b] = portEntry.key; + } + } + } + + final removals = []; + final additions = >{}; + var bufIdx = 0; + + // Avoid name collisions with existing concat_buf_* cells. + for (final name in cells.keys) { + if (name.startsWith('concat_buf_')) { + final idx = int.tryParse(name.substring('concat_buf_'.length)); + if (idx != null && idx >= bufIdx) { + bufIdx = idx + 1; + } + } + } + + for (final entry in cells.entries) { + final cell = entry.value; + if ((cell['type'] as String?) != r'$concat') { + continue; + } + + final conns = cell['connections'] as Map? ?? {}; + final pdirs = cell['port_directions'] as Map? ?? {}; + + // Collect input ranges and the Y output. + // Port names follow the pattern "[upper:lower]" or "[bit]". + final rangedInputs = >{}; // lower → bits + List? yBits; + + for (final pe in conns.entries) { + final dir = pdirs[pe.key] as String? ?? ''; + final bits = (pe.value as List).cast(); + if (dir == 'output' && pe.key == 'Y') { + yBits = bits; + continue; + } + if (dir != 'input') { + continue; + } + // Parse "[upper:lower]" or "[bit]". + final match = NetlistUtils.rangePortRe.firstMatch(pe.key); + if (match == null) { + // Also accept the 2-input A/B form. + if (pe.key == 'A') { + rangedInputs[0] = bits; + } else if (pe.key == 'B') { + // Determine A width to set the offset. + final aBits = conns['A'] as List?; + if (aBits != null) { + rangedInputs[aBits.length] = bits; + } + } + continue; + } + final upper = int.parse(match.group(1)!); + final lower = + match.group(2) != null ? int.parse(match.group(2)!) : upper; + rangedInputs[lower] = bits; + } + + if (yBits == null || rangedInputs.isEmpty) { + continue; + } + + // Assemble input bits in LSB-to-MSB order. + final sortedLowers = rangedInputs.keys.toList()..sort(); + final allInputBits = []; + for (final lower in sortedLowers) { + allInputBits.addAll(rangedInputs[lower]!); + } + + // Check that every input bit belongs to the same module port. + String? sourcePort; + var allFromSamePort = true; + for (final b in allInputBits) { + if (b is! int) { + allFromSamePort = false; + break; + } + final port = bitToPort[b]; + if (port == null) { + allFromSamePort = false; + break; + } + sourcePort ??= port; + if (port != sourcePort) { + allFromSamePort = false; + break; + } + } + + if (!allFromSamePort || sourcePort == null) { + continue; + } + + // Verify full-width coverage of the source port. + final portBits = modPorts[sourcePort]!['bits']! as List; + if (allInputBits.length != portBits.length) { + continue; + } + + // Replace $concat with $buf. + removals.add(entry.key); + additions['concat_buf_$bufIdx'] = + NetlistUtils.makeBufCell(allInputBits.length, allInputBits, yBits); + bufIdx++; + } + + removals.forEach(cells.remove); + cells.addAll(additions); + } + } + + // -- Collapse selects into struct_pack --------------------------------- + + /// Finds `$slice` cells whose outputs feed exclusively into a + /// `$struct_pack` input port. The slice is absorbed: the pack input + /// port is rewired to the slice's source bits directly and the + /// now-redundant slice is removed. + /// + /// This is the "selects into a pack" optimization: when a flat bus is + /// decomposed through individual slices and then repacked into a struct, + /// the intermediate slice cells add visual noise beyond what the + /// struct_pack field metadata already provides. + static void applyCollapseSelectsIntoPack( + Map> allModules, + ) { + for (final moduleDef in allModules.values) { + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); + + final cellsToRemove = {}; + + for (final packEntry in cells.entries.toList()) { + final packName = packEntry.key; + final packCell = packEntry.value; + if ((packCell['type'] as String?) != r'$struct_pack') { + continue; + } + + final conns = packCell['connections'] as Map? ?? {}; + final dirs = packCell['port_directions'] as Map? ?? {}; + + for (final portName in conns.keys.toList()) { + if (dirs[portName] != 'input') { + continue; + } + final bits = [ + for (final b in conns[portName] as List) + if (b is int) b, + ]; + if (bits.isEmpty) { + continue; + } + + // All bits must be driven by the same $slice cell. + final firstDriver = wireDriverCell[bits.first]; + if (firstDriver == null) { + continue; + } + final driverCell = cells[firstDriver]; + if (driverCell == null) { + continue; + } + if ((driverCell['type'] as String?) != r'$slice') { + continue; + } + if (cellsToRemove.contains(firstDriver)) { + continue; + } + + final allFromSameSlice = bits.every( + (b) => wireDriverCell[b] == firstDriver, + ); + if (!allFromSameSlice) { + continue; + } + + // The slice must exclusively feed this pack. + final sliceConns = + driverCell['connections'] as Map? ?? {}; + final sliceYBits = [ + for (final b in sliceConns['Y'] as List) + if (b is int) b, + ]; + final exclusive = sliceYBits.every((b) { + final consumers = wireConsumerCells[b]; + if (consumers == null) { + return true; + } + return consumers.every((c) => c == packName || c == '__port__'); + }); + if (!exclusive) { + continue; + } + + // Rewire: replace the pack's input bits with the slice's + // source bits (A port) at the correct offset. + final sliceABits = sliceConns['A'] as List; + final params = + driverCell['parameters'] as Map? ?? {}; + final offset = params['OFFSET'] as int? ?? 0; + final yWidth = sliceYBits.length; + + final newBits = [ + for (var i = 0; i < yWidth; i++) sliceABits[offset + i] as Object, + ]; + conns[portName] = newBits; + + cellsToRemove.add(firstDriver); + } + } + + cellsToRemove.forEach(cells.remove); + + // Second pass: collapse struct_pack → $buf when all field inputs + // form a contiguous ascending sequence (identity pack). + for (final packEntry in cells.entries.toList()) { + final packName = packEntry.key; + final packCell = packEntry.value; + if ((packCell['type'] as String?) != r'$struct_pack') { + continue; + } + + final conns = packCell['connections'] as Map? ?? {}; + final dirs = packCell['port_directions'] as Map? ?? {}; + + // Collect all input bits in field declaration order. + final allInputBits = []; + for (final portName in conns.keys) { + if (dirs[portName] != 'input') { + continue; + } + for (final b in conns[portName] as List) { + if (b is int) { + allInputBits.add(b); + } + } + } + if (allInputBits.length < 2) { + continue; + } + + // Check: input bits must form a contiguous ascending sequence. + var contiguous = true; + for (var i = 1; i < allInputBits.length; i++) { + if (allInputBits[i] != allInputBits[i - 1] + 1) { + contiguous = false; + break; + } + } + if (!contiguous) { + continue; + } + + final yBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; + if (yBits.length != allInputBits.length) { + continue; + } + + // Replace struct_pack with $buf. + cells[packName] = { + 'type': r'$buf', + 'parameters': {'WIDTH': allInputBits.length}, + 'port_directions': {'A': 'input', 'Y': 'output'}, + 'connections': >{ + 'A': allInputBits.cast(), + 'Y': yBits, + }, + }; + } + } + } + + // -- Collapse struct_unpack to concat ---------------------------------- + + /// Finds `$concat` cells whose input ports are driven (directly or + /// through exclusive `$buf`/`$slice` chains) by output ports of + /// `$struct_unpack` cells. When all inputs trace back through a single + /// unpack to its source bus, the concat and intermediate cells are + /// replaced by a `$buf` or `$slice` from the unpack's A bus. + /// + /// Partial collapse is also supported: contiguous runs of concat ports + /// that trace to the same unpack are collapsed individually. + static void applyCollapseUnpackToConcat( + Map> allModules, + ) { + for (final moduleEntry in allModules.entries) { + final moduleDef = moduleEntry.value; + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + // Iterate until convergence: each pass may create bufs that enable + // the next outer concat/unpack to collapse. + var globalReplIdx = 0; + var anyChanged = true; + var iteration = 0; + while (anyChanged && iteration < 20) { + anyChanged = false; + iteration++; + + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); + + final cellsToRemove = {}; + final cellsToAdd = >{}; + var replIdx = globalReplIdx; + + for (final concatEntry in cells.entries.toList()) { + final concatName = concatEntry.key; + final concatCell = concatEntry.value; + if ((concatCell['type'] as String?) != r'$concat') { + continue; + } + if (cellsToRemove.contains(concatName)) { + continue; + } + + final conns = + concatCell['connections'] as Map? ?? {}; + + // Parse input ports into ordered list. + final inputPorts = <(int lo, String portName, List bits)>[]; + var hasRangePorts = false; + for (final portName in conns.keys) { + if (portName == 'Y') { + continue; + } + final m = NetlistUtils.rangePortRe.firstMatch(portName); + if (m != null) { + hasRangePorts = true; + final hi = int.parse(m.group(1)!); + final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; + inputPorts.add(( + lo, + portName, + [ + for (final b in conns[portName] as List) + if (b is int) b + ], + )); + } + } + if (!hasRangePorts) { + if (conns.containsKey('A') && conns.containsKey('B')) { + final aBits = [ + for (final b in conns['A'] as List) + if (b is int) b, + ]; + final bBits = [ + for (final b in conns['B'] as List) + if (b is int) b, + ]; + inputPorts + ..add((0, 'A', aBits)) + ..add((aBits.length, 'B', bBits)); + } + } + inputPorts.sort((a, b) => a.$1.compareTo(b.$1)); + if (inputPorts.length < 2) { + continue; + } + + // --- Extended trace: through $buf/$slice AND $struct_unpack ------ + final portTraces = <({ + String? unpackName, + List? unpackABits, + List sourceIndices, + Set intermediates, + bool valid, + })>[]; + + for (final (_, _, bits) in inputPorts) { + final sourceIndices = []; + final intermediates = {}; + String? unpackName; + List? unpackABits; + var valid = true; + + for (final bit in bits) { + final (traced, chain) = + NetlistUtils.traceBackward(bit, wireDriverCell, cells); + intermediates.addAll(chain); + + // Check if traced bit is driven by a $struct_unpack output. + final driverName = wireDriverCell[traced]; + if (driverName == null) { + valid = false; + break; + } + final driverCell = cells[driverName]; + if (driverCell == null || + (driverCell['type'] as String?) != r'$struct_unpack') { + valid = false; + break; + } + + final uConns = + driverCell['connections'] as Map? ?? {}; + final uDirs = + driverCell['port_directions'] as Map? ?? {}; + final aBits = [ + for (final b in uConns['A'] as List) + if (b is int) b, + ]; + + // Find which output port contains this bit and its index + // within that port. + String? outPort; + int? bitIdx; + for (final pe in uConns.entries) { + if (pe.key == 'A') { + continue; + } + if (uDirs[pe.key] != 'output') { + continue; + } + final pBits = [ + for (final b in pe.value as List) + if (b is int) b, + ]; + final idx = pBits.indexOf(traced); + if (idx >= 0) { + outPort = pe.key; + bitIdx = idx; + break; + } + } + + if (outPort == null || bitIdx == null) { + valid = false; + break; + } + + // Find the field offset for this output port. + final params = + driverCell['parameters'] as Map? ?? {}; + final fc = params['FIELD_COUNT'] as int? ?? 0; + int? fieldOffset; + for (var fi = 0; fi < fc; fi++) { + final fname = params['FIELD_${fi}_NAME'] as String? ?? ''; + if (fname == outPort || outPort == '${fname}_$fi') { + fieldOffset = params['FIELD_${fi}_OFFSET'] as int? ?? 0; + break; + } + } + + if (fieldOffset == null) { + valid = false; + break; + } + + final aIdx = fieldOffset + bitIdx; + if (aIdx >= aBits.length) { + valid = false; + break; + } + + intermediates.add(driverName); + + if (unpackName == null) { + unpackName = driverName; + unpackABits = aBits; + } else if (unpackName != driverName) { + valid = false; + break; + } + sourceIndices.add(aIdx); + } + + portTraces.add(( + unpackName: unpackName, + unpackABits: unpackABits, + sourceIndices: sourceIndices, + intermediates: intermediates, + valid: valid, + )); + } + + // --- Find runs of consecutive ports tracing to the same unpack --- + final runs = <(int startIdx, int endIdx)>[]; + var runStart = 0; + while (runStart < inputPorts.length) { + final t = portTraces[runStart]; + if (!t.valid || t.unpackName == null) { + runStart++; + continue; + } + var runEnd = runStart; + while (runEnd + 1 < inputPorts.length) { + final nextT = portTraces[runEnd + 1]; + if (!nextT.valid || nextT.unpackName != t.unpackName) { + break; + } + final curLast = portTraces[runEnd].sourceIndices.last; + final nextFirst = nextT.sourceIndices.first; + if (nextFirst != curLast + 1) { + break; + } + runEnd++; + } + if (runEnd > runStart) { + runs.add((runStart, runEnd)); + } + runStart = runEnd + 1; + } + + if (runs.isEmpty) { + // No contiguous ascending runs, but check if ALL ports trace + // to the same unpack (general reorder / swizzle case). + final allValid = portTraces.every((t) => t.valid); + if (!allValid) { + continue; + } + final unpackNames = portTraces.map((t) => t.unpackName).toSet(); + if (unpackNames.length != 1 || unpackNames.first == null) { + continue; + } + final uName = unpackNames.first!; + final uABits = portTraces.first.unpackABits!; + + // Gather all intermediates and verify exclusivity. + final allIntermediates = {}; + for (final t in portTraces) { + allIntermediates.addAll(t.intermediates); + } + final removable = allIntermediates.where((c) { + final ct = cells[c]?['type'] as String?; + return ct == r'$buf' || ct == r'$slice'; + }).toSet(); + if (removable.isNotEmpty && + !NetlistUtils.isExclusiveChain( + intermediates: removable, + ownerCell: concatName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + continue; + } + + // Build reordered A bits: for each concat input port (in + // order), map the source indices back to the unpack's A bus. + final reorderedA = []; + for (final t in portTraces) { + for (final aIdx in t.sourceIndices) { + reorderedA.add(uABits[aIdx] as Object); + } + } + final outputBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; + if (reorderedA.length != outputBits.length) { + continue; + } + + cellsToRemove + ..addAll(removable) + ..add(uName) + ..add(concatName); + cellsToAdd['unpack_concat_buf_$replIdx'] = NetlistUtils.makeBufCell( + reorderedA.length, reorderedA, outputBits); + replIdx++; + continue; + } + + // --- Verify exclusivity of non-unpack intermediates ------ + final validRuns = + <(int startIdx, int endIdx, Set intermediates)>[]; + for (final (startIdx, endIdx) in runs) { + final allIntermediates = {}; + for (var i = startIdx; i <= endIdx; i++) { + allIntermediates.addAll(portTraces[i].intermediates); + } + // Only remove $buf/$slice intermediates, not the unpack itself. + final removable = allIntermediates.where((c) { + final ct = cells[c]?['type'] as String?; + return ct == r'$buf' || ct == r'$slice'; + }).toSet(); + if (removable.isEmpty || + NetlistUtils.isExclusiveChain( + intermediates: removable, + ownerCell: concatName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + validRuns.add((startIdx, endIdx, removable)); + } + } + + if (validRuns.isEmpty) { + continue; + } + + final allCollapsed = validRuns.length == 1 && + validRuns.first.$1 == 0 && + validRuns.first.$2 == inputPorts.length - 1; + + for (final (_, _, intermediates) in validRuns) { + cellsToRemove.addAll(intermediates); + } + + if (allCollapsed) { + // Full collapse — replace concat with $buf or $slice. + // Since we remove intermediates (buf/slice chains between the + // unpack outputs and the concat inputs), we must source the + // replacement buf from the unpack's A bus, not the concat's + // input bits which may reference wires driven by the removed + // intermediates. + final t0 = portTraces.first; + final srcOffset = t0.sourceIndices.first; + final yWidth = (conns['Y'] as List).whereType().length; + final aWidth = t0.unpackABits!.length; + final sourceBits = t0.unpackABits!.cast().toList(); + final outputBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; + + cellsToRemove + ..add(concatName) + // Also remove the unpack itself — all its outputs are consumed + // exclusively through intermediates into this concat. + ..add(t0.unpackName!); + if (yWidth == aWidth) { + cellsToAdd['unpack_concat_buf_$replIdx'] = + NetlistUtils.makeBufCell(aWidth, sourceBits, outputBits); + } else { + cellsToAdd['unpack_concat_buf_$replIdx'] = + NetlistUtils.makeSliceCell( + srcOffset, aWidth, yWidth, sourceBits, outputBits); + } + replIdx++; + continue; + } + + // --- Partial collapse — rebuild concat with fewer ports --------- + cellsToRemove.add(concatName); + + final newConns = >{}; + final newDirs = {}; + var outBitOffset = 0; + + var portIdx = 0; + while (portIdx < inputPorts.length) { + (int, int, Set)? activeRun; + for (final run in validRuns) { + if (run.$1 == portIdx) { + activeRun = run; + break; + } + } + + if (activeRun != null) { + final (startIdx, endIdx, _) = activeRun; + // Collect the traced source bits — the unpack output bits + // that traceBackward found. We cannot use the concat's raw + // input bits because intermediates (buf/slice chains) between + // the unpack outputs and the concat are being removed. + final tracedBits = []; + final t0 = portTraces[startIdx]; + final uConns = cells[t0.unpackName!]!['connections'] + as Map? ?? + {}; + final uDirs = cells[t0.unpackName!]!['port_directions'] + as Map? ?? + {}; + // Rebuild the unpack's output bits in field declaration order + // to create a mapping from A-index to wire ID. + final unpackOutBitList = []; + for (final pe in uConns.entries) { + if (pe.key == 'A') { + continue; + } + if (uDirs[pe.key] != 'output') { + continue; + } + for (final b in pe.value as List) { + if (b is int) { + unpackOutBitList.add(b); + } + } + } + // Build A-index -> output wire ID map. + final aToOutBit = {}; + final uParams = cells[t0.unpackName!]!['parameters'] + as Map? ?? + {}; + final fc = uParams['FIELD_COUNT'] as int? ?? 0; + var outIdx = 0; + for (var fi = 0; fi < fc; fi++) { + final fw = uParams['FIELD_${fi}_WIDTH'] as int? ?? 0; + final fo = uParams['FIELD_${fi}_OFFSET'] as int? ?? 0; + for (var bi = 0; bi < fw; bi++) { + if (outIdx < unpackOutBitList.length) { + aToOutBit[fo + bi] = unpackOutBitList[outIdx]; + } + outIdx++; + } + } + for (var i = startIdx; i <= endIdx; i++) { + for (final aIdx in portTraces[i].sourceIndices) { + final outBit = aToOutBit[aIdx]; + if (outBit != null) { + tracedBits.add(outBit); + } + } + } + final width = tracedBits.length; + + final hi = outBitOffset + width - 1; + final portName = + hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; + newConns[portName] = tracedBits; + newDirs[portName] = 'input'; + outBitOffset += width; + + portIdx = endIdx + 1; + } else { + final port = inputPorts[portIdx]; + final width = port.$3.length; + final hi = outBitOffset + width - 1; + final portName = + hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; + newConns[portName] = port.$3.cast(); + newDirs[portName] = 'input'; + outBitOffset += width; + portIdx++; + } + } + + newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; + newDirs['Y'] = 'output'; + + cellsToAdd['${concatName}_collapsed'] = { + 'hide_name': concatCell['hide_name'], + 'type': r'$concat', + 'parameters': {}, + 'attributes': concatCell['attributes'] ?? {}, + 'port_directions': newDirs, + 'connections': newConns, + }; + } + + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); + if (cellsToRemove.isNotEmpty || cellsToAdd.isNotEmpty) { + anyChanged = true; + } + globalReplIdx = replIdx; + + // Second pass: collapse identity struct_unpack → $buf chains. + // If ALL outputs of a struct_unpack go exclusively to one $buf whose + // A bits are exactly those outputs in order, replace both with a + // single $buf from the unpack's A to the buf's Y. + final unpacksToRemove = {}; + final bufsToRemove = {}; + final bufsToAdd = >{}; + var identBufIdx = 0; + + final wireMaps2 = NetlistUtils.buildWireMaps(cells, moduleDef); + final wireConsumerCells2 = wireMaps2.wireConsumerCells; + + for (final entry in cells.entries.toList()) { + final unpackName = entry.key; + final unpackCell = entry.value; + if ((unpackCell['type'] as String?) != r'$struct_unpack') { + continue; + } + if (unpacksToRemove.contains(unpackName)) { + continue; + } + + final uConns = + unpackCell['connections'] as Map? ?? {}; + final uDirs = + unpackCell['port_directions'] as Map? ?? {}; + + // Collect all output bits in field declaration order. + final allOutputBits = []; + for (final pname in uConns.keys) { + if (uDirs[pname] != 'output') { + continue; + } + for (final b in uConns[pname] as List) { + if (b is int) { + allOutputBits.add(b); + } + } + } + if (allOutputBits.isEmpty) { + continue; + } + + // Every output bit must be consumed by exactly one $buf cell + // (the same one). + String? targetBufName; + var allToOneBuf = true; + for (final bit in allOutputBits) { + final consumers = wireConsumerCells2[bit]; + if (consumers == null || consumers.length != 1) { + allToOneBuf = false; + break; + } + final consumer = consumers.first; + if (consumer == '__port__') { + allToOneBuf = false; + break; + } + final consumerCell = cells[consumer]; + if (consumerCell == null || + (consumerCell['type'] as String?) != r'$buf') { + allToOneBuf = false; + break; + } + if (targetBufName == null) { + targetBufName = consumer; + } else if (consumer != targetBufName) { + allToOneBuf = false; + break; + } + } + if (!allToOneBuf || targetBufName == null) { + continue; + } + if (bufsToRemove.contains(targetBufName)) { + continue; + } + + final bufCell = cells[targetBufName]!; + final bufConns = + bufCell['connections'] as Map? ?? {}; + final bufABits = [ + for (final b in bufConns['A'] as List) + if (b is int) b, + ]; + + // The buf's A bits must be exactly the unpack's output bits. + if (bufABits.length != allOutputBits.length) { + continue; + } + var bitsMatch = true; + for (var i = 0; i < bufABits.length; i++) { + if (bufABits[i] != allOutputBits[i]) { + bitsMatch = false; + break; + } + } + if (!bitsMatch) { + continue; + } + + // Collapse: single buf from unpack.A → buf.Y + final unpackABits = [ + for (final b in uConns['A'] as List) + if (b is int) b, + ]; + final bufYBits = [ + for (final b in bufConns['Y'] as List) + if (b is int) b, + ]; + + if (unpackABits.length != bufYBits.length) { + continue; + } + + bufsToAdd['${unpackName}_buf_$identBufIdx'] = { + 'type': r'$buf', + 'parameters': {'WIDTH': unpackABits.length}, + 'port_directions': {'A': 'input', 'Y': 'output'}, + 'connections': >{ + 'A': unpackABits, + 'Y': bufYBits, + }, + }; + identBufIdx++; + unpacksToRemove.add(unpackName); + bufsToRemove.add(targetBufName); + } + + unpacksToRemove.forEach(cells.remove); + bufsToRemove.forEach(cells.remove); + cells.addAll(bufsToAdd); + if (unpacksToRemove.isNotEmpty || bufsToRemove.isNotEmpty) { + anyChanged = true; + } + } // end while (anyChanged) + } + } + + // -- Collapse struct_unpack to struct_pack ----------------------------- + + /// Finds `$struct_pack` cells whose input ports are driven (directly + /// or through exclusive `$buf`/`$slice` chains) by output ports of + /// `$struct_unpack` cells. The exclusive intermediate `$buf`/`$slice` + /// cells are removed, and the pack input ports are rewired to the + /// unpack output bits directly. + /// + /// The unpack cell itself is preserved (it may have other consumers). + /// Only the intermediate routing cells are removed. + static void applyCollapseUnpackToPack( + Map> allModules, + ) { + for (final moduleDef in allModules.values) { + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); + + final cellsToRemove = {}; + + for (final packEntry in cells.entries.toList()) { + final packName = packEntry.key; + final packCell = packEntry.value; + if ((packCell['type'] as String?) != r'$struct_pack') { + continue; + } + + final conns = packCell['connections'] as Map? ?? {}; + final dirs = packCell['port_directions'] as Map? ?? {}; + + for (final portName in conns.keys.toList()) { + if (dirs[portName] != 'input') { + continue; + } + final bits = [ + for (final b in conns[portName] as List) + if (b is int) b, + ]; + if (bits.isEmpty) { + continue; + } + + // Trace each bit backward through $buf/$slice chains. + final tracedBits = []; + final intermediates = {}; + var allTraceToUnpack = true; + String? unpackName; + + for (final bit in bits) { + final (traced, chain) = + NetlistUtils.traceBackward(bit, wireDriverCell, cells); + intermediates.addAll(chain); + + // Check if traced bit is driven by a $struct_unpack. + final driverName = wireDriverCell[traced]; + if (driverName == null) { + allTraceToUnpack = false; + break; + } + final driverCell = cells[driverName]; + if (driverCell == null || + (driverCell['type'] as String?) != r'$struct_unpack') { + allTraceToUnpack = false; + break; + } + + if (unpackName == null) { + unpackName = driverName; + } else if (unpackName != driverName) { + allTraceToUnpack = false; + break; + } + + tracedBits.add(traced); + } + + if (!allTraceToUnpack || intermediates.isEmpty) { + continue; + } + + // Only remove $buf/$slice intermediates (not the unpack itself). + final removable = intermediates.where((c) { + final ct = cells[c]?['type'] as String?; + return ct == r'$buf' || ct == r'$slice'; + }).toSet(); + + if (removable.isEmpty) { + continue; + } + + // Verify exclusivity. + if (!NetlistUtils.isExclusiveChain( + intermediates: removable, + ownerCell: packName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + continue; + } + + // Rewire: replace the pack's input port with the traced bits. + conns[portName] = tracedBits.cast().toList(); + cellsToRemove.addAll(removable); + } + } + + cellsToRemove.forEach(cells.remove); + } + } +} diff --git a/lib/src/synthesizers/netlist/netlist_service.dart b/lib/src/synthesizers/netlist/netlist_service.dart new file mode 100644 index 000000000..07aec9105 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_service.dart @@ -0,0 +1,224 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_service.dart +// Service wrapper for netlist synthesis. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; + +/// A service that wraps netlist (Yosys JSON) synthesis of a [Module] +/// hierarchy. +/// +/// Provides access to the full hierarchy JSON and per-module JSON with +/// lazy caching, and optionally registers with [ModuleServices] for +/// DevTools inspection. +/// +/// Example: +/// ```dart +/// final dut = MyModule(...); +/// await dut.build(); +/// final netlist = await NetlistService.create(dut); +/// +/// // Full hierarchy JSON: +/// print(netlist.toJson()); +/// +/// // Single module (lazy, cached): +/// print(netlist.moduleJson('FilterChannel')); +/// ``` +class NetlistService { + /// The top-level [Module] being synthesized. + final Module module; + + /// The [NetlistSynthesizer] used for synthesis. + final NetlistSynthesizer synthesizer; + + /// The underlying [SynthBuilder]. + late final SynthBuilder synthBuilder; + + /// The combined JSON string for the full hierarchy. + late final String _fullJson; + + /// Cached per-module JSON, keyed by definition name. + final Map _moduleJsonCache = {}; + + /// The parsed modules map from the combined JSON. + late final Map _modulesMap; + + NetlistService._(this.module, this.synthesizer, this._fullJson) { + final decoded = jsonDecode(_fullJson) as Map; + _modulesMap = + (decoded['modules'] as Map?) ?? {}; + } + + /// Creates a [NetlistService] for [module]. + /// + /// [module] must already be built. Set [register] to `true` (the + /// default) to register this service with [ModuleServices] for + /// DevTools access. + /// + /// The [options] parameter controls netlist synthesis behaviour; + /// see [NetlistOptions] for details. + static Future create( + Module module, { + NetlistOptions options = const NetlistOptions(), + bool register = true, + }) async { + if (!module.hasBuilt) { + throw Exception('Module must be built before creating NetlistService. ' + 'Call build() first.'); + } + + final synthesizer = NetlistSynthesizer(options: options); + final json = await synthesizer.synthesizeToJson(module); + + final service = NetlistService._(module, synthesizer, json); + + if (register) { + ModuleServices.instance.netlistService = service; + } + + return service; + } + + /// Returns the full netlist hierarchy as a JSON string. + String toJson() => _fullJson; + + /// Returns the netlist JSON for a single module [definitionName]. + /// + /// If the module is not found, returns a JSON error object. + String moduleJson(String definitionName) => + _moduleJsonCache.putIfAbsent(definitionName, () { + final modData = _modulesMap[definitionName]; + if (modData == null) { + return jsonEncode({ + 'status': 'not_found', + 'reason': 'module "$definitionName" not in netlist', + }); + } + return jsonEncode({ + 'creator': 'ROHD netlist synthesizer', + 'modules': {definitionName: modData}, + }); + }); + + /// Returns the set of module definition names in the netlist. + Set get moduleNames => _modulesMap.keys.toSet(); + + /// Read-only access to the parsed modules map. + /// + /// Each key is a definition name and each value is the Yosys-style + /// module descriptor containing `ports`, `cells`, and `netnames`. + Map get synthesizedModules => + Map.unmodifiable(_modulesMap); + + /// Cached slim JSON (lazy). + String? _slimJsonCache; + + /// Returns a slim netlist JSON string — same structure as [toJson] but + /// with cell `connections` stripped. + /// + /// The slim representation preserves ports, cells (type + port_directions + /// + port_widths), and netnames so the DevTools extension can render the + /// hierarchy and signal tree without the full connectivity payload. + /// Full per-module connectivity is fetched on demand via [moduleJson]. + String get slimJson => _slimJsonCache ??= _buildSlimJson(); + + String _buildSlimJson() { + final slimModules = {}; + for (final entry in _modulesMap.entries) { + final mod = entry.value as Map; + final cells = mod['cells'] as Map? ?? {}; + final slimCells = {}; + for (final cellEntry in cells.entries) { + final cell = cellEntry.value as Map; + // Compute per-port widths from connections (bit-array lengths). + final conns = cell['connections'] as Map?; + final portWidths = {}; + if (conns != null) { + for (final c in conns.entries) { + final bits = c.value; + if (bits is List) { + portWidths[c.key] = bits.length; + } + } + } + slimCells[cellEntry.key] = { + 'hide_name': cell['hide_name'] ?? 0, + 'type': cell['type'], + 'parameters': cell['parameters'] ?? {}, + 'attributes': cell['attributes'] ?? {}, + 'port_directions': cell['port_directions'] ?? {}, + if (portWidths.isNotEmpty) 'port_widths': portWidths, + // connections intentionally omitted → slim + }; + } + + // Determine which module-level ports have internal connectivity. + final ports = mod['ports'] as Map? ?? {}; + final slimPorts = {}; + final cellConnectedBits = {}; + for (final cellEntry in cells.values) { + final cell = cellEntry as Map; + final conns = cell['connections'] as Map?; + if (conns == null) { + continue; + } + for (final bits in conns.values) { + if (bits is List) { + for (final b in bits) { + if (b is int) { + cellConnectedBits.add(b); + } + } + } + } + } + for (final portEntry in ports.entries) { + final portData = portEntry.value as Map; + final bits = portData['bits'] as List?; + var connected = false; + if (bits != null) { + for (final b in bits) { + if (b is int && cellConnectedBits.contains(b)) { + connected = true; + break; + } + } + } + slimPorts[portEntry.key] = { + ...portData, + if (connected) 'connected': true, + }; + } + + final netnames = mod['netnames'] as Map? ?? {}; + + slimModules[entry.key] = { + 'attributes': { + ...(mod['attributes'] as Map? ?? {}), + 'original_signal_count': netnames.length, + 'original_cell_count': slimCells.length, + }, + 'ports': slimPorts, + 'cells': slimCells, + 'netnames': netnames, + }; + } + + final rootName = + module.hasBuilt ? module.uniqueInstanceName : module.name; + + return jsonEncode({ + 'netlist': { + 'creator': 'ROHD NetlistService (slim)', + 'rootInstanceName': rootName, + 'modules': slimModules, + }, + }); + } +} diff --git a/lib/src/synthesizers/netlist/netlist_synthesis_result.dart b/lib/src/synthesizers/netlist/netlist_synthesis_result.dart new file mode 100644 index 000000000..aaa7d8f6e --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_synthesis_result.dart @@ -0,0 +1,84 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_synthesis_result.dart +// A simple SynthesisResult that holds netlist data for one module. +// +// 2026 February 11 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; + +/// A [SynthesisResult] that holds the netlist representation of a single +/// module level: its ports, cells, and netnames. +class NetlistSynthesisResult extends SynthesisResult { + /// The ports map: name → {direction, bits}. + final Map> ports; + + /// The cells map: instance name → cell data. + final Map> cells; + + /// The netnames map: net name → {bits, attributes}. + final Map netnames; + + /// Attributes for this module (e.g., top marker). + final Map attributes; + + /// Cached JSON string for comparison and output. + late final String _cachedJson = _buildJson(); + + /// Creates a [NetlistSynthesisResult] for [module]. + NetlistSynthesisResult( + super.module, + super.getInstanceTypeOfModule, { + required this.ports, + required this.cells, + required this.netnames, + this.attributes = const {}, + }); + + String _buildJson() { + final moduleEntry = { + 'attributes': attributes, + 'ports': ports, + 'cells': cells, + 'netnames': netnames, + }; + return const JsonEncoder().convert(moduleEntry); + } + + @override + bool matchesImplementation(SynthesisResult other) => + other is NetlistSynthesisResult && _cachedJson == other._cachedJson; + + @override + int get matchHashCode => _cachedJson.hashCode; + + @override + @Deprecated('Use `toSynthFileContents()` instead.') + String toFileContents() => toSynthFileContents().first.contents; + + @override + List toSynthFileContents() { + final typeName = instanceTypeName; + final moduleEntry = { + 'attributes': attributes, + 'ports': ports, + 'cells': cells, + 'netnames': netnames, + }; + final contents = const JsonEncoder.withIndent(' ').convert({ + 'creator': 'NetlistSynthesizer (rohd)', + 'modules': {typeName: moduleEntry}, + }); + return [ + SynthFileContents( + name: '$typeName.rohd.json', + description: 'netlist for $typeName', + contents: contents, + ), + ]; + } +} diff --git a/lib/src/synthesizers/netlist/netlist_synthesizer.dart b/lib/src/synthesizers/netlist/netlist_synthesizer.dart new file mode 100644 index 000000000..a21760b15 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_synthesizer.dart @@ -0,0 +1,1788 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_synthesizer.dart +// A netlist synthesizer built on [SynthModuleDefinition]. +// +// 2026 February 11 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:rohd/src/utilities/sanitizer.dart'; + +/// +/// Skips SystemVerilog-specific processing (chain collapsing, net connects, +/// inOut inline replacement) since netlist represents all sub-modules as +/// cells rather than inline assignment expressions. +class _NetlistSynthModuleDefinition extends SynthModuleDefinition { + _NetlistSynthModuleDefinition(Module module) : super(module) { + // Create explicit $slice cells for LogicArray input ports so the + // netlist shows select gates for element extraction rather than + // flat bit aliasing. + module.inputs.values + .whereType() + .forEach(_subsetReceiveArrayPort); + + // Same for LogicArray outputs on submodules (received into this scope). + module.subModules + .expand((sub) => sub.outputs.values) + .whereType() + .forEach(_subsetReceiveArrayPort); + + // Create explicit $concat cells for internal LogicArrays whose elements + // are driven independently (e.g. by constants) and then consumed by + // submodule input ports. This parallels what _subsetReceiveArrayPort does + // on the decomposition side. + // + // Skip arrays that were merged with a port array's SynthLogic — those + // are already structurally decomposed by the $slice cells created above + // and reassembling them would create a circular driver on the port bus. + final portArrays = { + ...module.inputs.values.whereType(), + ...module.outputs.values.whereType(), + ...module.inOuts.values.whereType(), + }; + final portArraySynthLogics = {}; + for (final pa in portArrays) { + final sl = logicToSynthMap[pa]; + if (sl != null) { + portArraySynthLogics.add(sl.replacement ?? sl); + } + } + module.internalSignals.whereType().where((sig) { + if (portArrays.contains(sig)) { + return false; + } + final sl = logicToSynthMap[sig]; + if (sl == null) { + return false; + } + final resolved = sl.replacement ?? sl; + return !portArraySynthLogics.contains(resolved); + }).forEach(_concatAssembleArray); + } + + /// Creates explicit `$slice` cells for each element of a [LogicArray] port. + /// + /// Each element gets a [_BusSubsetForArraySlice] that extracts its bit range + /// from the packed parent bus. This produces explicit select gates in the + /// netlist, making array decomposition visible and traceable. + void _subsetReceiveArrayPort(LogicArray port) { + final portSynth = getSynthLogic(port)!; + + var idx = 0; + for (final element in port.elements) { + final elemSynth = getSynthLogic(element)!; + internalSignals.add(elemSynth); + + final subsetMod = _BusSubsetForArraySlice( + Logic(width: port.width, name: 'DUMMY'), + idx, + idx + element.width - 1, + ); + + getSynthSubModuleInstantiation(subsetMod) + ..setOutputMapping(subsetMod.subset.name, elemSynth) + ..setInputMapping(subsetMod.original.name, portSynth) + + // Pick a name now — this may be called after _pickNames() has run. + ..pickName(module); + + idx += element.width; + } + } + + /// Creates an explicit `$concat` cell that assembles a [LogicArray]'s + /// elements into the full packed array bus. + /// + /// This is the assembly counterpart to [_subsetReceiveArrayPort]: when + /// individual array elements are driven independently (e.g. by constants), + /// this makes the concatenation explicit as a visible gate in the netlist. + void _concatAssembleArray(LogicArray array) { + final arraySynth = getSynthLogic(array)!; + + // Build dummy signals matching each element's width. + final dummyElements = []; + for (final element in array.elements) { + dummyElements.add(Logic(width: element.width, name: 'DUMMY')); + } + + // Pass reversed dummies so that Swizzle's internal reversal cancels out, + // leaving in0 aligned with element[0] (LSB) and inN with element[N]. + final concatMod = _SwizzleForArrayConcat(dummyElements.reversed.toList()); + + final ssmi = getSynthSubModuleInstantiation(concatMod) + // Map the concat output to the full array. + ..setOutputMapping(concatMod.out.name, arraySynth); + + // Map each element input. + // Because we reversed dummies above, in0 corresponds to element[0], + // in1 to element[1], etc. + for (var i = 0; i < array.elements.length; i++) { + final elemSynth = getSynthLogic(array.elements[i])!; + internalSignals.add(elemSynth); + final inputName = concatMod.inputs.keys.elementAt(i); + ssmi.setInputMapping(inputName, elemSynth); + } + + // Pick a name now — this may be called after _pickNames() has run. + ssmi.pickName(module); + } + + @override + void process() { + // No SV-specific transformations -- we want every sub-module to remain + // as a cell in the JSON. + } +} + +/// A simple [Synthesizer] that produces netlist-compatible JSON. +/// +/// Leverages [SynthModuleDefinition] for signal tracing, naming, and +/// constant resolution, then maps the resulting [SynthLogic]s to integer +/// wire-bit IDs for netlist JSON output. +/// +/// Leaf modules (those with no sub-modules, or special cases like [FlipFlop]) +/// do *not* get their own module definition -- they appear only as cells +/// inside their parent. +/// +/// Usage: +/// ```dart +/// const options = NetlistOptions(groupStructConversions: true); +/// final synth = NetlistSynthesizer(options: options); +/// final builder = SynthBuilder(topModule, synth); +/// final json = await synth.synthesizeToJson(topModule); +/// ``` +class NetlistSynthesizer extends Synthesizer { + /// The configuration options controlling netlist synthesis. + /// + /// See [NetlistOptions] for documentation on individual fields. + final NetlistOptions options; + + /// Convenience accessor for the leaf-cell mapper. + LeafCellMapper get leafCellMapper => + options.leafCellMapper ?? LeafCellMapper.defaultMapper; + + /// Creates a [NetlistSynthesizer]. + /// + /// All synthesis parameters are bundled in [options]; see + /// [NetlistOptions] for documentation on each field. + NetlistSynthesizer({this.options = const NetlistOptions()}); + + @override + bool generatesDefinition(Module module) => + // Only modules with sub-modules generate their own module definition. + // Leaf modules (no children) become cells inside their parent. + // FlipFlop has internal Sequential sub-modules but should be emitted as + // a flat Yosys $dff primitive, not as a hierarchical module. + module is! FlipFlop && module.subModules.isNotEmpty; + + @override + SynthesisResult synthesize( + Module module, + String Function(Module module) getInstanceTypeOfModule, { + SynthesisResult? Function(Module module)? lookupExistingResult, + Map? existingResults, + }) { + final isTop = module.parent == null; + final attr = {'src': 'generated'}; + if (isTop) { + attr['top'] = 1; + } + + // -- Build SynthModuleDefinition ------------------------------------ + // This does all signal tracing, naming, constant handling, + // assignment collapsing, and unused signal pruning. + final canBuildSynthDef = !(module is SystemVerilog && + module.generatedDefinitionType == DefinitionGenerationType.none); + final synthDef = + canBuildSynthDef ? _NetlistSynthModuleDefinition(module) : null; + + // -- Wire-ID allocation --------------------------------------------- + // Start wire IDs at 2 to avoid collision with Yosys constant string + // bits "0" and "1". JavaScript viewers coerce object keys to strings, + // so integer wire ID 0 becomes "0", clashing with the constant-bit + // string "0". + var nextId = 2; + + // Map from SynthLogic -> assigned wire-bit IDs. + final synthLogicIds = >{}; + + /// Allocate or retrieve wire IDs for a [SynthLogic]. + /// For constants, do NOT follow the replacement chain to ensure each + /// constant usage gets its own separate driver cell in netlist. + List getIds(SynthLogic sl) { + var resolved = sl; + // For non-constants, follow replacement chain to resolve merged logics. + // For constants, keep them separate to create distinct const drivers. + if (!sl.isConstant) { + resolved = NetlistUtils.resolveReplacement(resolved); + } + final ids = synthLogicIds.putIfAbsent( + resolved, () => List.generate(resolved.width, (_) => nextId++)); + return ids; + } + + // -- Ports ----------------------------------------------------------- + final ports = >{}; + + final portGroups = [ + ('input', synthDef?.inputs, module.inputs), + ('output', synthDef?.outputs, module.outputs), + ('inout', synthDef?.inOuts, module.inOuts), + ]; + for (final (direction, synthLogics, modulePorts) in portGroups) { + if (synthLogics != null) { + for (final sl in synthLogics) { + final ids = getIds(sl); + final portName = NetlistUtils.portNameForSynthLogic(sl, modulePorts); + if (portName != null) { + ports[portName] = {'direction': direction, 'bits': ids}; + } + } + } else { + for (final entry in modulePorts.entries) { + final ids = List.generate(entry.value.width, (_) => nextId++); + ports[entry.key] = {'direction': direction, 'bits': ids}; + } + } + } + + // -- Pre-allocate IDs for internal signals in Module order ----------- + // This ensures that internals get IDs in the same order as + // Module.internalSignals, matching WaveformService._collectSignals. + // Signals already allocated during the port phase are skipped by + // putIfAbsent. Synthesis-generated wires get IDs later (during cell + // emission), so they are naturally appended after internals. + // + // Three-tier ordering guarantee: + // Tier 0 (ports): inputs → outputs → inOuts [above] + // Tier 1 (internals): module.internalSignals [here] + // Tier 2 (synth): cell emission wires [below] + if (synthDef != null) { + module.internalSignals + .map((sig) => synthDef.logicToSynthMap[sig]) + .whereType() + .where((sl) => !sl.isConstant) + .forEach(getIds); + } + + // -- Cell emission --------------------------------------------------- + final cells = >{}; + + // Track constant SynthLogics consumed exclusively by + // Combinational/Sequential so we can suppress their driver cells. + final blockedConstSynthLogics = {}; + + // Track emitted cell keys per instance for purging later. + final emittedCellKeys = {}; + + if (synthDef != null) { + for (final instance in synthDef.subModuleInstantiations) { + if (!instance.needsInstantiation) { + continue; + } + + final sub = instance.module; + + final isLeaf = !generatesDefinition(sub); + final defaultCellType = + isLeaf ? sub.definitionName : getInstanceTypeOfModule(sub); + + // Build port directions and connections from instance mappings. + final rawPortDirs = {}; + final rawConnections = >{}; + + for (final (dir, mapping) in [ + ('input', instance.inputMapping), + ('output', instance.outputMapping), + ('inout', instance.inOutMapping), + ]) { + for (final e in mapping.entries) { + rawPortDirs[e.key] = dir; + final ids = getIds(e.value); + rawConnections[e.key] = ids.cast(); + } + } + + // Map leaf cells to Yosys primitive types where possible. + final mapped = isLeaf + ? leafCellMapper.map(sub, rawPortDirs, rawConnections) + : null; + + final cellPortDirs = mapped?.portDirs ?? rawPortDirs; + final cellConns = mapped?.connections ?? rawConnections; + + // Use the SSMI's uniquified name as cell key to avoid + // collisions between identically-named modules (e.g. multiple + // struct_slice instances that share the same Module.name). + final cellKey = instance.name; + emittedCellKeys[instance] = cellKey; + + // -- Collapse bit-slice ports on Combinational / Sequential ---- + if (sub is Combinational || sub is Sequential) { + NetlistUtils.collapseAlwaysBlockPorts( + synthDef, + instance, + cellPortDirs, + cellConns, + getIds, + ); + } + + // -- Filter constant inputs from Combinational / Sequential ---- + if (sub is Combinational || sub is Sequential) { + final portsToRemove = []; + for (final pe in cellConns.entries) { + final portName = pe.key; + final synthLogic = instance.inputMapping[portName] ?? + instance.inOutMapping[portName]; + if (synthLogic != null && + NetlistUtils.isConstantSynthLogic(synthLogic)) { + portsToRemove.add(portName); + blockedConstSynthLogics.add(synthLogic.replacement ?? synthLogic); + } + } + for (final p in portsToRemove) { + cellConns.remove(p); + cellPortDirs.remove(p); + } + } + + // -- Rename Seq/Comb ports to Namer wire names ----------------- + // The port names from _Always.addInput/addOutput are internal + // (e.g. `_out`, `_enable`). Replace them with the Namer's + // resolved wire name so they match SystemVerilog and WaveDumper. + if (sub is Combinational || sub is Sequential) { + final renames = {}; + for (final portName in cellConns.keys.toList()) { + final sl = instance.inputMapping[portName] ?? + instance.outputMapping[portName] ?? + instance.inOutMapping[portName]; + if (sl == null) { + continue; // aggregated port, already renamed + } + final resolved = NetlistUtils.resolveReplacement(sl); + final namerName = NetlistUtils.tryGetSynthLogicName(resolved); + if (namerName != null && namerName != portName) { + renames[portName] = namerName; + } + } + for (final entry in renames.entries) { + final bits = cellConns.remove(entry.key)!; + final dir = cellPortDirs.remove(entry.key)!; + var newName = entry.value; + // Avoid collision with existing port names. + if (cellConns.containsKey(newName)) { + newName = '${entry.value}_${entry.key}'; + } + cellConns[newName] = bits; + cellPortDirs[newName] = dir; + } + } + + cells[cellKey] = { + 'hide_name': 0, + 'type': mapped?.cellType ?? defaultCellType, + 'parameters': mapped?.parameters ?? {}, + 'attributes': {}, + 'port_directions': cellPortDirs, + 'connections': cellConns, + }; + } + } + + // -- Remove cells that were cleared by collapseAlwaysBlockPorts ------ + // Because the iteration order may process a Swizzle/BusSubset cell + // BEFORE the Combinational/Sequential that clears it, we need to purge + // stale cells after all collapsing has been applied. + if (synthDef != null) { + synthDef.subModuleInstantiations + .where((i) => !i.needsInstantiation) + .map((i) => emittedCellKeys[i]) + .whereType() + .forEach(cells.remove); + } + + // -- Wire-ID aliasing from remaining assignments ------------------- + // SynthModuleDefinition._collapseAssignments may leave assignments + // between non-mergeable SynthLogics (e.g., reserved port + + // renameable internal signal). In SV synthesis these become + // `assign` statements. In netlist we need the two sides to + // share wire IDs so that the netlist is properly connected. + // + // Similarly, PartialSynthAssignments for output struct ports tell + // us which leaf-field IDs should compose the port's bits, and + // input-struct BusSubsets (which may be pruned) tell us which + // leaf-field IDs should be carved from the port's bits. + final idAlias = {}; + + // Pending $struct_field cells collected during Step 3. + // Each entry records a single field extraction from a parent struct. + // The `parentLogic` and `fullParentIds` fields are used to group + // entries from the same LogicStructure into a single multi-port + // `$struct_unpack` cell. + final structFieldCells = <({ + List parentIds, + List elemIds, + int offset, + int width, + Logic elemLogic, + Logic parentLogic, + List fullParentIds, + })>[]; + + // Pending $struct_compose cells: for output struct ports, instead of + // aliasing port bits to leaf bits (which causes "shorting"), we + // collect composition operations and emit explicit cells later. + // Each entry records: field (src) → port sub-range [lower:upper]. + final structComposeCells = <({ + List srcIds, + List dstIds, + int dstLowerIndex, + int dstUpperIndex, + SynthLogic srcSynthLogic, + SynthLogic dstSynthLogic, + })>[]; + + // Track struct ports (both output ports of the current module AND + // sub-module input struct ports) so Step 3 can skip $struct_field + // collection for them ($struct_pack handles these instead). + final outputStructPortLogics = {}; + + if (synthDef != null) { + // 1. Non-partial assignments: src drives dst → dst IDs become + // src IDs (the driver's IDs are canonical). + for (final assignment + in synthDef.assignments.where((a) => a is! PartialSynthAssignment)) { + final srcIds = getIds(assignment.src); + final dstIds = getIds(assignment.dst); + final len = + srcIds.length < dstIds.length ? srcIds.length : dstIds.length; + for (var i = 0; i < len; i++) { + if (dstIds[i] != srcIds[i]) { + idAlias[dstIds[i]] = srcIds[i]; + } + } + } + + // 2. Partial assignments (output / sub-module struct ports): + // src → dst[lower:upper]. The port-slice IDs become the + // leaf's IDs so that the port is composed from its fields. + // + // For struct ports (both output ports of the current module + // AND sub-module input struct ports), we keep distinct port + // and field IDs and instead collect pending $struct_pack + // cells. This avoids "shorting" where field wires are + // aliased directly to port bits, which creates multi-driver + // conflicts with $struct_unpack cells emitted in Step 3. + // + // For non-struct sub-module input ports, we alias as before. + + /// Recursively add [struct] and all its nested [LogicStructure] + /// descendants (excluding [LogicArray]) to [set]. + void addStructAndDescendants(LogicStructure struct, Set set) { + set.add(struct); + for (final elem in struct.elements) { + if (elem is LogicStructure && elem is! LogicArray) { + addStructAndDescendants(elem, set); + } + } + } + + for (final pa + in synthDef.assignments.whereType()) { + final srcIds = getIds(pa.src); + final dstIds = getIds(pa.dst); + + // Detect: is pa.dst an output struct port of the current module? + final isCurrentModuleOutputPort = + pa.dst.isPort(module) && pa.dst.logics.any((l) => l.isOutput); + + // Detect: is pa.dst a sub-module input struct port? + // (LogicStructure but not LogicArray, and not an output of the + // current module.) + final isSubModuleInputStructPort = !isCurrentModuleOutputPort && + pa.dst.logics.any((l) => l is LogicStructure && l is! LogicArray); + + if (isCurrentModuleOutputPort || isSubModuleInputStructPort) { + // Record as pending compose cell instead of aliasing. + structComposeCells.add(( + srcIds: srcIds, + dstIds: dstIds, + dstLowerIndex: pa.dstLowerIndex, + dstUpperIndex: pa.dstUpperIndex, + srcSynthLogic: pa.src, + dstSynthLogic: pa.dst, + )); + // Track the Logic (and nested structs) so Step 3 skips + // $struct_unpack for them. + for (final l in pa.dst.logics) { + if (l is LogicStructure && l is! LogicArray) { + addStructAndDescendants(l, outputStructPortLogics); + } + } + } else { + // Non-struct sub-module input port: alias as before. + for (var i = 0; i < srcIds.length; i++) { + final dstIdx = pa.dstLowerIndex + i; + if (dstIdx < dstIds.length && dstIds[dstIdx] != srcIds[i]) { + idAlias[dstIds[dstIdx]] = srcIds[i]; + } + } + } + } + + // 3. LogicStructure and LogicArray: child IDs → parent-slice IDs. + // + // LogicArray elements alias their IDs to matching parent bits + // so array connectivity works. + // + // Non-array LogicStructure elements are NOT aliased. Instead, + // their parent→element mappings are collected in + // [structFieldCells] and emitted as explicit $struct_field + // cells after alias resolution. This preserves element signals + // (e.g. "a_mantissa") as distinct named wires visible in the + // schematic, rather than collapsing them into parent bit ranges. + // + // For arrays with explicit $slice/$concat cells (from + // _BusSubsetForArraySlice / _SwizzleForArrayConcat), aliasing + // is skipped entirely — the cells provide the structural link. + // + // Applied to ALL instances (ports AND internal signals) since + // internal arrays/structs (e.g. constant-driven coefficients) + // also need child→parent aliasing. + // + // - LogicStructure (non-array): walks leafElements (recursive) + // - LogicArray: walks elements (direct children only, since + // each element is already a flat bitvector). + // For input array ports that have _BusSubsetForArraySlice + // cells, we skip aliasing so the $slice cells provide the + // structural connection (see _subsetReceiveArrayPort). + // + // When a child ID was already aliased (e.g. by step 1 to a + // constant driver), we also redirect that prior target to the + // parent ID so the transitive chain resolves correctly: + // constId → childId → parentId. + void aliasChildToParent(int childId, int parentId) { + if (childId == parentId) { + return; + } + // If childId already aliases somewhere (e.g. constId → childId + // was set in step 1 as childId → constId), redirect that old + // target to parentId as well, so constId → parentId. + final existing = idAlias[childId]; + if (existing != null && existing != parentId) { + idAlias[existing] = parentId; + } + idAlias[childId] = parentId; + } + + // Collect LogicArray ports that have explicit array_slice or + // array_concat submodules so we can skip aliasing them (the + // $slice/$concat cells provide the structural link). + final arraysWithExplicitCells = {}; + for (final inst in synthDef.subModuleInstantiations) { + if (inst.module is _BusSubsetForArraySlice) { + // The input of the BusSubset is the array port. + for (final inputSL in inst.inputMapping.values) { + final logic = synthDef.logicToSynthMap.entries + .where( + (e) => e.value == inputSL || e.value.replacement == inputSL) + .map((e) => e.key) + .firstOrNull; + if (logic != null && logic is LogicArray) { + arraysWithExplicitCells.add(logic); + } + // Also check the resolved replacement chain. + final resolved = NetlistUtils.resolveReplacement(inputSL); + final logic2 = synthDef.logicToSynthMap.entries + .where((e) => e.value == resolved) + .map((e) => e.key) + .firstOrNull; + if (logic2 != null && logic2 is LogicArray) { + arraysWithExplicitCells.add(logic2); + } + } + } + if (inst.module is _SwizzleForArrayConcat) { + // The output of the Swizzle is the array signal. + for (final outputSL in inst.outputMapping.values) { + final logic = synthDef.logicToSynthMap.entries + .where((e) => + e.value == outputSL || e.value.replacement == outputSL) + .map((e) => e.key) + .firstOrNull; + if (logic != null && logic is LogicArray) { + arraysWithExplicitCells.add(logic); + } + } + } + } + + for (final entry in synthDef.logicToSynthMap.entries) { + final logic = entry.key; + if (logic is! LogicStructure) { + continue; + } + final parentSL = entry.value; + final parentIds = getIds(parentSL); + + if (logic is LogicArray) { + // Skip aliasing for arrays that have explicit $slice/$concat cells. + if (arraysWithExplicitCells.contains(logic)) { + continue; + } + // Array: alias each element's IDs to matching parent slice. + var idx = 0; + for (final element in logic.elements) { + final elemSL = synthDef.logicToSynthMap[element]; + if (elemSL != null) { + final elemIds = getIds(elemSL); + for (var i = 0; + i < elemIds.length && idx + i < parentIds.length; + i++) { + aliasChildToParent(elemIds[i], parentIds[idx + i]); + } + } + idx += element.width; + } + } else { + // Struct: collect element→parent mappings for $struct_field + // cell emission instead of aliasing. This preserves named + // field signals as distinct wires connected through explicit + // cells, making them visible in the schematic and evaluable + // by the netlist evaluator. + // + // Skip output struct ports of the current module — those are + // handled by $struct_compose cells (from Step 2). + if (outputStructPortLogics.contains(logic)) { + continue; + } + var idx = 0; + for (final elem in logic.elements) { + final elemSL = synthDef.logicToSynthMap[elem]; + if (elemSL != null) { + final elemIds = getIds(elemSL); + final sliceLen = elemIds.length < parentIds.length - idx + ? elemIds.length + : parentIds.length - idx; + if (sliceLen > 0) { + structFieldCells.add(( + parentIds: parentIds.sublist(idx, idx + sliceLen), + elemIds: elemIds.sublist(0, sliceLen), + offset: idx, + width: sliceLen, + elemLogic: elem, + parentLogic: logic, + fullParentIds: parentIds, + )); + } + } + idx += elem.width; + } + } + } + } + + // Transitively resolve an alias chain to its canonical ID. + // Uses a visited set to detect cycles created by conflicting + // child→parent and assignment aliasing directions. + int resolveAlias(int id) { + var resolved = id; + final visited = {}; + while (idAlias.containsKey(resolved)) { + if (!visited.add(resolved)) { + // Cycle detected — break the cycle by removing this entry. + idAlias.remove(resolved); + break; + } + resolved = idAlias[resolved]!; + } + return resolved; + } + + // Apply aliases to a list of bit IDs / string constants. + List applyAlias(List bits) => idAlias.isEmpty + ? bits + : bits.map((b) => b is int ? resolveAlias(b) : b).toList(); + + // Alias port bits. + if (idAlias.isNotEmpty) { + for (final p in ports.values) { + p['bits'] = applyAlias((p['bits']! as List).cast()); + } + // Alias cell connections. + for (final c in cells.values) { + final conns = c['connections']! as Map; + for (final key in conns.keys.toList()) { + conns[key] = applyAlias((conns[key] as List).cast()); + } + } + + // -- Elide trivial $slice cells ---------------------------------- + // Also elide struct_slice cells (`_BusSubsetForStructSlice` + // instances from `_subsetReceiveStructPort`) because the new + // `$struct_unpack` cells emitted below supersede them with + // better-named field-level connections. + cells.removeWhere((cellKey, cell) { + if (cell['type'] != r'$slice') { + return false; + } + // Unconditionally remove struct_slice cells — they are + // duplicated by $struct_unpack cells which carry field names. + if (cellKey.startsWith('struct_slice')) { + return true; + } + final params = cell['parameters'] as Map?; + final offset = params?['OFFSET']; + if (offset is! int) { + return false; + } + final conns = cell['connections']! as Map; + final aBits = conns['A'] as List?; + final yBits = conns['Y'] as List?; + if (aBits == null || yBits == null) { + return false; + } + return yBits.indexed.every((e) => + offset + e.$1 < aBits.length && e.$2 == aBits[offset + e.$1]); + }); + } + + // -- Emit $struct_unpack cells for LogicStructure elements ---------- + // Group per-field entries by their parent LogicStructure and emit a + // single multi-port cell per group. Each group has: + // • input port A: the full parent bus (packed bitvector) + // • one output port per non-trivial field: bits for that field + // This replaces the old per-field $struct_field cells. + if (synthDef != null && structFieldCells.isNotEmpty) { + // Group by parent Logic identity. + final groups = parentIds, + List elemIds, + int offset, + int width, + Logic elemLogic, + Logic parentLogic, + List fullParentIds, + })>>{}; + for (final sf in structFieldCells) { + (groups[sf.parentLogic] ??= []).add(sf); + } + + var suIdx = 0; + for (final entry in groups.entries) { + final parentLogic = entry.key; + final fields = entry.value; + final fullParentIds = fields.first.fullParentIds; + final resolvedParentBits = applyAlias(fullParentIds.cast()); + + // Filter out trivial fields (input slice == output after aliasing). + final nonTrivialFields = fields + .map((sf) { + final resolvedElemBits = applyAlias(sf.elemIds.cast()); + return ( + resolvedElemBits: resolvedElemBits, + offset: sf.offset, + width: sf.width, + elemLogic: sf.elemLogic, + ); + }) + .where((f) => !f.resolvedElemBits.indexed.every((e) { + final (i, bit) = e; + return f.offset + i < resolvedParentBits.length && + bit == resolvedParentBits[f.offset + i]; + })) + .toList(); + + if (nonTrivialFields.isEmpty) { + continue; + } + + // Derive struct name for the cell key. + final structName = Sanitizer.sanitizeSV(parentLogic.name); + + // Build element range table for the parent struct so we can + // derive proper field names even when the leaf Logic objects + // have unpreferred names like `_swizzled`. + // Same strategy as $struct_pack: walk the hierarchy collecting + // (start, end, name, path, indexInParent) and look up the + // narrowest non-unpreferred range for each field offset. + final suElementRanges = <({ + int start, + int end, + String name, + String path, + int indexInParent, + })>[]; + if (parentLogic is LogicStructure) { + void walkStruct( + LogicStructure struct, int baseOffset, String parentPath) { + var offset = baseOffset; + for (var idx = 0; idx < struct.elements.length; idx++) { + final elem = struct.elements[idx]; + final elemEnd = offset + elem.width; + final elemPath = + parentPath.isEmpty ? elem.name : '${parentPath}_${elem.name}'; + suElementRanges.add(( + start: offset, + end: elemEnd, + name: elem.name, + path: elemPath, + indexInParent: idx, + )); + if (elem is LogicStructure && elem is! LogicArray) { + walkStruct(elem, offset, elemPath); + } + offset = elemEnd; + } + } + + walkStruct(parentLogic, 0, ''); + } + + String suFieldNameFor(int fieldOffset, String fallbackName) { + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? bestNamed; + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? bestAny; + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? narrowest; + + for (final r in suElementRanges) { + if (fieldOffset >= r.start && fieldOffset < r.end) { + final span = r.end - r.start; + if (narrowest == null || + span < (narrowest.end - narrowest.start)) { + narrowest = r; + } + if (bestAny == null || span < (bestAny.end - bestAny.start)) { + bestAny = r; + } + if (!Naming.isUnpreferred(r.name)) { + if (bestNamed == null || + span < (bestNamed.end - bestNamed.start)) { + bestNamed = r; + } + } + } + } + + if (bestNamed != null) { + if (narrowest != null && + (narrowest.end - narrowest.start) < + (bestNamed.end - bestNamed.start)) { + final bestNamedPrefix = bestNamed.path; + if (narrowest.path.length > bestNamedPrefix.length && + narrowest.path.startsWith(bestNamedPrefix)) { + final suffix = + narrowest.path.substring(bestNamedPrefix.length + 1); + if (!Naming.isUnpreferred(suffix)) { + return '${bestNamed.name}_$suffix'; + } + } + return '${bestNamed.name}_${narrowest.indexInParent}'; + } + return bestNamed.name; + } + // All matching elements have unpreferred names — use the + // narrowest element's positional index as discriminator. + if (narrowest != null && Naming.isUnpreferred(narrowest.name)) { + return 'anonymous_${narrowest.indexInParent}'; + } + return bestAny?.name ?? fallbackName; + } + + // Build port_directions and connections with one output per field. + final portDirs = {'A': 'input'}; + final conns = >{'A': resolvedParentBits}; + + for (var i = 0; i < nonTrivialFields.length; i++) { + final f = nonTrivialFields[i]; + final fieldName = suFieldNameFor(f.offset, f.elemLogic.name); + // Disambiguate duplicate field names with index suffix. + var portName = fieldName; + if (portDirs.containsKey(portName)) { + portName = '${fieldName}_$i'; + } + portDirs[portName] = 'output'; + conns[portName] = f.resolvedElemBits; + } + + // Parameters list field metadata for the schematic viewer. + final params = { + 'STRUCT_NAME': parentLogic.name, + 'FIELD_COUNT': nonTrivialFields.length, + }; + for (var i = 0; i < nonTrivialFields.length; i++) { + final f = nonTrivialFields[i]; + params['FIELD_${i}_NAME'] = + suFieldNameFor(f.offset, f.elemLogic.name); + params['FIELD_${i}_OFFSET'] = f.offset; + params['FIELD_${i}_WIDTH'] = f.width; + } + + cells['struct_unpack_${suIdx}_$structName'] = { + 'hide_name': 0, + 'type': r'$struct_unpack', + 'parameters': params, + 'attributes': {}, + 'port_directions': portDirs, + 'connections': conns, + }; + suIdx++; + } + } + + // -- Emit $struct_pack cells for output struct ports ------------------ + // Group compose entries by destination port and emit a single + // multi-port cell per group. Each group has: + // • one input port per non-trivial field + // • output port Y: the full packed output bus + // This replaces the old per-field $struct_compose cells. + if (structComposeCells.isNotEmpty) { + // Group by destination SynthLogic identity. + final composeGroups = srcIds, + List dstIds, + int dstLowerIndex, + int dstUpperIndex, + SynthLogic srcSynthLogic, + SynthLogic dstSynthLogic, + })>>{}; + for (final sc in structComposeCells) { + (composeGroups[sc.dstSynthLogic] ??= []).add(sc); + } + + var spIdx = 0; + for (final entry in composeGroups.entries) { + final dstSynthLogic = entry.key; + final fields = entry.value; + final resolvedDstBits = applyAlias(fields.first.dstIds.cast()); + + // Filter out trivial fields. + final nonTrivialFields = fields + .map((sc) { + final resolvedSrcBits = applyAlias(sc.srcIds.cast()); + final yBits = resolvedDstBits.sublist( + sc.dstLowerIndex, sc.dstUpperIndex + 1); + return ( + resolvedSrcBits: resolvedSrcBits, + yBits: yBits, + dstLowerIndex: sc.dstLowerIndex, + dstUpperIndex: sc.dstUpperIndex, + srcSynthLogic: sc.srcSynthLogic, + ); + }) + .where((f) => !f.resolvedSrcBits + .take(f.yBits.length) + .indexed + .every((e) => e.$2 == f.yBits[e.$1])) + .toList(); + + if (nonTrivialFields.isEmpty) { + continue; + } + + // Derive struct name from the destination Logic. + final dstLogic = dstSynthLogic.logics.firstOrNull; + final structName = dstLogic != null + ? Sanitizer.sanitizeSV(dstLogic.name) + : 'struct_$spIdx'; + + // Build a lookup from bit offset to the best struct element + // name, so that field names come from the struct definition + // (e.g. "data", "last", "poison") rather than the source + // signal name (which may be an internal like "_swizzled"). + // + // Elements pack LSB-first via `rswizzle`, so element[0] + // starts at offset 0, element[1] at element[0].width, etc. + // + // We collect (start, end, name, path, parentElementIndex) + // ranges for every element at every nesting level. The + // `path` carries the chain of parent struct names so we can + // produce qualified names like "mmu_info_mmuSid". When + // leaf names are unpreferred, `parentElementIndex` provides + // a fallback discriminator like "mmu_info_0". + final dstElementRanges = <({ + int start, + int end, + String name, + String path, + int indexInParent, + })>[]; + if (dstLogic is LogicStructure) { + void walkStruct( + LogicStructure struct, int baseOffset, String parentPath) { + var offset = baseOffset; + for (var idx = 0; idx < struct.elements.length; idx++) { + final elem = struct.elements[idx]; + final elemEnd = offset + elem.width; + final elemPath = + parentPath.isEmpty ? elem.name : '${parentPath}_${elem.name}'; + dstElementRanges.add(( + start: offset, + end: elemEnd, + name: elem.name, + path: elemPath, + indexInParent: idx, + )); + if (elem is LogicStructure && elem is! LogicArray) { + walkStruct(elem, offset, elemPath); + } + offset = elemEnd; + } + } + + walkStruct(dstLogic, 0, ''); + } + + /// Look up the field name for a compose entry by finding the + /// best struct element whose range contains [dstLowerIndex]. + /// + /// Strategy (deepest-first): + /// 1. Find the narrowest element with a non-unpreferred name. + /// 2. If a narrower unpreferred leaf exists under a named + /// parent, try to qualify with the leaf's proper name + /// (e.g. `mmu_info_mmuSid`). + /// 3. If the leaf name is also unpreferred, fall back to the + /// parent name qualified by the leaf's positional index + /// (e.g. `mmu_info_0`, `mmu_info_1`). + /// 4. Falls back to the resolved source SynthLogic name. + String fieldNameFor( + int dstLowerIndex, + SynthLogic srcSynthLogic, + ) { + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? bestNamed; + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? bestAny; + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? narrowest; + + for (final r in dstElementRanges) { + if (dstLowerIndex >= r.start && dstLowerIndex < r.end) { + final span = r.end - r.start; + if (narrowest == null || + span < (narrowest.end - narrowest.start)) { + narrowest = r; + } + if (bestAny == null || span < (bestAny.end - bestAny.start)) { + bestAny = r; + } + if (!Naming.isUnpreferred(r.name)) { + if (bestNamed == null || + span < (bestNamed.end - bestNamed.start)) { + bestNamed = r; + } + } + } + } + + if (bestNamed != null) { + // Check if there's a narrower child element under + // bestNamed that we can use to discriminate. + if (narrowest != null && + (narrowest.end - narrowest.start) < + (bestNamed.end - bestNamed.start)) { + final bestNamedPrefix = bestNamed.path; + // Try using the child's proper name as qualifier. + if (narrowest.path.length > bestNamedPrefix.length && + narrowest.path.startsWith(bestNamedPrefix)) { + final suffix = + narrowest.path.substring(bestNamedPrefix.length + 1); + if (!Naming.isUnpreferred(suffix)) { + return '${bestNamed.name}_$suffix'; + } + } + // Child has unpreferred name — use positional index. + return '${bestNamed.name}_${narrowest.indexInParent}'; + } + return bestNamed.name; + } + return bestAny?.name ?? + NetlistUtils.resolveReplacement(srcSynthLogic).name; + } + + // Build port_directions and connections. + final portDirs = {}; + final conns = >{}; + + for (var i = 0; i < nonTrivialFields.length; i++) { + final f = nonTrivialFields[i]; + final fieldName = fieldNameFor(f.dstLowerIndex, f.srcSynthLogic); + var portName = fieldName; + if (portDirs.containsKey(portName)) { + portName = '${fieldName}_$i'; + } + portDirs[portName] = 'input'; + conns[portName] = f.resolvedSrcBits; + } + + // Output port Y: full destination bus. + portDirs['Y'] = 'output'; + conns['Y'] = resolvedDstBits; + + // Parameters list field metadata for the schematic viewer. + final params = { + 'STRUCT_NAME': dstLogic?.name ?? 'struct', + 'FIELD_COUNT': nonTrivialFields.length, + }; + for (var i = 0; i < nonTrivialFields.length; i++) { + final f = nonTrivialFields[i]; + params['FIELD_${i}_NAME'] = + fieldNameFor(f.dstLowerIndex, f.srcSynthLogic); + params['FIELD_${i}_OFFSET'] = f.dstLowerIndex; + params['FIELD_${i}_WIDTH'] = f.dstUpperIndex - f.dstLowerIndex + 1; + } + + cells['struct_pack_${spIdx}_$structName'] = { + 'hide_name': 0, + 'type': r'$struct_pack', + 'parameters': params, + 'attributes': {}, + 'port_directions': portDirs, + 'connections': conns, + }; + spIdx++; + } + } + + // -- Passthrough buffer insertion ------------------------------------ + // When a signal passes directly from an input port to an output port, + // they share the same wire IDs after aliasing. This causes the signal + // to appear routed *around* the module in the netlist rather than + // *through* it. Insert a `$buf` cell to break the wire-ID sharing, + // giving the output port fresh IDs driven by the buffer. + { + final inputBitIds = ports.values + .where((p) => p['direction'] == 'input' || p['direction'] == 'inout') + .expand((p) => p['bits']! as List) + .whereType() + .toSet(); + + // Check each output port for overlap with input bits. + var bufIdx = 0; + for (final p + in ports.entries.where((p) => p.value['direction'] == 'output')) { + final outBits = (p.value['bits']! as List).cast(); + if (!outBits.any((b) => b is int && inputBitIds.contains(b))) { + continue; + } + + // Allocate fresh wire IDs for the output side of the buffer. + final freshBits = + List.generate(outBits.length, (_) => nextId++); + + // Insert a $buf cell: input = original (shared) IDs, + // output = fresh IDs. + cells['passthrough_buf_$bufIdx'] = + NetlistUtils.makeBufCell(outBits.length, outBits, freshBits); + + // Update the output port to use the fresh IDs. + p.value['bits'] = freshBits; + bufIdx++; + } + } + + // -- Dead-cell elimination (DCE) ------------------------------------- + // After aliasing and elision, some cells may have inputs whose wire + // IDs are not driven by any cell output or module input port. This + // typically happens when a LogicStructure's `packed` representation + // creates a Swizzle chain whose inputs reference sub-module-internal + // signals that are not accessible from the synthesised module's + // scope. Iteratively remove such dead cells using both forward + // (all-inputs-undriven) and backward (all-outputs-unconsumed) DCE. + if (options.enableDCE) { + var dceChanged = true; + while (dceChanged) { + dceChanged = false; + + // Build set of driven wire IDs (from input/inout ports and cell + // outputs). + final drivenIds = { + ...ports.values + .where( + (p) => p['direction'] == 'input' || p['direction'] == 'inout') + .expand((p) => p['bits']! as List) + .whereType(), + ...cells.values.expand((c) { + final cell = c as Map; + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + return conns.entries + .where((pe) => pdirs[pe.key] == 'output') + .expand((pe) => pe.value as List) + .whereType(); + }), + }; + + // Build set of consumed wire IDs (from output/inout ports and + // cell inputs). + final consumedIds = { + ...ports.values + .where((p) => + p['direction'] == 'output' || p['direction'] == 'inout') + .expand((p) => p['bits']! as List) + .whereType(), + ...cells.values.expand((c) { + final cell = c as Map; + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + return conns.entries + .where((pe) => pdirs[pe.key] == 'input') + .expand((pe) => pe.value as List) + .whereType(); + }), + }; + + // Forward DCE: remove cells whose inputs are ALL undriven. + cells + ..removeWhere((cellKey, cellVal) { + final cell = cellVal as Map; + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + final inputPorts = + conns.entries.where((pe) => pdirs[pe.key] == 'input'); + if (inputPorts.isEmpty) { + return false; + } + final allUndriven = !inputPorts + .expand((pe) => pe.value as List) + .any((b) => (b is int && drivenIds.contains(b)) || b is String); + if (allUndriven) { + dceChanged = true; + return true; + } + return false; + }) + + // Backward DCE: remove cells whose outputs are ALL unconsumed. + // Preserve non-leaf cells (user module instances) — their type + // does not start with '$' (Yosys primitive convention). Users + // expect to see all instantiated modules in the schematic even + // when outputs are unconnected. + ..removeWhere((cellKey, cellVal) { + final cell = cellVal as Map; + final cellType = cell['type'] as String? ?? ''; + if (!cellType.startsWith(r'$')) { + return false; + } + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + final outputPorts = + conns.entries.where((pe) => pdirs[pe.key] == 'output'); + if (outputPorts.isEmpty) { + return false; + } + final allUnconsumed = !outputPorts + .expand((pe) => pe.value as List) + .whereType() + .any(consumedIds.contains); + if (allUnconsumed) { + dceChanged = true; + return true; + } + return false; + }); + } + } + + // -- Constant driver cells ------------------------------------------- + // Generated AFTER the aliasing pass so that constants discovered + // during aliasing (via getIds(assignment.src)) are included. + // Constant IDs may have been redirected by step 3 (struct/array + // child→parent aliasing), so apply alias resolution to their + // connection bits. + { + var constIdx = 0; + final emittedConstWires = {}; + for (final entry in synthLogicIds.entries + .where((e) => e.key.isConstant) + .where((e) => !blockedConstSynthLogics.contains(e.key)) + .where((e) => e.value.isNotEmpty)) { + final sl = entry.key; + final constValue = NetlistUtils.constValueFromSynthLogic(sl); + if (constValue == null) { + continue; + } + final ids = entry.value; + + // Resolve aliases and skip if these wires are already driven + // by a previously emitted $const cell (can happen when aliasing + // merges two SynthLogic constants onto the same wire IDs). + final resolvedIds = applyAlias(ids.cast()); + final firstWire = + resolvedIds.firstWhere((b) => b is int, orElse: () => -1); + if (firstWire is int && firstWire >= 0) { + if (emittedConstWires.contains(firstWire)) { + continue; + } + emittedConstWires.addAll(resolvedIds.whereType()); + } + + final valuePart = NetlistUtils.constValuePart(constValue); + final cellName = 'const_${constIdx}_$valuePart'; + final valueLiteral = valuePart.replaceFirst('_', "'"); + + cells[cellName] = { + 'hide_name': 0, + 'type': r'$const', + 'parameters': {}, + 'attributes': {}, + 'port_directions': {valueLiteral: 'output'}, + 'connections': >{ + valueLiteral: resolvedIds, + }, + }; + constIdx++; + } + } + + // -- Remove floating $const cells ------------------------------------ + // The $const cells were emitted after the main DCE pass, so they + // may reference wire IDs that no cell input or output port consumes. + if (options.enableDCE) { + final consumedByInputs = { + ...ports.values + .where( + (p) => p['direction'] == 'output' || p['direction'] == 'inout') + .expand((p) => p['bits']! as List) + .whereType(), + ...cells.values.expand((c) { + final cell = c as Map; + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + return conns.entries + .where((pe) => pdirs[pe.key] == 'input') + .expand((pe) => pe.value as List) + .whereType(); + }), + }; + + cells.removeWhere((cellKey, cellVal) { + final cell = cellVal as Map; + if (cell['type'] != r'$const') { + return false; + } + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + return !conns.entries + .where((pe) => pdirs[pe.key] == 'output') + .expand((pe) => pe.value as List) + .whereType() + .any(consumedByInputs.contains); + }); + } + + // -- Break shared wire IDs for array_concat cells -------------------- + // After aliasing, the concat inputs share the same wire IDs as the + // concat Y output (because LogicArray elements share the parent's + // bit storage). This makes the concat transparent -- constants + // appear to drive the parent array directly. + // + // To fix: allocate fresh wire IDs for each concat input port, + // then redirect all other cells whose outputs used those old IDs + // to drive the fresh IDs instead. The concat Y output keeps the + // original parent-array IDs, so the data flow becomes: + // const → fresh_IDs → concat input → concat Y (= parent IDs) + final arrayConcatOldToNew = {}; + + for (final cellEntry in cells.entries) { + if (!cellEntry.key.startsWith('array_concat')) { + continue; + } + final cell = cellEntry.value as Map; + final conns = cell['connections'] as Map; + final dirs = cell['port_directions'] as Map; + + for (final portEntry in conns.entries.toList()) { + if (dirs[portEntry.key] != 'input') { + continue; + } + final oldBits = (portEntry.value as List).cast(); + conns[portEntry.key] = [ + for (final b in oldBits) + b is int ? arrayConcatOldToNew.putIfAbsent(b, () => nextId++) : b, + ]; + } + } + + // Redirect other cells: any output port bit that matches an old ID + // gets replaced with the corresponding fresh ID. + if (arrayConcatOldToNew.isNotEmpty) { + for (final cellEntry in cells.entries) { + if (cellEntry.key.startsWith('array_concat')) { + continue; // skip the concat cells themselves + } + final cell = cellEntry.value as Map; + final conns = cell['connections'] as Map; + final dirs = cell['port_directions'] as Map; + + for (final portEntry in conns.entries.toList()) { + if (dirs[portEntry.key] != 'output') { + continue; + } + final bits = (portEntry.value as List).cast(); + final newBits = [ + for (final b in bits) b is int ? (arrayConcatOldToNew[b] ?? b) : b, + ]; + if (bits.indexed.any((e) => e.$2 != newBits[e.$1])) { + conns[portEntry.key] = newBits; + } + } + } + } + + // -- Netnames -------------------------------------------------------- + final netnames = {}; + final emittedNames = {}; + + // InlineSystemVerilog modules are pure combinational — all their + // signals are derivable from the gate netlist. + final isInlineSV = module is InlineSystemVerilog; + + void addNetname(String name, List bits, + {bool hideName = false, bool computed = false}) { + if (emittedNames.contains(name)) { + return; + } + emittedNames.add(name); + netnames[name] = { + 'bits': bits, + if (hideName) 'hide_name': 1, + 'attributes': { + if (computed || isInlineSV) 'computed': 1, + }, + }; + } + + // Port nets (already aliased above). + for (final p in ports.entries) { + addNetname(Sanitizer.sanitizeSV(p.key), + (p.value['bits']! as List).cast()); + } + + // Named signals from SynthModuleDefinition. + if (synthDef != null) { + for (final entry in synthLogicIds.entries + .where((e) => !e.key.isConstant && !e.key.declarationCleared)) { + final sl = entry.key; + final name = NetlistUtils.tryGetSynthLogicName(sl); + if (name != null) { + var bits = applyAlias(entry.value.cast()); + // For element signals whose IDs were remapped by the + // array_concat fresh-ID pass, apply that mapping so the + // element netname matches the concat input (fresh) IDs. + if (arrayConcatOldToNew.isNotEmpty && sl is SynthLogicArrayElement) { + bits = bits + .map((b) => b is int ? (arrayConcatOldToNew[b] ?? b) : b) + .toList(); + } + addNetname(Sanitizer.sanitizeSV(name), bits); + } + } + } + + // Constant netnames for non-blocked constants (already aliased via + // cell connections above). + for (final cellEntry + in cells.entries.where((e) => e.value['type'] == r'$const')) { + final conns = + cellEntry.value['connections'] as Map>?; + if (conns != null && conns.isNotEmpty) { + addNetname(cellEntry.key, conns.values.first, computed: true); + } + } + + // -- Ensure every bit ID in cell connections has a netname ------------ + { + final coveredIds = netnames.values + .expand( + (nn) => ((nn! as Map)['bits'] as List?) ?? []) + .whereType() + .toSet(); + + for (final cellEntry in cells.entries) { + final cellName = cellEntry.key; + final conns = + cellEntry.value['connections'] as Map? ?? {}; + for (final connEntry in conns.entries) { + final portName = connEntry.key; + final bits = connEntry.value as List; + final missingBits = []; + for (final b in bits) { + if (b is int && !coveredIds.contains(b)) { + missingBits.add(b); + coveredIds.add(b); + } + } + if (missingBits.isNotEmpty) { + addNetname( + Sanitizer.sanitizeSV('${cellName}_$portName'), missingBits, + hideName: true); + } + } + } + } + + // -- Slim: strip cell connections ------------------------------------ + // The full pipeline ran identically, so the cell set (keys, ordering) + // is canonical. Now drop the connection maps to reduce the output + // size. This is the ONLY difference between slim and full output. + if (options.slimMode) { + for (final cell in cells.values) { + cell.remove('connections'); + } + } + + return NetlistSynthesisResult( + module, + getInstanceTypeOfModule, + ports: ports, + cells: cells, + netnames: netnames, + attributes: attr, + ); + } + + /// Apply all post-processing passes to the modules map. + /// + /// This is the canonical pass ordering used by both netlist flows: + /// **Flow 1** (slim batch via `_synthesizeSlimModules`) and + /// **Flow 2** (incremental full via `moduleNetlistJson`). + /// Also used internally by [buildModulesMap] / [synthesizeToJson]. + void applyPostProcessingPasses( + Map> modules, + ) { + if (options.groupStructConversions) { + if (options.groupMaximalSubsets) { + NetlistPasses.applyMaximalSubsetGrouping(modules); + } + if (options.collapseConcats) { + NetlistPasses.applyCollapseConcats(modules); + } + if (options.collapseSelectsIntoPack) { + NetlistPasses.applyCollapseSelectsIntoPack(modules); + } + if (options.collapseUnpackToConcat) { + NetlistPasses.applyCollapseUnpackToConcat(modules); + } + if (options.collapseUnpackToPack) { + NetlistPasses.applyCollapseUnpackToPack(modules); + } + NetlistPasses.applyStructConversionGrouping(modules); + if (options.collapseStructGroups) { + NetlistPasses.collapseStructGroupModules(modules); + } + NetlistPasses.applyStructBufferInsertion(modules); + NetlistPasses.applyConcatToBufferReplacement(modules); + } + } + + /// Build the processed modules map from a [SynthBuilder]'s results. + /// + /// Returns the intermediate module map (definition name → module data) + /// after all post-processing passes have been applied. This allows + /// callers to retain per-module results for incremental serving while + /// avoiding redundant re-synthesis. + Future>> buildModulesMap( + SynthBuilder synth, Module top) async { + final swEntries = Stopwatch()..start(); + final modules = NetlistPasses.collectModuleEntries(synth.synthesisResults, + topModule: top); + swEntries.stop(); + + final swPasses = Stopwatch()..start(); + applyPostProcessingPasses(modules); + swPasses.stop(); + + return modules; + } + + /// Generate the combined netlist JSON from a [SynthBuilder]'s results. + Future generateCombinedJson(SynthBuilder synth, Module top) async { + final swCollect = Stopwatch()..start(); + final modules = await buildModulesMap(synth, top); + swCollect.stop(); + + final swCompress = Stopwatch()..start(); + if (options.compressBitRanges) { + _compressModulesMap(modules); + } + swCompress.stop(); + + final combined = { + 'creator': 'NetlistSynthesizer (rohd)', + 'modules': modules, + }; + + final swEncode = Stopwatch()..start(); + final encoder = options.compactJson + ? const JsonEncoder() + : const JsonEncoder.withIndent(' '); + final result = encoder.convert(combined); + swEncode.stop(); + + return result; + } + + /// Compresses a list of bit IDs by replacing contiguous ascending runs of + /// 3 or more integers with `"start:end"` range strings. + static List _compressBits(List bits) { + final result = []; + final pending = []; + + void flushPending() { + if (pending.isEmpty) { + return; + } + var i = 0; + while (i < pending.length) { + var j = i; + while (j + 1 < pending.length && pending[j + 1] == pending[j] + 1) { + j++; + } + final runLen = j - i + 1; + if (runLen >= 3) { + result.add('${pending[i]}:${pending[j]}'); + } else { + for (var k = i; k <= j; k++) { + result.add(pending[k]); + } + } + i = j + 1; + } + pending.clear(); + } + + for (final element in bits) { + if (element is int) { + pending.add(element); + } else { + flushPending(); + result.add(element); + } + } + flushPending(); + return result; + } + + /// Applies [_compressBits] to all `bits` arrays and cell `connections` + /// arrays in a modules map. + static void _compressModulesMap( + Map> modules, + ) { + for (final moduleDef in modules.values) { + final ports = moduleDef['ports'] as Map>?; + if (ports != null) { + for (final port in ports.values) { + final bits = port['bits']; + if (bits is List) { + port['bits'] = _compressBits(bits.cast()); + } + } + } + + final cells = moduleDef['cells'] as Map>?; + if (cells != null) { + for (final cell in cells.values) { + final conns = cell['connections'] as Map>?; + if (conns != null) { + for (final key in conns.keys.toList()) { + conns[key] = _compressBits(conns[key]!); + } + } + } + } + + final netnames = moduleDef['netnames'] as Map?; + if (netnames != null) { + for (final entry in netnames.values) { + if (entry is Map) { + final bits = entry['bits']; + if (bits is List) { + entry['bits'] = _compressBits(bits.cast()); + } + } + } + } + } + } + + /// Convenience: synthesize [top] into a combined netlist JSON string. + /// + /// Builds a [SynthBuilder] internally and returns the full JSON. + Future synthesizeToJson(Module top) async { + final sb = SynthBuilder(top, this); + return generateCombinedJson(sb, top); + } +} + +/// A version of [BusSubset] that creates explicit `$slice` cells for +/// [LogicArray] element extraction in the netlist. +/// +/// When a [LogicArray] port is decomposed into its elements, each element +/// gets its own [_BusSubsetForArraySlice] so the netlist shows explicit +/// select gates rather than flat bit aliasing. +class _BusSubsetForArraySlice extends BusSubset { + _BusSubsetForArraySlice( + super.bus, + super.startIndex, + super.endIndex, + ) : super(name: 'array_slice'); + + @override + bool get hasBuilt => true; +} + +/// A version of [Swizzle] that creates explicit `$concat` cells for +/// [LogicArray] element assembly in the netlist. +/// +/// When a [LogicArray]'s elements are driven independently (e.g. by +/// constants), this creates a visible concat gate in the netlist that +/// assembles the element signals into the full packed array bus. +class _SwizzleForArrayConcat extends Swizzle { + _SwizzleForArrayConcat(super.signals) : super(name: 'array_concat'); + + @override + bool get hasBuilt => true; +} diff --git a/lib/src/synthesizers/netlist/netlist_utils.dart b/lib/src/synthesizers/netlist/netlist_utils.dart new file mode 100644 index 000000000..258fe56a3 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_utils.dart @@ -0,0 +1,531 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_utils.dart +// Shared utility functions for netlist synthesis and post-processing passes. +// +// 2026 February 11 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/synthesizers/utilities/utilities.dart'; + +/// Shared utility functions for netlist synthesis and post-processing passes. +/// +/// All methods are static — no instances are created. +class NetlistUtils { + NetlistUtils._(); + + /// Find the port name in [portMap] that corresponds to [sl]. + static String? portNameForSynthLogic( + SynthLogic sl, Map portMap) { + for (final e in portMap.entries) { + if (sl.logics.contains(e.value)) { + return e.key; + } + } + return null; + } + + /// Safely retrieve the name from a [SynthLogic], returning null if + /// retrieval fails (e.g. name not yet picked, or the SynthLogic has + /// been replaced). + static String? tryGetSynthLogicName(SynthLogic sl) { + try { + return sl.name; + // ignore: avoid_catches_without_on_clauses + } catch (_) { + return null; + } + } + + /// Resolves [sl] to the end of its replacement chain. + static SynthLogic resolveReplacement(SynthLogic sl) { + var r = sl; + while (r.replacement != null) { + r = r.replacement!; + } + return r; + } + + /// Anchored regex for range-named concat port labels like `[7:0]` or `[3]`. + static final rangePortRe = RegExp(r'^\[(\d+)(?::(\d+))?\]$'); + + /// Create a `$buf` cell map. + static Map makeBufCell( + int width, + List aBits, + List yBits, + ) => + { + 'hide_name': 0, + 'type': r'$buf', + 'parameters': {'WIDTH': width}, + 'attributes': {}, + 'port_directions': {'A': 'input', 'Y': 'output'}, + 'connections': >{'A': aBits, 'Y': yBits}, + }; + + /// Create a `$slice` cell map. + static Map makeSliceCell( + int offset, + int aWidth, + int yWidth, + List aBits, + List yBits, + ) => + { + 'hide_name': 0, + 'type': r'$slice', + 'parameters': { + 'OFFSET': offset, + 'A_WIDTH': aWidth, + 'Y_WIDTH': yWidth, + }, + 'attributes': {}, + 'port_directions': {'A': 'input', 'Y': 'output'}, + 'connections': >{'A': aBits, 'Y': yBits}, + }; + + /// Build wire-driver, wire-consumer, and bit-to-net maps for a module. + /// + /// Scans every cell's connections to find which cell drives each wire bit + /// (output direction) and which cells consume it (input direction). + /// Module output-port bits are registered as pseudo-consumers (`__port__`) + /// so that cells feeding module ports are never accidentally removed. + static ({ + Map wireDriverCell, + Map> wireConsumerCells, + Map)> bitToNetInfo, + }) buildWireMaps( + Map> cells, + Map moduleDef, + ) { + final wireDriverCell = {}; + final wireConsumerCells = >{}; + for (final entry in cells.entries) { + final cell = entry.value; + final conns = cell['connections'] as Map? ?? {}; + final pdirs = cell['port_directions'] as Map? ?? {}; + for (final pe in conns.entries) { + final d = pdirs[pe.key] as String? ?? ''; + for (final b in pe.value as List) { + if (b is int) { + if (d == 'output') { + wireDriverCell[b] = entry.key; + } else if (d == 'input') { + (wireConsumerCells[b] ??= {}).add(entry.key); + } + } + } + } + } + + final modPorts = moduleDef['ports'] as Map>?; + if (modPorts != null) { + for (final port in modPorts.values) { + if ((port['direction'] as String?) == 'output') { + for (final b in port['bits'] as List? ?? []) { + if (b is int) { + (wireConsumerCells[b] ??= {}).add('__port__'); + } + } + } + } + } + + final netnames = moduleDef['netnames'] as Map? ?? {}; + final bitToNetInfo = )>{}; + for (final nnEntry in netnames.entries) { + final nd = nnEntry.value! as Map; + final bits = (nd['bits'] as List?)?.cast() ?? []; + for (final b in bits) { + bitToNetInfo[b] = (nnEntry.key, bits); + } + } + + return ( + wireDriverCell: wireDriverCell, + wireConsumerCells: wireConsumerCells, + bitToNetInfo: bitToNetInfo, + ); + } + + /// Trace a single wire bit backward through `$buf`/`$slice` cells, + /// returning the ultimate source bit and the set of intermediate cell + /// names visited along the chain. + static (int sourceBit, Set intermediates) traceBackward( + int startBit, + Map wireDriverCell, + Map> cells, + ) { + var current = startBit; + final chain = {}; + while (true) { + final driverName = wireDriverCell[current]; + if (driverName == null) { + break; + } + final driverCell = cells[driverName]; + if (driverCell == null) { + break; + } + final dt = driverCell['type'] as String?; + if (dt != r'$buf' && dt != r'$slice') { + break; + } + if (chain.contains(driverName)) { + break; // Cycle detected — stop tracing. + } + chain.add(driverName); + final dc = driverCell['connections'] as Map? ?? {}; + if (dt == r'$buf') { + final yBits = dc['Y'] as List; + final aBits = dc['A'] as List; + final idx = yBits.indexOf(current); + if (idx < 0 || idx >= aBits.length || aBits[idx] is! int) { + break; + } + current = aBits[idx] as int; + } else { + final yBits = dc['Y'] as List; + final aBits = dc['A'] as List; + final dp = driverCell['parameters'] as Map? ?? {}; + final offset = dp['OFFSET'] as int? ?? 0; + final idx = yBits.indexOf(current); + if (idx < 0) { + break; + } + final srcIdx = offset + idx; + if (srcIdx < 0 || srcIdx >= aBits.length || aBits[srcIdx] is! int) { + break; + } + current = aBits[srcIdx] as int; + } + } + return (current, chain); + } + + /// Whether every intermediate cell in [intermediates] exclusively feeds + /// [ownerCell] or other cells in [intermediates]. + /// + /// When [allowPortConsumers] is true, `'__port__'` pseudo-consumers are + /// also accepted (used when module-output ports registered as consumers). + static bool isExclusiveChain({ + required Set intermediates, + required String ownerCell, + required Map> cells, + required Map> wireConsumerCells, + bool allowPortConsumers = false, + }) { + for (final ic in intermediates) { + final icCell = cells[ic]; + if (icCell == null) { + return false; + } + final icConns = icCell['connections'] as Map? ?? {}; + final icDirs = icCell['port_directions'] as Map? ?? {}; + for (final pe in icConns.entries) { + if ((icDirs[pe.key] as String?) != 'output') { + continue; + } + for (final b in pe.value as List) { + if (b is! int) { + continue; + } + final consumers = wireConsumerCells[b]; + if (consumers == null) { + continue; + } + for (final cn in consumers) { + if (cn != ownerCell && !intermediates.contains(cn)) { + if (allowPortConsumers && cn == '__port__') { + continue; + } + return false; + } + } + } + } + } + return true; + } + + /// Collapses bit-slice ports of a Combinational/Sequential cell into + /// aggregate ports. + /// + /// **Input side**: When a Combinational references individual struct fields, + /// each field creates a BusSubset in the parent scope, and each slice + /// becomes a separate input port. This method detects groups of input + /// ports whose SynthLogics are outputs of BusSubset submodule + /// instantiations that slice the same root signal. For each group + /// forming a contiguous bit range, the N individual ports are replaced + /// with a single aggregate port connected to the corresponding sub-range + /// of the root signal's wire IDs. + /// + /// **Output side**: Similarly, Combinational output ports that feed into + /// the inputs of the same Swizzle submodule are collapsed into a single + /// aggregate port connected to the Swizzle's output wire IDs. + static void collapseAlwaysBlockPorts( + SynthModuleDefinition synthDef, + SynthSubModuleInstantiation instance, + Map portDirs, + Map> connections, + List Function(SynthLogic) getIds, + ) { + // ── Input-side collapsing (BusSubset → Combinational) ────────────── + + // Build reverse lookup: resolved BusSubset output SynthLogic → + // (BusSubset module, resolved root input SynthLogic, + // SynthSubModuleInstantiation). + final busSubsetLookup = + {}; + for (final bsInst in synthDef.subModuleInstantiations) { + if (bsInst.module is! BusSubset) { + continue; + } + final bsMod = bsInst.module as BusSubset; + + // BusSubset has input 'original' and output 'subset' + final outputSL = bsInst.outputMapping.values.firstOrNull; + final inputSL = bsInst.inputMapping.values.firstOrNull; + if (outputSL == null || inputSL == null) { + continue; + } + + final resolvedOutput = resolveReplacement(outputSL); + final resolvedInput = resolveReplacement(inputSL); + + busSubsetLookup[resolvedOutput] = (bsMod, resolvedInput, bsInst); + } + + // Group input ports by root signal, also tracking the BusSubset + // instantiations that produced each port. + final inputGroups = >{}; + + for (final e in instance.inputMapping.entries) { + final portName = e.key; + if (!connections.containsKey(portName)) { + continue; // already filtered + } + + final resolved = resolveReplacement(e.value); + final info = busSubsetLookup[resolved]; + if (info != null) { + final (bsMod, rootSL, bsInst) = info; + final width = bsMod.endIndex - bsMod.startIndex + 1; + inputGroups + .putIfAbsent(rootSL, () => []) + .add((portName, bsMod.startIndex, width, bsInst)); + } + } + + // Collapse each group with > 1 contiguous member. + for (final entry in inputGroups.entries) { + if (entry.value.length <= 1) { + continue; + } + + final rootSL = entry.key; + final ports = entry.value..sort((a, b) => a.$2.compareTo(b.$2)); + + // Verify contiguous non-overlapping coverage. + var expectedBit = ports.first.$2; + var contiguous = true; + for (final (_, startIdx, width, _) in ports) { + if (startIdx != expectedBit) { + contiguous = false; + break; + } + expectedBit += width; + } + if (!contiguous) { + continue; + } + + final minBit = ports.first.$2; + final maxBit = ports.last.$2 + ports.last.$3 - 1; + + // Get the root signal's full wire IDs and extract the sub-range. + final rootIds = getIds(rootSL); + if (maxBit >= rootIds.length) { + continue; // safety check + } + final aggBits = rootIds.sublist(minBit, maxBit + 1).cast(); + + // Choose a name for the aggregate port. + final rootName = tryGetSynthLogicName(rootSL) ?? 'agg_${minBit}_$maxBit'; + + // Replace individual ports with the aggregate. The bypassed + // BusSubset cells are left in place; the post-synthesis DCE pass + // will remove them if their outputs are no longer consumed. + for (final (portName, _, _, _) in ports) { + connections.remove(portName); + portDirs.remove(portName); + } + connections[rootName] = aggBits; + portDirs[rootName] = 'input'; + } + + // ── Output-side collapsing (Combinational → Swizzle) ─────────────── + + // Build reverse lookup: resolved Swizzle input SynthLogic → + // (Swizzle port name, bit offset within the Swizzle output, + // port width, resolved Swizzle output SynthLogic, + // SynthSubModuleInstantiation). + final swizzleLookup = {}; + for (final szInst in synthDef.subModuleInstantiations) { + if (szInst.module is! Swizzle) { + continue; + } + final outputSL = szInst.outputMapping.values.firstOrNull; + if (outputSL == null) { + continue; + } + final resolvedOutput = resolveReplacement(outputSL); + + // Swizzle inputs are in0, in1, ... with bit-0 first. + var offset = 0; + for (final inEntry in szInst.inputMapping.entries) { + final resolvedInput = resolveReplacement(inEntry.value); + final w = resolvedInput.width; + swizzleLookup[resolvedInput] = + (inEntry.key, offset, w, resolvedOutput, szInst); + offset += w; + } + } + + // Group output ports by Swizzle output signal. + final outputGroups = >{}; + + for (final e in instance.outputMapping.entries) { + final portName = e.key; + if (!connections.containsKey(portName)) { + continue; + } + + final resolved = resolveReplacement(e.value); + final info = swizzleLookup[resolved]; + if (info != null) { + final (_, offset, width, swizzleOutputSL, szInst) = info; + outputGroups + .putIfAbsent(swizzleOutputSL, () => []) + .add((portName, offset, width, szInst)); + } + } + + // Collapse each group with > 1 contiguous member. + for (final entry in outputGroups.entries) { + if (entry.value.length <= 1) { + continue; + } + + final swizOutSL = entry.key; + final ports = entry.value..sort((a, b) => a.$2.compareTo(b.$2)); + + // Verify contiguous. + var expectedBit = ports.first.$2; + var contiguous = true; + for (final (_, offset, width, _) in ports) { + if (offset != expectedBit) { + contiguous = false; + break; + } + expectedBit += width; + } + if (!contiguous) { + continue; + } + + final minBit = ports.first.$2; + final maxBit = ports.last.$2 + ports.last.$3 - 1; + + final outIds = getIds(swizOutSL); + if (maxBit >= outIds.length) { + continue; + } + final aggBits = outIds.sublist(minBit, maxBit + 1).cast(); + + final outName = + tryGetSynthLogicName(swizOutSL) ?? 'agg_out_${minBit}_$maxBit'; + + // Replace individual ports with the aggregate. The bypassed + // Swizzle cells are left in place; the post-synthesis DCE pass + // will remove them if their outputs are no longer consumed. + for (final (portName, _, _, _) in ports) { + connections.remove(portName); + portDirs.remove(portName); + } + connections[outName] = aggBits; + portDirs[outName] = 'output'; + } + } + + /// Check if a SynthLogic is a constant (following replacement chain). + static bool isConstantSynthLogic(SynthLogic sl) => + resolveReplacement(sl).isConstant; + + /// Extract the Const value from a constant SynthLogic. + static Const? constValueFromSynthLogic(SynthLogic sl) { + final resolved = resolveReplacement(sl); + for (final logic in resolved.logics) { + if (logic is Const) { + return logic; + } + } + return null; + } + + /// Value portion of a constant name: `_h` or `_b`. + static String constValuePart(Const c) { + final bitChars = []; + var hasXZ = false; + for (var i = c.width - 1; i >= 0; i--) { + final v = c.value[i]; + switch (v) { + case LogicValue.zero: + bitChars.add('0'); + case LogicValue.one: + bitChars.add('1'); + case LogicValue.x: + bitChars.add('x'); + hasXZ = true; + case LogicValue.z: + bitChars.add('z'); + hasXZ = true; + } + } + if (hasXZ) { + return '${c.width}_b${bitChars.join()}'; + } + var value = BigInt.zero; + for (var i = c.width - 1; i >= 0; i--) { + value = value << 1; + if (c.value[i] == LogicValue.one) { + value = value | BigInt.one; + } + } + return '${c.width}_h${value.toRadixString(16)}'; + } +} diff --git a/lib/src/synthesizers/synthesizers.dart b/lib/src/synthesizers/synthesizers.dart index b8c8523ec..da5d76586 100644 --- a/lib/src/synthesizers/synthesizers.dart +++ b/lib/src/synthesizers/synthesizers.dart @@ -1,6 +1,7 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'netlist/netlist.dart'; export 'synth_builder.dart'; export 'synth_file_contents.dart'; export 'synthesis_result.dart'; diff --git a/lib/src/synthesizers/systemverilog/sv_service.dart b/lib/src/synthesizers/systemverilog/sv_service.dart index 65dc12ab2..d2d9f359e 100644 --- a/lib/src/synthesizers/systemverilog/sv_service.dart +++ b/lib/src/synthesizers/systemverilog/sv_service.dart @@ -11,6 +11,8 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/config.dart'; +import 'package:rohd/src/utilities/timestamper.dart'; /// A service that wraps SystemVerilog synthesis of a [Module] hierarchy. /// @@ -31,6 +33,12 @@ import 'package:rohd/rohd.dart'; /// print(sv.allContents); /// ``` class SvService { + /// The separator inserted between module definitions in the + /// concatenated single-file output from [allContents]. + /// + /// Matches the format historically produced by `Module.generateSynth()`. + static const moduleSeparator = '\n\n////////////////////\n\n'; + /// The top-level [Module] being synthesized. final Module module; @@ -45,16 +53,25 @@ class SvService { /// [module] must already be built. Set [register] to `true` (the /// default) to register this service with [ModuleServices] for /// DevTools access. - SvService(this.module, {bool register = true}) { + /// + /// If [outputPath] is provided, the concatenated SV output (with + /// header) is written to that file. The parent directory is created + /// if needed. + SvService(this.module, {bool register = true, String? outputPath}) { if (!module.hasBuilt) { - throw Exception( - 'Module must be built before creating SvService. ' + throw Exception('Module must be built before creating SvService. ' 'Call build() first.'); } synthBuilder = SynthBuilder(module, SystemVerilogSynthesizer()); fileContents = synthBuilder.getSynthFileContents(); + if (outputPath != null) { + final file = File(outputPath); + file.parent.createSync(recursive: true); + file.writeAsStringSync(synthOutput); + } + if (register) { ModuleServices.instance.svService = this; } @@ -63,10 +80,29 @@ class SvService { /// All [SynthesisResult]s produced by synthesis. Set get synthesisResults => synthBuilder.synthesisResults; - /// Returns the concatenated SystemVerilog output as a single string, - /// matching the format of [Module.generateSynth]. + // ─── Single-file output ─────────────────────────────────────── + + /// Returns the concatenated SystemVerilog module definitions as a + /// single string, without the generation header. + /// + /// For the full output with header (matching `Module.generateSynth()`), + /// use [synthOutput]. String get allContents => - fileContents.map((fc) => fc.contents).join('\n\n'); + fileContents.map((fc) => fc.contents).join(moduleSeparator); + + /// The ROHD generation header prepended to single-file output. + String get synthHeader => ''' +/** + * Generated by ROHD - www.github.com/intel/rohd + * Generation time: ${Timestamper.stamp()} + * ROHD Version: ${Config.version} + */ + +'''; + + /// Returns the full single-file SystemVerilog output with header, + /// identical to `Module.generateSynth()`. + String get synthOutput => synthHeader + allContents; /// Returns a map from module definition name to its SV file contents. /// diff --git a/test/incremental_expansion_test.dart b/test/incremental_expansion_test.dart new file mode 100644 index 000000000..d5bdeb121 --- /dev/null +++ b/test/incremental_expansion_test.dart @@ -0,0 +1,116 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// incremental_expansion_test.dart +// Tests for the incremental expansion protocol: +// - original_signal_count / original_cell_count attributes in slim JSON +// - HierarchyNode.extendSignals / extendChildren + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/example.dart' as ex; +import '../example/filter_bank.dart'; + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + // ─────── original_signal_count / original_cell_count ──────────────── + + group('original_signal_count / original_cell_count', () { + test('Counter slim JSON has counts in attributes', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final counter = ex.Counter(en, reset, clk); + await counter.build(); + final netSvc = await NetlistService.create(counter); + + final slimStr = netSvc.slimJson; + final unified = jsonDecode(slimStr) as Map; + final netlist = unified['netlist'] as Map; + final modules = netlist['modules'] as Map; + + for (final entry in modules.entries) { + final mod = entry.value as Map; + final attrs = mod['attributes'] as Map; + expect( + attrs.containsKey('original_signal_count'), + isTrue, + reason: '${entry.key} missing original_signal_count', + ); + expect( + attrs.containsKey('original_cell_count'), + isTrue, + reason: '${entry.key} missing original_cell_count', + ); + + final sigCount = attrs['original_signal_count'] as int; + final cellCount = attrs['original_cell_count'] as int; + final netnames = mod['netnames'] as Map? ?? {}; + final cells = mod['cells'] as Map? ?? {}; + + // Counts must match the actual number of entries in slim JSON. + expect( + sigCount, + equals(netnames.length), + reason: '${entry.key}: original_signal_count mismatch', + ); + expect( + cellCount, + equals(cells.length), + reason: '${entry.key}: original_cell_count mismatch', + ); + } + }); + + test('FilterBank slim JSON has counts in attributes', () async { + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], 16, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + final filterBank = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: 4, + dataWidth: 16, + coefficients: [List.filled(4, 1), List.filled(4, 1)], + ); + await filterBank.build(); + final netSvc = await NetlistService.create(filterBank); + + final slimStr = netSvc.slimJson; + final unified = jsonDecode(slimStr) as Map; + final netlist = unified['netlist'] as Map; + final modules = netlist['modules'] as Map; + + // At least the root module should have counts. + expect(modules.isNotEmpty, isTrue); + for (final entry in modules.entries) { + final mod = entry.value as Map; + final attrs = mod['attributes'] as Map; + expect( + attrs['original_signal_count'], + isA(), + reason: '${entry.key}: original_signal_count not int', + ); + expect( + attrs['original_cell_count'], + isA(), + reason: '${entry.key}: original_cell_count not int', + ); + } + }); + }); +} diff --git a/test/module_services_test.dart b/test/module_services_test.dart index f9b0ac075..38350dd14 100644 --- a/test/module_services_test.dart +++ b/test/module_services_test.dart @@ -2,133 +2,319 @@ // SPDX-License-Identifier: BSD-3-Clause // // module_services_test.dart -// Unit tests for ModuleServices and SvService. -// -// 2026 April 25 -// Author: Desmond Kirkpatrick +// Tests for ModuleServices, SvService, and NetlistService. + +@TestOn('vm') +library; import 'dart:convert'; -import 'dart:io'; import 'package:rohd/rohd.dart'; import 'package:test/test.dart'; -class SimpleModule extends Module { - SimpleModule(Logic a) : super(name: 'simple') { +// --------------------------------------------------------------------------- +// Simple test modules +// --------------------------------------------------------------------------- + +class _InverterModule extends Module { + Logic get out => output('out'); + + _InverterModule(Logic inp) : super(name: 'inverter') { + inp = addInput('inp', inp); + final out = addOutput('out'); + out <= ~inp; + } +} + +class _TopModule extends Module { + Logic get out => output('out'); + + _TopModule(Logic a, Logic b) : super(name: 'top') { a = addInput('a', a); - addOutput('b') <= ~a; + b = addInput('b', b); + final out = addOutput('out'); + + final inv = _InverterModule(a); + out <= inv.out & b; } } void main() { - tearDown(() { + tearDown(() async { + await Simulator.reset(); ModuleServices.instance.reset(); }); group('ModuleServices', () { - test('rootModule is set after build', () async { - final mod = SimpleModule(Logic()); + test('rootModule is set by Module.build', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); + expect(ModuleServices.instance.rootModule, equals(mod)); }); - test('hierarchyJSON returns valid JSON', () async { - final mod = SimpleModule(Logic()); + test('hierarchyJSON returns valid JSON after build', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); + final json = ModuleServices.instance.hierarchyJSON; - expect(() => jsonDecode(json), returnsNormally); + final decoded = jsonDecode(json) as Map; + expect(decoded['name'], equals('top')); }); - test('inspectorJSON matches hierarchyJSON', () async { - final mod = SimpleModule(Logic()); - await mod.build(); - expect(ModuleServices.instance.inspectorJSON, - equals(ModuleServices.instance.hierarchyJSON)); + test('svJSON returns unavailable when no SvService registered', () { + final json = ModuleServices.instance.svJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['status'], equals('unavailable')); }); - test('svJSON returns unavailable when no service registered', () async { - final mod = SimpleModule(Logic()); - await mod.build(); - final result = - jsonDecode(ModuleServices.instance.svJSON) as Map; - expect(result['status'], equals('unavailable')); - }); + test( + 'netlistJSON returns unavailable when no NetlistService registered', + () { + final json = ModuleServices.instance.netlistJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['status'], equals('unavailable')); + }, + ); test('reset clears all services', () async { - final mod = SimpleModule(Logic()); + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); + expect(ModuleServices.instance.rootModule, isNotNull); ModuleServices.instance.reset(); expect(ModuleServices.instance.rootModule, isNull); - expect(ModuleServices.instance.svService, isNull); + }); + + test('inspectorJSON returns slim netlist when service registered', + () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + await NetlistService.create(mod); + + final json = ModuleServices.instance.inspectorJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['netlist'], isA>()); + final netlist = decoded['netlist'] as Map; + expect(netlist['modules'], isA>()); + }); + + test('inspectorJSON falls back to hierarchy when no netlist', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + + final json = ModuleServices.instance.inspectorJSON; + final decoded = jsonDecode(json) as Map; + // Falls back to hierarchy JSON format. + expect(decoded['name'], equals('top')); + }); + + test('inspectorModuleJSON returns per-module netlist', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + final netSvc = await NetlistService.create(mod); + + for (final name in netSvc.moduleNames) { + final json = ModuleServices.instance.inspectorModuleJSON(name); + final decoded = jsonDecode(json) as Map; + expect(decoded['modules'], isA>()); + } }); }); group('SvService', () { - test('registers with ModuleServices on creation', () async { - final mod = SimpleModule(Logic()); + test('generates SV for a module hierarchy', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); + final sv = SvService(mod); - expect(ModuleServices.instance.svService, equals(sv)); + + expect(sv.fileContents, isNotEmpty); + expect(sv.allContents, contains('module')); + expect(sv.allContents, contains('endmodule')); }); - test('allContents is non-empty', () async { - final mod = SimpleModule(Logic()); + test('registers with ModuleServices by default', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - final sv = SvService(mod); - expect(sv.allContents, isNotEmpty); + + SvService(mod); + + expect(ModuleServices.instance.svService, isNotNull); + final json = ModuleServices.instance.svJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['modules'], isA>()); }); - test('contentsByName has entries', () async { - final mod = SimpleModule(Logic()); + test('register: false does not register', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - final sv = SvService(mod); - expect(sv.contentsByName, isNotEmpty); + + SvService(mod, register: false); + + expect(ModuleServices.instance.svService, isNull); }); - test('contentsByDefinitionName has entries', () async { - final mod = SimpleModule(Logic()); + test('contentsByName returns per-module SV', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - final sv = SvService(mod); - expect(sv.contentsByDefinitionName, isNotEmpty); - expect(sv.contentsByDefinitionName.containsKey('SimpleModule'), isTrue); + + final sv = SvService(mod, register: false); + final byName = sv.contentsByName; + + // Should have at least the top module and the inverter. + expect(byName.length, greaterThanOrEqualTo(2)); + for (final content in byName.values) { + expect(content, contains('module')); + } }); - test('svJSON returns valid JSON after registration', () async { - final mod = SimpleModule(Logic()); + test('synthOutput includes header', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - SvService(mod); - final result = - jsonDecode(ModuleServices.instance.svJSON) as Map; - expect(result['modules'], isList); + + final sv = SvService(mod, register: false); + expect(sv.synthOutput, contains('Generated by ROHD')); + expect(sv.synthOutput, contains(sv.allContents)); }); + }); - test('writeFiles creates SV files', () async { - final mod = SimpleModule(Logic()); + group('NetlistService', () { + test('generates netlist JSON for a module hierarchy', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - final sv = SvService(mod); - final dir = Directory.systemTemp.createTempSync('sv_test_'); - try { - sv.writeFiles(dir.path); - final files = dir.listSync().whereType().toList(); - expect(files, isNotEmpty); - expect(files.any((f) => f.path.endsWith('.sv')), isTrue); - } finally { - dir.deleteSync(recursive: true); + + final netlist = await NetlistService.create(mod); + final json = netlist.toJson(); + final decoded = jsonDecode(json) as Map; + + expect(decoded['modules'], isA>()); + expect(netlist.moduleNames, isNotEmpty); + }); + + test('registers with ModuleServices by default', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + + await NetlistService.create(mod); + + expect(ModuleServices.instance.netlistService, isNotNull); + final json = ModuleServices.instance.netlistJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['modules'], isA>()); + }); + + test('register: false does not register', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + + await NetlistService.create(mod, register: false); + + expect(ModuleServices.instance.netlistService, isNull); + }); + + test('moduleJson returns single module data', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + + final netlist = await NetlistService.create(mod, register: false); + + // Query for a module that exists. + for (final name in netlist.moduleNames) { + final moduleJson = netlist.moduleJson(name); + final decoded = jsonDecode(moduleJson) as Map; + expect(decoded['modules'], isA>()); + expect((decoded['modules'] as Map).containsKey(name), isTrue); } + + // Query for a module that doesn't exist. + final missing = netlist.moduleJson('nonexistent'); + final decoded = jsonDecode(missing) as Map; + expect(decoded['status'], equals('not_found')); }); - test('register false does not register', () async { - final mod = SimpleModule(Logic()); + test('slimJson returns netlist envelope without connections', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - ModuleServices.instance.reset(); - SvService(mod, register: false); - expect(ModuleServices.instance.svService, isNull); + + final netlist = await NetlistService.create(mod, register: false); + final slim = netlist.slimJson; + final decoded = jsonDecode(slim) as Map; + + expect(decoded['netlist'], isA>()); + final netlistSection = decoded['netlist'] as Map; + expect(netlistSection['rootInstanceName'], isNotNull); + expect(netlistSection['modules'], isA>()); + + // Verify cells have no connections + final modules = netlistSection['modules'] as Map; + for (final modEntry in modules.values) { + final cells = + (modEntry as Map)['cells'] as Map; + for (final cellEntry in cells.values) { + final cell = cellEntry as Map; + expect(cell.containsKey('connections'), isFalse, + reason: 'Slim cells should not have connections'); + } + } }); - test('throws if module not built', () { - final mod = SimpleModule(Logic()); - expect(() => SvService(mod), throwsException); + test('synthesizedModules provides read-only access', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + + final netlist = await NetlistService.create(mod, register: false); + final modules = netlist.synthesizedModules; + expect(modules, isNotEmpty); + expect(modules.keys, equals(netlist.moduleNames)); + }); + }); + + group('Module.build netlistOptions integration', () { + test('netlistOptions creates and registers NetlistService', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(netlistOptions: const NetlistOptions()); + + expect(ModuleServices.instance.netlistService, isNotNull); + final json = ModuleServices.instance.netlistJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['modules'], isA>()); }); }); } diff --git a/test/netlist_example_test.dart b/test/netlist_example_test.dart new file mode 100644 index 000000000..e5d7b7cbe --- /dev/null +++ b/test/netlist_example_test.dart @@ -0,0 +1,285 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_example_test.dart +// Convert examples to netlist JSON and check the produced output. + +// 2026 March 31 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; +import 'dart:io'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/example.dart'; +import '../example/fir_filter.dart'; +import '../example/logic_array.dart'; +import '../example/oven_fsm.dart'; +import '../example/tree.dart'; + +void main() { + // Detect whether running in JS (dart2js) environment. In JS many + // `dart:io` APIs are unsupported; when running tests with + // `--platform node` we skip filesystem and loader assertions. + const isJS = identical(0, 0.0); + + // Helper used by the tests to synthesize `top` and optionally write the + // produced JSON to `outPath` when running on VM. Returns the decoded + // modules map from the Yosys-format JSON. + Future> convertTestWriteNetlist( + Module top, + String outPath, + ) async { + final synth = SynthBuilder(top, NetlistSynthesizer()); + final jsonStr = + await (synth.synthesizer as NetlistSynthesizer).synthesizeToJson(top); + if (!isJS) { + final file = File(outPath); + await file.create(recursive: true); + await file.writeAsString(jsonStr); + } + final decoded = jsonDecode(jsonStr) as Map; + return decoded['modules'] as Map; + } + + test('Netlist dump for example Counter', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + + final counter = Counter(en, reset, clk); + await counter.build(); + counter.generateSynth(); + + final modules = await convertTestWriteNetlist( + counter, + 'build/Counter.rohd.json', + ); + + expect( + modules, + isNotEmpty, + reason: 'Counter netlist should have module definitions', + ); + // The top module should have cells (sub-module instances or gates) + final topMod = modules[counter.definitionName] as Map; + final cells = topMod['cells'] as Map? ?? {}; + expect(cells, isNotEmpty, reason: 'Counter should have cells'); + }); + + group('SynthBuilder netlist generation for examples', () { + test('SynthBuilder netlist for Counter', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + + final counter = Counter(en, reset, clk); + await counter.build(); + + final modules = await convertTestWriteNetlist( + counter, + 'build/Counter.synth.rohd.json', + ); + expect( + modules, + isNotEmpty, + reason: 'Counter synth netlist should have modules', + ); + }); + + test('SynthBuilder netlist for FIR filter example', () async { + final en = Logic(name: 'en'); + final resetB = Logic(name: 'resetB'); + final clk = SimpleClockGenerator(10).clk; + final inputVal = Logic(name: 'inputVal', width: 8); + + final fir = + FirFilter(en, resetB, clk, inputVal, [0, 0, 0, 1], bitWidth: 8); + await fir.build(); + + final synth = SynthBuilder(fir, NetlistSynthesizer()); + expect(synth.synthesisResults.isNotEmpty, isTrue); + + final modules = await convertTestWriteNetlist( + fir, + 'build/FirFilter.synth.rohd.json', + ); + expect( + modules, + isNotEmpty, + reason: 'FirFilter synth netlist should have modules', + ); + }); + + test('SynthBuilder netlist for LogicArray example', () async { + final arrayA = LogicArray([4], 8, name: 'arrayA'); + final id = Logic(name: 'id', width: 3); + final selectIndexValue = Logic(name: 'selectIndexValue', width: 8); + final selectFromValue = Logic(name: 'selectFromValue', width: 8); + + final la = LogicArrayExample( + arrayA, + id, + selectIndexValue, + selectFromValue, + ); + await la.build(); + + final synth = SynthBuilder(la, NetlistSynthesizer()); + expect(synth.synthesisResults.isNotEmpty, isTrue); + + final modules = await convertTestWriteNetlist( + la, + 'build/LogicArrayExample.synth.rohd.json', + ); + expect( + modules, + isNotEmpty, + reason: 'LogicArrayExample synth netlist should have modules', + ); + }); + + test('SynthBuilder netlist for OvenModule example', () async { + final button = Logic(name: 'button', width: 2); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + + final oven = OvenModule(button, reset, clk); + await oven.build(); + + final synth = SynthBuilder(oven, NetlistSynthesizer()); + expect(synth.synthesisResults.isNotEmpty, isTrue); + + final modules = await convertTestWriteNetlist( + oven, + 'build/OvenModule.synth.rohd.json', + ); + expect( + modules, + isNotEmpty, + reason: 'OvenModule synth netlist should have modules', + ); + }); + + test('SynthBuilder netlist for TreeOfTwoInputModules example', () async { + final seq = List.generate(4, (_) => Logic(width: 8)); + final tree = TreeOfTwoInputModules(seq, (a, b) => mux(a > b, a, b)); + await tree.build(); + + final synth = SynthBuilder(tree, NetlistSynthesizer()); + expect(synth.synthesisResults.isNotEmpty, isTrue); + + // Only verify JSON generation succeeds; the deeply nested hierarchy + // causes a stack overflow in any recursive parser (pure Dart or JS). + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(tree); + expect( + json, + isNotEmpty, + reason: 'TreeOfTwoInputModules should produce non-empty JSON', + ); + if (!isJS) { + final file = File('build/TreeOfTwoInputModules.synth.rohd.json'); + await file.create(recursive: true); + await file.writeAsString(json); + } + }); + }); + + test('Netlist dump for FIR filter example', () async { + final en = Logic(name: 'en'); + final resetB = Logic(name: 'resetB'); + final clk = SimpleClockGenerator(10).clk; + final inputVal = Logic(name: 'inputVal', width: 8); + + final fir = FirFilter(en, resetB, clk, inputVal, [0, 0, 0, 1], bitWidth: 8); + await fir.build(); + + const outPath = 'build/FirFilter.rohd.json'; + final modules = await convertTestWriteNetlist(fir, outPath); + if (!isJS) { + final f = File(outPath); + expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); + final contents = await f.readAsString(); + expect(contents.trim().isNotEmpty, isTrue); + } + expect( + modules, + isNotEmpty, + reason: 'FirFilter netlist should have module definitions', + ); + }); + + test('Netlist dump for LogicArray example', () async { + final arrayA = LogicArray([4], 8, name: 'arrayA'); + final id = Logic(name: 'id', width: 3); + final selectIndexValue = Logic(name: 'selectIndexValue', width: 8); + final selectFromValue = Logic(name: 'selectFromValue', width: 8); + + final la = LogicArrayExample(arrayA, id, selectIndexValue, selectFromValue); + await la.build(); + + const outPath = 'build/LogicArrayExample.rohd.json'; + final modules = await convertTestWriteNetlist(la, outPath); + if (!isJS) { + final f = File(outPath); + expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); + final contents = await f.readAsString(); + expect(contents.trim().isNotEmpty, isTrue); + } + expect( + modules, + isNotEmpty, + reason: 'LogicArrayExample netlist should have module definitions', + ); + }); + + test('Netlist dump for OvenModule example', () async { + final button = Logic(name: 'button', width: 2); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + + final oven = OvenModule(button, reset, clk); + await oven.build(); + + const outPath = 'build/OvenModule.rohd.json'; + final modules = await convertTestWriteNetlist(oven, outPath); + if (!isJS) { + final f = File(outPath); + expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); + final contents = await f.readAsString(); + expect(contents.trim().isNotEmpty, isTrue); + } + expect( + modules, + isNotEmpty, + reason: 'OvenModule netlist should have module definitions', + ); + }); + + test('Netlist dump for TreeOfTwoInputModules example', () async { + final seq = List.generate(4, (_) => Logic(width: 8)); + final tree = TreeOfTwoInputModules(seq, (a, b) => mux(a > b, a, b)); + await tree.build(); + + // Only verify JSON generation succeeds; the deeply nested hierarchy + // causes a stack overflow in any recursive parser. + const outPath = 'build/TreeOfTwoInputModules.rohd.json'; + final synth = SynthBuilder(tree, NetlistSynthesizer()); + final json = + await (synth.synthesizer as NetlistSynthesizer).synthesizeToJson(tree); + expect( + json, + isNotEmpty, + reason: 'TreeOfTwoInputModules should produce non-empty JSON', + ); + if (!isJS) { + final file = File(outPath); + await file.create(recursive: true); + await file.writeAsString(json); + expect(file.existsSync(), isTrue, reason: 'ROHD JSON should be created'); + } + }); +} diff --git a/test/netlist_synthesizer_test.dart b/test/netlist_synthesizer_test.dart new file mode 100644 index 000000000..0c985f18d --- /dev/null +++ b/test/netlist_synthesizer_test.dart @@ -0,0 +1,1437 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_synthesizer_test.dart +// Comprehensive tests for the netlist synthesizer covering leaf cell +// mapping, structural validation, options permutations, and real +// example designs. +// +// 2026 April 13 +// Author: Auto-generated + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/examples/filter_bank_modules.dart'; +import 'package:test/test.dart'; + +import '../example/example.dart'; +import '../example/fir_filter.dart'; +import '../example/logic_array.dart'; +import '../example/oven_fsm.dart'; +import '../example/tree.dart'; + +// ──────────────────────────────────────────────────────────────────── +// Tiny helper modules for targeted gate-level tests +// ──────────────────────────────────────────────────────────────────── + +/// Exercises And2Gate. +class AndModule extends Module { + Logic get y => output('y'); + AndModule(Logic a, Logic b) : super(name: 'andmod') { + a = addInput('a', a); + b = addInput('b', b); + addOutput('y') <= a & b; + } +} + +/// Exercises Or2Gate. +class OrModule extends Module { + Logic get y => output('y'); + OrModule(Logic a, Logic b) : super(name: 'ormod') { + a = addInput('a', a); + b = addInput('b', b); + addOutput('y') <= a | b; + } +} + +/// Exercises Xor2Gate. +class XorModule extends Module { + Logic get y => output('y'); + XorModule(Logic a, Logic b) : super(name: 'xormod') { + a = addInput('a', a); + b = addInput('b', b); + addOutput('y') <= a ^ b; + } +} + +/// Exercises NotGate. +class NotModule extends Module { + Logic get y => output('y'); + NotModule(Logic a) : super(name: 'notmod') { + a = addInput('a', a); + addOutput('y') <= ~a; + } +} + +/// Exercises Mux. +class MuxModule extends Module { + Logic get y => output('y'); + MuxModule(Logic sel, Logic a, Logic b, {int width = 8}) : super(name: 'mux') { + sel = addInput('sel', sel); + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y', width: width) <= mux(sel, a, b); + } +} + +/// Exercises FlipFlop. +class FlopModule extends Module { + Logic get q => output('q'); + FlopModule(Logic clk, Logic d, {int width = 8}) : super(name: 'flopmod') { + clk = addInput('clk', clk); + d = addInput('d', d, width: width); + addOutput('q', width: width) <= flop(clk, d); + } +} + +/// Exercises Add. +class AddModule extends Module { + Logic get sum => output('sum'); + AddModule(Logic a, Logic b, {int width = 8}) : super(name: 'addmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('sum', width: width) <= a + b; + } +} + +/// Exercises Multiply. +class MulModule extends Module { + Logic get prod => output('prod'); + MulModule(Logic a, Logic b, {int width = 8}) : super(name: 'mulmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('prod', width: width) <= a * b; + } +} + +/// Exercises BusSubset ($slice). +class SliceModule extends Module { + Logic get y => output('y'); + SliceModule(Logic a) : super(name: 'slicemod') { + a = addInput('a', a, width: 8); + addOutput('y', width: 4) <= a.getRange(2, 6); + } +} + +/// Exercises comparison operators. +class CompareModule extends Module { + Logic get lt => output('lt'); + Logic get gt => output('gt'); + Logic get eq => output('eq'); + CompareModule(Logic a, Logic b, {int width = 8}) : super(name: 'cmpmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('lt') <= LessThan(a, b).out; + addOutput('gt') <= GreaterThan(a, b).out; + addOutput('eq') <= a.eq(b); + } +} + +/// Exercises shift operations. +class ShiftModule extends Module { + Logic get shl => output('shl'); + Logic get shr => output('shr'); + ShiftModule(Logic a, Logic amt, {int width = 8}) : super(name: 'shiftmod') { + a = addInput('a', a, width: width); + amt = addInput('amt', amt, width: width); + addOutput('shl', width: width) <= a << amt; + addOutput('shr', width: width) <= a >>> amt; + } +} + +/// Exercises Xor2Gate. +class XorGateModule extends Module { + Logic get y => output('y'); + XorGateModule(Logic a, Logic b) : super(name: 'xormod2') { + a = addInput('a', a); + b = addInput('b', b); + addOutput('y') <= a ^ b; + } +} + +/// Exercises Subtract. +class SubModule extends Module { + Logic get diff => output('diff'); + SubModule(Logic a, Logic b, {int width = 8}) : super(name: 'submod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('diff', width: width) <= a - b; + } +} + +/// Exercises Swizzle ($concat). +class SwizzleModule extends Module { + Logic get y => output('y'); + SwizzleModule(Logic a, Logic b, {int width = 4}) : super(name: 'swizmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y', width: width * 2) <= [a, b].swizzle(); + } +} + +/// Exercises arithmetic right shift (ARShift). +class ARShiftModule extends Module { + Logic get y => output('y'); + ARShiftModule(Logic a, Logic amt, {int width = 8}) + : super(name: 'arshiftmod') { + a = addInput('a', a, width: width); + amt = addInput('amt', amt, width: width); + addOutput('y', width: width) <= a >> amt; + } +} + +/// Exercises unary reduction ops. +class ReduceModule extends Module { + Logic get andR => output('andR'); + Logic get orR => output('orR'); + Logic get xorR => output('xorR'); + ReduceModule(Logic a, {int width = 8}) : super(name: 'reducemod') { + a = addInput('a', a, width: width); + addOutput('andR') <= a.and(); + addOutput('orR') <= a.or(); + addOutput('xorR') <= a.xor(); + } +} + +/// Exercises individual comparison ops for cell-type checking. +class LtModule extends Module { + Logic get y => output('y'); + LtModule(Logic a, Logic b, {int width = 8}) : super(name: 'ltmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.lt(b); + } +} + +class GtModule extends Module { + Logic get y => output('y'); + GtModule(Logic a, Logic b, {int width = 8}) : super(name: 'gtmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.gt(b); + } +} + +class EqModule extends Module { + Logic get y => output('y'); + EqModule(Logic a, Logic b, {int width = 8}) : super(name: 'eqmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.eq(b); + } +} + +class NeqModule extends Module { + Logic get y => output('y'); + NeqModule(Logic a, Logic b, {int width = 8}) : super(name: 'neqmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.neq(b); + } +} + +class LeqModule extends Module { + Logic get y => output('y'); + LeqModule(Logic a, Logic b, {int width = 8}) : super(name: 'leqmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.lte(b); + } +} + +class GeqModule extends Module { + Logic get y => output('y'); + GeqModule(Logic a, Logic b, {int width = 8}) : super(name: 'geqmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.gte(b); + } +} + +/// Exercises TriStateBuffer. +class TriBufModule extends Module { + Logic get bus => inOut('bus'); + TriBufModule(LogicNet busNet, Logic data, Logic en) + : super(name: 'tribufmod') { + final bus = addInOut('bus', busNet, width: data.width); + data = addInput('data', data, width: data.width); + en = addInput('en', en); + TriStateBuffer(data, enable: en, name: 'tsb').out.gets(bus); + } +} + +/// Exercises Combinational with If. +class CombIfModule extends Module { + Logic get y => output('y'); + CombIfModule(Logic sel, Logic a, Logic b, {int width = 8}) + : super(name: 'combif') { + sel = addInput('sel', sel); + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + final y = addOutput('y', width: width); + Combinational([ + If(sel, then: [y < a], orElse: [y < b]), + ]); + } +} + +/// Exercises Sequential with If. +class SeqIfModule extends Module { + Logic get q => output('q'); + SeqIfModule(Logic clk, Logic en, Logic d, {int width = 8}) + : super(name: 'seqif') { + clk = addInput('clk', clk); + en = addInput('en', en); + d = addInput('d', d, width: width); + final q = addOutput('q', width: width); + Sequential(clk, [ + If(en, then: [q < d]), + ]); + } +} + +/// Module with multiple instances of the same sub-module (dedup test). +class DedupTop extends Module { + Logic get y0 => output('y0'); + Logic get y1 => output('y1'); + DedupTop(Logic a, Logic b, {int width = 8}) + : super(name: 'deduptop', definitionName: 'DedupTop') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y0', width: width) <= AddModule(a, b, width: width).sum; + addOutput('y1', width: width) <= AddModule(a, b, width: width).sum; + } +} + +/// Module with different-width instances (no dedup). +class NoDedupTop extends Module { + Logic get y0 => output('y0'); + Logic get y1 => output('y1'); + NoDedupTop(Logic a4, Logic b4, Logic a8, Logic b8) + : super(name: 'nodeduptop', definitionName: 'NoDedupTop') { + a4 = addInput('a4', a4, width: 4); + b4 = addInput('b4', b4, width: 4); + a8 = addInput('a8', a8, width: 8); + b8 = addInput('b8', b8, width: 8); + addOutput('y0', width: 4) <= AddModule(a4, b4, width: 4).sum; + addOutput('y1', width: 8) <= AddModule(a8, b8).sum; + } +} + +// ──────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────── + +/// Build a FilterBank module for testing (not yet built). +FilterBank _buildFilterBank() { + const dataWidth = 16; + const numTaps = 3; + const coeffs0 = [1, 2, 1]; + const coeffs1 = [1, -2, 1]; + + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + return FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: [coeffs0, coeffs1], + ); +} + +/// Build a module and synthesize to a parsed JSON map. +Future> _synthToMap( + Module mod, { + NetlistOptions options = const NetlistOptions(), +}) async { + await mod.build(); + final synth = SynthBuilder(mod, NetlistSynthesizer(options: options)); + final json = await (synth.synthesizer as NetlistSynthesizer).synthesizeToJson( + mod, + ); + return jsonDecode(json) as Map; +} + +/// Extract the `modules` map from a synthesized JSON map. +Map _modules(Map json) => + json['modules'] as Map; + +/// Get cells map from a module definition. +Map _cells(Map moduleDef) => + moduleDef['cells'] as Map? ?? {}; + +/// Get ports map from a module definition. +Map _ports(Map moduleDef) => + moduleDef['ports'] as Map? ?? {}; + +/// Get netnames map from a module definition. +Map _netnames(Map moduleDef) => + moduleDef['netnames'] as Map? ?? {}; + +/// Check that a module definition has a port with given name and direction. +void _expectPort( + Map moduleDef, + String portName, + String direction, +) { + final ports = _ports(moduleDef); + expect(ports, contains(portName), reason: 'Expected port "$portName"'); + final port = ports[portName] as Map; + expect( + port['direction'], + equals(direction), + reason: 'Port "$portName" should be "$direction"', + ); +} + +/// Returns true if any cell in any module definition has the given type. +bool _hasCellType(Map json, String cellType) { + final mod = _modules(json); + return mod.values.any((m) { + final def = m as Map; + return _cells(def).values.any((c) { + final cell = c as Map; + return (cell['type'] as String) == cellType; + }); + }); +} + +// ──────────────────────────────────────────────────────────────────── +// Tests +// ──────────────────────────────────────────────────────────────────── + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + // ── Group 1: Leaf cell mapper — individual gate mappings ─────────── + + group('leaf cell mapping', () { + test(r'And2Gate maps to $and cell', () async { + final json = await _synthToMap(AndModule(Logic(), Logic())); + expect(_hasCellType(json, r'$and'), isTrue); + }); + + test(r'Or2Gate maps to $or cell', () async { + final json = await _synthToMap(OrModule(Logic(), Logic())); + expect(_hasCellType(json, r'$or'), isTrue); + }); + + test(r'Xor2Gate maps to $xor cell', () async { + final json = await _synthToMap(XorGateModule(Logic(), Logic())); + expect(_hasCellType(json, r'$xor'), isTrue); + }); + + test(r'NotGate maps to $not cell', () async { + final json = await _synthToMap(NotModule(Logic())); + expect(_hasCellType(json, r'$not'), isTrue); + }); + + test(r'Mux maps to $mux cell', () async { + final json = await _synthToMap( + MuxModule(Logic(), Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$mux'), isTrue); + }); + + test(r'FlipFlop maps to $dff cell', () async { + final clk = SimpleClockGenerator(10).clk; + final json = await _synthToMap(FlopModule(clk, Logic(width: 8))); + expect(_hasCellType(json, r'$dff'), isTrue); + }); + + test(r'Add maps to $add cell', () async { + final json = await _synthToMap( + AddModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$add'), isTrue); + }); + + test(r'Subtract maps to $sub cell', () async { + final json = await _synthToMap( + SubModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$sub'), isTrue); + }); + + test(r'Multiply maps to $mul cell', () async { + final json = await _synthToMap( + MulModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$mul'), isTrue); + }); + + test(r'BusSubset maps to $slice cell', () async { + final json = await _synthToMap(SliceModule(Logic(width: 8))); + expect(_hasCellType(json, r'$slice'), isTrue); + }); + + test(r'Swizzle maps to $concat cell', () async { + final json = await _synthToMap( + SwizzleModule(Logic(width: 4), Logic(width: 4)), + ); + expect(_hasCellType(json, r'$concat'), isTrue); + }); + + test(r'LessThan maps to $lt cell', () async { + final json = await _synthToMap( + LtModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$lt'), isTrue); + }); + + test(r'GreaterThan maps to $gt cell', () async { + final json = await _synthToMap( + GtModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$gt'), isTrue); + }); + + test(r'Equals maps to $eq cell', () async { + final json = await _synthToMap( + EqModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$eq'), isTrue); + }); + + test(r'NotEquals maps to $ne cell', () async { + final json = await _synthToMap( + NeqModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$ne'), isTrue); + }); + + test(r'LessThanOrEqual maps to $le cell', () async { + final json = await _synthToMap( + LeqModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$le'), isTrue); + }); + + test(r'GreaterThanOrEqual maps to $ge cell', () async { + final json = await _synthToMap( + GeqModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$ge'), isTrue); + }); + + test(r'LShift maps to $shl cell', () async { + final json = await _synthToMap( + ShiftModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$shl'), isTrue); + }); + + test(r'RShift maps to $shr cell', () async { + final json = await _synthToMap( + ShiftModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$shr'), isTrue); + }); + + test(r'ARShift maps to $shiftx cell', () async { + final json = await _synthToMap( + ARShiftModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$shiftx'), isTrue); + }); + + test(r'AndUnary maps to $reduce_and cell', () async { + final json = await _synthToMap(ReduceModule(Logic(width: 8))); + expect(_hasCellType(json, r'$reduce_and'), isTrue); + }); + + test(r'OrUnary maps to $reduce_or cell', () async { + final json = await _synthToMap(ReduceModule(Logic(width: 8))); + expect(_hasCellType(json, r'$reduce_or'), isTrue); + }); + + test(r'XorUnary maps to $reduce_xor cell', () async { + final json = await _synthToMap(ReduceModule(Logic(width: 8))); + expect(_hasCellType(json, r'$reduce_xor'), isTrue); + }); + + test(r'TriStateBuffer maps to $tribuf cell', () async { + final busNet = LogicNet(width: 8); + final json = await _synthToMap( + TriBufModule(busNet, Logic(width: 8), Logic()), + ); + expect(_hasCellType(json, r'$tribuf'), isTrue); + }); + }); + + // ── Group 2: Structural content validation ───────────────────────── + + group('structural validation', () { + test('ports have correct direction', () async { + final json = await _synthToMap( + AddModule(Logic(width: 8), Logic(width: 8)), + ); + // Find the top-level or AddModule definition + final mod = _modules(json); + for (final def in mod.values) { + final d = def as Map; + final ports = _ports(d); + for (final port in ports.entries) { + final p = port.value as Map; + expect( + ['input', 'output', 'inout'].contains(p['direction']), + isTrue, + reason: 'Port ${port.key} should have valid direction', + ); + // Each port should have bits + expect( + p['bits'], + isNotNull, + reason: 'Port ${port.key} should have bits array', + ); + } + } + }); + + test('cells have type and connections', () async { + final json = await _synthToMap( + MuxModule(Logic(), Logic(width: 8), Logic(width: 8)), + ); + final mod = _modules(json); + for (final def in mod.values) { + final d = def as Map; + for (final cell in _cells(d).values) { + final c = cell as Map; + expect(c['type'], isNotNull, reason: 'Every cell should have a type'); + expect( + c['connections'], + isNotNull, + reason: 'Every cell should have connections', + ); + } + } + }); + + test('netnames have bits arrays', () async { + final json = await _synthToMap( + AddModule(Logic(width: 8), Logic(width: 8)), + ); + final mod = _modules(json); + for (final def in mod.values) { + final d = def as Map; + for (final nn in _netnames(d).values) { + final n = nn as Map; + expect( + n['bits'], + isA>(), + reason: 'Each netname should have a bits list', + ); + } + } + }); + + test('inOut ports have direction inout', () async { + final busNet = LogicNet(width: 8); + final json = await _synthToMap( + TriBufModule(busNet, Logic(width: 8), Logic()), + ); + final mod = _modules(json); + // Find the TriBufModule definition + final tribufDef = mod.values.firstWhere((m) { + final d = m as Map; + return _ports(d).values.any((p) { + final port = p as Map; + return port['direction'] == 'inout'; + }); + }, orElse: () => {}) as Map; + expect( + tribufDef, + isNotEmpty, + reason: 'Should have a module with inout ports', + ); + }); + + test('Combinational If produces Combinational cell', () async { + final json = await _synthToMap( + CombIfModule(Logic(), Logic(width: 8), Logic(width: 8)), + ); + // Combinational blocks become Combinational cell type + expect( + _hasCellType(json, 'Combinational'), + isTrue, + reason: 'Combinational If should produce a Combinational cell', + ); + }); + + test('Sequential If produces dff cells', () async { + final clk = SimpleClockGenerator(10).clk; + final json = await _synthToMap( + SeqIfModule(clk, Logic(), Logic(width: 8)), + ); + final mod = _modules(json); + final hasSeq = mod.values.any((m) { + final def = m as Map; + final cells = _cells(def); + return cells.values.any((c) { + final cell = c as Map; + return (cell['type'] as String).contains('Sequential'); + }); + }); + expect( + hasSeq, + isTrue, + reason: 'Sequential If should contain Sequential cells', + ); + }); + }); + + // ── Group 3: Module deduplication ────────────────────────────────── + + group('deduplication', () { + test('identical sub-modules are deduplicated', () async { + final json = await _synthToMap( + DedupTop(Logic(width: 8), Logic(width: 8)), + ); + final mod = _modules(json); + // AddModule should appear only once as a definition + final addDefs = mod.keys.where((k) => k.contains('Add')).toList(); + expect( + addDefs.length, + equals(1), + reason: 'Two identical AddModules should produce one definition', + ); + // But should be instantiated twice in the top-level cells + final topDef = mod.entries + .firstWhere((e) => e.key.contains('DedupTop')) + .value as Map; + final addCells = _cells(topDef).values.where((c) { + final cell = c as Map; + return (cell['type'] as String).contains('Add'); + }).toList(); + expect( + addCells.length, + equals(2), + reason: 'Top module should instantiate AddModule twice', + ); + }); + + test('different-width sub-modules are not deduplicated', () async { + final json = await _synthToMap( + NoDedupTop( + Logic(width: 4), + Logic(width: 4), + Logic(width: 8), + Logic(width: 8), + ), + ); + final mod = _modules(json); + // Should have two distinct AddModule definitions (different widths) + final addDefs = mod.keys.where((k) => k.contains('Add')).toList(); + expect( + addDefs.length, + greaterThanOrEqualTo(2), + reason: 'Different-width AddModules should NOT be deduplicated', + ); + }); + }); + + // ── Group 4: NetlistOptions permutations ───────────────────────── + + group('NetlistOptions', () { + late Module filterBank; + + setUp(() async { + await Simulator.reset(); + filterBank = _buildFilterBank(); + await filterBank.build(); + }); + + test('default options produce valid netlist', () async { + final synth = SynthBuilder(filterBank, NetlistSynthesizer()); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + expect(_modules(parsed), isNotEmpty); + }); + + test('slimMode omits connections', () async { + final synth = SynthBuilder( + filterBank, + NetlistSynthesizer(options: const NetlistOptions(slimMode: true)), + ); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + final mod = _modules(parsed); + expect(mod, isNotEmpty); + // In slim mode, cells should exist but connections should be empty + for (final def in mod.values) { + final d = def as Map; + for (final cell in _cells(d).values) { + final c = cell as Map; + final conns = c['connections'] as Map?; + if (conns != null) { + expect( + conns, + isEmpty, + reason: 'Slim mode cells should have empty connections', + ); + } + } + } + }); + + test('groupStructConversions produces valid netlist', () async { + final synth = SynthBuilder( + filterBank, + NetlistSynthesizer( + options: const NetlistOptions(groupStructConversions: true), + ), + ); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + expect(_modules(parsed), isNotEmpty); + }); + + test('collapseStructGroups with groupStructConversions', () async { + final synth = SynthBuilder( + filterBank, + NetlistSynthesizer( + options: const NetlistOptions( + groupStructConversions: true, + collapseStructGroups: true, + ), + ), + ); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + expect(_modules(parsed), isNotEmpty); + }); + + test('DCE disabled still produces valid netlist', () async { + final synth = SynthBuilder( + filterBank, + NetlistSynthesizer(options: const NetlistOptions(enableDCE: false)), + ); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + expect(_modules(parsed), isNotEmpty); + }); + + test('all optimizations disabled produces valid netlist', () async { + final synth = SynthBuilder( + filterBank, + NetlistSynthesizer(options: const NetlistOptions(enableDCE: false)), + ); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + expect(_modules(parsed), isNotEmpty); + }); + + test('slim and full produce same module definitions', () async { + final fullSynth = SynthBuilder(filterBank, NetlistSynthesizer()); + final fullJson = await (fullSynth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final fullParsed = jsonDecode(fullJson) as Map; + + // Rebuild for slim + await Simulator.reset(); + final fb2 = _buildFilterBank(); + await fb2.build(); + final slimSynth = SynthBuilder( + fb2, + NetlistSynthesizer(options: const NetlistOptions(slimMode: true)), + ); + final slimJson = await (slimSynth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(fb2); + final slimParsed = jsonDecode(slimJson) as Map; + + // Same module definition names + expect( + _modules(slimParsed).keys.toSet(), + equals(_modules(fullParsed).keys.toSet()), + reason: 'Slim and full should have identical module definition names', + ); + }); + }); + + // ── Group 5: Example designs — structural checks ─────────────────── + + group('example designs', () { + test('Counter netlist has FlipFlop and FSM-related cells', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final counter = Counter(en, reset, clk); + final json = await _synthToMap(counter); + final mod = _modules(json); + + expect( + mod, + isNotEmpty, + reason: 'Counter should produce module definitions', + ); + // Should have a Counter definition + expect(mod.keys.any((k) => k.contains('Counter')), isTrue); + }); + + test('FirFilter netlist has pipeline and multiplier cells', () async { + final en = Logic(name: 'en'); + final resetB = Logic(name: 'resetB'); + final clk = SimpleClockGenerator(10).clk; + final inputVal = Logic(name: 'inputVal', width: 8); + final fir = FirFilter( + en, + resetB, + clk, + inputVal, + [ + 0, + 0, + 0, + 1, + ], + bitWidth: 8); + final json = await _synthToMap(fir); + final mod = _modules(json); + + expect( + mod, + isNotEmpty, + reason: 'FirFilter should produce module definitions', + ); + }); + + test('OvenModule netlist has FSM states', () async { + final button = Logic(name: 'button', width: 2); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final oven = OvenModule(button, reset, clk); + final json = await _synthToMap(oven); + final mod = _modules(json); + + expect(mod, isNotEmpty); + // Should have OvenModule definition + expect( + mod.keys.any((k) => k.contains('Oven') || k.contains('oven')), + isTrue, + ); + }); + + test('LogicArrayExample netlist has array-related cells', () async { + final arrayA = LogicArray([4], 8, name: 'arrayA'); + final id = Logic(name: 'id', width: 3); + final selectIndexValue = Logic(name: 'selectIndexValue', width: 8); + final selectFromValue = Logic(name: 'selectFromValue', width: 8); + final la = LogicArrayExample( + arrayA, + id, + selectIndexValue, + selectFromValue, + ); + final json = await _synthToMap(la); + final mod = _modules(json); + + expect(mod, isNotEmpty); + }); + + test('TreeOfTwoInputModules netlist has recursive hierarchy', () async { + final seq = List.generate(4, (_) => Logic(width: 8)); + final tree = TreeOfTwoInputModules(seq, (a, b) => mux(a > b, a, b)); + await tree.build(); + final synth = SynthBuilder(tree, NetlistSynthesizer()); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(tree); + expect(json, isNotEmpty); + final parsed = jsonDecode(json) as Map; + final mod = _modules(parsed); + expect(mod, isNotEmpty, reason: 'Tree should have module definitions'); + }); + }); + + // ── Group 6: FilterBank deep structural checks ───────────────────── + + group('FilterBank netlist structure', () { + late Map json; + + setUpAll(() async { + final fb = _buildFilterBank(); + json = await _synthToMap(fb); + }); + + test('contains expected module definitions', () { + final mod = _modules(json); + final defNames = mod.keys.toSet(); + + // FilterBank, FilterChannel, CoeffBank, MacUnit, FilterController + // should all appear (possibly with parameterized suffixes) + expect( + defNames.any((k) => k.contains('FilterBank')), + isTrue, + reason: 'Should have FilterBank definition', + ); + expect( + defNames.any((k) => k.contains('FilterChannel')), + isTrue, + reason: 'Should have FilterChannel definition', + ); + expect( + defNames.any((k) => k.contains('CoeffBank')), + isTrue, + reason: 'Should have CoeffBank definition', + ); + expect( + defNames.any((k) => k.contains('MacUnit')), + isTrue, + reason: 'Should have MacUnit definition', + ); + expect( + defNames.any((k) => k.contains('FilterController')), + isTrue, + reason: 'Should have FilterController definition', + ); + }); + + test('FilterBank has array ports', () { + final mod = _modules(json); + final fbDef = mod.entries + .firstWhere((e) => e.key.contains('FilterBank')) + .value as Map; + final ports = _ports(fbDef); + + // Should have samplesIn and channelOut as array ports + expect( + ports.keys.any( + (k) => k.contains('samplesIn') || k.contains('channelOut'), + ), + isTrue, + reason: 'FilterBank should have array port signals', + ); + }); + + test('FilterBank top instantiates two FilterChannels', () { + final mod = _modules(json); + final fbDef = mod.entries + .firstWhere((e) => e.key.contains('FilterBank')) + .value as Map; + final cells = _cells(fbDef); + + final channelCells = cells.entries.where((e) { + final cell = e.value as Map; + return (cell['type'] as String).contains('FilterChannel'); + }).toList(); + + expect( + channelCells.length, + equals(2), + reason: 'FilterBank should instantiate 2 FilterChannels', + ); + }); + + test( + 'FilterChannels with different coefficients get separate definitions', + () { + final mod = _modules(json); + final channelDefs = + mod.keys.where((k) => k.contains('FilterChannel')).toList(); + + expect( + channelDefs.length, + equals(2), + reason: 'Two FilterChannels with different coefficients ' + 'should produce distinct definitions', + ); + }, + ); + + test('MacUnit definition contains Pipeline-generated cells', () { + final mod = _modules(json); + final macDef = mod.entries + .firstWhere((e) => e.key.contains('MacUnit')) + .value as Map; + final cells = _cells(macDef); + + // Pipeline generates Sequential cells for stage registers + final hasSeq = cells.values.any((c) { + final cell = c as Map; + final type = cell['type'] as String; + return type.contains('Sequential'); + }); + expect( + hasSeq, + isTrue, + reason: 'MacUnit Pipeline should produce Sequential cells', + ); + }); + + test('CoeffBank has coeffArray input port', () { + final mod = _modules(json); + final coeffDef = mod.entries + .firstWhere((e) => e.key.contains('CoeffBank')) + .value as Map; + final ports = _ports(coeffDef); + + // Should have coeffArray-related port names + expect( + ports.keys.any((k) => k.contains('coeffArray')), + isTrue, + reason: 'CoeffBank should have coeffArray port', + ); + + // tapIndex should be input + expect( + ports.keys.any((k) => k.contains('tapIndex')), + isTrue, + reason: 'CoeffBank should have tapIndex port', + ); + }); + + test('FilterController has FSM state output', () { + final mod = _modules(json); + final ctrlDef = mod.entries + .firstWhere((e) => e.key.contains('FilterController')) + .value as Map; + final ports = _ports(ctrlDef); + + _expectPort(ctrlDef, 'state', 'output'); + _expectPort(ctrlDef, 'filterEnable', 'output'); + _expectPort(ctrlDef, 'doneFlag', 'output'); + expect(ports.keys.any((k) => k.contains('clk')), isTrue); + expect(ports.keys.any((k) => k.contains('reset')), isTrue); + }); + + test('all module definitions have valid JSON structure', () { + final mod = _modules(json); + for (final entry in mod.entries) { + final defName = entry.key; + final def = entry.value as Map; + + // Every definition must have ports and cells + expect( + def.containsKey('ports'), + isTrue, + reason: '$defName should have ports', + ); + expect( + def.containsKey('cells'), + isTrue, + reason: '$defName should have cells', + ); + + // All ports must have direction and bits + for (final port in _ports(def).entries) { + final p = port.value as Map; + expect( + p.containsKey('direction'), + isTrue, + reason: '$defName.${port.key} should have direction', + ); + expect( + p.containsKey('bits'), + isTrue, + reason: '$defName.${port.key} should have bits', + ); + } + + // All cells must have type + for (final cell in _cells(def).entries) { + final c = cell.value as Map; + expect( + c.containsKey('type'), + isTrue, + reason: '$defName cell ${cell.key} should have type', + ); + } + } + }); + }); + + // ── Group 7: Design API path ─────────────────────────────────────── + + group('Design API path', () { + test('build with netlistOptions enables NetlistService', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final counter = Counter(en, reset, clk); + + await counter.build(); + final netSvc = await NetlistService.create(counter); + + final fullJson = netSvc.toJson(); + expect(fullJson, isNotNull); + + final parsed = jsonDecode(fullJson) as Map; + expect(parsed.containsKey('modules'), isTrue); + }); + + test('moduleJson returns per-module data', () async { + final fb = _buildFilterBank(); + await fb.build(); + final netSvc = await NetlistService.create(fb); + + // Fetch FilterBank definition specifically + final fbJson = netSvc.moduleJson(fb.definitionName); + final parsed = jsonDecode(fbJson) as Map; + expect(parsed.containsKey('modules'), isTrue); + final modules = parsed['modules'] as Map; + expect(modules.containsKey(fb.definitionName), isTrue); + }); + + test('slimJson produces slim output', () async { + final fb = _buildFilterBank(); + await fb.build(); + final netSvc = await NetlistService.create(fb); + + final slimJson = netSvc.slimJson; + + final parsed = jsonDecode(slimJson) as Map; + expect(parsed.containsKey('netlist'), isTrue); + final netlist = parsed['netlist'] as Map; + final modules = netlist['modules'] as Map; + expect(modules, isNotEmpty); + }); + }); + + // ── Group 8: Wire ID and structural invariants ───────────────────── + + group('wire ID and structural invariants', () { + test('all wire IDs are >= 2 (0 and 1 reserved for constants)', () async { + final json = await _synthToMap( + AddModule(Logic(width: 8), Logic(width: 8)), + ); + final mod = _modules(json); + for (final entry in mod.entries) { + final def = entry.value as Map; + // Check ports + for (final port in _ports(def).entries) { + final p = port.value as Map; + final bits = p['bits'] as List; + for (final bit in bits) { + if (bit is int) { + expect( + bit, + greaterThanOrEqualTo(2), + reason: 'Wire ID ${port.key} bit $bit should be >= 2', + ); + } + } + } + } + }); + + test(r'FilterBank contains $const cells for constant drivers', () async { + final json = await _synthToMap(_buildFilterBank()); + expect( + _hasCellType(json, r'$const'), + isTrue, + reason: r'FilterBank should have $const cells for constant values', + ); + }); + + test('passthrough buffers prevent input-output wire sharing', () async { + // A module whose output directly comes from an input should get a + // $buf for wire-ID isolation. + final json = await _synthToMap( + AddModule(Logic(width: 8), Logic(width: 8)), + ); + final mod = _modules(json); + // Verify input and output port bits don't overlap in any definition + for (final entry in mod.entries) { + final def = entry.value as Map; + final ports = _ports(def); + final inputBits = {}; + final outputBits = {}; + for (final port in ports.entries) { + final p = port.value as Map; + final bits = (p['bits'] as List).whereType().toSet(); + final dir = p['direction'] as String; + if (dir == 'input') { + inputBits.addAll(bits); + } else if (dir == 'output') { + outputBits.addAll(bits); + } + } + expect( + inputBits.intersection(outputBits), + isEmpty, + reason: '${entry.key}: input and output ports should not share wire ' + 'IDs (passthrough buffer should break sharing)', + ); + } + }); + }); + + // ── Group 9: DCE (dead-cell elimination) verification ────────────── + + group('dead-cell elimination', () { + test('DCE enabled produces fewer cells than DCE disabled', () async { + final fbDce = _buildFilterBank(); + final jsonDce = await _synthToMap(fbDce); + int countCells(Map j) { + var total = 0; + for (final def in _modules(j).values) { + total += _cells(def as Map).length; + } + return total; + } + + final fbNoDce = _buildFilterBank(); + final jsonNoDce = await _synthToMap( + fbNoDce, + options: const NetlistOptions(enableDCE: false), + ); + + final dceCells = countCells(jsonDce); + final noDceCells = countCells(jsonNoDce); + expect( + dceCells, + lessThanOrEqualTo(noDceCells), + reason: 'DCE should remove at least as many cells as no-DCE', + ); + }); + + test(r'DCE removes floating $const cells', () async { + // With DCE disabled, there may be more $const cells + final fbDce = _buildFilterBank(); + final jsonDce = await _synthToMap(fbDce); + int countConstCells(Map j) { + var total = 0; + for (final def in _modules(j).values) { + final d = def as Map; + for (final cell in _cells(d).values) { + final c = cell as Map; + if ((c['type'] as String) == r'$const') { + total++; + } + } + } + return total; + } + + final fbNoDce = _buildFilterBank(); + final jsonNoDce = await _synthToMap( + fbNoDce, + options: const NetlistOptions(enableDCE: false), + ); + + expect( + countConstCells(jsonDce), + lessThanOrEqualTo(countConstCells(jsonNoDce)), + reason: r'DCE should not produce more $const cells than no-DCE', + ); + }); + }); + + // ── Group 10: Post-processing option combinations ────────────────── + + group('post-processing options', () { + test('groupMaximalSubsets produces valid netlist', () async { + final fb = _buildFilterBank(); + final json = await _synthToMap( + fb, + options: const NetlistOptions( + groupStructConversions: true, + groupMaximalSubsets: true, + ), + ); + expect(_modules(json), isNotEmpty); + }); + + test('collapseConcats produces valid netlist', () async { + final fb = _buildFilterBank(); + final json = await _synthToMap( + fb, + options: const NetlistOptions( + groupStructConversions: true, + collapseConcats: true, + ), + ); + expect(_modules(json), isNotEmpty); + }); + + test( + 'all post-processing options enabled produces valid netlist', + () async { + final fb = _buildFilterBank(); + final json = await _synthToMap( + fb, + options: const NetlistOptions( + groupStructConversions: true, + collapseStructGroups: true, + groupMaximalSubsets: true, + collapseConcats: true, + ), + ); + expect(_modules(json), isNotEmpty); + }, + ); + + test('groupStructConversions adds synthetic module definitions', () async { + final fbPlain = _buildFilterBank(); + final jsonPlain = await _synthToMap(fbPlain); + final plainCount = _modules(jsonPlain).length; + + final fbGrouped = _buildFilterBank(); + final jsonGrouped = await _synthToMap( + fbGrouped, + options: const NetlistOptions(groupStructConversions: true), + ); + final groupedCount = _modules(jsonGrouped).length; + + // groupStructConversions should introduce additional synthetic modules + expect( + groupedCount, + greaterThanOrEqualTo(plainCount), + reason: 'Struct grouping should add module definitions', + ); + }); + + test( + 'collapseStructGroups reduces cells vs groupStructConversions only', + () async { + final fb1 = _buildFilterBank(); + final json1 = await _synthToMap( + fb1, + options: const NetlistOptions(groupStructConversions: true), + ); + int countCells(Map j) { + var total = 0; + for (final def in _modules(j).values) { + total += _cells(def as Map).length; + } + return total; + } + + final fb2 = _buildFilterBank(); + final json2 = await _synthToMap( + fb2, + options: const NetlistOptions( + groupStructConversions: true, + collapseStructGroups: true, + ), + ); + + expect( + countCells(json2), + lessThanOrEqualTo(countCells(json1)), + reason: 'Collapsing struct groups should not increase cell count', + ); + }, + ); + }); + +} diff --git a/test/netlist_test.dart b/test/netlist_test.dart new file mode 100644 index 000000000..43eb94cc4 --- /dev/null +++ b/test/netlist_test.dart @@ -0,0 +1,706 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_test.dart +// Tests for the netlist synthesizer: JSON structure, SynthBuilder, +// NetlistSynthesisResult, collectModuleEntries, NetlistOptions, +// and example-based smoke tests. +// +// 2026 March 31 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; +import 'dart:io'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/examples/filter_bank_modules.dart'; +import 'package:test/test.dart'; + +import '../example/example.dart'; +import '../example/fir_filter.dart'; +import '../example/logic_array.dart'; +import '../example/oven_fsm.dart'; +import '../example/tree.dart'; + +// --------------------------------------------------------------------------- +// Simple test modules (self-contained, no example imports needed) +// --------------------------------------------------------------------------- + +/// A trivial module that inverts a single-bit input. +class _InverterModule extends Module { + Logic get out => output('out'); + + _InverterModule(Logic inp) : super(name: 'inverter') { + inp = addInput('inp', inp); + final out = addOutput('out'); + out <= ~inp; + } +} + +/// A module that instantiates two sub-modules: an inverter and an AND gate. +class _CompositeModule extends Module { + Logic get out => output('out'); + + _CompositeModule(Logic a, Logic b) : super(name: 'composite') { + a = addInput('a', a); + b = addInput('b', b); + final out = addOutput('out'); + + final invA = _InverterModule(a); + out <= (_InverterModule(invA.out).out & b); + } +} + +/// A simple adder module with a configurable width. +class _AdderModule extends Module { + Logic get sum => output('sum'); + + _AdderModule(Logic a, Logic b, {int width = 8}) : super(name: 'adder') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + final sum = addOutput('sum', width: width); + sum <= a + b; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Detect whether running in JS (dart2js) environment. +const _isJS = identical(0, 0.0); + +/// Synthesize [top] and optionally write the produced JSON to [outPath]. +/// Returns the decoded modules map from the Yosys-format JSON. +Future> _synthesizeAndWrite( + Module top, + String outPath, +) async { + final synth = SynthBuilder(top, NetlistSynthesizer()); + final jsonStr = + await (synth.synthesizer as NetlistSynthesizer).synthesizeToJson(top); + if (!_isJS) { + final file = File(outPath); + await file.create(recursive: true); + await file.writeAsString(jsonStr); + } + final decoded = jsonDecode(jsonStr) as Map; + return decoded['modules'] as Map; +} + +/// Build a FilterBank with default test parameters. +FilterBank _buildFilterBank({ + int dataWidth = 16, + int numTaps = 3, + List> coefficients = const [ + [1, 2, 1], + [1, -2, 1], + ], +}) { + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = + LogicArray([coefficients.length], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + return FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: coefficients, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + tearDown(() async { + await Simulator.reset(); + }); + + // ── Example smoke tests ─────────────────────────────────────────────── + // + // Each example is synthesized once, verifying that the netlist is + // non-empty and (on VM) that the JSON file is written successfully. + + group('Example netlist smoke tests', () { + test('Counter', () async { + final counter = Counter(Logic(name: 'en'), Logic(name: 'reset'), + SimpleClockGenerator(10).clk); + await counter.build(); + + final modules = + await _synthesizeAndWrite(counter, 'build/Counter.rohd.json'); + expect(modules, isNotEmpty); + + final topMod = modules[counter.definitionName] as Map; + final cells = topMod['cells'] as Map? ?? {}; + expect(cells, isNotEmpty, reason: 'Counter should have cells'); + }); + + test('FIR filter', () async { + final fir = FirFilter( + Logic(name: 'en'), + Logic(name: 'resetB'), + SimpleClockGenerator(10).clk, + Logic(name: 'inputVal', width: 8), + [0, 0, 0, 1], + bitWidth: 8, + ); + await fir.build(); + + final modules = + await _synthesizeAndWrite(fir, 'build/FirFilter.rohd.json'); + expect(modules, isNotEmpty); + if (!_isJS) { + expect(File('build/FirFilter.rohd.json').existsSync(), isTrue); + } + }); + + test('LogicArray', () async { + final la = LogicArrayExample( + LogicArray([4], 8, name: 'arrayA'), + Logic(name: 'id', width: 3), + Logic(name: 'selectIndexValue', width: 8), + Logic(name: 'selectFromValue', width: 8), + ); + await la.build(); + + final modules = + await _synthesizeAndWrite(la, 'build/LogicArrayExample.rohd.json'); + expect(modules, isNotEmpty); + }); + + test('OvenModule', () async { + final oven = OvenModule( + Logic(name: 'button', width: 2), + Logic(name: 'reset'), + SimpleClockGenerator(10).clk, + ); + await oven.build(); + + final modules = + await _synthesizeAndWrite(oven, 'build/OvenModule.rohd.json'); + expect(modules, isNotEmpty); + }); + + test('TreeOfTwoInputModules', () async { + final seq = List.generate(4, (_) => Logic(width: 8)); + final tree = TreeOfTwoInputModules(seq, (a, b) => mux(a > b, a, b)); + await tree.build(); + + // Only verify JSON generation succeeds; the deeply nested hierarchy + // causes a stack overflow in any recursive parser. + final json = await NetlistSynthesizer().synthesizeToJson(tree); + expect(json, isNotEmpty); + if (!_isJS) { + final file = File('build/TreeOfTwoInputModules.rohd.json'); + await file.create(recursive: true); + await file.writeAsString(json); + } + }); + + test('FilterBank', () async { + final fb = _buildFilterBank(); + await fb.build(); + + final modules = + await _synthesizeAndWrite(fb, 'build/FilterBank.smoke.rohd.json'); + expect(modules, isNotEmpty); + expect(modules.length, greaterThan(1), + reason: 'FilterBank should have sub-module definitions'); + }); + }); + + // ── JSON structure ──────────────────────────────────────────────────── + + group('JSON structure', () { + test('synthesizeToJson returns valid JSON with modules key', () async { + final mod = _InverterModule(Logic(name: 'inp')); + await mod.build(); + + final json = await NetlistSynthesizer().synthesizeToJson(mod); + expect(json, isNotEmpty); + final decoded = jsonDecode(json) as Map; + expect(decoded, contains('modules')); + }); + + test('top module is present with correct ports and top attribute', + () async { + final mod = _InverterModule(Logic(name: 'inp')); + await mod.build(); + + final json = await NetlistSynthesizer().synthesizeToJson(mod); + final decoded = jsonDecode(json) as Map; + final modules = decoded['modules'] as Map; + expect(modules, contains(mod.definitionName)); + + final topMod = modules[mod.definitionName] as Map; + + // Port directions + final ports = topMod['ports'] as Map; + expect(ports, contains('inp')); + expect(ports, contains('out')); + expect((ports['inp'] as Map)['direction'], equals('input')); + expect((ports['out'] as Map)['direction'], equals('output')); + + // Top attribute + final attrs = topMod['attributes'] as Map?; + expect(attrs, isNotNull); + expect(attrs!['top'], equals(1)); + }); + + test('port bit widths match module interface', () async { + const width = 16; + final mod = _AdderModule( + Logic(name: 'a', width: width), Logic(name: 'b', width: width), + width: width); + await mod.build(); + + final json = await NetlistSynthesizer().synthesizeToJson(mod); + final decoded = jsonDecode(json) as Map; + final modules = decoded['modules'] as Map; + final topMod = modules[mod.definitionName] as Map; + final ports = topMod['ports'] as Map; + + expect((ports['a'] as Map)['bits'], hasLength(width)); + expect((ports['b'] as Map)['bits'], hasLength(width)); + expect((ports['sum'] as Map)['bits'], hasLength(width)); + }); + + test('cells have connections in default mode', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final json = await NetlistSynthesizer().synthesizeToJson(mod); + final decoded = jsonDecode(json) as Map; + final modules = decoded['modules'] as Map; + final topMod = modules[mod.definitionName] as Map; + final cells = topMod['cells'] as Map? ?? {}; + + final hasConnections = cells.values.any((cell) { + final c = cell as Map; + final conns = c['connections'] as Map?; + return conns != null && conns.isNotEmpty; + }); + expect(hasConnections, isTrue); + }); + + test('generateCombinedJson and synthesizeToJson produce same module keys', + () async { + final mod = _InverterModule(Logic(name: 'inp')); + await mod.build(); + + final synthesizer = NetlistSynthesizer(); + final synth = SynthBuilder(mod, synthesizer); + + final fromCombined = await synthesizer.generateCombinedJson(synth, mod); + final fromConvenience = await NetlistSynthesizer().synthesizeToJson(mod); + + final combinedModules = + (jsonDecode(fromCombined) as Map)['modules'] as Map; + final convenienceModules = + (jsonDecode(fromConvenience) as Map)['modules'] as Map; + expect(combinedModules.keys.toSet(), + equals(convenienceModules.keys.toSet())); + }); + }); + + // ── SynthBuilder ────────────────────────────────────────────────────── + + group('SynthBuilder', () { + test('synthesisResults are NetlistSynthesisResult instances', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final synth = SynthBuilder(mod, NetlistSynthesizer()); + expect(synth.synthesisResults, isNotEmpty); + for (final result in synth.synthesisResults) { + expect(result, isA()); + } + }); + + test('composite module includes sub-module definitions', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final synth = SynthBuilder(mod, NetlistSynthesizer()); + final names = + synth.synthesisResults.map((r) => r.instanceTypeName).toSet(); + expect(names, contains(mod.definitionName)); + expect(synth.synthesisResults.length, greaterThan(1)); + }); + + test('toSynthFileContents produces valid JSON per definition', () async { + final mod = _InverterModule(Logic(name: 'inp')); + await mod.build(); + + final fileContents = + SynthBuilder(mod, NetlistSynthesizer()).getSynthFileContents(); + expect(fileContents, isNotEmpty); + for (final fc in fileContents) { + expect(fc.name, isNotEmpty); + expect(jsonDecode(fc.contents), isA>()); + } + }); + }); + + // ── NetlistSynthesisResult maps ─────────────────────────────────────── + + group('NetlistSynthesisResult maps', () { + test('ports map has direction and bits for each port', () async { + final mod = + _AdderModule(Logic(name: 'a', width: 8), Logic(name: 'b', width: 8)); + await mod.build(); + + final result = SynthBuilder(mod, NetlistSynthesizer()) + .synthesisResults + .whereType() + .firstWhere((r) => r.module == mod); + + for (final portName in ['a', 'b', 'sum']) { + expect(result.ports, contains(portName)); + final port = result.ports[portName]!; + expect(port, contains('direction')); + expect(port, contains('bits')); + } + }); + + test('netnames map is populated', () async { + final mod = _InverterModule(Logic(name: 'inp')); + await mod.build(); + + final result = SynthBuilder(mod, NetlistSynthesizer()) + .synthesisResults + .whereType() + .firstWhere((r) => r.module == mod); + expect(result.netnames, isNotEmpty); + }); + }); + + // ── collectModuleEntries ────────────────────────────────────────────── + + group('collectModuleEntries', () { + test('gathers results with correct structure and top attribute', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final synth = SynthBuilder(mod, NetlistSynthesizer()); + final modulesMap = NetlistPasses.collectModuleEntries( + synth.synthesisResults, + topModule: mod); + + expect(modulesMap, contains(mod.definitionName)); + expect(modulesMap.length, greaterThan(1)); + + // Top attribute + final topAttrs = modulesMap[mod.definitionName]!['attributes']! + as Map; + expect(topAttrs['top'], equals(1)); + + // Every entry has the expected sections + for (final entry in modulesMap.values) { + expect(entry, contains('ports')); + expect(entry, contains('cells')); + expect(entry, contains('netnames')); + } + }); + }); + + // ── buildModulesMap ─────────────────────────────────────────────────── + + group('buildModulesMap', () { + test('returns map with all definitions and expected sections', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final synthesizer = NetlistSynthesizer(); + final synth = SynthBuilder(mod, synthesizer); + final modulesMap = await synthesizer.buildModulesMap(synth, mod); + + expect(modulesMap, contains(mod.definitionName)); + expect(modulesMap.length, greaterThan(1)); + for (final modEntry in modulesMap.entries) { + final data = modEntry.value; + expect(data, contains('ports'), reason: modEntry.key); + expect(data, contains('cells'), reason: modEntry.key); + expect(data, contains('netnames'), reason: modEntry.key); + } + }); + }); + + // ── NetlistOptions ─────────────────────────────────────────────────── + + group('NetlistOptions', () { + test('slimMode omits cell connections', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final slimSynth = + NetlistSynthesizer(options: const NetlistOptions(slimMode: true)); + final json = await slimSynth.synthesizeToJson(mod); + final decoded = jsonDecode(json) as Map; + final modules = decoded['modules'] as Map; + + for (final modEntry in modules.values) { + final data = modEntry as Map; + final cells = data['cells'] as Map? ?? {}; + for (final cell in cells.values) { + final c = cell as Map; + final conns = c['connections'] as Map?; + if (conns != null) { + expect(conns, isEmpty, reason: 'slim mode should omit connections'); + } + } + } + }); + }); + + // ── FilterBank (multi-channel, dedup, loopback) ─────────────────────── + + group('FilterBank netlist', () { + test('produces valid netlist with multiple module definitions', () async { + final mod = _buildFilterBank(); + await mod.build(); + + final modules = + await _synthesizeAndWrite(mod, 'build/FilterBank.rohd.json'); + expect(modules, isNotEmpty); + expect(modules.length, greaterThan(1), + reason: 'FilterBank should have sub-module definitions'); + + // Top module should have cells + final topMod = modules[mod.definitionName] as Map; + final cells = topMod['cells'] as Map? ?? {}; + expect(cells, isNotEmpty, reason: 'FilterBank should have cells'); + }); + + test('FilterChannel definitions are deduplicated', () async { + final mod = _buildFilterBank(); + await mod.build(); + + final json = await NetlistSynthesizer().synthesizeToJson(mod); + final parsed = jsonDecode(json) as Map; + final modules = parsed['modules'] as Map; + final channelDefs = + modules.keys.where((k) => k.contains('FilterChannel')).toList(); + // Two channels with different coefficients should produce + // separate definitions (not fully deduplicated). + expect(channelDefs, isNotEmpty, + reason: 'FilterChannel definitions should be present'); + }); + + test('all module entries have ports, cells, and netnames', () async { + final mod = _buildFilterBank(); + await mod.build(); + + final synthesizer = NetlistSynthesizer(); + final synth = SynthBuilder(mod, synthesizer); + final modulesMap = await synthesizer.buildModulesMap(synth, mod); + + for (final entry in modulesMap.entries) { + final data = entry.value; + expect(data, contains('ports'), reason: '${entry.key} missing ports'); + expect(data, contains('cells'), reason: '${entry.key} missing cells'); + expect(data, contains('netnames'), + reason: '${entry.key} missing netnames'); + } + }); + + test('ports have correct directions on sub-modules', () async { + final mod = _buildFilterBank(); + await mod.build(); + + final synthesizer = NetlistSynthesizer(); + final synth = SynthBuilder(mod, synthesizer); + + for (final result + in synth.synthesisResults.whereType()) { + for (final port in result.ports.entries) { + final dir = port.value['direction']! as String; + expect(['input', 'output', 'inout'], contains(dir), + reason: '${result.instanceTypeName}.${port.key} ' + 'has invalid direction'); + } + } + }); + }); + + // ----------------------------------------------------------------------- + // Bit-range compression & compact JSON + // ----------------------------------------------------------------------- + group('Bit-range compression', () { + test('compressBitRanges option produces range strings in JSON', () async { + final a = Logic(name: 'a', width: 8); + final mod = _AdderModule(a, Logic(name: 'b', width: 8)); + await mod.build(); + + final synthCompressed = NetlistSynthesizer( + options: const NetlistOptions(compressBitRanges: true), + ); + final jsonCompressed = await synthCompressed.synthesizeToJson(mod); + + final synthNormal = NetlistSynthesizer(); + final jsonNormal = await synthNormal.synthesizeToJson(mod); + + // Compressed should be shorter. + expect(jsonCompressed.length, lessThan(jsonNormal.length)); + + // Both should parse as valid JSON with the same module keys. + final decodedCompressed = jsonDecode(jsonCompressed) as Map; + final decodedNormal = jsonDecode(jsonNormal) as Map; + expect( + (decodedCompressed['modules'] as Map).keys.toSet(), + equals((decodedNormal['modules'] as Map).keys.toSet()), + ); + + // Compressed JSON should contain range strings like "2:9". + expect(jsonCompressed, contains(RegExp(r'"\d+:\d+"'))); + // Normal JSON should NOT contain range strings. + expect(jsonNormal, isNot(contains(RegExp(r'"\d+:\d+"')))); + }); + + test('compressed ranges preserve constant bit strings', () async { + // Use a module that produces constant "0"/"1" bits in the netlist. + final a = Logic(name: 'a'); + final mod = _InverterModule(a); + await mod.build(); + + final synth = NetlistSynthesizer( + options: const NetlistOptions(compressBitRanges: true), + ); + final json = await synth.synthesizeToJson(mod); + final decoded = jsonDecode(json) as Map; + + // Should still be valid JSON. + expect(decoded['modules'], isNotNull); + }); + + test('compactJson option removes indentation', () async { + final a = Logic(name: 'a', width: 8); + final mod = _AdderModule(a, Logic(name: 'b', width: 8)); + await mod.build(); + + final synthCompact = NetlistSynthesizer( + options: const NetlistOptions(compactJson: true), + ); + final jsonCompact = await synthCompact.synthesizeToJson(mod); + + final synthNormal = NetlistSynthesizer(); + final jsonNormal = await synthNormal.synthesizeToJson(mod); + + // Compact should be shorter. + expect(jsonCompact.length, lessThan(jsonNormal.length)); + // Compact should have no leading whitespace lines. + expect(jsonCompact, isNot(contains('\n '))); + // Both should be valid JSON with the same module keys. + final decodedCompact = jsonDecode(jsonCompact) as Map; + final decodedNormal = jsonDecode(jsonNormal) as Map; + expect( + (decodedCompact['modules'] as Map).keys.toSet(), + equals((decodedNormal['modules'] as Map).keys.toSet()), + ); + }); + + test('both options together produce smallest output', () async { + final a = Logic(name: 'a', width: 8); + final mod = _AdderModule(a, Logic(name: 'b', width: 8)); + await mod.build(); + + final synthBoth = NetlistSynthesizer( + options: const NetlistOptions( + compressBitRanges: true, + compactJson: true, + ), + ); + final jsonBoth = await synthBoth.synthesizeToJson(mod); + + final synthCompressOnly = NetlistSynthesizer( + options: const NetlistOptions(compressBitRanges: true), + ); + final jsonCompressOnly = await synthCompressOnly.synthesizeToJson(mod); + + final synthCompactOnly = NetlistSynthesizer( + options: const NetlistOptions(compactJson: true), + ); + final jsonCompactOnly = await synthCompactOnly.synthesizeToJson(mod); + + expect(jsonBoth.length, lessThan(jsonCompressOnly.length)); + expect(jsonBoth.length, lessThan(jsonCompactOnly.length)); + }); + + test( + 'compressed FilterBank round-trips: range strings expand to ' + 'same bit IDs as uncompressed', () async { + final mod = _buildFilterBank(); + await mod.build(); + + // Generate both compressed and uncompressed. + final synthNormal = NetlistSynthesizer(); + final jsonNormal = await synthNormal.synthesizeToJson(mod); + final normalModules = (jsonDecode(jsonNormal) + as Map)['modules'] as Map; + + final synthCompressed = NetlistSynthesizer( + options: const NetlistOptions(compressBitRanges: true), + ); + final jsonCompressed = await synthCompressed.synthesizeToJson(mod); + final compressedModules = (jsonDecode(jsonCompressed) + as Map)['modules'] as Map; + + // Compressed should be smaller. + expect(jsonCompressed.length, lessThan(jsonNormal.length)); + + // Same module keys. + expect(compressedModules.keys.toSet(), normalModules.keys.toSet()); + + // Verify compressed JSON contains range strings. + expect(jsonCompressed, contains(RegExp(r'"\d+:\d+"'))); + + // For each module, expand compressed port bits and compare to normal. + for (final modName in normalModules.keys) { + final normalPorts = (normalModules[modName] + as Map)['ports'] as Map?; + final compPorts = (compressedModules[modName] + as Map)['ports'] as Map?; + if (normalPorts == null || compPorts == null) { + continue; + } + + for (final portName in normalPorts.keys) { + final normalBits = + (normalPorts[portName] as Map)['bits'] as List; + final compBits = + (compPorts[portName] as Map)['bits'] as List; + + // Expand any range strings in the compressed bits. + final expanded = []; + for (final b in compBits) { + if (b is String && b.contains(':')) { + final parts = b.split(':'); + final start = int.parse(parts[0]); + final end = int.parse(parts[1]); + for (var i = start; i <= end; i++) { + expanded.add(i); + } + } else { + expanded.add(b); + } + } + + expect(expanded, normalBits, + reason: 'round-trip failed for $modName.$portName'); + } + } + }); + }); +} diff --git a/test/signal_registry_test.dart b/test/signal_registry_test.dart index d1719c85e..d51f1f0cd 100644 --- a/test/signal_registry_test.dart +++ b/test/signal_registry_test.dart @@ -10,6 +10,7 @@ import 'package:rohd/rohd.dart'; import 'package:test/test.dart'; +import '../example/filter_bank.dart'; // ──────────────────────────────────────────────────────────────── // Simple test modules // ──────────────────────────────────────────────────────────────── @@ -140,4 +141,43 @@ void main() { expect(names1, equals(names2)); }); }); + + group('filter_bank hierarchy', () { + test('submodule canonical names work independently', () async { + const dataWidth = 16; + const numTaps = 3; + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + final dut = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: [ + [1, 2, 1], + [1, -2, 1], + ], + ); + await dut.build(); + + expect(dut.namer.signalNameOf(dut.input('clk')), equals('clk')); + expect(dut.namer.signalNameOf(dut.output('done')), equals('done')); + + for (final sub in dut.subModules) { + for (final entry in sub.inputs.entries) { + final name = sub.namer.signalNameOf(entry.value); + expect(name, isNotEmpty); + } + } + }); + }); } diff --git a/test/slim_connected_port_test.dart b/test/slim_connected_port_test.dart new file mode 100644 index 000000000..1f2f642e0 --- /dev/null +++ b/test/slim_connected_port_test.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +class SimpleModule extends Module { + SimpleModule(Logic a) : super(name: 'SimpleTest') { + a = addInput('a', a, width: 8); + final b = addOutput('b', width: 8); + b <= ~a; + addOutput('unused_port', width: 4); // not connected internally + } +} + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + test('slim ports carry connected attribute', () async { + final a = Logic(width: 8, name: 'a'); + final mod = SimpleModule(a); + await mod.build(); + final netSvc = await NetlistService.create(mod); + + final slim = netSvc.slimJson; + + final parsed = json.decode(slim) as Map; + final netlist = parsed['netlist'] as Map; + final modules = netlist['modules'] as Map; + + // Find the SimpleTest module + // ignore: avoid_print + print('Module keys: ${modules.keys.toList()}'); + // The module name may be the type name or uniquified; find it + final simpleTestKey = modules.keys.firstWhere( + (k) => k.contains('SimpleTest'), + orElse: () => modules.keys.first, + ); + final simpleTest = modules[simpleTestKey] as Map; + // ignore: avoid_print + print('Using module: $simpleTestKey'); + + final ports = simpleTest['ports'] as Map; + + // Port 'a' is connected internally (feeds ~a) + final portA = ports['a'] as Map; + expect( + portA['connected'], + isTrue, + reason: 'Port a should be marked connected', + ); + + // Port 'b' is connected internally (output of ~a) + final portB = ports['b'] as Map; + expect( + portB['connected'], + isTrue, + reason: 'Port b should be marked connected', + ); + + // Port 'unused_port' is NOT connected internally + final portUnused = ports['unused_port'] as Map; + expect( + portUnused.containsKey('connected'), + isFalse, + reason: 'unused_port should not have connected attribute', + ); + + // Print for visibility + for (final p in ports.entries) { + final pd = p.value as Map; + // ignore: avoid_print + print('Port ${p.key}: connected=${pd["connected"]}'); + } + }); +} diff --git a/test/slim_full_canonical_test.dart b/test/slim_full_canonical_test.dart new file mode 100644 index 000000000..ce979b5fa --- /dev/null +++ b/test/slim_full_canonical_test.dart @@ -0,0 +1,180 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// slim_full_canonical_test.dart +// Validates that slim and full synthesis produce identical cell sets. + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/filter_bank.dart'; + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + test('slim and full produce identical cell keys for FilterBank', () async { + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], 16, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + final dut = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: 3, + dataWidth: 16, + coefficients: [ + [1, 2, 1], + [1, -2, 1], + ], + ); + await dut.build(); + final netSvc = await NetlistService.create(dut); + + // 1. Get slim JSON + final slimJsonStr = netSvc.slimJson; + + final slimUnified = jsonDecode(slimJsonStr) as Map; + final slimNetlist = slimUnified['netlist'] as Map; + final slimModules = slimNetlist['modules'] as Map; + + expect(slimModules, isNotEmpty, reason: 'No slim modules found'); + + // 2. For each slim module, fetch full and compare cell keys + var modulesTested = 0; + final mismatches = []; + + for (final moduleKey in slimModules.keys) { + final slimMod = slimModules[moduleKey] as Map; + final slimCells = slimMod['cells'] as Map? ?? {}; + + // Fetch full data + final fullJsonStr = netSvc.moduleJson(moduleKey); + final fullJson = jsonDecode(fullJsonStr) as Map; + if (fullJson.containsKey('status')) { + mismatches.add('$moduleKey: full fetch returned not_found'); + continue; + } + + final fullModules = fullJson['modules'] as Map?; + final fullMod = fullModules?[moduleKey] as Map?; + if (fullMod == null) { + // The full data might be under a different key (base definition). + // Try the first key. + final firstKey = fullModules?.keys.first; + final altMod = firstKey != null + ? fullModules![firstKey] as Map? + : null; + if (altMod == null) { + mismatches.add('$moduleKey: no module data in full response'); + continue; + } + // Use the alt module + _compareCells(moduleKey, slimCells, altMod, mismatches); + } else { + _compareCells(moduleKey, slimCells, fullMod, mismatches); + } + modulesTested++; + } + + // Report + if (mismatches.isNotEmpty) { + fail( + 'Cell key mismatches found in $modulesTested modules:\n' + '${mismatches.join('\n')}', + ); + } + + // Sanity: we tested a reasonable number of modules + expect(modulesTested, greaterThan(0), reason: 'No modules were tested'); + // ignore: avoid_print + print( + 'Validated $modulesTested modules — all cell keys match ' + 'between slim and full.', + ); + }); +} + +void _compareCells( + String moduleKey, + Map slimCells, + Map fullMod, + List mismatches, +) { + final fullCells = fullMod['cells'] as Map? ?? {}; + + final slimKeys = slimCells.keys.toList(); + final fullKeys = fullCells.keys.toList(); + + if (slimKeys.length != fullKeys.length) { + mismatches.add( + '$moduleKey: cell count differs — ' + 'slim=${slimKeys.length}, full=${fullKeys.length}', + ); + // Show which keys differ + final slimOnly = slimKeys.toSet().difference(fullKeys.toSet()); + final fullOnly = fullKeys.toSet().difference(slimKeys.toSet()); + if (slimOnly.isNotEmpty) { + mismatches.add(' slim-only: $slimOnly'); + } + if (fullOnly.isNotEmpty) { + mismatches.add(' full-only: $fullOnly'); + } + return; + } + + // Check ordering matches + for (var i = 0; i < slimKeys.length; i++) { + if (slimKeys[i] != fullKeys[i]) { + mismatches.add( + '$moduleKey: cell key ordering differs at index $i — ' + 'slim="${slimKeys[i]}", full="${fullKeys[i]}"', + ); + return; + } + } + + // Check cell types match + for (final key in slimKeys) { + final slimCell = slimCells[key] as Map; + final fullCell = fullCells[key] as Map; + final slimType = slimCell['type'] as String?; + final fullType = fullCell['type'] as String?; + if (slimType != fullType) { + mismatches.add( + '$moduleKey: cell "$key" type differs — ' + 'slim="$slimType", full="$fullType"', + ); + } + } + + // Verify slim cells DON'T have connections + for (final key in slimKeys) { + final slimCell = slimCells[key] as Map; + if (slimCell.containsKey('connections')) { + mismatches.add( + '$moduleKey: slim cell "$key" has connections ' + '(should be stripped)', + ); + } + } + + // Verify full cells DO have connections + for (final key in fullKeys) { + final fullCell = fullCells[key] as Map; + if (!fullCell.containsKey('connections')) { + mismatches.add('$moduleKey: full cell "$key" missing connections'); + } + } +} diff --git a/test/slim_incremental_equivalence_test.dart b/test/slim_incremental_equivalence_test.dart new file mode 100644 index 000000000..cd5c84f00 --- /dev/null +++ b/test/slim_incremental_equivalence_test.dart @@ -0,0 +1,310 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// slim_incremental_equivalence_test.dart +// Validates that assembling full data from slim + per-module fetches +// produces the same result as pulling the full netlist in one shot. + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/example.dart' as ex; +import '../example/filter_bank.dart'; +import '../example/fir_filter.dart'; +import '../example/oven_fsm.dart'; + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + /// Builds [module] with netlist synthesis, then verifies that + /// reassembling the full netlist from slim + per-module fetches + /// produces a result equivalent to [toFullJson]. + /// + /// For each module definition in the slim netlist: + /// 1. Cell keys, types, and ordering must match the full netlist. + /// 2. Port definitions (direction, bit indices) must match. + /// 3. Fetching full data via [moduleJson] adds connections + /// that exactly match those in the full netlist. + Future validateSlimIncrementalEquivalence(Module module) async { + await module.build(); + final netSvc = await NetlistService.create(module); + + // ── Pull full netlist in one shot ───────────────────────────── + final fullJsonStr = netSvc.toJson(); + final fullNetlist = jsonDecode(fullJsonStr) as Map; + final fullModules = fullNetlist['modules'] as Map; + + // ── Pull slim netlist ───────────────────────────────────────── + final slimJsonStr = netSvc.slimJson; + final slimUnified = jsonDecode(slimJsonStr) as Map; + final slimNetlist = slimUnified['netlist'] as Map; + final slimModules = slimNetlist['modules'] as Map; + + // ── Same set of module definition keys ──────────────────────── + expect( + slimModules.keys.toSet(), + equals(fullModules.keys.toSet()), + reason: 'Slim and full should have identical module keys', + ); + + // ── Per-module comparison ───────────────────────────────────── + final errors = []; + + for (final moduleKey in fullModules.keys) { + final fullMod = fullModules[moduleKey] as Map; + final slimMod = slimModules[moduleKey] as Map; + + final fullCells = fullMod['cells'] as Map? ?? {}; + final slimCells = slimMod['cells'] as Map? ?? {}; + + // ── Cell keys and ordering ────────────────────────────────── + final fullCellKeys = fullCells.keys.toList(); + final slimCellKeys = slimCells.keys.toList(); + if (!_listsEqual(fullCellKeys, slimCellKeys)) { + errors.add( + '$moduleKey: cell keys differ — ' + 'full=$fullCellKeys, slim=$slimCellKeys', + ); + continue; // Skip deeper checks for this module + } + + // ── Cell types match ──────────────────────────────────────── + for (final cellKey in fullCellKeys) { + final fullCell = fullCells[cellKey] as Map; + final slimCell = slimCells[cellKey] as Map; + if (fullCell['type'] != slimCell['type']) { + errors.add( + '$moduleKey.$cellKey: type mismatch — ' + 'full="${fullCell['type']}", slim="${slimCell['type']}"', + ); + } + } + + // ── Port definitions match ────────────────────────────────── + final fullPorts = fullMod['ports'] as Map? ?? {}; + final slimPorts = slimMod['ports'] as Map? ?? {}; + if (!_listsEqual(fullPorts.keys.toList(), slimPorts.keys.toList())) { + errors.add( + '$moduleKey: port keys differ — ' + 'full=${fullPorts.keys.toList()}, ' + 'slim=${slimPorts.keys.toList()}', + ); + } else { + for (final portKey in fullPorts.keys) { + final fullPort = fullPorts[portKey] as Map; + final slimPort = slimPorts[portKey] as Map; + if (fullPort['direction'] != slimPort['direction']) { + errors.add('$moduleKey port $portKey: direction mismatch'); + } + final fullBits = fullPort['bits'] as List?; + final slimBits = slimPort['bits'] as List?; + if (!_listsEqual(fullBits ?? [], slimBits ?? [])) { + errors.add( + '$moduleKey port $portKey: bits mismatch — ' + 'full=$fullBits, slim=$slimBits', + ); + } + } + } + + // ── Slim cells must NOT have connections ──────────────────── + for (final cellKey in slimCellKeys) { + final slimCell = slimCells[cellKey] as Map; + if (slimCell.containsKey('connections')) { + errors.add( + '$moduleKey.$cellKey: slim cell has connections ' + '(should be stripped)', + ); + } + } + + // ── Full cells must have connections ──────────────────────── + for (final cellKey in fullCellKeys) { + final fullCell = fullCells[cellKey] as Map; + if (!fullCell.containsKey('connections')) { + errors.add('$moduleKey.$cellKey: full cell missing connections'); + } + } + + // ── Fetch full data via moduleJson ───────────────────────── + // This is the incremental-loading contract: for EVERY module + // in the slim netlist, fetching full data must recover the + // exact connections present in the one-shot full netlist. + final fetchedStr = netSvc.moduleJson(moduleKey); + final fetchedJson = jsonDecode(fetchedStr) as Map; + if (fetchedJson.containsKey('status')) { + errors.add('$moduleKey: moduleJson returned not_found'); + continue; + } + + // The fetched result is {"creator":..., "modules": {key: data}}. + final fetchedModules = + fetchedJson['modules'] as Map? ?? fetchedJson; + final fetchedMod = + (fetchedModules[moduleKey] ?? fetchedModules.values.first) + as Map; + + final fetchedCells = fetchedMod['cells'] as Map? ?? {}; + + // ── Fetched cell keys must match full ─────────────────────── + if (!_listsEqual(fetchedCells.keys.toList(), fullCellKeys)) { + errors.add( + '$moduleKey: fetched cell keys differ from full — ' + 'fetched=${fetchedCells.keys.toList()}, ' + 'full=$fullCellKeys', + ); + continue; + } + + // ── Fetched connections must match full exactly ───────────── + for (final cellKey in fullCellKeys) { + final fullCell = fullCells[cellKey] as Map; + final fetchedCell = fetchedCells[cellKey] as Map; + + final fullConns = + fullCell['connections'] as Map? ?? {}; + final fetchedConns = + fetchedCell['connections'] as Map? ?? {}; + + if (!_connectionsEqual(fullConns, fetchedConns)) { + errors.add( + '$moduleKey.$cellKey: connections mismatch — ' + 'full=$fullConns, fetched=$fetchedConns', + ); + } + } + + // ── Fetched ports must match full ─────────────────────────── + final fetchedPorts = fetchedMod['ports'] as Map? ?? {}; + for (final portKey in fullPorts.keys) { + final fullPort = fullPorts[portKey] as Map; + final fetchedPort = fetchedPorts[portKey] as Map?; + if (fetchedPort == null) { + errors.add('$moduleKey port $portKey: missing in fetched data'); + continue; + } + if (fullPort['direction'] != fetchedPort['direction']) { + errors.add( + '$moduleKey port $portKey: direction mismatch ' + 'in fetched data', + ); + } + final fullBits = fullPort['bits'] as List?; + final fetchedBits = fetchedPort['bits'] as List?; + if (!_listsEqual(fullBits ?? [], fetchedBits ?? [])) { + errors.add( + '$moduleKey port $portKey: bits mismatch — ' + 'full=$fullBits, fetched=$fetchedBits', + ); + } + } + } + + // ── Report ──────────────────────────────────────────────────── + if (errors.isNotEmpty) { + fail('Slim incremental equivalence errors:\n${errors.join('\n')}'); + } + + // ignore: avoid_print + print( + 'Validated ${fullModules.length} modules — ' + 'slim + incremental fetch matches full.', + ); + } + + test('Counter: slim + incremental fetch == full', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final counter = ex.Counter(en, reset, clk); + await validateSlimIncrementalEquivalence(counter); + }); + + test('FIR filter: slim + incremental fetch == full', () async { + final en = Logic(name: 'en'); + final resetB = Logic(name: 'resetB'); + final clk = SimpleClockGenerator(10).clk; + final inputVal = Logic(name: 'inputVal', width: 8); + final fir = FirFilter(en, resetB, clk, inputVal, [0, 0, 0, 1], bitWidth: 8); + await validateSlimIncrementalEquivalence(fir); + }); + + test('OvenModule: slim + incremental fetch == full', () async { + final button = Logic(name: 'button', width: 2); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final oven = OvenModule(button, reset, clk); + await validateSlimIncrementalEquivalence(oven); + }); + + test('FilterBank: slim + incremental fetch == full', () async { + const dataWidth = 16; + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + final dut = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: 3, + dataWidth: dataWidth, + coefficients: [ + [1, 2, 1], + [1, -2, 1], + ], + ); + await validateSlimIncrementalEquivalence(dut); + }); +} + +/// Deep-compare two lists element by element. +bool _listsEqual(List a, List b) { + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; +} + +/// Compare two connection maps: {portName: [bit indices]}. +/// +/// All bit indices are numeric IDs; order within each port's list matters +/// because it encodes the wire mapping. +bool _connectionsEqual(Map a, Map b) { + if (a.length != b.length) { + return false; + } + for (final key in a.keys) { + if (!b.containsKey(key)) { + return false; + } + final aBits = a[key] as List?; + final bBits = b[key] as List?; + if (aBits == null && bBits == null) { + continue; + } + if (aBits == null || bBits == null) { + return false; + } + if (!_listsEqual(aBits, bBits)) { + return false; + } + } + return true; +} diff --git a/test/struct_port_pruning_test.dart b/test/struct_port_pruning_test.dart new file mode 100644 index 000000000..b13346ebe --- /dev/null +++ b/test/struct_port_pruning_test.dart @@ -0,0 +1,143 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// struct_port_pruning_test.dart +// Verifies that struct port elements on submodules are not incorrectly +// pruned during SV synthesis. Exercises the `submoduleOutputSynths` / +// `submoduleInputSynths` fix in `_pruneUnused`. +// +// 2026 April 17 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +// ── Struct definition ────────────────────────────────────────── + +class PairStruct extends LogicStructure { + PairStruct({Logic? a, Logic? b, super.name = 'pair'}) + : super([a ?? Logic(name: 'a'), b ?? Logic(name: 'b')]); + + @override + PairStruct clone({String? name}) => PairStruct(name: name); +} + +// ── Leaf submodule with a struct output port ─────────────────── + +class StructProducer extends Module { + Logic get out => PairStruct()..gets(output('out')); + + StructProducer(Logic x, Logic y) : super(name: 'struct_producer') { + x = addInput('x', x); + y = addInput('y', y); + + final s = PairStruct(a: x, b: y); + addOutput('out', width: s.width) <= s; + } +} + +// ── Leaf submodule with a struct input port ──────────────────── + +class StructConsumer extends Module { + Logic get sum => output('sum'); + + StructConsumer(Logic pair) : super(name: 'struct_consumer') { + pair = addInput('pair', pair, width: pair.width); + + final s = PairStruct()..gets(pair); + addOutput('sum') <= s.elements[0] ^ s.elements[1]; + } +} + +// ── Top module: struct output from submodule → struct input ─── + +class StructPipeTop extends Module { + Logic get result => output('result'); + + StructPipeTop(Logic x, Logic y) : super(name: 'struct_pipe_top') { + x = addInput('x', x); + y = addInput('y', y); + + final producer = StructProducer(x, y); + final consumer = StructConsumer(producer.out); + + addOutput('result') <= consumer.sum; + } +} + +void main() { + tearDown(() async { + await Simulator.reset(); + }); + + group('struct port pruning', () { + test('SV output retains struct element signals from submodule', () async { + final dut = StructPipeTop(Logic(), Logic()); + await dut.build(); + + final svStr = dut.generateSynth(); + + // The struct_producer submodule should appear in the SV. + expect( + svStr, + contains('struct_producer'), + reason: 'Submodule with struct output should not be pruned', + ); + + // The struct_consumer submodule should appear in the SV. + expect( + svStr, + contains('struct_consumer'), + reason: 'Submodule with struct input should not be pruned', + ); + + // The output port 'out' of struct_producer (width 2) must have a + // connection in the parent — it should not be pruned away. + expect( + svStr, + contains('.out('), + reason: 'Struct output port connection should not be pruned', + ); + + // The input port 'pair' of struct_consumer must be connected. + expect( + svStr, + contains('.pair('), + reason: 'Struct input port connection should not be pruned', + ); + }); + + test('struct element signals survive SV synthesis for producer', () async { + final dut = StructProducer(Logic(), Logic()); + await dut.build(); + + final svStr = dut.generateSynth(); + + // Inside StructProducer, the struct elements (a, b from PairStruct) + // drive the output via struct_slice decomposition. They must not + // be pruned. + expect(svStr, contains('out'), reason: 'Output port should appear in SV'); + expect( + svStr, + contains('input'), + reason: 'Input ports should appear in SV', + ); + }); + + test('struct element signals survive SV synthesis for consumer', () async { + final dut = StructConsumer(Logic(width: 2)); + await dut.build(); + + final svStr = dut.generateSynth(); + + // Inside StructConsumer, the struct elements are extracted from the + // packed input. The XOR of elements drives the output. + expect(svStr, contains('sum'), reason: 'Output port should appear in SV'); + expect( + svStr, + contains('pair'), + reason: 'Input struct port should appear in SV', + ); + }); + }); +} diff --git a/test/synth_name_parity_test.dart b/test/synth_name_parity_test.dart new file mode 100644 index 000000000..64b7dcc45 --- /dev/null +++ b/test/synth_name_parity_test.dart @@ -0,0 +1,125 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// synth_name_parity_test.dart +// Tests that verify canonicalNameOf works consistently across +// different synthesis paths (SV and netlist). +// +// 2026 April 14 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/filter_bank.dart'; + +class _Counter extends Module { + _Counter(Logic en, Logic reset, {int width = 8}) : super(name: 'counter') { + en = addInput('en', en); + reset = addInput('reset', reset); + final val = addOutput('val', width: width); + final nextVal = Logic(name: 'nextVal', width: width); + nextVal <= val + 1; + Sequential.multi( + [SimpleClockGenerator(10).clk, reset], + [ + If( + reset, + then: [val < 0], + orElse: [ + If(en, then: [val < nextVal]), + ], + ), + ], + ); + } +} + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + group('canonicalNameOf after netlist synthesis', () { + test('counter — returns names after netlist synthesis', () async { + final mod = _Counter(Logic(), Logic()); + await mod.build(); + await NetlistService.create(mod); + + expect(mod.namer.signalNameOf(mod.input('en')), equals('en')); + expect(mod.namer.signalNameOf(mod.input('reset')), equals('reset')); + expect(mod.namer.signalNameOf(mod.output('val')), equals('val')); + }); + + test('filter_bank — returns names for sub-module signals', () async { + const dataWidth = 16; + const numTaps = 3; + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + final dut = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: [ + [1, 2, 1], + [1, -2, 1], + ], + ); + await dut.build(); + await NetlistService.create(dut); + + expect(dut.namer.signalNameOf(dut.input('clk')), equals('clk')); + expect(dut.namer.signalNameOf(dut.input('reset')), equals('reset')); + expect(dut.namer.signalNameOf(dut.output('done')), equals('done')); + }); + }); + + group('canonicalNameOf after SV synthesis', () { + test('counter — returns canonical name after SV synth', () async { + final mod = _Counter(Logic(), Logic()); + await mod.build(); + + mod.generateSynth(); + + expect(mod.namer.signalNameOf(mod.input('en')), equals('en')); + expect(mod.namer.signalNameOf(mod.input('reset')), equals('reset')); + }); + }); + + group('cross-synthesizer parity', () { + test( + 'counter — SV and netlist produce identical canonicalNameOf', + () async { + final modNetlist = _Counter(Logic(), Logic()); + await modNetlist.build(); + await NetlistService.create(modNetlist); + await Simulator.reset(); + + final modSv = _Counter(Logic(), Logic()); + await modSv.build(); + modSv.generateSynth(); + + // Both paths use the same Namer, so names must match. + final enNetlist = modNetlist.namer.signalNameOf(modNetlist.input('en')); + final enSv = modSv.namer.signalNameOf(modSv.input('en')); + + expect( + enSv, + equals(enNetlist), + reason: 'SV and netlist should produce identical canonical names', + ); + }, + ); + }); +} From 2f7f0d14e3bb15e415f189bb6f0af71c52c4a290 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 6 May 2026 15:16:16 -0700 Subject: [PATCH 13/20] formatting --- lib/src/synthesizers/netlist/netlist_service.dart | 3 +-- test/netlist_synthesizer_test.dart | 1 - test/slim_incremental_equivalence_test.dart | 5 ++--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/src/synthesizers/netlist/netlist_service.dart b/lib/src/synthesizers/netlist/netlist_service.dart index 07aec9105..32b164d94 100644 --- a/lib/src/synthesizers/netlist/netlist_service.dart +++ b/lib/src/synthesizers/netlist/netlist_service.dart @@ -210,8 +210,7 @@ class NetlistService { }; } - final rootName = - module.hasBuilt ? module.uniqueInstanceName : module.name; + final rootName = module.hasBuilt ? module.uniqueInstanceName : module.name; return jsonEncode({ 'netlist': { diff --git a/test/netlist_synthesizer_test.dart b/test/netlist_synthesizer_test.dart index 0c985f18d..8e08e2945 100644 --- a/test/netlist_synthesizer_test.dart +++ b/test/netlist_synthesizer_test.dart @@ -1433,5 +1433,4 @@ void main() { }, ); }); - } diff --git a/test/slim_incremental_equivalence_test.dart b/test/slim_incremental_equivalence_test.dart index cd5c84f00..09664a112 100644 --- a/test/slim_incremental_equivalence_test.dart +++ b/test/slim_incremental_equivalence_test.dart @@ -145,9 +145,8 @@ void main() { // The fetched result is {"creator":..., "modules": {key: data}}. final fetchedModules = fetchedJson['modules'] as Map? ?? fetchedJson; - final fetchedMod = - (fetchedModules[moduleKey] ?? fetchedModules.values.first) - as Map; + final fetchedMod = (fetchedModules[moduleKey] ?? + fetchedModules.values.first) as Map; final fetchedCells = fetchedMod['cells'] as Map? ?? {}; From 6dfe0f92b2cf89e5fe9c5c350d1277bac1e147aa Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Tue, 12 May 2026 06:56:45 -0700 Subject: [PATCH 14/20] simplified forModule, improved code doc --- lib/src/exceptions/logic/put_exception.dart | 7 ++++--- lib/src/module.dart | 6 +----- .../utilities/synth_module_definition.dart | 9 +++++---- .../synth_sub_module_instantiation.dart | 7 ++++--- lib/src/utilities/namer.dart | 18 +++++++----------- test/name_test.dart | 6 ++---- test/signal_registry_test.dart | 6 +++--- 7 files changed, 26 insertions(+), 33 deletions(-) diff --git a/lib/src/exceptions/logic/put_exception.dart b/lib/src/exceptions/logic/put_exception.dart index 36d8f8015..96bd51602 100644 --- a/lib/src/exceptions/logic/put_exception.dart +++ b/lib/src/exceptions/logic/put_exception.dart @@ -9,10 +9,11 @@ import 'package:rohd/rohd.dart'; -/// An exception that thrown when a [Logic] signal fails to `put`. +/// An exception that thrown when a [Logic] signal fails to [Logic.put]. class PutException extends RohdException { - /// Creates an exception for when a `put` fails on a `Logic` with [context] as - /// to where the + /// Creates an exception for when a [Logic.put] fails on a [Logic] with + /// [context] as to where the failure occurred and [message] describing the + /// failure. PutException(String context, String message) : super('Failed to put value on signal ($context): $message'); } diff --git a/lib/src/module.dart b/lib/src/module.dart index 02e02ad63..8c3c79692 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -61,11 +61,7 @@ abstract class Module { Namer _createNamer() { assert(hasBuilt, 'Module must be built before canonical names are bound.'); - return Namer.forModule( - inputs: _inputs, - outputs: _outputs, - inOuts: _inOuts, - ); + return Namer.forModule(this); } /// An internal mapping of inOut names to their sources to this [Module]. diff --git a/lib/src/synthesizers/utilities/synth_module_definition.dart b/lib/src/synthesizers/utilities/synth_module_definition.dart index 9ea120646..81c74b696 100644 --- a/lib/src/synthesizers/utilities/synth_module_definition.dart +++ b/lib/src/synthesizers/utilities/synth_module_definition.dart @@ -14,6 +14,7 @@ import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/collections/traverseable_collection.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:rohd/src/utilities/namer.dart'; /// A version of [BusSubset] that can be used for slicing on [LogicStructure] /// ports. @@ -758,11 +759,11 @@ class SynthModuleDefinition { /// Picks names of signals and sub-modules. /// - /// Signal names are read from `Namer.signalNameOf` (for user-created + /// Signal names are read from [Namer.signalNameOf] for user-created /// [Logic] objects) or kept as literal constants and are allocated from - /// `Namer.signalNameOf`. Submodule instance names are allocated - /// from `Namer.allocateRawName`. All names share a single - /// namespace managed by the module's `Namer`. + /// [Namer.signalNameOf]. Submodule instance names are allocated + /// from [Namer.allocateName]. All names share a single + /// namespace managed by the module's [Namer]. void _pickNames() { // Name allocation order matters — earlier claims get the unsuffixed name // when there are collisions. This matches production ROHD priority: diff --git a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart index cf7da28e8..343ca1714 100644 --- a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart +++ b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart @@ -11,6 +11,7 @@ import 'dart:collection'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:rohd/src/utilities/namer.dart'; /// Represents an instantiation of a module within another module. class SynthSubModuleInstantiation { @@ -25,12 +26,12 @@ class SynthSubModuleInstantiation { /// Selects a name for this module instance. Must be called exactly once. /// - /// Names are allocated from [parentModule]'s `Namer`'s shared namespace - /// via `Namer.allocateName`. + /// Names are allocated from [parentModule]'s [Namer]'s shared namespace + /// via [Namer.allocateName]. void pickName(Module parentModule) { assert(_name == null, 'Should only pick a name once.'); - _name = parentModule.namer.allocateRawName( + _name = parentModule.namer.allocateName( module.uniqueInstanceName, reserved: module.reserveName, ); diff --git a/lib/src/utilities/namer.dart b/lib/src/utilities/namer.dart index efbe8e3e4..d4c6eff85 100644 --- a/lib/src/utilities/namer.dart +++ b/lib/src/utilities/namer.dart @@ -21,7 +21,7 @@ import 'package:rohd/src/utilities/uniquifier.dart'; /// /// Port names are reserved at construction time. Internal signal names /// are assigned lazily on the first [signalNameOf] call. Instance names -/// are allocated explicitly via [allocateRawName]. +/// are allocated explicitly via [allocateName]. @internal class Namer { // ─── Shared namespace ─────────────────────────────────────────── @@ -43,19 +43,15 @@ class Namer { }) : _uniquifier = uniquifier, _portLogics = portLogics; - /// Creates a [Namer] for the given module ports. + /// Creates a [Namer] for the given [module]'s ports. /// /// Port names are reserved in the shared namespace. Port names are /// guaranteed sanitary by [Module]'s `_checkForSafePortName`. - factory Namer.forModule({ - required Map inputs, - required Map outputs, - required Map inOuts, - }) { + factory Namer.forModule(Module module) { final portLogics = { - ...inputs.values, - ...outputs.values, - ...inOuts.values, + ...module.inputs.values, + ...module.outputs.values, + ...module.inOuts.values, }; final uniquifier = Uniquifier(); @@ -78,7 +74,7 @@ class Namer { /// /// When [reserved] is `true`, the exact [baseName] (after sanitization) /// is claimed without modification; an exception is thrown if it collides. - String allocateRawName(String baseName, {bool reserved = false}) => + String allocateName(String baseName, {bool reserved = false}) => _uniquifier.getUniqueName( initialName: Sanitizer.sanitizeSV(baseName), reserved: reserved, diff --git a/test/name_test.dart b/test/name_test.dart index bde8a9c9f..afa757cc8 100644 --- a/test/name_test.dart +++ b/test/name_test.dart @@ -137,10 +137,8 @@ void main() { // skip ones that actually *should* cause a failure // - // Note: SystemVerilog allows using the same identifier for a signal - // and an instance because they are different namespaces. However, - // Icarus Verilog rejects that pattern, so ROHD treats those as - // conflicts for simulator compatibility. + // Note: SystemVerilog does not allow using the same identifier for a + // signal and an instance. final shouldConflict = [ { NameType.internalModuleDefinition, diff --git a/test/signal_registry_test.dart b/test/signal_registry_test.dart index d1719c85e..5fd19c2e3 100644 --- a/test/signal_registry_test.dart +++ b/test/signal_registry_test.dart @@ -96,7 +96,7 @@ void main() { final mod = _Counter(Logic(), Logic()); await mod.build(); - final allocated = mod.namer.allocateRawName('en'); + final allocated = mod.namer.allocateName('en'); expect(allocated, isNot(equals('en')), reason: 'Should not collide with existing port name'); expect(allocated, contains('en'), @@ -107,8 +107,8 @@ void main() { final mod = _Counter(Logic(), Logic()); await mod.build(); - final a = mod.namer.allocateRawName('wire'); - final b = mod.namer.allocateRawName('wire'); + final a = mod.namer.allocateName('wire'); + final b = mod.namer.allocateName('wire'); expect(a, isNot(equals(b)), reason: 'Each allocation should be unique'); }); }); From 3c90e5dc6096d2cb1b7c64be963b6828dfd06c09 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Tue, 12 May 2026 08:21:35 -0700 Subject: [PATCH 15/20] more coverage for Namer --- test/signal_registry_test.dart | 172 +++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/test/signal_registry_test.dart b/test/signal_registry_test.dart index 5fd19c2e3..ffae4ae5f 100644 --- a/test/signal_registry_test.dart +++ b/test/signal_registry_test.dart @@ -8,6 +8,7 @@ // Author: Desmond Kirkpatrick import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/namer.dart'; import 'package:test/test.dart'; // ──────────────────────────────────────────────────────────────── @@ -140,4 +141,175 @@ void main() { expect(names1, equals(names2)); }); }); + + group('isAvailable', () { + test('port names are not available', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + expect(mod.namer.isAvailable('a'), isFalse); + expect(mod.namer.isAvailable('b'), isFalse); + expect(mod.namer.isAvailable('a_bar'), isFalse); + expect(mod.namer.isAvailable('a_and_b'), isFalse); + }); + + test('unallocated names are available', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + expect(mod.namer.isAvailable('xyz'), isTrue); + expect(mod.namer.isAvailable('new_signal'), isTrue); + }); + + test('allocated names become unavailable', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + final name = mod.namer.allocateName('wire'); + expect(mod.namer.isAvailable(name), isFalse); + }); + }); + + group('allocateName reserved', () { + test('reserved allocation claims exact name', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + final name = mod.namer.allocateName('my_wire', reserved: true); + expect(name, equals('my_wire')); + expect(mod.namer.isAvailable('my_wire'), isFalse); + }); + + test('reserved collision throws', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + // 'a' is already a port name + expect( + () => mod.namer.allocateName('a', reserved: true), + throwsException, + ); + }); + }); + + group('baseName', () { + test('reserved signal uses name directly', () { + final sig = Logic(name: 'myReserved', naming: Naming.reserved); + expect(Namer.baseName(sig), equals('myReserved')); + }); + + test('renameable signal uses sanitized structureName', () { + final sig = Logic(name: 'mySignal', naming: Naming.renameable); + // structureName for a top-level signal equals its name + expect(Namer.baseName(sig), contains('mySignal')); + }); + + test('unpreferred name detected', () { + expect(Naming.isUnpreferred('_hidden'), isTrue); + expect(Naming.isUnpreferred('visible'), isFalse); + }); + }); + + group('signalNameOfBest', () { + test('const value returns value string', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + final c = Const(LogicValue.ofString('01')); + final sig = Logic(name: 'x'); + final name = mod.namer.signalNameOfBest( + [sig], + constValue: c, + ); + expect(name, equals(c.value.toString())); + }); + + test('constNameDisallowed falls through to candidates', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + final c = Const(LogicValue.ofString('01')); + final sig = Logic(name: 'fallback', naming: Naming.renameable); + final name = mod.namer.signalNameOfBest( + [sig], + constValue: c, + constNameDisallowed: true, + ); + expect(name, isNot(equals(c.value.toString()))); + expect(name, contains('fallback')); + }); + + test('port wins over other candidates', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + final port = mod.input('a'); // this module's port + final reserved = Logic(name: 'res', naming: Naming.reserved); + final name = mod.namer.signalNameOfBest([reserved, port]); + expect(name, equals('a')); + }); + + test('reserved wins over mergeable', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + final reserved = Logic(name: 'special', naming: Naming.reserved); + final mergeable = Logic(name: 'other', naming: Naming.mergeable); + final name = mod.namer.signalNameOfBest([mergeable, reserved]); + expect(name, equals('special')); + }); + + test('renameable wins over mergeable', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + final renameable = Logic(name: 'ren', naming: Naming.renameable); + final mergeable = Logic(name: 'mrg', naming: Naming.mergeable); + final name = mod.namer.signalNameOfBest([mergeable, renameable]); + expect(name, contains('ren')); + }); + + test('preferred mergeable wins over unpreferred', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + final preferred = Logic(name: 'good', naming: Naming.mergeable); + final unpreferred = + Logic(name: Naming.unpreferredName('bad'), naming: Naming.mergeable); + final name = mod.namer.signalNameOfBest([unpreferred, preferred]); + expect(name, contains('good')); + }); + + test('caches name for all candidates', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + final s1 = Logic(name: 'winner', naming: Naming.renameable); + final s2 = Logic(name: 'loser', naming: Naming.mergeable); + final name = mod.namer.signalNameOfBest([s1, s2]); + + // Both should resolve to the same cached name + expect(mod.namer.signalNameOf(s1), equals(name)); + expect(mod.namer.signalNameOf(s2), equals(name)); + }); + + test('empty candidates throws', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + expect( + () => mod.namer.signalNameOfBest([]), + throwsA(isA()), + ); + }); + + test('unnamed signals get a name', () async { + final mod = _GateMod(Logic(), Logic()); + await mod.build(); + + final unnamed = Logic(naming: Naming.unnamed); + final name = mod.namer.signalNameOfBest([unnamed]); + expect(name, isNotEmpty); + }); + }); } From b2c9715d2172150d7d916d11feff3b0684fb927a Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Tue, 12 May 2026 08:47:40 -0700 Subject: [PATCH 16/20] streamlined netlist optimization passes --- .../synthesizers/netlist/netlist_options.dart | 59 +- .../synthesizers/netlist/netlist_passes.dart | 2609 ++--------------- .../netlist/netlist_synthesizer.dart | 54 +- .../synthesizers/netlist/netlist_utils.dart | 188 -- test/netlist_synthesizer_test.dart | 118 +- test/slim_connected_port_test.dart | 11 - test/slim_full_canonical_test.dart | 5 - test/slim_incremental_equivalence_test.dart | 6 - 8 files changed, 255 insertions(+), 2795 deletions(-) diff --git a/lib/src/synthesizers/netlist/netlist_options.dart b/lib/src/synthesizers/netlist/netlist_options.dart index 95d0856c1..a92be2217 100644 --- a/lib/src/synthesizers/netlist/netlist_options.dart +++ b/lib/src/synthesizers/netlist/netlist_options.dart @@ -37,8 +37,7 @@ import 'package:rohd/src/synthesizers/netlist/leaf_cell_mapper.dart'; /// Example usage: /// ```dart /// const options = NetlistOptions( -/// groupStructConversions: true, -/// collapseStructGroups: true, +/// collapseTransparentClusters: true, /// ); /// final synth = NetlistSynthesizer(options: options); /// ``` @@ -48,47 +47,13 @@ class NetlistOptions { /// is used. final LeafCellMapper? leafCellMapper; - /// When `true`, groups of `$slice` + `$concat` cells that represent - /// structure-to-structure signal conversions are collapsed into - /// synthetic child modules, reducing visual clutter in the netlist. - final bool groupStructConversions; - - /// When `true` (requires [groupStructConversions] to also be `true`), - /// the synthetic child modules created for struct conversions will have - /// all their internal `$slice`/`$concat` cells and intermediate nets - /// removed, leaving only a single `$buf` cell that directly connects - /// each input port to the corresponding output port. - final bool collapseStructGroups; - - /// When `true` (requires [groupStructConversions] to also be `true`), - /// enables an additional grouping pass that finds `$concat` cells whose - /// input bits all trace back through `$buf`/`$slice` chains to a - /// contiguous sub-range of a single source bus. - final bool groupMaximalSubsets; - - /// When `true` (requires [groupStructConversions] to also be `true`), - /// enables an additional pass that finds `$concat` cells where a - /// contiguous run of input ports trace back through `$buf`/`$slice` - /// chains to a contiguous sub-range of a single source bus. - final bool collapseConcats; - - /// When `true`, `$slice` cells whose outputs feed directly into a - /// `$struct_pack` input port are absorbed into the pack cell, which - /// already describes the field decomposition. The redundant slices - /// are removed. - final bool collapseSelectsIntoPack; - - /// When `true`, `$struct_unpack` output ports that feed directly into - /// `$concat` input ports are collapsed: the concat is replaced by a - /// `$buf` or `$slice` from the unpack's source bus when all inputs - /// trace back to it contiguously. - final bool collapseUnpackToConcat; - - /// When `true`, `$struct_unpack` output ports that feed (possibly - /// through `$buf`/`$slice` chains) into `$struct_pack` input ports - /// are collapsed: the intermediate cells are removed and the pack - /// input wires are rewired to the unpack output wires directly. - final bool collapseUnpackToPack; + /// When `true`, a single unified pass finds connected components of + /// all transparent cells (`$buf`, `$slice`, `$concat`, + /// `$struct_unpack`, `$struct_pack`), traces each cluster's output + /// bits back to their ultimate source bits, and replaces every + /// multi-cell cluster with a direct `$buf`. This subsumes all of + /// the individual collapse passes above. + final bool collapseTransparentClusters; /// When `true`, dead-cell elimination is performed after aliasing to /// remove cells whose inputs are entirely undriven or whose outputs @@ -123,13 +88,7 @@ class NetlistOptions { /// netlist synthesizer behaviour. const NetlistOptions({ this.leafCellMapper, - this.groupStructConversions = false, - this.collapseStructGroups = false, - this.groupMaximalSubsets = false, - this.collapseConcats = false, - this.collapseSelectsIntoPack = false, - this.collapseUnpackToConcat = false, - this.collapseUnpackToPack = false, + this.collapseTransparentClusters = false, this.enableDCE = true, this.slimMode = false, this.compressBitRanges = false, diff --git a/lib/src/synthesizers/netlist/netlist_passes.dart b/lib/src/synthesizers/netlist/netlist_passes.dart index 7fdcf57fb..2e19b45b6 100644 --- a/lib/src/synthesizers/netlist/netlist_passes.dart +++ b/lib/src/synthesizers/netlist/netlist_passes.dart @@ -45,19 +45,33 @@ class NetlistPasses { return allModules; } - // -- Maximal-subset grouping ------------------------------------------- - - /// Finds `$concat` cells whose input bits all trace back through - /// `$buf`/`$slice` chains to a contiguous sub-range of a single source - /// bus. Replaces the entire concat-tree (the concat itself plus the - /// intermediate `$buf` and `$slice` cells that exclusively serve it) - /// with a single `$slice` (or `$buf` when the sub-range covers the - /// full source width). + // ════════════════════════════════════════════════════════════════════ + // Unified transparent-cell clustering + // ════════════════════════════════════════════════════════════════════ + + /// Transparent cell types that only reshuffle / rename bits. + static const _transparentTypes = { + r'$buf', + r'$slice', + r'$concat', + r'$struct_unpack', + r'$struct_pack', + }; + + /// Unified transparent-cell clustering pass. + /// + /// **Phase 1 — Cluster identification:** + /// Builds an undirected graph over transparent cells (two cells are + /// neighbours when one's output wire feeds the other's input) and + /// finds connected components via BFS. /// - /// This pass runs *before* the connected-component grouping so that - /// the simplified cells can be picked up by the standard struct-assign - /// grouping and collapse passes. - static void applyMaximalSubsetGrouping( + /// **Phase 2 — Cluster collapse:** + /// For every multi-cell component, traces each externally-consumed + /// output bit backward through the component's bit-level mapping + /// until reaching an external source bit, then replaces the entire + /// component with a single `$buf` wired from traced sources to + /// destinations. + static void applyTransparentClustering( Map> allModules, ) { for (final moduleDef in allModules.values) { @@ -66,633 +80,94 @@ class NetlistPasses { continue; } - // Build wire-driver, wire-consumer, and bit-to-net maps. - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - NetlistUtils.buildWireMaps(cells, moduleDef); - - final cellsToRemove = {}; - final cellsToAdd = >{}; - var replIdx = 0; - - // Process each $concat cell. - for (final concatEntry in cells.entries.toList()) { - final concatName = concatEntry.key; - final concatCell = concatEntry.value; - if ((concatCell['type'] as String?) != r'$concat') { - continue; - } - if (cellsToRemove.contains(concatName)) { - continue; - } - - final conns = concatCell['connections'] as Map? ?? {}; - - // Gather the concat's input bits in LSB-first order. - final inputBits = []; - if (conns.containsKey('A')) { - // Standard 2-input concat: A (LSB), B (MSB). - for (final b in conns['A'] as List) { - if (b is int) { - inputBits.add(b); - } - } - for (final b in conns['B'] as List) { - if (b is int) { - inputBits.add(b); - } - } - } else { - // Multi-input concat: range-named ports [lo:hi]. - final rangePorts = >{}; - for (final portName in conns.keys) { - if (portName == 'Y') { - continue; - } - final m = NetlistUtils.rangePortRe.firstMatch(portName); - if (m != null) { - final hi = int.parse(m.group(1)!); - final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; - rangePorts[lo] = [ - for (final b in conns[portName] as List) - if (b is int) b, - ]; - } - } - for (final k in rangePorts.keys.toList()..sort()) { - inputBits.addAll(rangePorts[k]!); - } - } - - if (inputBits.isEmpty) { - continue; - } - - final outputBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; - - // Trace each input bit backward through $buf and $slice cells - // to find its ultimate source bit. Record the chain of - // intermediate cells visited. - final sourceBits = []; - final intermediateCells = {}; - var allFromOneBus = true; - String? sourceBusNet; - List? sourceBusBits; - - for (final inputBit in inputBits) { - final (traced, chain) = - NetlistUtils.traceBackward(inputBit, wireDriverCell, cells); - sourceBits.add(traced); - intermediateCells.addAll(chain); - - // Identify which named bus this bit belongs to. - final info = bitToNetInfo[traced]; - if (info == null) { - allFromOneBus = false; - break; - } - if (sourceBusNet == null) { - sourceBusNet = info.$1; - sourceBusBits = info.$2; - } else if (sourceBusNet != info.$1) { - allFromOneBus = false; - break; - } - } - - if (!allFromOneBus || sourceBusNet == null || sourceBusBits == null) { - continue; - } - - // Verify the traced source bits form a contiguous sub-range - // of the source bus. - if (sourceBits.length != inputBits.length) { - continue; - } - - // Find each source bit's index within the source bus. - final indices = []; - var contiguous = true; - for (final sb in sourceBits) { - final idx = sourceBusBits.indexOf(sb); - if (idx < 0) { - contiguous = false; - break; - } - indices.add(idx); - } - if (!contiguous || indices.isEmpty) { - continue; - } - - // Check that indices are sequential (contiguous ascending). - for (var i = 1; i < indices.length; i++) { - if (indices[i] != indices[i - 1] + 1) { - contiguous = false; - break; - } - } - if (!contiguous) { - continue; - } - - // Verify that every intermediate cell is used exclusively - // by this concat chain (no fanout to other consumers). - if (!NetlistUtils.isExclusiveChain( - intermediates: intermediateCells, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - allowPortConsumers: true, - )) { - continue; - } - - // Build the source bus bits list (the full bus from the module). - // We need the A connection to be the full source bus. - final sourceBusParentBits = sourceBusBits.cast().toList(); - - final offset = indices.first; - final yWidth = outputBits.length; - final aWidth = sourceBusBits.length; - - // Mark intermediate cells and the concat for removal. - cellsToRemove - ..addAll(intermediateCells) - ..add(concatName); - - if (yWidth == aWidth) { - cellsToAdd['maxsub_buf_$replIdx'] = NetlistUtils.makeBufCell( - aWidth, sourceBusParentBits, outputBits.cast()); - } else { - cellsToAdd['maxsub_slice_$replIdx'] = NetlistUtils.makeSliceCell( - offset, - aWidth, - yWidth, - sourceBusParentBits, - outputBits.cast()); - } - replIdx++; - } - - // Apply removals and additions. - cellsToRemove.forEach(cells.remove); - cells.addAll(cellsToAdd); - } - } - - // -- Partial concat collapsing ----------------------------------------- + final ports = moduleDef['ports'] as Map? ?? {}; - /// Scans every module in [allModules] for `$concat` cells where a - /// contiguous run of input ports (≥ 2) all trace back through - /// `$buf`/`$slice` chains to a contiguous sub-range of a single source - /// bus with exclusive fan-out. Each such run is replaced by a single - /// `$slice` and the concat is rebuilt with fewer input ports. - /// - /// If *all* ports of a concat qualify as a single run, the concat is - /// eliminated entirely and replaced with a `$slice` (or `$buf` for - /// full-width). - static void applyCollapseConcats( - Map> allModules, - ) { - for (final entry in allModules.entries) { - final moduleDef = entry.value; - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } + // ── Gather transparent cells ── - final concatCount = cells.values - .where((c) => (c['type'] as String?) == r'$concat') - .length; - if (concatCount == 0) { + final tCells = { + for (final e in cells.entries) + if (_transparentTypes.contains(e.value['type'] as String?)) e.key, + }; + if (tCells.isEmpty) { continue; } - // Iterate until no more collapses are possible. Nested concat chains - // (e.g. swizzle_4 feeding swizzle_3) require multiple passes because - // traceBackward only traces through $slice/$buf, not $concat. - var changed = true; - var replIdx = 0; - var iteration = 0; - while (changed && iteration < 20) { - changed = false; - iteration++; - - // --- Build wire-driver, wire-consumer, and bit-to-net maps ------- - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - NetlistUtils.buildWireMaps(cells, moduleDef); - - final cellsToRemove = {}; - final cellsToAdd = >{}; - - // --- Process each $concat cell ------------------------------------ - for (final concatEntry in cells.entries.toList()) { - final concatName = concatEntry.key; - final concatCell = concatEntry.value; - if ((concatCell['type'] as String?) != r'$concat') { - continue; - } - if (cellsToRemove.contains(concatName)) { - continue; - } - - final conns = - concatCell['connections'] as Map? ?? {}; - - // Parse input ports into an ordered list. - // Supports both range-named ports [hi:lo] and A/B form. - final inputPorts = <(int lo, String portName, List bits)>[]; - var hasRangePorts = false; - for (final portName in conns.keys) { - if (portName == 'Y') { - continue; - } - final m = NetlistUtils.rangePortRe.firstMatch(portName); - if (m != null) { - hasRangePorts = true; - final hi = int.parse(m.group(1)!); - final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; - inputPorts.add(( - lo, - portName, - [ - for (final b in conns[portName] as List) - if (b is int) b, - ], - )); - } - } - if (!hasRangePorts) { - // A/B form: convert to ordered list. - if (conns.containsKey('A') && conns.containsKey('B')) { - final aBits = [ - for (final b in conns['A'] as List) - if (b is int) b, - ]; - final bBits = [ - for (final b in conns['B'] as List) - if (b is int) b, - ]; - inputPorts - ..add((0, 'A', aBits)) - ..add((aBits.length, 'B', bBits)); - } - } - inputPorts.sort((a, b) => a.$1.compareTo(b.$1)); - - if (inputPorts.length < 2) { - continue; - } - - // --- Trace each port's bits back to a source bus ---------------- - final portTraces = <({ - String? busName, - List? busBits, - List sourceIndices, - Set intermediates, - bool valid, - })>[]; - - for (final (_, _, bits) in inputPorts) { - final sourceIndices = []; - final intermediates = {}; - String? busName; - List? busBits; - var valid = true; - - for (final bit in bits) { - final (traced, chain) = - NetlistUtils.traceBackward(bit, wireDriverCell, cells); - intermediates.addAll(chain); - - // Identify source net. - final info = bitToNetInfo[traced]; - if (info == null) { - valid = false; - break; - } - if (busName == null) { - busName = info.$1; - busBits = info.$2; - } else if (busName != info.$1) { - valid = false; - break; - } - final idx = busBits!.indexOf(traced); - if (idx < 0) { - valid = false; - break; - } - sourceIndices.add(idx); - } - - // Check contiguous within this port. - if (valid && sourceIndices.length == bits.length) { - for (var i = 1; i < sourceIndices.length; i++) { - if (sourceIndices[i] != sourceIndices[i - 1] + 1) { - valid = false; - break; - } - } - } else { - valid = false; - } - - portTraces.add(( - busName: busName, - busBits: busBits, - sourceIndices: sourceIndices, - intermediates: intermediates, - valid: valid, - )); - } - - // --- Find maximal runs of consecutive traceable ports ----------- - final runs = <(int startIdx, int endIdx)>[]; - var runStart = 0; - while (runStart < inputPorts.length) { - final t = portTraces[runStart]; - if (!t.valid || t.busName == null) { - runStart++; - continue; - } - var runEnd = runStart; - while (runEnd + 1 < inputPorts.length) { - final nextT = portTraces[runEnd + 1]; - if (!nextT.valid) { - break; - } - if (nextT.busName != t.busName) { - break; - } - // Check contiguity across port boundary. - final curLast = portTraces[runEnd].sourceIndices.last; - final nextFirst = nextT.sourceIndices.first; - if (nextFirst != curLast + 1) { - break; - } - runEnd++; - } - if (runEnd > runStart) { - runs.add((runStart, runEnd)); - } - runStart = runEnd + 1; - } - - if (runs.isEmpty) { - continue; - } - - // --- Verify exclusivity of intermediate cells for each run ------ - final validRuns = - <(int startIdx, int endIdx, Set intermediates)>[]; - for (final (startIdx, endIdx) in runs) { - final allIntermediates = {}; - for (var i = startIdx; i <= endIdx; i++) { - allIntermediates.addAll(portTraces[i].intermediates); - } - if (NetlistUtils.isExclusiveChain( - intermediates: allIntermediates, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - validRuns.add((startIdx, endIdx, allIntermediates)); - } - } - - if (validRuns.isEmpty) { - continue; - } - - // --- Check whether ALL ports form a single valid run ------------ - final allCollapsed = validRuns.length == 1 && - validRuns.first.$1 == 0 && - validRuns.first.$2 == inputPorts.length - 1; - - // Remove exclusive intermediate cells for all valid runs. - for (final (_, _, intermediates) in validRuns) { - cellsToRemove.addAll(intermediates); - } + // ── Wire maps ── - if (allCollapsed) { - // Full collapse — replace concat with a single $slice or $buf. - final t0 = portTraces.first; - final srcOffset = t0.sourceIndices.first; - final yWidth = (conns['Y'] as List).whereType().length; - final aWidth = t0.busBits!.length; - final sourceBusParentBits = t0.busBits!.cast().toList(); - final outputBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; + final wireConsumers = >{}; - cellsToRemove.add(concatName); - if (yWidth == aWidth) { - cellsToAdd['collapse_buf_$replIdx'] = NetlistUtils.makeBufCell( - aWidth, sourceBusParentBits, outputBits); - } else { - cellsToAdd['collapse_slice_$replIdx'] = - NetlistUtils.makeSliceCell(srcOffset, aWidth, yWidth, - sourceBusParentBits, outputBits); - } - replIdx++; + for (final e in cells.entries) { + final dirs = e.value['port_directions'] as Map? ?? {}; + final conns = e.value['connections'] as Map? ?? {}; + for (final pe in conns.entries) { + if ((dirs[pe.key] as String?) == 'output') { continue; } - - // --- Partial collapse — rebuild concat with fewer ports --------- - cellsToRemove.add(concatName); - - final newConns = >{}; - final newDirs = {}; - var outBitOffset = 0; - - var portIdx = 0; - while (portIdx < inputPorts.length) { - // Check if this port starts a valid run. - (int, int, Set)? activeRun; - for (final run in validRuns) { - if (run.$1 == portIdx) { - activeRun = run; - break; - } - } - - if (activeRun != null) { - final (startIdx, endIdx, _) = activeRun; - // Compute combined width and collect original input wire bits. - final originalBits = []; - for (var i = startIdx; i <= endIdx; i++) { - originalBits.addAll(inputPorts[i].$3.cast()); - } - final width = originalBits.length; - final t0 = portTraces[startIdx]; - final srcOffset = t0.sourceIndices.first; - final sourceBusBits = t0.busBits!.cast().toList(); - - // Reuse the original concat-input wire bits as the $slice - // output so that existing netname associations are preserved. - cellsToAdd['collapse_slice_$replIdx'] = - NetlistUtils.makeSliceCell(srcOffset, t0.busBits!.length, - width, sourceBusBits, originalBits); - replIdx++; - - // Add the combined port to the rebuilt concat. - final hi = outBitOffset + width - 1; - final portName = - hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; - newConns[portName] = originalBits; - newDirs[portName] = 'input'; - outBitOffset += width; - - portIdx = endIdx + 1; - } else { - // Keep this port as-is. - final port = inputPorts[portIdx]; - final width = port.$3.length; - final hi = outBitOffset + width - 1; - final portName = - hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; - newConns[portName] = port.$3.cast(); - newDirs[portName] = 'input'; - outBitOffset += width; - portIdx++; + for (final b in pe.value as List) { + if (b is int) { + (wireConsumers[b] ??= {}).add(e.key); } } - - // Preserve Y. - newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; - newDirs['Y'] = 'output'; - - // Remove original concat, add collapsed replacement. - cellsToRemove.add(concatName); - cellsToAdd['${concatName}_collapsed'] = { - 'hide_name': concatCell['hide_name'], - 'type': r'$concat', - 'parameters': {}, - 'attributes': concatCell['attributes'] ?? {}, - 'port_directions': newDirs, - 'connections': newConns, - }; } - - // Apply removals and additions. - cellsToRemove.forEach(cells.remove); - cells.addAll(cellsToAdd); - if (cellsToRemove.isNotEmpty || cellsToAdd.isNotEmpty) { - changed = true; - } - } // end while (changed) - } - } - - // -- Struct-conversion grouping ---------------------------------------- - - /// Scans every module in [allModules] for connected components of `$slice` - /// and `$concat` cells that form reconvergent struct-conversion trees. - /// Such trees arise from `LogicStructure.gets()` when a flat bus is - /// assigned to a struct (or vice-versa): leaf fields are sliced out and - /// re-packed through potentially multiple levels of concats. - /// - /// Each connected component is extracted into a new synthetic module - /// definition (added to [allModules]) and replaced in the parent with a - /// single hierarchical cell. This collapses the visual noise in the - /// netlist into a tidy "struct_assign_*" box. - static void applyStructConversionGrouping( - Map> allModules, - ) { - // Collect new module definitions to add (avoid modifying map during - // iteration). - final newModuleDefs = >{}; - - // Process each existing module definition. - for (final moduleName in allModules.keys.toList()) { - final moduleDef = allModules[moduleName]!; - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; } - // Identify all $slice and $concat cells. - final sliceConcat = {}; - for (final entry in cells.entries) { - final type = entry.value['type'] as String?; - if (type == r'$slice' || type == r'$concat') { - sliceConcat.add(entry.key); + // Bits consumed by module output / inout ports. + final portOutBits = {}; + for (final pv in ports.values) { + final pm = pv as Map; + final dir = pm['direction'] as String?; + if (dir == 'output' || dir == 'inout') { + for (final b in pm['bits'] as List) { + if (b is int) { + portOutBits.add(b); + } + } } } - if (sliceConcat.length < 2) { - continue; - } - // Build wire-ID → driver cell and wire-ID → consumer cells maps. - final ( - :wireDriverCell, - wireConsumerCells: wireConsumerSets, - :bitToNetInfo, - ) = NetlistUtils.buildWireMaps(cells, moduleDef); - // Convert Set consumers to List for iteration. - final wireConsumerCells = >{ - for (final e in wireConsumerSets.entries) e.key: e.value.toList(), - }; - final modPorts = moduleDef['ports'] as Map>?; + // ── Phase 1: connected components ── - // Build adjacency among sliceConcat cells: two are adjacent if one's - // output feeds the other's input. final adj = >{ - for (final cn in sliceConcat) cn: {}, + for (final tc in tCells) tc: {}, }; - for (final cn in sliceConcat) { - final cell = cells[cn]!; - final conns = cell['connections'] as Map? ?? {}; - final pdirs = cell['port_directions'] as Map? ?? {}; + + for (final tc in tCells) { + final dirs = + cells[tc]!['port_directions'] as Map? ?? {}; + final conns = cells[tc]!['connections'] as Map? ?? {}; for (final pe in conns.entries) { - final d = pdirs[pe.key] as String? ?? ''; + if ((dirs[pe.key] as String?) != 'output') { + continue; + } for (final b in pe.value as List) { if (b is! int) { continue; } - if (d == 'output') { - // Find consumers in sliceConcat. - for (final consumer in wireConsumerCells[b] ?? []) { - if (consumer != cn && sliceConcat.contains(consumer)) { - adj[cn]!.add(consumer); - adj[consumer]!.add(cn); - } - } - } else if (d == 'input') { - // Find driver in sliceConcat. - final drv = wireDriverCell[b]; - if (drv != null && drv != cn && sliceConcat.contains(drv)) { - adj[cn]!.add(drv); - adj[drv]!.add(cn); + for (final c in wireConsumers[b] ?? const {}) { + if (c != tc && tCells.contains(c)) { + adj[tc]!.add(c); + adj[c]!.add(tc); } } } } } - // Find connected components via BFS. final visited = {}; final components = >[]; - for (final start in sliceConcat) { - if (visited.contains(start)) { + + for (final tc in tCells) { + if (!visited.add(tc)) { continue; } - final comp = {}; - final queue = [start]; - while (queue.isNotEmpty) { - final node = queue.removeLast(); - if (!comp.add(node)) { - continue; - } - visited.add(node); - for (final nb in adj[node]!) { - if (!comp.contains(nb)) { - queue.add(nb); + final comp = {tc}; + final stack = [tc]; + while (stack.isNotEmpty) { + final cur = stack.removeLast(); + for (final nb in adj[cur]!) { + if (visited.add(nb)) { + comp.add(nb); + stack.add(nb); } } } @@ -701,1841 +176,183 @@ class NetlistPasses { } } - // For each connected component, extract it into a synthetic module. - var groupIdx = 0; - final groupQueue = [...components]; - var gqi = 0; - final claimedCells = {}; - while (gqi < groupQueue.length) { - final comp = groupQueue[gqi++]..removeAll(claimedCells); - if (comp.length < 2) { - continue; - } + if (components.isEmpty) { + continue; + } - // Collect all wire IDs used inside the component and classify them - // as internal-only (driven AND consumed within comp) or external - // (boundary ports of the synthetic module). - // - // External inputs = wire IDs consumed by comp cells but driven - // outside the component. - // External outputs = wire IDs produced by comp cells but consumed - // outside the component (or by module ports). - final compOutputIds = {}; // driven by comp - final compInputIds = {}; // consumed by comp + // ── Phase 2: trace & replace ── + + final cellsToRemove = {}; + final cellsToAdd = >{}; + for (final comp in components) { + // Build output-bit → input-bit map for the whole cluster. + final bitMap = {}; for (final cn in comp) { - final cell = cells[cn]!; - final conns = cell['connections'] as Map? ?? {}; - final pdirs = cell['port_directions'] as Map? ?? {}; - for (final pe in conns.entries) { - final d = pdirs[pe.key] as String? ?? ''; - for (final b in pe.value as List) { - if (b is! int) { - continue; - } - if (d == 'output') { - compOutputIds.add(b); - } else if (d == 'input') { - compInputIds.add(b); - } - } - } + _mapCellBits(cells[cn]!, bitMap); } - // External input bits: consumed by comp but NOT driven by comp. - final extInputBits = compInputIds.difference(compOutputIds); - // External output bits: driven by comp but consumed outside comp - // (by non-comp cells or by module output ports). - final extOutputBits = {}; - for (final b in compOutputIds) { - // Check non-comp cell consumers. - for (final consumer in wireConsumerCells[b] ?? []) { - if (!comp.contains(consumer)) { - extOutputBits.add(b); - break; + // External output bits: produced by the cluster but consumed + // by something outside it (another cell or module output port). + final extOut = []; + for (final cn in comp) { + final dirs = + cells[cn]!['port_directions'] as Map? ?? {}; + final conns = + cells[cn]!['connections'] as Map? ?? {}; + for (final pe in conns.entries) { + if ((dirs[pe.key] as String?) != 'output') { + continue; } - } - // Check module output ports. - if (!extOutputBits.contains(b) && modPorts != null) { - for (final portEntry in modPorts.values) { - final dir = portEntry['direction'] as String?; - if (dir != 'output') { + for (final b in pe.value as List) { + if (b is! int) { continue; } - final bits = portEntry['bits'] as List?; - if (bits != null && bits.contains(b)) { - extOutputBits.add(b); - break; + if (portOutBits.contains(b) || + (wireConsumers[b]?.any((c) => !comp.contains(c)) ?? false)) { + extOut.add(b); } } } } - if (extInputBits.isEmpty || extOutputBits.isEmpty) { - continue; // degenerate component, skip + if (extOut.isEmpty) { + // Fully dead cluster — remove. + cellsToRemove.addAll(comp); + continue; } - // Group external bits by netname to form named ports. - // Build a net-name → sorted bit IDs mapping for inputs and outputs. - final netnames = moduleDef['netnames'] as Map? ?? {}; + // Trace each external output back through the cluster to an + // external source bit. + final aList = []; + final yList = []; + var ok = true; - // Wire → netname map (for bits in this component). - final wireToNet = {}; - for (final nnEntry in netnames.entries) { - final nd = nnEntry.value! as Map; - final bits = nd['bits'] as List? ?? []; - for (final b in bits) { - if (b is int) { - wireToNet[b] = nnEntry.key; + for (final ob in extOut) { + Object cur = ob; + final seen = {}; + while (cur is int && bitMap.containsKey(cur)) { + if (!seen.add(cur)) { + ok = false; + break; } + cur = bitMap[cur]!; } + if (!ok) { + break; + } + aList.add(cur); + yList.add(ob); } - // Group external input bits by their netname, preserving order. - final inputGroups = >{}; - for (final b in extInputBits) { - final nn = wireToNet[b] ?? 'in_$b'; - (inputGroups[nn] ??= []).add(b); - } - for (final v in inputGroups.values) { - v.sort(); + if (!ok) { + continue; } - // Group external output bits by their netname, preserving order. - final outputGroups = >{}; - for (final b in extOutputBits) { - final nn = wireToNet[b] ?? 'out_$b'; - (outputGroups[nn] ??= []).add(b); - } - for (final v in outputGroups.values) { - v.sort(); - } + cellsToAdd['cluster_buf_${comp.first}'] = + NetlistUtils.makeBufCell(aList.length, aList, yList); + cellsToRemove.addAll(comp); + } - // Guard: only group when the component is a true struct - // assignment — one signal split into selections then re-assembled - // into one signal. The input may be wider than the output when - // fields are dropped (e.g. a nonCacheable bit unused in the - // destination struct). Multi-source concats (e.g. swizzles - // combining independent signals) and simple bit-range selections - // must remain as standalone cells. - if (inputGroups.length != 1 || - outputGroups.length != 1 || - extInputBits.length < extOutputBits.length) { - // Try sub-component extraction: for each $concat cell in the - // component, backward-BFS to find the subset of cells that - // transitively feed it. If that subset is strictly smaller - // than the full component it may pass the guard on its own. - for (final cn in comp.toList()) { - final cell = cells[cn]; - if (cell == null) { - continue; - } - if ((cell['type'] as String?) != r'$concat') { - continue; - } + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); + } + } - final subComp = {cn}; - final bfsQueue = [cn]; - while (bfsQueue.isNotEmpty) { - final cur = bfsQueue.removeLast(); - final curCell = cells[cur]; - if (curCell == null) { - continue; - } - final cConns = - curCell['connections'] as Map? ?? {}; - final cDirs = - curCell['port_directions'] as Map? ?? {}; - for (final pe in cConns.entries) { - if ((cDirs[pe.key] as String?) != 'input') { - continue; - } - for (final b in pe.value as List) { - if (b is! int) { - continue; - } - final drv = wireDriverCell[b]; - if (drv != null && - comp.contains(drv) && - !subComp.contains(drv)) { - subComp.add(drv); - bfsQueue.add(drv); - } - } - } - } + /// Populates [bitMap] with output-wire-bit → input-wire-bit entries + /// for a single transparent cell. + static void _mapCellBits( + Map cell, + Map bitMap, + ) { + final type = cell['type']! as String; + final dirs = cell['port_directions'] as Map? ?? {}; + final conns = cell['connections'] as Map? ?? {}; + final params = cell['parameters'] as Map? ?? {}; + + switch (type) { + case r'$buf': + _mapPairwise(conns['A'] as List, conns['Y'] as List, bitMap); + + case r'$slice': + final a = conns['A'] as List; + final y = conns['Y'] as List; + final off = params['OFFSET'] as int? ?? 0; + for (var i = 0; i < y.length; i++) { + if (y[i] is int && (off + i) < a.length) { + bitMap[y[i] as int] = a[off + i] as Object; + } + } + + case r'$concat': + final y = conns['Y'] as List; + // Input ports are in connection-map order; their bits + // concatenate to form Y (first port at LSB). + final inBits = [ + for (final pe in conns.entries) + if ((dirs[pe.key] as String?) != 'output') + ...(pe.value as List).cast(), + ]; + _mapPairwise(inBits, y, bitMap); - if (subComp.length >= 2 && subComp.length < comp.length) { - groupQueue.add(subComp); - } + case r'$struct_unpack': + final a = conns['A'] as List; + final fc = params['FIELD_COUNT'] as int? ?? 0; + for (var f = 0; f < fc; f++) { + final fn = params['FIELD_${f}_NAME'] as String?; + final fo = params['FIELD_${f}_OFFSET'] as int? ?? 0; + if (fn == null) { + continue; } - continue; - } - - // Build the synthetic module's internal wire-ID space. - final usedIds = {}; - for (final cn in comp) { - final cell = cells[cn]; - if (cell == null) { + final fb = conns[fn] as List?; + if (fb == null) { continue; } - final conns = cell['connections'] as Map? ?? {}; - for (final bits in conns.values) { - for (final b in bits as List) { - if (b is int) { - usedIds.add(b); - } + for (var i = 0; i < fb.length; i++) { + if (fb[i] is int && (fo + i) < a.length) { + bitMap[fb[i] as int] = a[fo + i] as Object; } } } - var nextLocalId = 2; - final idRemap = {}; - for (final id in usedIds) { - idRemap[id] = nextLocalId++; - } - - List remapBits(List bits) => - bits.map((b) => b is int ? (idRemap[b] ?? b) : b).toList(); - - // Build ports: one input port per input group, one output port per - // output group. - final childPorts = >{}; - final instanceConns = >{}; - final instancePortDirs = {}; - - for (final entry in inputGroups.entries) { - final portName = 'in_${entry.key}'; - final parentBits = entry.value.cast(); - childPorts[portName] = { - 'direction': 'input', - 'bits': remapBits(parentBits), - }; - instanceConns[portName] = parentBits; - instancePortDirs[portName] = 'input'; - } - - for (final entry in outputGroups.entries) { - final portName = 'out_${entry.key}'; - final parentBits = entry.value.cast(); - childPorts[portName] = { - 'direction': 'output', - 'bits': remapBits(parentBits), - }; - instanceConns[portName] = parentBits; - instancePortDirs[portName] = 'output'; - } - - // Re-map cells into the child's local ID space. - final childCells = >{}; - for (final cn in comp) { - final cell = Map.from(cells[cn]!); - final conns = Map.from( - cell['connections']! as Map); - for (final key in conns.keys.toList()) { - conns[key] = remapBits((conns[key] as List).cast()); + case r'$struct_pack': + final y = conns['Y'] as List; + final fc = params['FIELD_COUNT'] as int? ?? 0; + final src = List.filled(y.length, null); + for (var f = 0; f < fc; f++) { + final fn = params['FIELD_${f}_NAME'] as String?; + final fo = params['FIELD_${f}_OFFSET'] as int? ?? 0; + if (fn == null) { + continue; } - cell['connections'] = conns; - childCells[cn] = cell; - } - - // Build netnames for the child module. - final childNetnames = {}; - for (final pe in childPorts.entries) { - childNetnames[pe.key] = { - 'bits': pe.value['bits'], - 'attributes': {}, - }; - } - - final coveredIds = {}; - for (final nn in childNetnames.values) { - final bits = (nn! as Map)['bits']! as List; - for (final b in bits) { - if (b is int) { - coveredIds.add(b); + final fb = conns[fn] as List?; + if (fb == null) { + continue; + } + for (var i = 0; i < fb.length; i++) { + if ((fo + i) < src.length) { + src[fo + i] = fb[i]; } } } - for (final cellEntry in childCells.entries) { - final cellName = cellEntry.key; - final conns = - cellEntry.value['connections'] as Map? ?? {}; - for (final connEntry in conns.entries) { - final portName = connEntry.key; - final bits = connEntry.value as List; - final missingBits = []; - for (final b in bits) { - if (b is int && !coveredIds.contains(b)) { - missingBits.add(b); - coveredIds.add(b); - } - } - if (missingBits.isNotEmpty) { - childNetnames['${cellName}_$portName'] = { - 'bits': missingBits, - 'hide_name': 1, - 'attributes': {}, - }; - } + for (var i = 0; i < y.length; i++) { + if (y[i] is int && src[i] != null) { + bitMap[y[i] as int] = src[i]!; } } - - // Choose a name for the synthetic module type. - final syntheticTypeName = 'struct_assign_${moduleName}_$groupIdx'; - final syntheticInstanceName = 'struct_assign_$groupIdx'; - groupIdx++; - - // Register the synthetic module definition. - newModuleDefs[syntheticTypeName] = { - 'attributes': {'src': 'generated'}, - 'ports': childPorts, - 'cells': childCells, - 'netnames': childNetnames, - }; - - // Remove the grouped cells from the parent. - claimedCells.addAll(comp); - comp.forEach(cells.remove); - - // Add a hierarchical cell referencing the synthetic module. - cells[syntheticInstanceName] = { - 'hide_name': 0, - 'type': syntheticTypeName, - 'parameters': {}, - 'attributes': {}, - 'port_directions': instancePortDirs, - 'connections': instanceConns, - }; - } } - - // Add all new synthetic module definitions. - allModules.addAll(newModuleDefs); } - /// Replace groups of `$slice` cells that share the same input bus and - /// whose outputs all feed into the same destination cell+port with a - /// single `$buf` cell. - /// - /// This eliminates visual noise from struct-to-flat-bus decomposition - /// when the destination consumes the full struct value unchanged. - /// Both signal names (source struct and destination port) are preserved - /// as separate netnames connected through the buffer. - static void applyStructBufferInsertion( - Map> allModules, + /// Maps `Y[i]` → `A[i]` for identity-shaped cells. + static void _mapPairwise( + List a, + List y, + Map bitMap, ) { - for (final moduleName in allModules.keys.toList()) { - final moduleDef = allModules[moduleName]!; - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } - - // Group $slice cells by their input bus (A bits). - final slicesByInput = >{}; - for (final entry in cells.entries) { - final cell = entry.value; - if (cell['type'] != r'$slice') { - continue; - } - final conns = cell['connections'] as Map?; - if (conns == null) { - continue; - } - final aBits = conns['A'] as List?; - if (aBits == null) { - continue; - } - final key = aBits.join(','); - (slicesByInput[key] ??= []).add(entry.key); - } - - var bufIdx = 0; - for (final sliceGroup in slicesByInput.values) { - if (sliceGroup.length < 2) { - continue; - } - - // Collect all Y output bit IDs from the group. - final allYBitIds = {}; - for (final sliceName in sliceGroup) { - final cell = cells[sliceName]!; - final conns = cell['connections']! as Map; - for (final b in conns['Y']! as List) { - if (b is int) { - allYBitIds.add(b); - } - } - } - - // Check: do all Y bits go to the same destination cell+port - // (or a single module output port)? - String? destId; // unique identifier for the destination - var allSameDest = true; - - // Check cell port destinations. - for (final otherEntry in cells.entries) { - if (sliceGroup.contains(otherEntry.key)) { - continue; - } - final otherConns = - otherEntry.value['connections'] as Map? ?? {}; - for (final portEntry in otherConns.entries) { - final bits = portEntry.value as List; - if (bits.any((b) => b is int && allYBitIds.contains(b))) { - final id = '${otherEntry.key}.${portEntry.key}'; - if (destId == null) { - destId = id; - } else if (destId != id) { - allSameDest = false; - break; - } - } - } - if (!allSameDest) { - break; - } - } - - // Also check module output ports as potential destinations. - final modPorts = - moduleDef['ports'] as Map>?; - if (allSameDest && modPorts != null) { - for (final portEntry in modPorts.entries) { - final port = portEntry.value; - final dir = port['direction'] as String?; - if (dir != 'output') { - continue; - } - final bits = port['bits'] as List?; - if (bits != null && - bits.any((b) => b is int && allYBitIds.contains(b))) { - final id = '__port_${portEntry.key}'; - if (destId == null) { - destId = id; - } else if (destId != id) { - allSameDest = false; - break; - } - } - } - } - - if (!allSameDest || destId == null) { - continue; - } - - // Verify slices contiguously cover the full A bus. - final firstSlice = cells[sliceGroup.first]!; - final params0 = firstSlice['parameters'] as Map?; - final aWidth = params0?['A_WIDTH'] as int?; - if (aWidth == null) { - continue; - } - - // Map offset → Y bits list, and validate. - final coverageYBits = >{}; - var totalYBits = 0; - var valid = true; - for (final sliceName in sliceGroup) { - final cell = cells[sliceName]!; - final params = cell['parameters'] as Map?; - final offset = params?['OFFSET'] as int?; - final yWidth = params?['Y_WIDTH'] as int?; - if (offset == null || yWidth == null) { - valid = false; - break; - } - final conns = cell['connections']! as Map; - final yBits = (conns['Y']! as List).cast(); - if (yBits.length != yWidth) { - valid = false; - break; - } - coverageYBits[offset] = yBits; - totalYBits += yWidth; - } - if (!valid || totalYBits != aWidth) { - continue; - } - - // Verify contiguous coverage (no gaps or overlaps). - final sortedOffsets = coverageYBits.keys.toList()..sort(); - var expectedOffset = 0; - for (final off in sortedOffsets) { - if (off != expectedOffset) { - valid = false; - break; - } - expectedOffset += coverageYBits[off]!.length; - } - if (!valid || expectedOffset != aWidth) { - continue; - } - - // Build the buffer cell. - final firstConns = firstSlice['connections']! as Map; - final aBus = (firstConns['A']! as List).cast(); - - // Construct Y by concatenating slice outputs in offset order. - final yBus = []; - for (final off in sortedOffsets) { - yBus.addAll(coverageYBits[off]!); - } - - // Remove slice cells. - sliceGroup.forEach(cells.remove); - - // Insert $buf cell. - cells['struct_buf_$bufIdx'] = - NetlistUtils.makeBufCell(aWidth, aBus, yBus); - bufIdx++; + for (var i = 0; i < y.length && i < a.length; i++) { + if (y[i] is int) { + bitMap[y[i] as int] = a[i] as Object; } } } - - /// Replaces each `struct_assign_*` hierarchical instance in parent modules - /// with one `$buf` cell per output port and removes the synthetic module - /// definition. - /// - /// For each output port the internal `$slice`/`$concat` routing is traced - /// back to the corresponding input-port bits so that each `$buf` connects - /// only the bits belonging to that specific net. This keeps distinct - /// signal paths (e.g. `sum_0 → sumRpath` vs `sumP1 → sumPlusOneRpath`) - /// as separate cells so the schematic viewer can route them independently. - static void collapseStructGroupModules( - Map> allModules, - ) { - // Collect the names of all struct_assign module definitions to remove. - final structAssignTypes = { - for (final name in allModules.keys) - if (name.startsWith('struct_assign_')) name, - }; - - if (structAssignTypes.isEmpty) { - return; - } - - // Track which struct_assign types were fully collapsed (all instances - // replaced). Only those will have their definitions removed. - final collapsedTypes = {}; - final keptTypes = {}; - - // In each module, replace cells that instantiate a struct_assign type - // with a $buf cell. - for (final moduleDef in allModules.values) { - final cells = - moduleDef['cells'] as Map>? ?? {}; - - final replacements = >{}; - final removals = []; - - for (final entry in cells.entries) { - final cellName = entry.key; - final cell = entry.value; - final type = cell['type'] as String?; - if (type == null || !structAssignTypes.contains(type)) { - continue; - } - - final conns = cell['connections'] as Map? ?? {}; - - // Look up the synthetic module definition so we can trace the - // actual per-bit routing through its internal $slice/$concat cells. - final synthDef = allModules[type]; - if (synthDef == null) { - continue; - } - - final synthPorts = - synthDef['ports'] as Map>? ?? {}; - final synthCells = - synthDef['cells'] as Map>? ?? {}; - - // Map local (module-internal) input port bits → parent bit IDs, - // and also record which input port name each local bit belongs to - // plus its index within that port. - final localToParent = {}; - final localBitToInputPort = {}; - final localBitToIndex = {}; - final inputPortWidths = {}; - for (final pEntry in synthPorts.entries) { - final dir = pEntry.value['direction'] as String?; - if (dir != 'input' && dir != 'inout') { - continue; - } - final localBits = - (pEntry.value['bits'] as List?)?.cast() ?? []; - final parentBits = (conns[pEntry.key] as List?)?.cast() ?? []; - inputPortWidths[pEntry.key] = localBits.length; - for (var i = 0; i < localBits.length && i < parentBits.length; i++) { - if (localBits[i] is int) { - localToParent[localBits[i] as int] = parentBits[i]; - localBitToInputPort[localBits[i] as int] = pEntry.key; - localBitToIndex[localBits[i] as int] = i; - } - } - } - - final inputPortBits = localToParent.keys.toSet(); - - // Build a net-driver map inside the synthetic module by - // processing its $slice, $concat, and $buf cells. - final driver = {}; - - for (final sc in synthCells.values) { - final ct = sc['type'] as String?; - final cc = sc['connections'] as Map? ?? {}; - final cp = sc['parameters'] as Map? ?? {}; - - if (ct == r'$slice') { - final aBits = (cc['A'] as List?)?.cast() ?? []; - final yBits = (cc['Y'] as List?)?.cast() ?? []; - final offset = cp['OFFSET'] as int? ?? 0; - final yWidth = yBits.length; - final aWidth = aBits.length; - final reversed = offset + yWidth > aWidth; - for (var i = 0; i < yBits.length; i++) { - if (yBits[i] is int) { - final srcIdx = reversed ? (offset - i) : (offset + i); - if (srcIdx >= 0 && srcIdx < aBits.length) { - driver[yBits[i] as int] = aBits[srcIdx]; - } - } - } - } else if (ct == r'$concat') { - final yBits = (cc['Y'] as List?)?.cast() ?? []; - - // Gather input bits in LSB-first order. Two formats: - // 1. Standard 2-input: ports A (LSB) and B (MSB). - // 2. Multi-input: range-named ports [lo:hi] with - // INx_WIDTH parameters — ordered by range start. - final inputBits = []; - if (cc.containsKey('A')) { - inputBits - ..addAll((cc['A'] as List?)?.cast() ?? []) - ..addAll((cc['B'] as List?)?.cast() ?? []); - } else { - // Multi-input concat: collect range-named ports ordered - // by their starting bit position (LSB first). - final rangePorts = >{}; - for (final portName in cc.keys) { - if (portName == 'Y') { - continue; - } - final m = NetlistUtils.rangePortRe.firstMatch(portName); - if (m != null) { - final hi = int.parse(m.group(1)!); - final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; - rangePorts[lo] = - (cc[portName] as List?)?.cast() ?? []; - } - } - final sortedKeys = rangePorts.keys.toList()..sort(); - for (final k in sortedKeys) { - inputBits.addAll(rangePorts[k]!); - } - } - - for (var i = 0; i < yBits.length; i++) { - if (yBits[i] is int && i < inputBits.length) { - driver[yBits[i] as int] = inputBits[i]; - } - } - } else if (ct == r'$buf') { - final aBits = (cc['A'] as List?)?.cast() ?? []; - final yBits = (cc['Y'] as List?)?.cast() ?? []; - for (var i = 0; i < yBits.length && i < aBits.length; i++) { - if (yBits[i] is int) { - driver[yBits[i] as int] = aBits[i]; - } - } - } - } - - // Trace a local bit backwards through the driver map until we - // reach an input port bit or a string constant. - Object traceToSource(Object bit) { - final visited = {}; - var current = bit; - while (current is int && !inputPortBits.contains(current)) { - if (visited.contains(current)) { - break; - } - visited.add(current); - final next = driver[current]; - if (next == null) { - break; - } - current = next; - } - return current; - } - - // For each output port, trace its bits to their source and build - // the appropriate cell type: - // $buf – output has same width as its single source input port - // $slice – output is a contiguous sub-range of one input port - // $concat – output combines bits from multiple input ports - final perPortCells = >{}; - var anyUnresolved = false; - - for (final pEntry in synthPorts.entries) { - final dir = pEntry.value['direction'] as String?; - if (dir != 'output') { - continue; - } - final localBits = - (pEntry.value['bits'] as List?)?.cast() ?? []; - final parentBits = (conns[pEntry.key] as List?)?.cast() ?? []; - - final portOutputBits = []; - final portInputBits = []; - // Track per-bit source: local input-port bit ID (int) or null. - final sourceBitIds = []; - - for (var i = 0; i < parentBits.length; i++) { - portOutputBits.add(parentBits[i]); - if (i < localBits.length) { - final source = traceToSource(localBits[i]); - if (source is int && localToParent.containsKey(source)) { - portInputBits.add(localToParent[source]!); - sourceBitIds.add(source); - } else if (source is String) { - portInputBits.add(source); - sourceBitIds.add(null); - } else { - portInputBits.add('x'); - sourceBitIds.add(null); - } - } else { - portInputBits.add('x'); - sourceBitIds.add(null); - } - } - - if (portInputBits.contains('x')) { - anyUnresolved = true; - break; - } - - if (portOutputBits.isEmpty) { - continue; - } - - // Determine which input port(s) source this output port. - final sourcePortNames = {}; - for (final sid in sourceBitIds) { - if (sid != null && localBitToInputPort.containsKey(sid)) { - sourcePortNames.add(localBitToInputPort[sid]!); - } - } - - final cellKey = '${cellName}_${pEntry.key}'; - - if (sourcePortNames.length == 1) { - final srcPort = sourcePortNames.first; - final srcWidth = inputPortWidths[srcPort] ?? 0; - if (portOutputBits.length == srcWidth) { - // Same width → $buf - perPortCells['${cellKey}_buf'] = NetlistUtils.makeBufCell( - portOutputBits.length, portInputBits, portOutputBits); - } else { - // Subset of one input port → $slice. Determine the offset - // from the first traced bit's index within its input port. - final firstIdx = sourceBitIds.first; - final offset = - firstIdx != null ? (localBitToIndex[firstIdx] ?? 0) : 0; - perPortCells['${cellKey}_slice'] = NetlistUtils.makeSliceCell( - offset, - srcWidth, - portOutputBits.length, - (conns[srcPort] as List?)?.cast() ?? [], - portOutputBits); - } - } else { - // Multiple source ports – should be rare after the grouping - // guard excludes multi-source concats. Fall back to $buf. - perPortCells['${cellKey}_buf'] = NetlistUtils.makeBufCell( - portOutputBits.length, portInputBits, portOutputBits); - } - } - - if (perPortCells.isEmpty) { - continue; - } - - // Only collapse pure passthroughs: every output bit must trace - // back to an input-port bit or a string constant. If any bit - // fell through as 'x' the module is doing real computation - // (e.g. addition, muxing) and should be kept as a hierarchy. - if (anyUnresolved) { - keptTypes.add(type); - continue; - } - - collapsedTypes.add(type); - removals.add(cellName); - replacements.addAll(perPortCells); - } - - removals.forEach(cells.remove); - cells.addAll(replacements); - } - - // Remove only the synthetic module definitions whose instances were all - // successfully collapsed. Types that had at least one non-passthrough - // instance must keep their definition so the hierarchy is preserved. - collapsedTypes.difference(keptTypes).forEach(allModules.remove); - } - - /// Replace standalone `$concat` cells whose input bits all originate - /// from a single module input (or inout) port and cover its full width - /// with a simple `$buf` cell. - /// - /// This eliminates the visual noise of struct-to-bitvector reassembly - /// when an input [LogicStructure] port is decomposed into fields and - /// immediately re-packed via a [Swizzle]. - static void applyConcatToBufferReplacement( - Map> allModules, - ) { - for (final moduleDef in allModules.values) { - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } - - final modPorts = moduleDef['ports'] as Map>?; - if (modPorts == null) { - continue; - } - - // Build bit → port-name map for input / inout ports. - final bitToPort = {}; - for (final portEntry in modPorts.entries) { - final dir = portEntry.value['direction'] as String?; - if (dir != 'input' && dir != 'inout') { - continue; - } - final bits = portEntry.value['bits'] as List? ?? []; - for (final b in bits) { - if (b is int) { - bitToPort[b] = portEntry.key; - } - } - } - - final removals = []; - final additions = >{}; - var bufIdx = 0; - - // Avoid name collisions with existing concat_buf_* cells. - for (final name in cells.keys) { - if (name.startsWith('concat_buf_')) { - final idx = int.tryParse(name.substring('concat_buf_'.length)); - if (idx != null && idx >= bufIdx) { - bufIdx = idx + 1; - } - } - } - - for (final entry in cells.entries) { - final cell = entry.value; - if ((cell['type'] as String?) != r'$concat') { - continue; - } - - final conns = cell['connections'] as Map? ?? {}; - final pdirs = cell['port_directions'] as Map? ?? {}; - - // Collect input ranges and the Y output. - // Port names follow the pattern "[upper:lower]" or "[bit]". - final rangedInputs = >{}; // lower → bits - List? yBits; - - for (final pe in conns.entries) { - final dir = pdirs[pe.key] as String? ?? ''; - final bits = (pe.value as List).cast(); - if (dir == 'output' && pe.key == 'Y') { - yBits = bits; - continue; - } - if (dir != 'input') { - continue; - } - // Parse "[upper:lower]" or "[bit]". - final match = NetlistUtils.rangePortRe.firstMatch(pe.key); - if (match == null) { - // Also accept the 2-input A/B form. - if (pe.key == 'A') { - rangedInputs[0] = bits; - } else if (pe.key == 'B') { - // Determine A width to set the offset. - final aBits = conns['A'] as List?; - if (aBits != null) { - rangedInputs[aBits.length] = bits; - } - } - continue; - } - final upper = int.parse(match.group(1)!); - final lower = - match.group(2) != null ? int.parse(match.group(2)!) : upper; - rangedInputs[lower] = bits; - } - - if (yBits == null || rangedInputs.isEmpty) { - continue; - } - - // Assemble input bits in LSB-to-MSB order. - final sortedLowers = rangedInputs.keys.toList()..sort(); - final allInputBits = []; - for (final lower in sortedLowers) { - allInputBits.addAll(rangedInputs[lower]!); - } - - // Check that every input bit belongs to the same module port. - String? sourcePort; - var allFromSamePort = true; - for (final b in allInputBits) { - if (b is! int) { - allFromSamePort = false; - break; - } - final port = bitToPort[b]; - if (port == null) { - allFromSamePort = false; - break; - } - sourcePort ??= port; - if (port != sourcePort) { - allFromSamePort = false; - break; - } - } - - if (!allFromSamePort || sourcePort == null) { - continue; - } - - // Verify full-width coverage of the source port. - final portBits = modPorts[sourcePort]!['bits']! as List; - if (allInputBits.length != portBits.length) { - continue; - } - - // Replace $concat with $buf. - removals.add(entry.key); - additions['concat_buf_$bufIdx'] = - NetlistUtils.makeBufCell(allInputBits.length, allInputBits, yBits); - bufIdx++; - } - - removals.forEach(cells.remove); - cells.addAll(additions); - } - } - - // -- Collapse selects into struct_pack --------------------------------- - - /// Finds `$slice` cells whose outputs feed exclusively into a - /// `$struct_pack` input port. The slice is absorbed: the pack input - /// port is rewired to the slice's source bits directly and the - /// now-redundant slice is removed. - /// - /// This is the "selects into a pack" optimization: when a flat bus is - /// decomposed through individual slices and then repacked into a struct, - /// the intermediate slice cells add visual noise beyond what the - /// struct_pack field metadata already provides. - static void applyCollapseSelectsIntoPack( - Map> allModules, - ) { - for (final moduleDef in allModules.values) { - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } - - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - NetlistUtils.buildWireMaps(cells, moduleDef); - - final cellsToRemove = {}; - - for (final packEntry in cells.entries.toList()) { - final packName = packEntry.key; - final packCell = packEntry.value; - if ((packCell['type'] as String?) != r'$struct_pack') { - continue; - } - - final conns = packCell['connections'] as Map? ?? {}; - final dirs = packCell['port_directions'] as Map? ?? {}; - - for (final portName in conns.keys.toList()) { - if (dirs[portName] != 'input') { - continue; - } - final bits = [ - for (final b in conns[portName] as List) - if (b is int) b, - ]; - if (bits.isEmpty) { - continue; - } - - // All bits must be driven by the same $slice cell. - final firstDriver = wireDriverCell[bits.first]; - if (firstDriver == null) { - continue; - } - final driverCell = cells[firstDriver]; - if (driverCell == null) { - continue; - } - if ((driverCell['type'] as String?) != r'$slice') { - continue; - } - if (cellsToRemove.contains(firstDriver)) { - continue; - } - - final allFromSameSlice = bits.every( - (b) => wireDriverCell[b] == firstDriver, - ); - if (!allFromSameSlice) { - continue; - } - - // The slice must exclusively feed this pack. - final sliceConns = - driverCell['connections'] as Map? ?? {}; - final sliceYBits = [ - for (final b in sliceConns['Y'] as List) - if (b is int) b, - ]; - final exclusive = sliceYBits.every((b) { - final consumers = wireConsumerCells[b]; - if (consumers == null) { - return true; - } - return consumers.every((c) => c == packName || c == '__port__'); - }); - if (!exclusive) { - continue; - } - - // Rewire: replace the pack's input bits with the slice's - // source bits (A port) at the correct offset. - final sliceABits = sliceConns['A'] as List; - final params = - driverCell['parameters'] as Map? ?? {}; - final offset = params['OFFSET'] as int? ?? 0; - final yWidth = sliceYBits.length; - - final newBits = [ - for (var i = 0; i < yWidth; i++) sliceABits[offset + i] as Object, - ]; - conns[portName] = newBits; - - cellsToRemove.add(firstDriver); - } - } - - cellsToRemove.forEach(cells.remove); - - // Second pass: collapse struct_pack → $buf when all field inputs - // form a contiguous ascending sequence (identity pack). - for (final packEntry in cells.entries.toList()) { - final packName = packEntry.key; - final packCell = packEntry.value; - if ((packCell['type'] as String?) != r'$struct_pack') { - continue; - } - - final conns = packCell['connections'] as Map? ?? {}; - final dirs = packCell['port_directions'] as Map? ?? {}; - - // Collect all input bits in field declaration order. - final allInputBits = []; - for (final portName in conns.keys) { - if (dirs[portName] != 'input') { - continue; - } - for (final b in conns[portName] as List) { - if (b is int) { - allInputBits.add(b); - } - } - } - if (allInputBits.length < 2) { - continue; - } - - // Check: input bits must form a contiguous ascending sequence. - var contiguous = true; - for (var i = 1; i < allInputBits.length; i++) { - if (allInputBits[i] != allInputBits[i - 1] + 1) { - contiguous = false; - break; - } - } - if (!contiguous) { - continue; - } - - final yBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; - if (yBits.length != allInputBits.length) { - continue; - } - - // Replace struct_pack with $buf. - cells[packName] = { - 'type': r'$buf', - 'parameters': {'WIDTH': allInputBits.length}, - 'port_directions': {'A': 'input', 'Y': 'output'}, - 'connections': >{ - 'A': allInputBits.cast(), - 'Y': yBits, - }, - }; - } - } - } - - // -- Collapse struct_unpack to concat ---------------------------------- - - /// Finds `$concat` cells whose input ports are driven (directly or - /// through exclusive `$buf`/`$slice` chains) by output ports of - /// `$struct_unpack` cells. When all inputs trace back through a single - /// unpack to its source bus, the concat and intermediate cells are - /// replaced by a `$buf` or `$slice` from the unpack's A bus. - /// - /// Partial collapse is also supported: contiguous runs of concat ports - /// that trace to the same unpack are collapsed individually. - static void applyCollapseUnpackToConcat( - Map> allModules, - ) { - for (final moduleEntry in allModules.entries) { - final moduleDef = moduleEntry.value; - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } - - // Iterate until convergence: each pass may create bufs that enable - // the next outer concat/unpack to collapse. - var globalReplIdx = 0; - var anyChanged = true; - var iteration = 0; - while (anyChanged && iteration < 20) { - anyChanged = false; - iteration++; - - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - NetlistUtils.buildWireMaps(cells, moduleDef); - - final cellsToRemove = {}; - final cellsToAdd = >{}; - var replIdx = globalReplIdx; - - for (final concatEntry in cells.entries.toList()) { - final concatName = concatEntry.key; - final concatCell = concatEntry.value; - if ((concatCell['type'] as String?) != r'$concat') { - continue; - } - if (cellsToRemove.contains(concatName)) { - continue; - } - - final conns = - concatCell['connections'] as Map? ?? {}; - - // Parse input ports into ordered list. - final inputPorts = <(int lo, String portName, List bits)>[]; - var hasRangePorts = false; - for (final portName in conns.keys) { - if (portName == 'Y') { - continue; - } - final m = NetlistUtils.rangePortRe.firstMatch(portName); - if (m != null) { - hasRangePorts = true; - final hi = int.parse(m.group(1)!); - final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; - inputPorts.add(( - lo, - portName, - [ - for (final b in conns[portName] as List) - if (b is int) b - ], - )); - } - } - if (!hasRangePorts) { - if (conns.containsKey('A') && conns.containsKey('B')) { - final aBits = [ - for (final b in conns['A'] as List) - if (b is int) b, - ]; - final bBits = [ - for (final b in conns['B'] as List) - if (b is int) b, - ]; - inputPorts - ..add((0, 'A', aBits)) - ..add((aBits.length, 'B', bBits)); - } - } - inputPorts.sort((a, b) => a.$1.compareTo(b.$1)); - if (inputPorts.length < 2) { - continue; - } - - // --- Extended trace: through $buf/$slice AND $struct_unpack ------ - final portTraces = <({ - String? unpackName, - List? unpackABits, - List sourceIndices, - Set intermediates, - bool valid, - })>[]; - - for (final (_, _, bits) in inputPorts) { - final sourceIndices = []; - final intermediates = {}; - String? unpackName; - List? unpackABits; - var valid = true; - - for (final bit in bits) { - final (traced, chain) = - NetlistUtils.traceBackward(bit, wireDriverCell, cells); - intermediates.addAll(chain); - - // Check if traced bit is driven by a $struct_unpack output. - final driverName = wireDriverCell[traced]; - if (driverName == null) { - valid = false; - break; - } - final driverCell = cells[driverName]; - if (driverCell == null || - (driverCell['type'] as String?) != r'$struct_unpack') { - valid = false; - break; - } - - final uConns = - driverCell['connections'] as Map? ?? {}; - final uDirs = - driverCell['port_directions'] as Map? ?? {}; - final aBits = [ - for (final b in uConns['A'] as List) - if (b is int) b, - ]; - - // Find which output port contains this bit and its index - // within that port. - String? outPort; - int? bitIdx; - for (final pe in uConns.entries) { - if (pe.key == 'A') { - continue; - } - if (uDirs[pe.key] != 'output') { - continue; - } - final pBits = [ - for (final b in pe.value as List) - if (b is int) b, - ]; - final idx = pBits.indexOf(traced); - if (idx >= 0) { - outPort = pe.key; - bitIdx = idx; - break; - } - } - - if (outPort == null || bitIdx == null) { - valid = false; - break; - } - - // Find the field offset for this output port. - final params = - driverCell['parameters'] as Map? ?? {}; - final fc = params['FIELD_COUNT'] as int? ?? 0; - int? fieldOffset; - for (var fi = 0; fi < fc; fi++) { - final fname = params['FIELD_${fi}_NAME'] as String? ?? ''; - if (fname == outPort || outPort == '${fname}_$fi') { - fieldOffset = params['FIELD_${fi}_OFFSET'] as int? ?? 0; - break; - } - } - - if (fieldOffset == null) { - valid = false; - break; - } - - final aIdx = fieldOffset + bitIdx; - if (aIdx >= aBits.length) { - valid = false; - break; - } - - intermediates.add(driverName); - - if (unpackName == null) { - unpackName = driverName; - unpackABits = aBits; - } else if (unpackName != driverName) { - valid = false; - break; - } - sourceIndices.add(aIdx); - } - - portTraces.add(( - unpackName: unpackName, - unpackABits: unpackABits, - sourceIndices: sourceIndices, - intermediates: intermediates, - valid: valid, - )); - } - - // --- Find runs of consecutive ports tracing to the same unpack --- - final runs = <(int startIdx, int endIdx)>[]; - var runStart = 0; - while (runStart < inputPorts.length) { - final t = portTraces[runStart]; - if (!t.valid || t.unpackName == null) { - runStart++; - continue; - } - var runEnd = runStart; - while (runEnd + 1 < inputPorts.length) { - final nextT = portTraces[runEnd + 1]; - if (!nextT.valid || nextT.unpackName != t.unpackName) { - break; - } - final curLast = portTraces[runEnd].sourceIndices.last; - final nextFirst = nextT.sourceIndices.first; - if (nextFirst != curLast + 1) { - break; - } - runEnd++; - } - if (runEnd > runStart) { - runs.add((runStart, runEnd)); - } - runStart = runEnd + 1; - } - - if (runs.isEmpty) { - // No contiguous ascending runs, but check if ALL ports trace - // to the same unpack (general reorder / swizzle case). - final allValid = portTraces.every((t) => t.valid); - if (!allValid) { - continue; - } - final unpackNames = portTraces.map((t) => t.unpackName).toSet(); - if (unpackNames.length != 1 || unpackNames.first == null) { - continue; - } - final uName = unpackNames.first!; - final uABits = portTraces.first.unpackABits!; - - // Gather all intermediates and verify exclusivity. - final allIntermediates = {}; - for (final t in portTraces) { - allIntermediates.addAll(t.intermediates); - } - final removable = allIntermediates.where((c) { - final ct = cells[c]?['type'] as String?; - return ct == r'$buf' || ct == r'$slice'; - }).toSet(); - if (removable.isNotEmpty && - !NetlistUtils.isExclusiveChain( - intermediates: removable, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - continue; - } - - // Build reordered A bits: for each concat input port (in - // order), map the source indices back to the unpack's A bus. - final reorderedA = []; - for (final t in portTraces) { - for (final aIdx in t.sourceIndices) { - reorderedA.add(uABits[aIdx] as Object); - } - } - final outputBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; - if (reorderedA.length != outputBits.length) { - continue; - } - - cellsToRemove - ..addAll(removable) - ..add(uName) - ..add(concatName); - cellsToAdd['unpack_concat_buf_$replIdx'] = NetlistUtils.makeBufCell( - reorderedA.length, reorderedA, outputBits); - replIdx++; - continue; - } - - // --- Verify exclusivity of non-unpack intermediates ------ - final validRuns = - <(int startIdx, int endIdx, Set intermediates)>[]; - for (final (startIdx, endIdx) in runs) { - final allIntermediates = {}; - for (var i = startIdx; i <= endIdx; i++) { - allIntermediates.addAll(portTraces[i].intermediates); - } - // Only remove $buf/$slice intermediates, not the unpack itself. - final removable = allIntermediates.where((c) { - final ct = cells[c]?['type'] as String?; - return ct == r'$buf' || ct == r'$slice'; - }).toSet(); - if (removable.isEmpty || - NetlistUtils.isExclusiveChain( - intermediates: removable, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - validRuns.add((startIdx, endIdx, removable)); - } - } - - if (validRuns.isEmpty) { - continue; - } - - final allCollapsed = validRuns.length == 1 && - validRuns.first.$1 == 0 && - validRuns.first.$2 == inputPorts.length - 1; - - for (final (_, _, intermediates) in validRuns) { - cellsToRemove.addAll(intermediates); - } - - if (allCollapsed) { - // Full collapse — replace concat with $buf or $slice. - // Since we remove intermediates (buf/slice chains between the - // unpack outputs and the concat inputs), we must source the - // replacement buf from the unpack's A bus, not the concat's - // input bits which may reference wires driven by the removed - // intermediates. - final t0 = portTraces.first; - final srcOffset = t0.sourceIndices.first; - final yWidth = (conns['Y'] as List).whereType().length; - final aWidth = t0.unpackABits!.length; - final sourceBits = t0.unpackABits!.cast().toList(); - final outputBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; - - cellsToRemove - ..add(concatName) - // Also remove the unpack itself — all its outputs are consumed - // exclusively through intermediates into this concat. - ..add(t0.unpackName!); - if (yWidth == aWidth) { - cellsToAdd['unpack_concat_buf_$replIdx'] = - NetlistUtils.makeBufCell(aWidth, sourceBits, outputBits); - } else { - cellsToAdd['unpack_concat_buf_$replIdx'] = - NetlistUtils.makeSliceCell( - srcOffset, aWidth, yWidth, sourceBits, outputBits); - } - replIdx++; - continue; - } - - // --- Partial collapse — rebuild concat with fewer ports --------- - cellsToRemove.add(concatName); - - final newConns = >{}; - final newDirs = {}; - var outBitOffset = 0; - - var portIdx = 0; - while (portIdx < inputPorts.length) { - (int, int, Set)? activeRun; - for (final run in validRuns) { - if (run.$1 == portIdx) { - activeRun = run; - break; - } - } - - if (activeRun != null) { - final (startIdx, endIdx, _) = activeRun; - // Collect the traced source bits — the unpack output bits - // that traceBackward found. We cannot use the concat's raw - // input bits because intermediates (buf/slice chains) between - // the unpack outputs and the concat are being removed. - final tracedBits = []; - final t0 = portTraces[startIdx]; - final uConns = cells[t0.unpackName!]!['connections'] - as Map? ?? - {}; - final uDirs = cells[t0.unpackName!]!['port_directions'] - as Map? ?? - {}; - // Rebuild the unpack's output bits in field declaration order - // to create a mapping from A-index to wire ID. - final unpackOutBitList = []; - for (final pe in uConns.entries) { - if (pe.key == 'A') { - continue; - } - if (uDirs[pe.key] != 'output') { - continue; - } - for (final b in pe.value as List) { - if (b is int) { - unpackOutBitList.add(b); - } - } - } - // Build A-index -> output wire ID map. - final aToOutBit = {}; - final uParams = cells[t0.unpackName!]!['parameters'] - as Map? ?? - {}; - final fc = uParams['FIELD_COUNT'] as int? ?? 0; - var outIdx = 0; - for (var fi = 0; fi < fc; fi++) { - final fw = uParams['FIELD_${fi}_WIDTH'] as int? ?? 0; - final fo = uParams['FIELD_${fi}_OFFSET'] as int? ?? 0; - for (var bi = 0; bi < fw; bi++) { - if (outIdx < unpackOutBitList.length) { - aToOutBit[fo + bi] = unpackOutBitList[outIdx]; - } - outIdx++; - } - } - for (var i = startIdx; i <= endIdx; i++) { - for (final aIdx in portTraces[i].sourceIndices) { - final outBit = aToOutBit[aIdx]; - if (outBit != null) { - tracedBits.add(outBit); - } - } - } - final width = tracedBits.length; - - final hi = outBitOffset + width - 1; - final portName = - hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; - newConns[portName] = tracedBits; - newDirs[portName] = 'input'; - outBitOffset += width; - - portIdx = endIdx + 1; - } else { - final port = inputPorts[portIdx]; - final width = port.$3.length; - final hi = outBitOffset + width - 1; - final portName = - hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; - newConns[portName] = port.$3.cast(); - newDirs[portName] = 'input'; - outBitOffset += width; - portIdx++; - } - } - - newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; - newDirs['Y'] = 'output'; - - cellsToAdd['${concatName}_collapsed'] = { - 'hide_name': concatCell['hide_name'], - 'type': r'$concat', - 'parameters': {}, - 'attributes': concatCell['attributes'] ?? {}, - 'port_directions': newDirs, - 'connections': newConns, - }; - } - - cellsToRemove.forEach(cells.remove); - cells.addAll(cellsToAdd); - if (cellsToRemove.isNotEmpty || cellsToAdd.isNotEmpty) { - anyChanged = true; - } - globalReplIdx = replIdx; - - // Second pass: collapse identity struct_unpack → $buf chains. - // If ALL outputs of a struct_unpack go exclusively to one $buf whose - // A bits are exactly those outputs in order, replace both with a - // single $buf from the unpack's A to the buf's Y. - final unpacksToRemove = {}; - final bufsToRemove = {}; - final bufsToAdd = >{}; - var identBufIdx = 0; - - final wireMaps2 = NetlistUtils.buildWireMaps(cells, moduleDef); - final wireConsumerCells2 = wireMaps2.wireConsumerCells; - - for (final entry in cells.entries.toList()) { - final unpackName = entry.key; - final unpackCell = entry.value; - if ((unpackCell['type'] as String?) != r'$struct_unpack') { - continue; - } - if (unpacksToRemove.contains(unpackName)) { - continue; - } - - final uConns = - unpackCell['connections'] as Map? ?? {}; - final uDirs = - unpackCell['port_directions'] as Map? ?? {}; - - // Collect all output bits in field declaration order. - final allOutputBits = []; - for (final pname in uConns.keys) { - if (uDirs[pname] != 'output') { - continue; - } - for (final b in uConns[pname] as List) { - if (b is int) { - allOutputBits.add(b); - } - } - } - if (allOutputBits.isEmpty) { - continue; - } - - // Every output bit must be consumed by exactly one $buf cell - // (the same one). - String? targetBufName; - var allToOneBuf = true; - for (final bit in allOutputBits) { - final consumers = wireConsumerCells2[bit]; - if (consumers == null || consumers.length != 1) { - allToOneBuf = false; - break; - } - final consumer = consumers.first; - if (consumer == '__port__') { - allToOneBuf = false; - break; - } - final consumerCell = cells[consumer]; - if (consumerCell == null || - (consumerCell['type'] as String?) != r'$buf') { - allToOneBuf = false; - break; - } - if (targetBufName == null) { - targetBufName = consumer; - } else if (consumer != targetBufName) { - allToOneBuf = false; - break; - } - } - if (!allToOneBuf || targetBufName == null) { - continue; - } - if (bufsToRemove.contains(targetBufName)) { - continue; - } - - final bufCell = cells[targetBufName]!; - final bufConns = - bufCell['connections'] as Map? ?? {}; - final bufABits = [ - for (final b in bufConns['A'] as List) - if (b is int) b, - ]; - - // The buf's A bits must be exactly the unpack's output bits. - if (bufABits.length != allOutputBits.length) { - continue; - } - var bitsMatch = true; - for (var i = 0; i < bufABits.length; i++) { - if (bufABits[i] != allOutputBits[i]) { - bitsMatch = false; - break; - } - } - if (!bitsMatch) { - continue; - } - - // Collapse: single buf from unpack.A → buf.Y - final unpackABits = [ - for (final b in uConns['A'] as List) - if (b is int) b, - ]; - final bufYBits = [ - for (final b in bufConns['Y'] as List) - if (b is int) b, - ]; - - if (unpackABits.length != bufYBits.length) { - continue; - } - - bufsToAdd['${unpackName}_buf_$identBufIdx'] = { - 'type': r'$buf', - 'parameters': {'WIDTH': unpackABits.length}, - 'port_directions': {'A': 'input', 'Y': 'output'}, - 'connections': >{ - 'A': unpackABits, - 'Y': bufYBits, - }, - }; - identBufIdx++; - unpacksToRemove.add(unpackName); - bufsToRemove.add(targetBufName); - } - - unpacksToRemove.forEach(cells.remove); - bufsToRemove.forEach(cells.remove); - cells.addAll(bufsToAdd); - if (unpacksToRemove.isNotEmpty || bufsToRemove.isNotEmpty) { - anyChanged = true; - } - } // end while (anyChanged) - } - } - - // -- Collapse struct_unpack to struct_pack ----------------------------- - - /// Finds `$struct_pack` cells whose input ports are driven (directly - /// or through exclusive `$buf`/`$slice` chains) by output ports of - /// `$struct_unpack` cells. The exclusive intermediate `$buf`/`$slice` - /// cells are removed, and the pack input ports are rewired to the - /// unpack output bits directly. - /// - /// The unpack cell itself is preserved (it may have other consumers). - /// Only the intermediate routing cells are removed. - static void applyCollapseUnpackToPack( - Map> allModules, - ) { - for (final moduleDef in allModules.values) { - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } - - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - NetlistUtils.buildWireMaps(cells, moduleDef); - - final cellsToRemove = {}; - - for (final packEntry in cells.entries.toList()) { - final packName = packEntry.key; - final packCell = packEntry.value; - if ((packCell['type'] as String?) != r'$struct_pack') { - continue; - } - - final conns = packCell['connections'] as Map? ?? {}; - final dirs = packCell['port_directions'] as Map? ?? {}; - - for (final portName in conns.keys.toList()) { - if (dirs[portName] != 'input') { - continue; - } - final bits = [ - for (final b in conns[portName] as List) - if (b is int) b, - ]; - if (bits.isEmpty) { - continue; - } - - // Trace each bit backward through $buf/$slice chains. - final tracedBits = []; - final intermediates = {}; - var allTraceToUnpack = true; - String? unpackName; - - for (final bit in bits) { - final (traced, chain) = - NetlistUtils.traceBackward(bit, wireDriverCell, cells); - intermediates.addAll(chain); - - // Check if traced bit is driven by a $struct_unpack. - final driverName = wireDriverCell[traced]; - if (driverName == null) { - allTraceToUnpack = false; - break; - } - final driverCell = cells[driverName]; - if (driverCell == null || - (driverCell['type'] as String?) != r'$struct_unpack') { - allTraceToUnpack = false; - break; - } - - if (unpackName == null) { - unpackName = driverName; - } else if (unpackName != driverName) { - allTraceToUnpack = false; - break; - } - - tracedBits.add(traced); - } - - if (!allTraceToUnpack || intermediates.isEmpty) { - continue; - } - - // Only remove $buf/$slice intermediates (not the unpack itself). - final removable = intermediates.where((c) { - final ct = cells[c]?['type'] as String?; - return ct == r'$buf' || ct == r'$slice'; - }).toSet(); - - if (removable.isEmpty) { - continue; - } - - // Verify exclusivity. - if (!NetlistUtils.isExclusiveChain( - intermediates: removable, - ownerCell: packName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - continue; - } - - // Rewire: replace the pack's input port with the traced bits. - conns[portName] = tracedBits.cast().toList(); - cellsToRemove.addAll(removable); - } - } - - cellsToRemove.forEach(cells.remove); - } - } } diff --git a/lib/src/synthesizers/netlist/netlist_synthesizer.dart b/lib/src/synthesizers/netlist/netlist_synthesizer.dart index a21760b15..59608f8ba 100644 --- a/lib/src/synthesizers/netlist/netlist_synthesizer.dart +++ b/lib/src/synthesizers/netlist/netlist_synthesizer.dart @@ -151,7 +151,7 @@ class _NetlistSynthModuleDefinition extends SynthModuleDefinition { /// /// Usage: /// ```dart -/// const options = NetlistOptions(groupStructConversions: true); +/// const options = NetlistOptions(collapseTransparentClusters: true); /// final synth = NetlistSynthesizer(options: options); /// final builder = SynthBuilder(topModule, synth); /// final json = await synth.synthesizeToJson(topModule); @@ -681,6 +681,34 @@ class NetlistSynthesizer extends Synthesizer { fullParentIds: parentIds, )); } + } else if (elem is LogicStructure && elem is! LogicArray) { + // Nested InterfaceStructure: the intermediate struct + // itself has no SynthLogic, but its leaf elements do + // (created by _subsetReceiveStructPort). Walk leaf + // elements and emit struct field entries for each, + // using the top-level parent as the parent Logic. + var leafIdx = idx; + for (final leaf in elem.leafElements) { + final leafSL = synthDef.logicToSynthMap[leaf]; + if (leafSL != null) { + final leafIds = getIds(leafSL); + final sliceLen = leafIds.length < parentIds.length - leafIdx + ? leafIds.length + : parentIds.length - leafIdx; + if (sliceLen > 0) { + structFieldCells.add(( + parentIds: parentIds.sublist(leafIdx, leafIdx + sliceLen), + elemIds: leafIds.sublist(0, sliceLen), + offset: leafIdx, + width: sliceLen, + elemLogic: leaf, + parentLogic: logic, + fullParentIds: parentIds, + )); + } + } + leafIdx += leaf.width; + } } idx += elem.width; } @@ -1593,28 +1621,8 @@ class NetlistSynthesizer extends Synthesizer { void applyPostProcessingPasses( Map> modules, ) { - if (options.groupStructConversions) { - if (options.groupMaximalSubsets) { - NetlistPasses.applyMaximalSubsetGrouping(modules); - } - if (options.collapseConcats) { - NetlistPasses.applyCollapseConcats(modules); - } - if (options.collapseSelectsIntoPack) { - NetlistPasses.applyCollapseSelectsIntoPack(modules); - } - if (options.collapseUnpackToConcat) { - NetlistPasses.applyCollapseUnpackToConcat(modules); - } - if (options.collapseUnpackToPack) { - NetlistPasses.applyCollapseUnpackToPack(modules); - } - NetlistPasses.applyStructConversionGrouping(modules); - if (options.collapseStructGroups) { - NetlistPasses.collapseStructGroupModules(modules); - } - NetlistPasses.applyStructBufferInsertion(modules); - NetlistPasses.applyConcatToBufferReplacement(modules); + if (options.collapseTransparentClusters) { + NetlistPasses.applyTransparentClustering(modules); } } diff --git a/lib/src/synthesizers/netlist/netlist_utils.dart b/lib/src/synthesizers/netlist/netlist_utils.dart index 258fe56a3..b6ee2c087 100644 --- a/lib/src/synthesizers/netlist/netlist_utils.dart +++ b/lib/src/synthesizers/netlist/netlist_utils.dart @@ -48,9 +48,6 @@ class NetlistUtils { return r; } - /// Anchored regex for range-named concat port labels like `[7:0]` or `[3]`. - static final rangePortRe = RegExp(r'^\[(\d+)(?::(\d+))?\]$'); - /// Create a `$buf` cell map. static Map makeBufCell( int width, @@ -66,191 +63,6 @@ class NetlistUtils { 'connections': >{'A': aBits, 'Y': yBits}, }; - /// Create a `$slice` cell map. - static Map makeSliceCell( - int offset, - int aWidth, - int yWidth, - List aBits, - List yBits, - ) => - { - 'hide_name': 0, - 'type': r'$slice', - 'parameters': { - 'OFFSET': offset, - 'A_WIDTH': aWidth, - 'Y_WIDTH': yWidth, - }, - 'attributes': {}, - 'port_directions': {'A': 'input', 'Y': 'output'}, - 'connections': >{'A': aBits, 'Y': yBits}, - }; - - /// Build wire-driver, wire-consumer, and bit-to-net maps for a module. - /// - /// Scans every cell's connections to find which cell drives each wire bit - /// (output direction) and which cells consume it (input direction). - /// Module output-port bits are registered as pseudo-consumers (`__port__`) - /// so that cells feeding module ports are never accidentally removed. - static ({ - Map wireDriverCell, - Map> wireConsumerCells, - Map)> bitToNetInfo, - }) buildWireMaps( - Map> cells, - Map moduleDef, - ) { - final wireDriverCell = {}; - final wireConsumerCells = >{}; - for (final entry in cells.entries) { - final cell = entry.value; - final conns = cell['connections'] as Map? ?? {}; - final pdirs = cell['port_directions'] as Map? ?? {}; - for (final pe in conns.entries) { - final d = pdirs[pe.key] as String? ?? ''; - for (final b in pe.value as List) { - if (b is int) { - if (d == 'output') { - wireDriverCell[b] = entry.key; - } else if (d == 'input') { - (wireConsumerCells[b] ??= {}).add(entry.key); - } - } - } - } - } - - final modPorts = moduleDef['ports'] as Map>?; - if (modPorts != null) { - for (final port in modPorts.values) { - if ((port['direction'] as String?) == 'output') { - for (final b in port['bits'] as List? ?? []) { - if (b is int) { - (wireConsumerCells[b] ??= {}).add('__port__'); - } - } - } - } - } - - final netnames = moduleDef['netnames'] as Map? ?? {}; - final bitToNetInfo = )>{}; - for (final nnEntry in netnames.entries) { - final nd = nnEntry.value! as Map; - final bits = (nd['bits'] as List?)?.cast() ?? []; - for (final b in bits) { - bitToNetInfo[b] = (nnEntry.key, bits); - } - } - - return ( - wireDriverCell: wireDriverCell, - wireConsumerCells: wireConsumerCells, - bitToNetInfo: bitToNetInfo, - ); - } - - /// Trace a single wire bit backward through `$buf`/`$slice` cells, - /// returning the ultimate source bit and the set of intermediate cell - /// names visited along the chain. - static (int sourceBit, Set intermediates) traceBackward( - int startBit, - Map wireDriverCell, - Map> cells, - ) { - var current = startBit; - final chain = {}; - while (true) { - final driverName = wireDriverCell[current]; - if (driverName == null) { - break; - } - final driverCell = cells[driverName]; - if (driverCell == null) { - break; - } - final dt = driverCell['type'] as String?; - if (dt != r'$buf' && dt != r'$slice') { - break; - } - if (chain.contains(driverName)) { - break; // Cycle detected — stop tracing. - } - chain.add(driverName); - final dc = driverCell['connections'] as Map? ?? {}; - if (dt == r'$buf') { - final yBits = dc['Y'] as List; - final aBits = dc['A'] as List; - final idx = yBits.indexOf(current); - if (idx < 0 || idx >= aBits.length || aBits[idx] is! int) { - break; - } - current = aBits[idx] as int; - } else { - final yBits = dc['Y'] as List; - final aBits = dc['A'] as List; - final dp = driverCell['parameters'] as Map? ?? {}; - final offset = dp['OFFSET'] as int? ?? 0; - final idx = yBits.indexOf(current); - if (idx < 0) { - break; - } - final srcIdx = offset + idx; - if (srcIdx < 0 || srcIdx >= aBits.length || aBits[srcIdx] is! int) { - break; - } - current = aBits[srcIdx] as int; - } - } - return (current, chain); - } - - /// Whether every intermediate cell in [intermediates] exclusively feeds - /// [ownerCell] or other cells in [intermediates]. - /// - /// When [allowPortConsumers] is true, `'__port__'` pseudo-consumers are - /// also accepted (used when module-output ports registered as consumers). - static bool isExclusiveChain({ - required Set intermediates, - required String ownerCell, - required Map> cells, - required Map> wireConsumerCells, - bool allowPortConsumers = false, - }) { - for (final ic in intermediates) { - final icCell = cells[ic]; - if (icCell == null) { - return false; - } - final icConns = icCell['connections'] as Map? ?? {}; - final icDirs = icCell['port_directions'] as Map? ?? {}; - for (final pe in icConns.entries) { - if ((icDirs[pe.key] as String?) != 'output') { - continue; - } - for (final b in pe.value as List) { - if (b is! int) { - continue; - } - final consumers = wireConsumerCells[b]; - if (consumers == null) { - continue; - } - for (final cn in consumers) { - if (cn != ownerCell && !intermediates.contains(cn)) { - if (allowPortConsumers && cn == '__port__') { - continue; - } - return false; - } - } - } - } - } - return true; - } - /// Collapses bit-slice ports of a Combinational/Sequential cell into /// aggregate ports. /// diff --git a/test/netlist_synthesizer_test.dart b/test/netlist_synthesizer_test.dart index 8e08e2945..1befef915 100644 --- a/test/netlist_synthesizer_test.dart +++ b/test/netlist_synthesizer_test.dart @@ -790,35 +790,6 @@ void main() { } }); - test('groupStructConversions produces valid netlist', () async { - final synth = SynthBuilder( - filterBank, - NetlistSynthesizer( - options: const NetlistOptions(groupStructConversions: true), - ), - ); - final json = await (synth.synthesizer as NetlistSynthesizer) - .synthesizeToJson(filterBank); - final parsed = jsonDecode(json) as Map; - expect(_modules(parsed), isNotEmpty); - }); - - test('collapseStructGroups with groupStructConversions', () async { - final synth = SynthBuilder( - filterBank, - NetlistSynthesizer( - options: const NetlistOptions( - groupStructConversions: true, - collapseStructGroups: true, - ), - ), - ); - final json = await (synth.synthesizer as NetlistSynthesizer) - .synthesizeToJson(filterBank); - final parsed = jsonDecode(json) as Map; - expect(_modules(parsed), isNotEmpty); - }); - test('DCE disabled still produces valid netlist', () async { final synth = SynthBuilder( filterBank, @@ -1339,98 +1310,13 @@ void main() { // ── Group 10: Post-processing option combinations ────────────────── group('post-processing options', () { - test('groupMaximalSubsets produces valid netlist', () async { + test('collapseTransparentClusters produces valid netlist', () async { final fb = _buildFilterBank(); final json = await _synthToMap( fb, - options: const NetlistOptions( - groupStructConversions: true, - groupMaximalSubsets: true, - ), + options: const NetlistOptions(collapseTransparentClusters: true), ); expect(_modules(json), isNotEmpty); }); - - test('collapseConcats produces valid netlist', () async { - final fb = _buildFilterBank(); - final json = await _synthToMap( - fb, - options: const NetlistOptions( - groupStructConversions: true, - collapseConcats: true, - ), - ); - expect(_modules(json), isNotEmpty); - }); - - test( - 'all post-processing options enabled produces valid netlist', - () async { - final fb = _buildFilterBank(); - final json = await _synthToMap( - fb, - options: const NetlistOptions( - groupStructConversions: true, - collapseStructGroups: true, - groupMaximalSubsets: true, - collapseConcats: true, - ), - ); - expect(_modules(json), isNotEmpty); - }, - ); - - test('groupStructConversions adds synthetic module definitions', () async { - final fbPlain = _buildFilterBank(); - final jsonPlain = await _synthToMap(fbPlain); - final plainCount = _modules(jsonPlain).length; - - final fbGrouped = _buildFilterBank(); - final jsonGrouped = await _synthToMap( - fbGrouped, - options: const NetlistOptions(groupStructConversions: true), - ); - final groupedCount = _modules(jsonGrouped).length; - - // groupStructConversions should introduce additional synthetic modules - expect( - groupedCount, - greaterThanOrEqualTo(plainCount), - reason: 'Struct grouping should add module definitions', - ); - }); - - test( - 'collapseStructGroups reduces cells vs groupStructConversions only', - () async { - final fb1 = _buildFilterBank(); - final json1 = await _synthToMap( - fb1, - options: const NetlistOptions(groupStructConversions: true), - ); - int countCells(Map j) { - var total = 0; - for (final def in _modules(j).values) { - total += _cells(def as Map).length; - } - return total; - } - - final fb2 = _buildFilterBank(); - final json2 = await _synthToMap( - fb2, - options: const NetlistOptions( - groupStructConversions: true, - collapseStructGroups: true, - ), - ); - - expect( - countCells(json2), - lessThanOrEqualTo(countCells(json1)), - reason: 'Collapsing struct groups should not increase cell count', - ); - }, - ); }); } diff --git a/test/slim_connected_port_test.dart b/test/slim_connected_port_test.dart index 1f2f642e0..05c0b99d2 100644 --- a/test/slim_connected_port_test.dart +++ b/test/slim_connected_port_test.dart @@ -30,16 +30,12 @@ void main() { final modules = netlist['modules'] as Map; // Find the SimpleTest module - // ignore: avoid_print - print('Module keys: ${modules.keys.toList()}'); // The module name may be the type name or uniquified; find it final simpleTestKey = modules.keys.firstWhere( (k) => k.contains('SimpleTest'), orElse: () => modules.keys.first, ); final simpleTest = modules[simpleTestKey] as Map; - // ignore: avoid_print - print('Using module: $simpleTestKey'); final ports = simpleTest['ports'] as Map; @@ -66,12 +62,5 @@ void main() { isFalse, reason: 'unused_port should not have connected attribute', ); - - // Print for visibility - for (final p in ports.entries) { - final pd = p.value as Map; - // ignore: avoid_print - print('Port ${p.key}: connected=${pd["connected"]}'); - } }); } diff --git a/test/slim_full_canonical_test.dart b/test/slim_full_canonical_test.dart index ce979b5fa..b3f230f34 100644 --- a/test/slim_full_canonical_test.dart +++ b/test/slim_full_canonical_test.dart @@ -98,11 +98,6 @@ void main() { // Sanity: we tested a reasonable number of modules expect(modulesTested, greaterThan(0), reason: 'No modules were tested'); - // ignore: avoid_print - print( - 'Validated $modulesTested modules — all cell keys match ' - 'between slim and full.', - ); }); } diff --git a/test/slim_incremental_equivalence_test.dart b/test/slim_incremental_equivalence_test.dart index 09664a112..697a95377 100644 --- a/test/slim_incremental_equivalence_test.dart +++ b/test/slim_incremental_equivalence_test.dart @@ -208,12 +208,6 @@ void main() { if (errors.isNotEmpty) { fail('Slim incremental equivalence errors:\n${errors.join('\n')}'); } - - // ignore: avoid_print - print( - 'Validated ${fullModules.length} modules — ' - 'slim + incremental fetch matches full.', - ); } test('Counter: slim + incremental fetch == full', () async { From 3e103f938a99b24a35be5091fc73201ba2944976 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 6 May 2026 12:32:17 -0700 Subject: [PATCH 17/20] Add ModuleServices singleton and SvService Introduces a singleton service registry (ModuleServices) that provides a unified query surface for DevTools and inspection tools. Module.build() now registers the root module with ModuleServices.instance. Also adds SvService which wraps SystemVerilog synthesis and registers with ModuleServices for DevTools access to SV metadata. This is a clean separation: no netlist code is included. The netlist branch will later extend ModuleServices with a netlistService field. --- lib/rohd.dart | 1 + lib/src/diagnostics/module_services.dart | 78 ++++++++++ lib/src/module.dart | 3 +- .../systemverilog/sv_service.dart | 114 +++++++++++++++ .../systemverilog/systemverilog.dart | 1 + test/module_services_test.dart | 134 ++++++++++++++++++ 6 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 lib/src/diagnostics/module_services.dart create mode 100644 lib/src/synthesizers/systemverilog/sv_service.dart create mode 100644 test/module_services_test.dart diff --git a/lib/rohd.dart b/lib/rohd.dart index 841505590..d0ea2a266 100644 --- a/lib/rohd.dart +++ b/lib/rohd.dart @@ -1,6 +1,7 @@ // Copyright (C) 2021-2023 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'src/diagnostics/module_services.dart'; export 'src/exceptions/exceptions.dart'; export 'src/external.dart'; export 'src/finite_state_machine.dart'; diff --git a/lib/src/diagnostics/module_services.dart b/lib/src/diagnostics/module_services.dart new file mode 100644 index 000000000..a0e583b59 --- /dev/null +++ b/lib/src/diagnostics/module_services.dart @@ -0,0 +1,78 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_services.dart +// Singleton service registry for DevTools integration. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/diagnostics/inspector_service.dart'; + +/// Singleton service registry that provides a unified query surface for +/// DevTools and other inspection tools. +/// +/// Services register themselves here on construction; DevTools evaluates +/// getters on [instance] via `EvalOnDartLibrary` to pull data. +/// +/// **Auto-registered:** +/// - [rootModule] / [hierarchyJSON] — set by [Module.build]. +/// +/// **Opt-in (registered by service constructors):** +/// - [svService] — SystemVerilog synthesis results. +/// +/// Additional services (netlist, trace, waveform) can be added by setting +/// the corresponding field after construction. +class ModuleServices { + ModuleServices._(); + + /// The singleton instance. + static final ModuleServices instance = ModuleServices._(); + + // ─── Hierarchy (auto-registered by Module.build) ────────────── + + /// The most recently built top-level [Module]. + /// + /// Set automatically at the end of [Module.build]. + Module? rootModule; + + /// Returns the module hierarchy as a JSON string. + /// + /// DevTools evaluates this via `EvalOnDartLibrary` to display + /// the module hierarchy. + String get hierarchyJSON { + ModuleTree.rootModuleInstance = rootModule; + return ModuleTree.instance.hierarchyJSON; + } + + /// Returns the primary inspector JSON for DevTools. + /// + /// Returns the hierarchy JSON. Downstream branches (e.g. netlist) may + /// override this to return richer data when available. + String get inspectorJSON => hierarchyJSON; + + // ─── SystemVerilog service (opt-in) ─────────────────────────── + + /// The active [SvService], if one has been registered. + SvService? svService; + + /// Returns SV synthesis metadata as JSON, or an unavailable status. + String get svJSON => + svService != null ? jsonEncode(svService!.toJson()) : _unavailable('sv'); + + // ─── Helpers ────────────────────────────────────────────────── + + static String _unavailable(String service) => jsonEncode({ + 'status': 'unavailable', + 'reason': '$service service not registered', + }); + + /// Resets all services. Intended for test teardown. + void reset() { + rootModule = null; + svService = null; + } +} diff --git a/lib/src/module.dart b/lib/src/module.dart index 02e02ad63..9f6ec634e 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -13,7 +13,6 @@ import 'dart:collection'; import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/collections/traverseable_collection.dart'; -import 'package:rohd/src/diagnostics/inspector_service.dart'; import 'package:rohd/src/utilities/config.dart'; import 'package:rohd/src/utilities/namer.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; @@ -333,7 +332,7 @@ abstract class Module { _hasBuilt = true; - ModuleTree.rootModuleInstance = this; + ModuleServices.instance.rootModule = this; } /// Confirms that the post-[build] hierarchy is valid. diff --git a/lib/src/synthesizers/systemverilog/sv_service.dart b/lib/src/synthesizers/systemverilog/sv_service.dart new file mode 100644 index 000000000..e1adf43cd --- /dev/null +++ b/lib/src/synthesizers/systemverilog/sv_service.dart @@ -0,0 +1,114 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// sv_service.dart +// Service wrapper for SystemVerilog synthesis. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:rohd/rohd.dart'; + +/// A service that wraps SystemVerilog synthesis of a [Module] hierarchy. +/// +/// Provides access to the generated SV file contents and per-module +/// synthesis results, and optionally registers with [ModuleServices] +/// for DevTools inspection. +/// +/// Example: +/// ```dart +/// final dut = MyModule(...); +/// await dut.build(); +/// final sv = SvService(dut); +/// +/// // Write individual .sv files: +/// sv.writeFiles('build/'); +/// +/// // Or get the concatenated output (like generateSynth): +/// print(sv.allContents); +/// ``` +class SvService { + /// The top-level [Module] being synthesized. + final Module module; + + /// The underlying [SynthBuilder] that drove synthesis. + late final SynthBuilder synthBuilder; + + /// The generated file contents (one per unique module definition). + late final List fileContents; + + /// Creates an [SvService] for [module]. + /// + /// [module] must already be built. Set [register] to `true` (the + /// default) to register this service with [ModuleServices] for + /// DevTools access. + SvService(this.module, {bool register = true}) { + if (!module.hasBuilt) { + throw Exception('Module must be built before creating SvService. ' + 'Call build() first.'); + } + + synthBuilder = SynthBuilder(module, SystemVerilogSynthesizer()); + fileContents = synthBuilder.getSynthFileContents(); + + if (register) { + ModuleServices.instance.svService = this; + } + } + + /// All [SynthesisResult]s produced by synthesis. + Set get synthesisResults => synthBuilder.synthesisResults; + + /// Returns the concatenated SystemVerilog output as a single string, + /// matching the format of [Module.generateSynth]. + String get allContents => fileContents.map((fc) => fc.contents).join('\n\n'); + + /// Returns a map from module definition name to its SV file contents. + /// + /// Keys are [SynthesisResult.instanceTypeName] (the uniquified definition + /// name used in the generated SV). + Map get contentsByName => { + for (final fc in fileContents) fc.name: fc.contents, + }; + + /// Returns a map from module definition name + /// ([Module.definitionName]) to its SV file contents. + /// + /// This uses the original definition name (not uniquified), matching + /// the keys used by FLC trace data. + Map get contentsByDefinitionName { + final result = {}; + for (final sr in synthesisResults) { + final defName = sr.module.definitionName; + final instanceName = sr.instanceTypeName; + // Find the file content matching this instance type name. + final fc = fileContents.firstWhereOrNull((f) => f.name == instanceName); + if (fc != null) { + result[defName] = fc.contents; + } + } + return result; + } + + /// Writes each module's SV to a separate file in [directory]. + /// + /// Files are named `.sv`. + void writeFiles(String directory) { + final dir = Directory(directory)..createSync(recursive: true); + for (final fc in fileContents) { + File('${dir.path}/${fc.name}.sv').writeAsStringSync(fc.contents); + } + } + + /// Returns a JSON-serialisable summary of the SV synthesis. + /// + /// Contains the list of generated module definition names. + Map toJson() => { + 'modules': [ + for (final fc in fileContents) fc.name, + ], + }; +} diff --git a/lib/src/synthesizers/systemverilog/systemverilog.dart b/lib/src/synthesizers/systemverilog/systemverilog.dart index 281b05df9..e5f772e44 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog.dart @@ -1,5 +1,6 @@ // Copyright (C) 2021-2024 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'sv_service.dart'; export 'systemverilog_mixins.dart'; export 'systemverilog_synthesizer.dart'; diff --git a/test/module_services_test.dart b/test/module_services_test.dart new file mode 100644 index 000000000..f9b0ac075 --- /dev/null +++ b/test/module_services_test.dart @@ -0,0 +1,134 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_services_test.dart +// Unit tests for ModuleServices and SvService. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; +import 'dart:io'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +class SimpleModule extends Module { + SimpleModule(Logic a) : super(name: 'simple') { + a = addInput('a', a); + addOutput('b') <= ~a; + } +} + +void main() { + tearDown(() { + ModuleServices.instance.reset(); + }); + + group('ModuleServices', () { + test('rootModule is set after build', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + expect(ModuleServices.instance.rootModule, equals(mod)); + }); + + test('hierarchyJSON returns valid JSON', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final json = ModuleServices.instance.hierarchyJSON; + expect(() => jsonDecode(json), returnsNormally); + }); + + test('inspectorJSON matches hierarchyJSON', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + expect(ModuleServices.instance.inspectorJSON, + equals(ModuleServices.instance.hierarchyJSON)); + }); + + test('svJSON returns unavailable when no service registered', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final result = + jsonDecode(ModuleServices.instance.svJSON) as Map; + expect(result['status'], equals('unavailable')); + }); + + test('reset clears all services', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + expect(ModuleServices.instance.rootModule, isNotNull); + ModuleServices.instance.reset(); + expect(ModuleServices.instance.rootModule, isNull); + expect(ModuleServices.instance.svService, isNull); + }); + }); + + group('SvService', () { + test('registers with ModuleServices on creation', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final sv = SvService(mod); + expect(ModuleServices.instance.svService, equals(sv)); + }); + + test('allContents is non-empty', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final sv = SvService(mod); + expect(sv.allContents, isNotEmpty); + }); + + test('contentsByName has entries', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final sv = SvService(mod); + expect(sv.contentsByName, isNotEmpty); + }); + + test('contentsByDefinitionName has entries', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final sv = SvService(mod); + expect(sv.contentsByDefinitionName, isNotEmpty); + expect(sv.contentsByDefinitionName.containsKey('SimpleModule'), isTrue); + }); + + test('svJSON returns valid JSON after registration', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + SvService(mod); + final result = + jsonDecode(ModuleServices.instance.svJSON) as Map; + expect(result['modules'], isList); + }); + + test('writeFiles creates SV files', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + final sv = SvService(mod); + final dir = Directory.systemTemp.createTempSync('sv_test_'); + try { + sv.writeFiles(dir.path); + final files = dir.listSync().whereType().toList(); + expect(files, isNotEmpty); + expect(files.any((f) => f.path.endsWith('.sv')), isTrue); + } finally { + dir.deleteSync(recursive: true); + } + }); + + test('register false does not register', () async { + final mod = SimpleModule(Logic()); + await mod.build(); + ModuleServices.instance.reset(); + SvService(mod, register: false); + expect(ModuleServices.instance.svService, isNull); + }); + + test('throws if module not built', () { + final mod = SimpleModule(Logic()); + expect(() => SvService(mod), throwsException); + }); + }); +} From d985446980dd533904aaea85477416146b3d95a1 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 6 May 2026 12:58:52 -0700 Subject: [PATCH 18/20] Add netlist synthesizer, NetlistService, and supporting infrastructure Adds a complete netlist synthesis pipeline that converts ROHD module hierarchies into Yosys-compatible JSON netlists. Key additions: - NetlistSynthesizer: traverses module hierarchy producing cell/wire JSON - NetlistService: async factory that registers with ModuleServices - NetlistOptions: configurable post-processing passes (DCE, struct grouping, concat collapsing, etc.) - NetlistPasses: optimization and structural transformations - LeafCellMapper: maps ROHD gates to standard cell definitions - NetlistUtils: wire naming, bit-range formatting, JSON helpers Also extends Module.build() with optional netlistOptions parameter, updates ModuleServices with netlistService field, and refactors examples into lib/src/examples/ for reuse in tests. Comprehensive test coverage across 11 new/updated test files (146 tests). --- example/example.dart | 40 +- example/filter_bank.dart | 117 + example/oven_fsm.dart | 175 +- example/tree.dart | 39 +- lib/rohd.dart | 3 +- lib/src/diagnostics/module_services.dart | 49 +- lib/src/examples/filter_bank_modules.dart | 954 +++++++ lib/src/examples/oven_fsm_modules.dart | 211 ++ lib/src/examples/tree_modules.dart | 63 + lib/src/module.dart | 25 +- .../netlist/leaf_cell_mapper.dart | 486 ++++ lib/src/synthesizers/netlist/netlist.dart | 16 + .../synthesizers/netlist/netlist_options.dart | 138 + .../synthesizers/netlist/netlist_passes.dart | 2541 +++++++++++++++++ .../synthesizers/netlist/netlist_service.dart | 224 ++ .../netlist/netlist_synthesis_result.dart | 84 + .../netlist/netlist_synthesizer.dart | 1788 ++++++++++++ .../synthesizers/netlist/netlist_utils.dart | 531 ++++ lib/src/synthesizers/synthesizers.dart | 3 +- .../systemverilog/sv_service.dart | 46 +- test/incremental_expansion_test.dart | 116 + test/module_services_test.dart | 324 ++- test/netlist_example_test.dart | 285 ++ test/netlist_synthesizer_test.dart | 1437 ++++++++++ test/netlist_test.dart | 706 +++++ test/signal_registry_test.dart | 40 + test/slim_connected_port_test.dart | 77 + test/slim_full_canonical_test.dart | 180 ++ test/slim_incremental_equivalence_test.dart | 310 ++ test/struct_port_pruning_test.dart | 143 + test/synth_name_parity_test.dart | 125 + 31 files changed, 10951 insertions(+), 325 deletions(-) create mode 100644 example/filter_bank.dart create mode 100644 lib/src/examples/filter_bank_modules.dart create mode 100644 lib/src/examples/oven_fsm_modules.dart create mode 100644 lib/src/examples/tree_modules.dart create mode 100644 lib/src/synthesizers/netlist/leaf_cell_mapper.dart create mode 100644 lib/src/synthesizers/netlist/netlist.dart create mode 100644 lib/src/synthesizers/netlist/netlist_options.dart create mode 100644 lib/src/synthesizers/netlist/netlist_passes.dart create mode 100644 lib/src/synthesizers/netlist/netlist_service.dart create mode 100644 lib/src/synthesizers/netlist/netlist_synthesis_result.dart create mode 100644 lib/src/synthesizers/netlist/netlist_synthesizer.dart create mode 100644 lib/src/synthesizers/netlist/netlist_utils.dart create mode 100644 test/incremental_expansion_test.dart create mode 100644 test/netlist_example_test.dart create mode 100644 test/netlist_synthesizer_test.dart create mode 100644 test/netlist_test.dart create mode 100644 test/slim_connected_port_test.dart create mode 100644 test/slim_full_canonical_test.dart create mode 100644 test/slim_incremental_equivalence_test.dart create mode 100644 test/struct_port_pruning_test.dart create mode 100644 test/synth_name_parity_test.dart diff --git a/example/example.dart b/example/example.dart index 2ddbfc738..7715ffb34 100644 --- a/example/example.dart +++ b/example/example.dart @@ -11,35 +11,16 @@ // allow `print` messages (disable lint): // ignore_for_file: avoid_print -// Import necessary dart packages for this file. +// Import necessary dart pacakges for this file. import 'dart:async'; // Import the ROHD package. import 'package:rohd/rohd.dart'; -// Define a class Counter that extends ROHD's abstract Module class. -class Counter extends Module { - // For convenience, map interesting outputs to short variable names for - // consumers of this module. - Logic get val => output('val'); - - // This counter supports any width, determined at run-time. - final int width; - - Counter(Logic en, Logic reset, Logic clk, - {this.width = 8, super.name = 'counter'}) { - // Register inputs and outputs of the module in the constructor. - // Module logic must consume registered inputs and output to registered - // outputs. - en = addInput('en', en); - reset = addInput('reset', reset); - clk = addInput('clk', clk); - addOutput('val', width: width); - - // We can use the `flop` function to automate creation of a `Sequential`. - val <= flop(clk, reset: reset, en: en, val + 1); - } -} +// Re-export the Counter module from the library examples so that +// existing tests that `import 'example/example.dart'` still see it. +import 'package:rohd/src/examples/oven_fsm_modules.dart' show Counter; +export 'package:rohd/src/examples/oven_fsm_modules.dart' show Counter; // Let's simulate with this counter a little, generate a waveform, and take a // look at generated SystemVerilog. @@ -76,8 +57,9 @@ Future main({bool noPrint = false}) async { // Let's also print a message every time the value on the counter changes, // just for this example to make it easier to see before we look at waves. if (!noPrint) { - counter.val.changed - .listen((e) => print('@${Simulator.time}: Value changed: $e')); + counter.val.changed.listen( + (e) => print('@${Simulator.time}: Value changed: $e'), + ); } // Start off with a disabled counter and asserting reset at the start. @@ -115,7 +97,9 @@ Future main({bool noPrint = false}) async { // We can take a look at the waves now. if (!noPrint) { - print('To view waves, check out waves.vcd with a waveform viewer' - ' (e.g. `gtkwave waves.vcd`).'); + print( + 'To view waves, check out waves.vcd with a waveform viewer' + ' (e.g. `gtkwave waves.vcd`).', + ); } } diff --git a/example/filter_bank.dart b/example/filter_bank.dart new file mode 100644 index 000000000..b1d6f6eb3 --- /dev/null +++ b/example/filter_bank.dart @@ -0,0 +1,117 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// filter_bank.dart +// A polyphase FIR filter bank design example exercising: +// - Deep hierarchy with shared sub-module definitions +// - Interface (FilterDataInterface) +// - LogicStructure (FilterSample) +// - LogicArray (coefficient storage) +// - Pipeline (pipelined MAC accumulation) +// - FiniteStateMachine (FilterController) +// +// The filter bank has two channels that share an identical MacUnit definition. +// A controller FSM sequences: idle → loading → running → draining → done. +// +// 2026 March 26 +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:rohd/rohd.dart'; + +// Import module definitions. +import 'package:rohd/src/examples/filter_bank_modules.dart'; + +// Re-export so downstream consumers (e.g. devtools loopback) can use. +export 'package:rohd/src/examples/filter_bank_modules.dart'; + +// ────────────────────────────────────────────────────────────────── +// Standalone simulation entry point +// ────────────────────────────────────────────────────────────────── + +Future main({bool noPrint = false}) async { + const dataWidth = 16; + const numTaps = 3; + + // Low-pass-ish coefficients (scaled integers) + const coeffs0 = [1, 2, 1]; // channel 0: symmetric LPF kernel + const coeffs1 = [1, -2, 1]; // channel 1: high-pass kernel + + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + final dut = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: [coeffs0, coeffs1], + ); + + // Before we can simulate or generate code, we need to build it. + await dut.build(); + + // Set a maximum time for the simulation so it doesn't keep running forever. + Simulator.setMaxSimTime(500); + + // Attach a waveform dumper so we can see what happens. + if (!noPrint) { + WaveDumper(dut, outputPath: 'filter_bank.vcd'); + } + + // Kick off the simulation. + unawaited(Simulator.run()); + + // ── Reset ── + reset.inject(1); + start.inject(0); + samplesIn.elements[0].inject(0); + samplesIn.elements[1].inject(0); + validIn.inject(0); + inputDone.inject(0); + + await clk.nextPosedge; + await clk.nextPosedge; + reset.inject(0); + + // ── Start filtering ── + await clk.nextPosedge; + start.inject(1); + await clk.nextPosedge; + start.inject(0); + validIn.inject(1); + + // ── Feed sample stream: impulse response test ── + // Send a single '1' followed by zeros to get the impulse response + samplesIn.elements[0].inject(1); + samplesIn.elements[1].inject(1); + await clk.nextPosedge; + + for (var i = 0; i < 8; i++) { + samplesIn.elements[0].inject(0); + samplesIn.elements[1].inject(0); + await clk.nextPosedge; + } + + // ── Signal end of input ── + validIn.inject(0); + inputDone.inject(1); + await clk.nextPosedge; + inputDone.inject(0); + + // ── Wait for drain ── + for (var i = 0; i < 15; i++) { + await clk.nextPosedge; + } + + await Simulator.endSimulation(); +} diff --git a/example/oven_fsm.dart b/example/oven_fsm.dart index 2788baa55..8f69f697b 100644 --- a/example/oven_fsm.dart +++ b/example/oven_fsm.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Intel Corporation +// Copyright (C) 2023-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // oven_fsm.dart @@ -14,175 +14,12 @@ import 'dart:async'; import 'package:rohd/rohd.dart'; -// Import the counter module implement in example.dart. -import './example.dart'; +// Import module definitions (Counter, OvenModule, enums). +import 'package:rohd/src/examples/oven_fsm_modules.dart'; -// Enumerated type named `OvenState` with four possible states: -// `standby`, `cooking`,`paused`, and `completed`. -enum OvenState { standby, cooking, paused, completed } - -// One-hot encoded `Button` using dart enhanced enums. -// Represent start, pause, and resume as integer value 0, 1, -// and 2 respectively. -enum Button { - start(value: 0), - pause(value: 1), - resume(value: 2); - - const Button({required this.value}); - - final int value; -} - -// One-hot encoded `LEDLight` using dart enhanced enums. -// Represent yellow, blue, red, and green as integer value 0, 1, -// 2, and 3 respectively. -enum LEDLight { - yellow(value: 0), - blue(value: 1), - red(value: 2), - green(value: 3); - - const LEDLight({required this.value}); - - final int value; -} - -// Define a class OvenModule that extends ROHD's abstract Module class. -class OvenModule extends Module { - // A private variable with type FiniteStateMachine `_oven`. - // - // Use `late` to indicate that the value will not be null - // and will be assign in the later section. - late FiniteStateMachine _oven; - - // We can expose an LED light output as a getter to retrieve it value. - Logic get led => output('led'); - - // This oven module receives a `button` and a `reset` input from runtime. - OvenModule(Logic button, Logic reset, Logic clk) : super(name: 'OvenModule') { - // Register inputs and outputs of the module in the constructor. - // Module logic must consume registered inputs and output to registered - // outputs. `led` output also added as the output port. - button = addInput('button', button, width: button.width); - reset = addInput('reset', reset); - clk = addInput('clk', clk); - final led = addOutput('led', width: button.width); - - // Register local signals, `counterReset` and `en` - // for Counter module. - final counterReset = Logic(name: 'counter_reset'); - final en = Logic(name: 'counter_en'); - - // An internal counter module that will be used to time the cooking state. - // Receive `en`, `counterReset` and `clk` as input. - final counter = Counter(en, counterReset, clk, name: 'counter_module'); - - // A list of `OvenState` that describe the FSM. Note that - // `OvenState` consists of identifier, events and actions. We - // can think of `identifier` as the state name, `events` is a map of event - // that trigger next state. `actions` is the behaviour of current state, - // like what is the actions need to be shown separate current state with - // other state. Represented as List of conditionals to be executed. - final states = [ - // identifier: standby state, represent by `OvenState.standby`. - State(OvenState.standby, - // events: - // When the button `start` is pressed during standby state, - // OvenState will changed to `OvenState.cooking` state. - events: { - Logic(name: 'button_start') - ..gets(button - .eq(Const(Button.start.value, width: button.width))): - OvenState.cooking, - }, - // actions: - // During the standby state, `led` is change to blue; timer's - // `counterReset` is set to 1 (Reset the timer); - // timer's `en` is set to 0 (Disable value update). - actions: [ - led < LEDLight.blue.value, - counterReset < 1, - en < 0, - ]), - - // identifier: cooking state, represent by `OvenState.cooking`. - State(OvenState.cooking, - // events: - // When the button `paused` is pressed during cooking state, - // OvenState will changed to `OvenState.paused` state. - // - // When the button `counter` time is elapsed during cooking state, - // OvenState will changed to `OvenState.completed` state. - events: { - Logic(name: 'button_pause') - ..gets(button - .eq(Const(Button.pause.value, width: button.width))): - OvenState.paused, - Logic(name: 'counter_time_complete')..gets(counter.val.eq(4)): - OvenState.completed - }, - // actions: - // During the cooking state, `led` is change to yellow; timer's - // `counterReset` is set to 0 (Do not reset); - // timer's `en` is set to 1 (Enable value update). - actions: [ - led < LEDLight.yellow.value, - counterReset < 0, - en < 1, - ]), - - // identifier: paused state, represent by `OvenState.paused`. - State(OvenState.paused, - // events: - // When the button `resume` is pressed during paused state, - // OvenState will changed to `OvenState.cooking` state. - events: { - Logic(name: 'button_resume') - ..gets(button - .eq(Const(Button.resume.value, width: button.width))): - OvenState.cooking - }, - // actions: - // During the paused state, `led` is change to red; timer's - // `counterReset` is set to 0 (Do not reset); - // timer's `en` is set to 0 (Disable value update). - actions: [ - led < LEDLight.red.value, - counterReset < 0, - en < 0, - ]), - - // identifier: completed state, represent by `OvenState.completed`. - State(OvenState.completed, - // events: - // When the button `start` is pressed during completed state, - // OvenState will changed to `OvenState.cooking` state. - events: { - Logic(name: 'button_start') - ..gets(button - .eq(Const(Button.start.value, width: button.width))): - OvenState.cooking - }, - // actions: - // During the start state, `led` is change to green; timer's - // `counterReset` is set to 1 (Reset value); - // timer's `en` is set to 0 (Disable value update). - actions: [ - led < LEDLight.green.value, - counterReset < 1, - en < 0, - ]) - ]; - - // Assign the _oven FiniteStateMachine object to private variable declared. - _oven = - FiniteStateMachine(clk, reset, OvenState.standby, states); - } - - // An oven FiniteStateMachine that represent in getter. - FiniteStateMachine get ovenStateMachine => _oven; -} +// Re-export module definitions so test files that import this file +// get access to OvenModule, OvenState, Button, LEDLight, etc. +export 'package:rohd/src/examples/oven_fsm_modules.dart' hide Counter; /// A helper function to wait for a number of cycles. Future waitCycles(Logic clk, int numCycles) async { diff --git a/example/tree.dart b/example/tree.dart index f5c30a979..8f5b2f96a 100644 --- a/example/tree.dart +++ b/example/tree.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2023 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // tree.dart @@ -13,6 +13,13 @@ import 'package:rohd/rohd.dart'; +// Import module definition. +import 'package:rohd/src/examples/tree_modules.dart'; + +// Re-export module definition so test files that import this file +// get access to TreeOfTwoInputModules. +export 'package:rohd/src/examples/tree_modules.dart'; + /// The below example demonstrates some aspects of the power of ROHD where /// writing equivalent design code in SystemVerilog can be challenging or /// impossible. The example is a port from an example used by Chisel. @@ -35,36 +42,6 @@ import 'package:rohd/rohd.dart'; /// number of inputs and different logic without any explicit /// parameterization. -class TreeOfTwoInputModules extends Module { - final Logic Function(Logic a, Logic b) _op; - final List _seq = []; - Logic get out => output('out'); - - TreeOfTwoInputModules(List seq, this._op) - : super(name: 'tree_of_two_input_modules') { - if (seq.isEmpty) { - throw Exception("Don't use TreeOfTwoInputModules with an empty sequence"); - } - - for (var i = 0; i < seq.length; i++) { - _seq.add(addInput('seq$i', seq[i], width: seq[i].width)); - } - addOutput('out', width: seq[0].width); - - if (_seq.length == 1) { - out <= _seq[0]; - } else { - final a = TreeOfTwoInputModules( - _seq.getRange(0, _seq.length ~/ 2).toList(), _op) - .out; - final b = TreeOfTwoInputModules( - _seq.getRange(_seq.length ~/ 2, _seq.length).toList(), _op) - .out; - out <= _op(a, b); - } - } -} - Future main({bool noPrint = false}) async { // You could instantiate this module with some code such as: final tree = TreeOfTwoInputModules( diff --git a/lib/rohd.dart b/lib/rohd.dart index d0ea2a266..075bda975 100644 --- a/lib/rohd.dart +++ b/lib/rohd.dart @@ -1,6 +1,7 @@ -// Copyright (C) 2021-2023 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'src/diagnostics/inspector_service.dart'; export 'src/diagnostics/module_services.dart'; export 'src/exceptions/exceptions.dart'; export 'src/external.dart'; diff --git a/lib/src/diagnostics/module_services.dart b/lib/src/diagnostics/module_services.dart index a0e583b59..30a51a410 100644 --- a/lib/src/diagnostics/module_services.dart +++ b/lib/src/diagnostics/module_services.dart @@ -10,7 +10,6 @@ import 'dart:convert'; import 'package:rohd/rohd.dart'; -import 'package:rohd/src/diagnostics/inspector_service.dart'; /// Singleton service registry that provides a unified query surface for /// DevTools and other inspection tools. @@ -23,9 +22,10 @@ import 'package:rohd/src/diagnostics/inspector_service.dart'; /// /// **Opt-in (registered by service constructors):** /// - [svService] — SystemVerilog synthesis results. +/// - [netlistService] — Yosys-format netlist JSON. /// -/// Additional services (netlist, trace, waveform) can be added by setting -/// the corresponding field after construction. +/// Additional services (trace, waveform) can be added by setting the +/// corresponding field after construction. class ModuleServices { ModuleServices._(); @@ -48,11 +48,30 @@ class ModuleServices { return ModuleTree.instance.hierarchyJSON; } - /// Returns the primary inspector JSON for DevTools. + /// Returns the unified inspector JSON — the primary entry point for + /// DevTools to load the design. /// - /// Returns the hierarchy JSON. Downstream branches (e.g. netlist) may - /// override this to return richer data when available. - String get inspectorJSON => hierarchyJSON; + /// When a [NetlistService] is registered, this returns the slim netlist + /// (hierarchy + ports + cells without connectivity). + /// + /// Falls back to the hierarchy JSON when no netlist service is available. + String get inspectorJSON { + if (netlistService != null) { + return netlistService!.slimJson; + } + return hierarchyJSON; + } + + /// Returns the full netlist JSON for a single module definition. + /// + /// When a [NetlistService] is registered, returns the per-module netlist + /// (with full connectivity). + String inspectorModuleJSON(String definitionName) { + if (netlistService != null) { + return netlistService!.moduleJson(definitionName); + } + return _unavailable('netlist'); + } // ─── SystemVerilog service (opt-in) ─────────────────────────── @@ -63,6 +82,21 @@ class ModuleServices { String get svJSON => svService != null ? jsonEncode(svService!.toJson()) : _unavailable('sv'); + // ─── Netlist service (opt-in) ───────────────────────────────── + + /// The active [NetlistService], if one has been registered. + NetlistService? netlistService; + + /// Returns the full netlist hierarchy as JSON, or an unavailable status. + String get netlistJSON => netlistService != null + ? netlistService!.toJson() + : _unavailable('netlist'); + + /// Returns the netlist for a single module definition, or unavailable. + String netlistModuleJSON(String definitionName) => netlistService != null + ? netlistService!.moduleJson(definitionName) + : _unavailable('netlist'); + // ─── Helpers ────────────────────────────────────────────────── static String _unavailable(String service) => jsonEncode({ @@ -74,5 +108,6 @@ class ModuleServices { void reset() { rootModule = null; svService = null; + netlistService = null; } } diff --git a/lib/src/examples/filter_bank_modules.dart b/lib/src/examples/filter_bank_modules.dart new file mode 100644 index 000000000..21a0f1ccd --- /dev/null +++ b/lib/src/examples/filter_bank_modules.dart @@ -0,0 +1,954 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// filter_bank_modules.dart +// Module class definitions for the polyphase FIR filter bank example. +// +// 2025 March 26 +// Author: Desmond Kirkpatrick +// +// Architecture: each FilterChannel uses a single MacUnit that is +// time-multiplexed across taps. A tap counter sequences CoeffBank +// and a delay-line mux so the MAC accumulates one tap per clock cycle. +// After numTaps cycles the accumulated result is latched as the output +// sample and the accumulator resets for the next input sample. +// +// ROHD features exercised: +// - LogicStructure (FilterSample) +// - Interface (FilterDataInterface) +// - LogicArray (CoeffBank coefficient ROM, delay line) +// - Pipeline (MacUnit multiply-accumulate) +// - FiniteStateMachine (FilterController) +// - Multiple instantiation (two FilterChannels share one definition) +// +// Separated from filter_bank.dart so these classes can be imported +// in web-targeted code (no dart:io dependency). +// +// 2026 March 26 +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; +import 'package:rohd/rohd.dart'; + +// ────────────────────────────────────────────────────────────────── +// LogicStructure: a typed sample word carrying data + valid + channel +// ────────────────────────────────────────────────────────────────── + +/// A structured signal bundling a data sample with metadata. +/// +/// Packs three fields — [data], [valid], and [channel] — into a single +/// bus that can be driven and sampled as a unit. Used throughout the +/// [FilterBank] to carry tagged samples between modules. +class FilterSample extends LogicStructure { + /// The sample data word. + late final Logic data; + + /// Whether this sample is valid. + late final Logic valid; + + /// The channel index this sample belongs to. + late final Logic channel; + + /// Creates a [FilterSample] with the given [dataWidth] (default 16) + /// and optional [name]. + FilterSample({int dataWidth = 16, String? name}) + : super( + [ + Logic(name: 'data', width: dataWidth), + Logic(name: 'valid'), + Logic(name: 'channel'), + ], + name: name ?? 'filter_sample', + ) { + data = elements[0]; + valid = elements[1]; + channel = elements[2]; + } + + // Private constructor for clone to share element structure. + FilterSample._clone(super.elements, {required super.name}) { + data = elements[0]; + valid = elements[1]; + channel = elements[2]; + } + + @override + + /// Returns a structural clone of this sample, preserving element names. + FilterSample clone({String? name}) => FilterSample._clone( + elements.map((e) => e.clone(name: e.name)), + name: name ?? this.name, + ); +} + +// ────────────────────────────────────────────────────────────────── +// Interface: tagged port bundle for filter data I/O +// ────────────────────────────────────────────────────────────────── + +/// Tags for grouping port directions in [FilterDataInterface]. +enum FilterPortTag { + /// Ports carrying data into the filter (`sampleIn`, `validIn`). + inputPorts, + + /// Ports carrying data out of the filter (`dataOut`, `validOut`). + outputPorts, +} + +/// An interface carrying sample data and control into/out of filter modules. +/// +/// Groups ports by [FilterPortTag] so that [connectIO] can wire +/// inputs and outputs in a single call. +class FilterDataInterface extends Interface { + /// Input sample data bus. + Logic get sampleIn => port('sampleIn'); + + /// Input valid strobe. + Logic get validIn => port('validIn'); + + /// Output filtered data bus. + Logic get dataOut => port('dataOut'); + + /// Output valid strobe. + Logic get validOut => port('validOut'); + + /// The data width used by this interface. + final int _dataWidth; + + /// Creates a [FilterDataInterface] with the given [dataWidth] + /// (default 16 bits). + FilterDataInterface({int dataWidth = 16}) : _dataWidth = dataWidth { + setPorts([ + Logic.port('sampleIn', dataWidth), + Logic.port('validIn'), + ], [ + FilterPortTag.inputPorts + ]); + + setPorts([ + Logic.port('dataOut', dataWidth), + Logic.port('validOut'), + ], [ + FilterPortTag.outputPorts + ]); + } + + @override + + /// Returns a new interface with the same data width. + FilterDataInterface clone() => FilterDataInterface(dataWidth: _dataWidth); +} + +// ────────────────────────────────────────────────────────────────── +// CoeffBank: stores FIR tap coefficients in a LogicArray +// ────────────────────────────────────────────────────────────────── + +/// A coefficient storage module backed by a [LogicArray] input port. +/// +/// Accepts a [LogicArray] of per-tap coefficients via [addInputArray] +/// and a tap index, then mux-selects the corresponding coefficient. +class CoeffBank extends Module { + /// The coefficient value at the selected index. + Logic get coeffOut => output('coeffOut'); + + /// The per-tap coefficient array (registered input port). + @protected + LogicArray get coeffArray => input('coeffArray') as LogicArray; + + /// The tap index input. + @protected + Logic get tapIndex => input('tapIndex'); + + /// Number of taps. + final int numTaps; + + /// Data width. + final int dataWidth; + + /// Creates a [CoeffBank] with [numTaps] taps at [dataWidth] bits. + /// + /// [coefficients] is a [LogicArray] with one element per tap — + /// registered as an input port via [addInputArray]. + /// [tapIndex] selects the active coefficient. + CoeffBank(Logic tapIndex, LogicArray coefficients, + {required this.numTaps, + required this.dataWidth, + super.name = 'CoeffBank'}) + : super(definitionName: 'CoeffBank_T${numTaps}_W$dataWidth') { + // Register ports + tapIndex = addInput('tapIndex', tapIndex, width: tapIndex.width); + final coeffArray = addInputArray('coeffArray', coefficients, + dimensions: [numTaps], elementWidth: dataWidth); + final coeffOut = addOutput('coeffOut', width: dataWidth); + + // Mux-chain ROM: priority-select coefficient by tap index. + Logic selected = Const(0, width: dataWidth); + for (var i = numTaps - 1; i >= 0; i--) { + selected = mux( + tapIndex.eq(Const(i, width: tapIndex.width)).named('tapMatch$i'), + coeffArray.elements[i], + selected, + ); + } + coeffOut <= selected; + } +} + +// ────────────────────────────────────────────────────────────────── +// MacUnit: a single multiply-accumulate pipeline stage +// ────────────────────────────────────────────────────────────────── + +/// A pipelined multiply-accumulate unit. +/// +/// Pipeline stage 0: multiply sample × coefficient +/// Pipeline stage 1: add product to running accumulator +class MacUnit extends Module { + /// Accumulated result. + Logic get result => output('result'); + + /// Sample data input. + @protected + Logic get sampleInPin => input('sampleIn'); + + /// Coefficient input. + @protected + Logic get coeffInPin => input('coeffIn'); + + /// Accumulator input. + @protected + Logic get accumInPin => input('accumIn'); + + /// Clock input. + @protected + Logic get clkPin => input('clk'); + + /// Reset input. + @protected + Logic get resetPin => input('reset'); + + /// Enable input. + @protected + Logic get enablePin => input('enable'); + + /// Data width. + final int dataWidth; + + /// Creates a [MacUnit] that multiplies [sampleIn] by [coeffIn] in + /// stage 0 and adds the product to [accumIn] in stage 1. + /// + /// [clk], [reset], and [enable] control the pipeline registers. + MacUnit(Logic sampleIn, Logic coeffIn, Logic accumIn, Logic clk, Logic reset, + Logic enable, + {required this.dataWidth, super.name = 'MacUnit'}) + : super(definitionName: 'MacUnit_W$dataWidth') { + sampleIn = addInput('sampleIn', sampleIn, width: dataWidth); + coeffIn = addInput('coeffIn', coeffIn, width: dataWidth); + accumIn = addInput('accumIn', accumIn, width: dataWidth); + clk = addInput('clk', clk); + reset = addInput('reset', reset); + enable = addInput('enable', enable); + final result = addOutput('result', width: dataWidth); + + // A 2-stage pipeline: multiply, then accumulate + final pipe = Pipeline( + clk, + reset: reset, + stages: [ + // Stage 0: multiply + (p) => [ + // Product = sample * coefficient (truncated to dataWidth) + p.get(sampleIn) < + (p.get(sampleIn) * p.get(coeffIn)).named('product'), + ], + // Stage 1: accumulate + (p) => [ + p.get(sampleIn) < + (p.get(sampleIn) + p.get(accumIn)).named('macSum'), + ], + ], + signals: [sampleIn, coeffIn, accumIn], + ); + + result <= pipe.get(sampleIn); + } +} + +// ────────────────────────────────────────────────────────────────── +// FilterChannel: one polyphase FIR channel with time-multiplexed MAC +// ────────────────────────────────────────────────────────────────── + +/// A single polyphase FIR filter channel with [numTaps] taps. +/// +/// Uses a [FilterDataInterface] for its sample I/O ports. +/// +/// Architecture: +/// - A delay line (shift register) captures incoming samples. +/// - A tap counter cycles 0 … numTaps-1 each sample period. +/// - [CoeffBank] provides the coefficient for the current tap. +/// - A mux selects the delay-line sample for the current tap. +/// - A single [MacUnit] multiplies the selected sample by the +/// coefficient and adds it to a running accumulator. +/// - After all taps are processed the accumulator is latched as +/// the output and the accumulator resets for the next sample. +class FilterChannel extends Module { + /// The data interface for this channel (internal use only). + @protected + late final FilterDataInterface intf; + + /// Filtered output. + Logic get dataOut => intf.dataOut; + + /// Output valid. + Logic get validOut => intf.validOut; + + /// Number of FIR taps in this channel. + final int numTaps; + + /// Bit width of each data sample. + final int dataWidth; + + /// Clock input. + @protected + Logic get clkPin => input('clk'); + + /// Reset input. + @protected + Logic get resetPin => input('reset'); + + /// Enable input. + @protected + Logic get enablePin => input('enable'); + + /// Creates a [FilterChannel] with [numTaps] taps at [dataWidth] bits. + /// + /// [srcIntf] provides the sample/valid input ports. [coefficients] + /// supplies per-tap constant coefficients. + FilterChannel( + FilterDataInterface srcIntf, + Logic clk, + Logic reset, + Logic enable, { + required this.numTaps, + required this.dataWidth, + required List coefficients, + super.name = 'FilterChannel', + }) : super(definitionName: 'FilterChannel_T${numTaps}_W$dataWidth') { + // Connect the Interface — creates module input/output ports + intf = FilterDataInterface(dataWidth: dataWidth) + ..connectIO(this, srcIntf, + inputTags: [FilterPortTag.inputPorts], + outputTags: [FilterPortTag.outputPorts]); + + final sampleIn = intf.sampleIn; + final validIn = intf.validIn; + clk = addInput('clk', clk); + reset = addInput('reset', reset); + enable = addInput('enable', enable); + + final tapIdxWidth = _bitsFor(numTaps); + + // ── Delay line (shift register via explicit flop bank + gates) ── + // AND gate: shift enable = enable & validIn & tapCounter==0 + // Samples shift in only when starting a new accumulation cycle. + final tapCounter = Logic(width: tapIdxWidth, name: 'tapCounter'); + final atFirstTap = + tapCounter.eq(Const(0, width: tapIdxWidth)).named('atFirstTap'); + final shiftEn = Logic(name: 'shiftEn'); + shiftEn <= (enable & validIn).named('enableAndValid') & atFirstTap; + + // LogicArray-backed delay line: one element per tap register. + final delayLine = LogicArray([numTaps], dataWidth, name: 'delayLine'); + for (var i = 0; i < numTaps; i++) { + final tapInput = (i == 0) ? sampleIn : delayLine.elements[i - 1]; + // Mux: hold current value or shift in new sample + final tapNext = Logic(width: dataWidth, name: 'nextTap$i'); + tapNext <= mux(shiftEn, tapInput, delayLine.elements[i]); + // Flop: register the next-state value + delayLine.elements[i] <= flop(clk, reset: reset, tapNext); + } + + // ── Coefficient bank — driven by tapCounter ── + // Build a LogicArray of constants from the coefficient list and + // pass it as an input port to CoeffBank (demonstrates addInputArray + // on a sub-module). + final coeffArray = LogicArray([numTaps], dataWidth, name: 'coeffArray'); + for (var i = 0; i < numTaps; i++) { + coeffArray.elements[i] <= Const(coefficients[i], width: dataWidth); + } + + final coeffBank = CoeffBank( + tapCounter, + coeffArray, + numTaps: numTaps, + dataWidth: dataWidth, + name: 'coeffBank', + ); + + // ── Delay-line mux — select sample for current tap ── + var selectedSample = delayLine.elements[0]; + for (var i = 1; i < numTaps; i++) { + final tapSelect = + tapCounter.eq(Const(i, width: tapIdxWidth)).named('tapSelect$i'); + selectedSample = mux(tapSelect, delayLine.elements[i], selectedSample) + .named('tapMux$i'); + } + + // ── Running accumulator (feedback register) ── + final accumReg = Logic(width: dataWidth, name: 'accumReg'); + // Reset accumulator at the start of each new sample (tap 0). + // Combinational block: equivalent to `always_comb` in SystemVerilog. + final accumFeedback = Logic(width: dataWidth, name: 'accumFeedback'); + Combinational([ + If(atFirstTap, then: [ + accumFeedback < Const(0, width: dataWidth), + ], orElse: [ + accumFeedback < accumReg, + ]), + ]); + + // ── Single MAC unit — time-multiplexed across taps ── + final mac = MacUnit( + selectedSample, + coeffBank.coeffOut, + accumFeedback, + clk, + reset, + enable, + dataWidth: dataWidth, + name: 'mac', + ); + + // Register the MAC result for accumulator feedback. + accumReg <= flop(clk, reset: reset, mac.result); + + // ── Tap counter: cycles 0 … numTaps-1 while enabled ── + // Sequential block: equivalent to `always_ff @(posedge clk)` in SV. + // When enabled, the counter increments and wraps at numTaps-1. + // When disabled, it resets to 0. + final lastTap = + tapCounter.eq(Const(numTaps - 1, width: tapIdxWidth)).named('lastTap'); + Sequential(clk, reset: reset, [ + If(enable, then: [ + If(lastTap, then: [ + tapCounter < Const(0, width: tapIdxWidth), + ], orElse: [ + tapCounter < tapCounter + Const(1, width: tapIdxWidth), + ]), + ], orElse: [ + tapCounter < Const(0, width: tapIdxWidth), + ]), + ]); + + // ── Output latch: capture accumulator when all taps processed ── + // The MAC pipeline has 2 stages, so the result is ready 2 cycles + // after the last tap enters. A 2-stage shift register of lastTap + // creates the latch strobe. + final lastTapD1 = Logic(name: 'lastTapD1'); + final lastTapD2 = Logic(name: 'lastTapD2'); + final outputReg = Logic(width: dataWidth, name: 'outputReg'); + + // Sequential block with If: latch strobe delay and output register. + Sequential(clk, reset: reset, [ + lastTapD1 < lastTap, + lastTapD2 < lastTapD1, + If(lastTapD2, then: [ + outputReg < accumReg, + ]), + ]); + + // ── Valid pipeline: track whether we have a valid output ── + // validIn is high during data injection. After the MAC pipeline + // latency (numTaps + 2 cycles), outputs become valid. + final validPipe = Logic(name: 'validPipe'); + final outputReady = (lastTapD2 & enable).named('outputReady'); + + // Sequential block: register the valid strobe and hold it. + Sequential(clk, reset: reset, [ + If(enable, then: [ + validPipe < outputReady, + ]), + ]); + + // Combinational block: gate the output to zero when not valid. + final dataOut = intf.dataOut; + final validOut = intf.validOut; + Combinational([ + If(validPipe, then: [ + dataOut < outputReg, + ], orElse: [ + dataOut < Const(0, width: dataWidth), + ]), + validOut < validPipe, + ]); + } + + /// Minimum bits needed to represent [n] values. + static int _bitsFor(int n) { + if (n <= 1) { + return 1; + } + var bits = 0; + var v = n - 1; + while (v > 0) { + bits++; + v >>= 1; + } + return bits; + } +} + +// ────────────────────────────────────────────────────────────────── +// FilterController: FSM sequencing the filter bank +// ────────────────────────────────────────────────────────────────── + +/// States for the [FilterController] finite state machine. +enum FilterState { + /// Waiting for the start signal. + idle, + + /// Accepting initial samples into the delay line. + loading, + + /// Normal filtering operation. + running, + + /// Flushing the pipeline after the input stream ends. + draining, + + /// Processing complete. + done, +} + +/// Controls the filter bank operation via a [FiniteStateMachine]. +/// +/// - idle: waiting for start signal +/// - loading: accepting initial samples into delay line +/// - running: normal filtering +/// - draining: flushing pipeline after input stream ends +/// - done: processing complete +class FilterController extends Module { + /// Encoded FSM state (3 bits). + Logic get state => output('state'); + + /// High while the filter channels should be processing. + Logic get filterEnable => output('filterEnable'); + + /// High during the initial sample-loading phase. + Logic get loadingPhase => output('loadingPhase'); + + /// Asserted when the filter bank has finished processing. + Logic get doneFlag => output('doneFlag'); + + /// Clock input. + @protected + Logic get clkPin => input('clk'); + + /// Reset input. + @protected + Logic get resetPin => input('reset'); + + /// Start input. + @protected + Logic get startPin => input('start'); + + /// Input valid. + @protected + Logic get inputValidPin => input('inputValid'); + + /// Input done. + @protected + Logic get inputDonePin => input('inputDone'); + + late final FiniteStateMachine _fsm; + + /// Returns the FSM's current state index for a given [FilterState]. + int? getStateIndex(FilterState s) => _fsm.getStateIndex(s); + + /// Creates a [FilterController] that sequences the filter bank. + /// + /// After [start] is asserted the FSM moves through loading → running + /// → draining (for [drainCycles] cycles) → done. + FilterController( + Logic clk, Logic reset, Logic start, Logic inputValid, Logic inputDone, + {required int drainCycles, super.name = 'FilterController'}) + : super(definitionName: 'FilterController') { + clk = addInput('clk', clk); + reset = addInput('reset', reset); + start = addInput('start', start); + inputValid = addInput('inputValid', inputValid); + inputDone = addInput('inputDone', inputDone); + + final filterEnable = addOutput('filterEnable'); + final loadingPhase = addOutput('loadingPhase'); + final doneFlag = addOutput('doneFlag'); + final state = addOutput('state', width: 3); + + // Drain counter + final drainCount = Logic(width: 8, name: 'drainCount'); + final drainDone = + drainCount.eq(Const(drainCycles, width: 8)).named('drainDone'); + + _fsm = FiniteStateMachine( + clk, + reset, + FilterState.idle, + [ + State( + FilterState.idle, + events: { + start: FilterState.loading, + }, + actions: [ + filterEnable < 0, + loadingPhase < 0, + doneFlag < 0, + ], + ), + State( + FilterState.loading, + events: { + inputValid: FilterState.running, + }, + actions: [ + filterEnable < 1, + loadingPhase < 1, + doneFlag < 0, + ], + ), + State( + FilterState.running, + events: { + inputDone: FilterState.draining, + }, + actions: [ + filterEnable < 1, + loadingPhase < 0, + doneFlag < 0, + ], + ), + State( + FilterState.draining, + events: { + drainDone: FilterState.done, + }, + actions: [ + filterEnable < 1, + loadingPhase < 0, + doneFlag < 0, + ], + ), + State( + FilterState.done, + events: {}, + actions: [ + filterEnable < 0, + loadingPhase < 0, + doneFlag < 1, + ], + ), + ], + ); + + state <= _fsm.currentState.zeroExtend(state.width); + + // Drain counter: Sequential block increments while draining, + // resets to zero otherwise. + final drainIdx = _fsm.getStateIndex(FilterState.draining)!; + final isDraining = Logic(name: 'isDraining'); + isDraining <= _fsm.currentState.eq(Const(drainIdx, width: _fsm.stateWidth)); + + Sequential(clk, reset: reset, [ + If(isDraining, then: [ + drainCount < drainCount + Const(1, width: 8), + ], orElse: [ + drainCount < Const(0, width: 8), + ]), + ]); + } +} + +// ────────────────────────────────────────────────────────────────── +// FilterBank: top-level 2-channel polyphase FIR filter +// ────────────────────────────────────────────────────────────────── + +/// A 2-channel polyphase FIR filter bank. +/// +/// Hierarchy: +/// ```text +/// FilterBank (top) +/// ├── FilterController (FSM) +/// ├── FilterChannel 'ch0' +/// │ ├── CoeffBank (coefficient ROM via LogicArray + mux chain) +/// │ └── MacUnit 'mac' (pipelined multiply-accumulate) +/// └── FilterChannel 'ch1' +/// ├── CoeffBank +/// └── MacUnit 'mac' +/// ``` +/// +/// Each channel time-multiplexes a single MacUnit across all taps, +/// sequenced by a tap counter that drives the CoeffBank tap index +/// and a delay-line sample mux. +/// +/// Uses: +/// - [FilterDataInterface] for I/O port bundles +/// - [FilterSample] LogicStructure for structured sample signals +/// - [LogicArray] in CoeffBank for coefficient storage +/// - [Pipeline] in MacUnit for pipelined MAC +/// - [FiniteStateMachine] in FilterController for sequencing +/// - Multiple instantiation: two [FilterChannel]s share one definition +/// - [LogicNet] / [addInOut] for bidirectional shared data bus + +// ────────────────────────────────────────────────────────────────── +// SharedDataBus: bidirectional port for coefficient/status I/O +// ────────────────────────────────────────────────────────────────── + +/// A module with a bidirectional data bus for loading/reading data. +/// +/// In real hardware, a shared data bus is common for: +/// - Loading filter coefficients from external memory +/// - Reading diagnostic status or filter output snapshots +/// +/// Direction is controlled by `writeEnable`: when high, the module's +/// internal [TriStateBuffer] drives `storedValue` onto `dataBus`; +/// when low, the external driver owns the bus and the module latches +/// the incoming value into a register. +/// +/// Exercises `addInOut` / `LogicNet` / [TriStateBuffer] / inout port +/// direction through the full ROHD stack: synthesis, hierarchy, +/// waveform capture, and DevTools rendering. +class SharedDataBus extends Module { + /// The bidirectional data bus port. + Logic get dataBus => inOut('dataBus'); + + /// The stored value (latched when the bus is driven externally). + Logic get storedValue => output('storedValue'); + + /// Write-enable input. + @protected + Logic get writeEnablePin => input('writeEnable'); + + /// Clock input. + @protected + Logic get clkPin => input('clk'); + + /// Reset input. + @protected + Logic get resetPin => input('reset'); + + /// Data width in bits. + final int dataWidth; + + /// Creates a [SharedDataBus] with a [dataWidth]-bit bidirectional port. + /// + /// [dataBusNet] is the external [LogicNet] to connect. + /// [writeEnable] controls bus direction: 1 = module drives bus, + /// 0 = external drives bus (module reads). + /// [clk] and [reset] provide synchronous storage. + SharedDataBus( + LogicNet dataBusNet, + Logic writeEnable, + Logic clk, + Logic reset, { + required this.dataWidth, + super.name = 'SharedDataBus', + }) : super(definitionName: 'SharedDataBus') { + final bus = addInOut('dataBus', dataBusNet, width: dataWidth); + writeEnable = addInput('writeEnable', writeEnable); + clk = addInput('clk', clk); + reset = addInput('reset', reset); + + final storedValue = addOutput('storedValue', width: dataWidth); + + // Latch the bus value on clock edge when the external side is driving. + storedValue <= + flop( + clk, + bus, + reset: reset, + en: ~writeEnable, + resetValue: Const(0, width: dataWidth), + ); + + // Drive the latched value back onto the bus when writeEnable is high. + // TriStateBuffer drives its out (a LogicNet) with storedValue when + // enabled; otherwise it outputs high-Z. Joining out↔bus makes the + // two nets share the same wire. + TriStateBuffer(storedValue, enable: writeEnable, name: 'busDriver') + .out + .gets(bus); + } +} + +/// The top-level polyphase FIR filter bank. +class FilterBank extends Module { + /// Per-channel filtered outputs as a [LogicArray]. + /// + /// `channelOut.elements[i]` is the filtered output of channel `i`. + LogicArray get channelOut => output('channelOut') as LogicArray; + + /// Channel 0 filtered output (convenience getter). + Logic get out0 => channelOut.elements[0]; + + /// Channel 1 filtered output (convenience getter). + Logic get out1 => channelOut.elements[1]; + + /// Output valid (aligned with filtered outputs). + Logic get validOut => output('validOut'); + + /// Done signal from the controller FSM. + Logic get done => output('done'); + + /// Controller state (for debug visibility). + Logic get state => output('state'); + + /// Clock input. + @protected + Logic get clkPin => input('clk'); + + /// Reset input. + @protected + Logic get resetPin => input('reset'); + + /// Start input. + @protected + Logic get startPin => input('start'); + + /// Per-channel sample input array. + @protected + LogicArray get samplesInPin => input('samplesIn') as LogicArray; + + /// Input valid strobe. + @protected + Logic get validInPin => input('validIn'); + + /// Input-done strobe. + @protected + Logic get inputDonePin => input('inputDone'); + + /// Number of FIR taps per channel. + final int numTaps; + + /// Bit width of each data sample. + final int dataWidth; + + /// Number of filter channels. + final int numChannels; + + /// Creates a [FilterBank] with [numChannels] channels (default 2). + /// + /// Each channel has [numTaps] FIR taps at [dataWidth] bits. + /// [coefficients] is a list of per-channel coefficient lists — + /// `coefficients[i]` supplies the tap weights for channel `i`. + /// [samplesIn] is a [LogicArray] with one element per channel. + /// [validIn] qualifies the sample data. Assert [start] to begin + /// and [inputDone] when the input stream is complete. + /// + /// Optionally pass [dataBus] (a `LogicNet`) and [writeEnable] to + /// attach a bidirectional shared data bus via [SharedDataBus]. + /// The bus latches external data when [writeEnable] is low and + /// drives `storedValue` output. + FilterBank( + Logic clk, + Logic reset, + Logic start, + LogicArray samplesIn, + Logic validIn, + Logic inputDone, { + required this.numTaps, + required this.dataWidth, + required List> coefficients, + this.numChannels = 2, + LogicNet? dataBus, + Logic? writeEnable, + super.name = 'FilterBank', + String? definitionName, + }) : super(definitionName: definitionName ?? 'FilterBank') { + if (coefficients.length != numChannels) { + throw Exception( + 'coefficients must have $numChannels entries (one per channel).'); + } + + // ── Register ports ── + clk = addInput('clk', clk); + reset = addInput('reset', reset); + start = addInput('start', start); + samplesIn = addInputArray('samplesIn', samplesIn, + dimensions: [numChannels], elementWidth: dataWidth); + validIn = addInput('validIn', validIn); + inputDone = addInput('inputDone', inputDone); + + final channelOut = addOutputArray('channelOut', + dimensions: [numChannels], elementWidth: dataWidth); + final validOut = addOutput('validOut'); + final done = addOutput('done'); + final state = addOutput('state', width: 3); + + // ── FilterSample LogicStructure for input bundling ── + final samples = []; + for (var ch = 0; ch < numChannels; ch++) { + final sample = FilterSample(dataWidth: dataWidth, name: 'sample$ch'); + sample.data <= samplesIn.elements[ch]; + sample.valid <= validIn; + sample.channel <= Const(ch); + samples.add(sample); + } + + // ── Controller FSM ── + // Drain cycles: numTaps cycles per accumulation + pipeline depth (2) + 1 + final controller = FilterController( + clk, + reset, + start, + validIn, + inputDone, + drainCycles: numTaps + 3, + name: 'controller', + ); + + final filterEnable = controller.filterEnable; + + // ── Per-channel filter instantiation ── + final srcIntfs = []; + for (var ch = 0; ch < numChannels; ch++) { + final srcIntf = FilterDataInterface(dataWidth: dataWidth); + srcIntf.sampleIn <= samples[ch].data; + srcIntf.validIn <= samples[ch].valid; + + FilterChannel( + srcIntf, + clk, + reset, + filterEnable, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: coefficients[ch], + name: 'ch$ch', + ); + + srcIntfs.add(srcIntf); + } + + // ── Connect outputs ── + for (var ch = 0; ch < numChannels; ch++) { + channelOut.elements[ch] <= srcIntfs[ch].dataOut; + } + validOut <= srcIntfs[0].validOut; + done <= controller.doneFlag; + state <= controller.state; + + // ── Optional shared data bus (inOut port) ── + if (dataBus != null && writeEnable != null) { + final busPort = addInOut('dataBus', dataBus, width: dataWidth); + writeEnable = addInput('writeEnable', writeEnable); + final storedValue = addOutput('storedValue', width: dataWidth); + + final sharedBus = SharedDataBus( + LogicNet(name: 'busNet', width: dataWidth)..gets(busPort), + writeEnable, + clk, + reset, + dataWidth: dataWidth, + ); + storedValue <= sharedBus.storedValue; + } + } +} diff --git a/lib/src/examples/oven_fsm_modules.dart b/lib/src/examples/oven_fsm_modules.dart new file mode 100644 index 000000000..b1f18a3f5 --- /dev/null +++ b/lib/src/examples/oven_fsm_modules.dart @@ -0,0 +1,211 @@ +// Copyright (C) 2023-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// oven_fsm_modules.dart +// Web-safe module class definitions for the Oven FSM example. +// +// Extracted from example/oven_fsm.dart and example/example.dart so these +// classes can be imported in web-targeted code (no dart:io dependency). +// +// 2026 April +// Original authors: Yao Jing Quek, Max Korbel + +import 'package:meta/meta.dart'; +import 'package:rohd/rohd.dart'; + +// ────────────────────────────────────────────────────────────────── +// Counter (from example/example.dart) +// ────────────────────────────────────────────────────────────────── + +/// A simple 8-bit counter with enable and synchronous reset. +class Counter extends Module { + /// The current counter value. + Logic get val => output('val'); + + /// The enable input. + @protected + Logic get en => input('en'); + + /// The reset input. + @protected + Logic get resetPin => input('reset'); + + /// The clock input. + @protected + Logic get clkPin => input('clk'); + + /// Bit width of the counter (default 8). + final int width; + + /// Creates a [Counter] of [width] bits driven by [clk]. + /// + /// Increments on each rising edge when [en] is high. + /// [reset] synchronously clears the count to zero. + Counter( + Logic en, + Logic reset, + Logic clk, { + this.width = 8, + super.name = 'counter', + }) : super(definitionName: 'Counter_W$width') { + en = addInput('en', en); + reset = addInput('reset', reset); + clk = addInput('clk', clk); + addOutput('val', width: width); + + val <= flop(clk, reset: reset, en: en, val + 1); + } +} + +// ────────────────────────────────────────────────────────────────── +// Oven FSM enums +// ────────────────────────────────────────────────────────────────── + +/// Oven states: standby → cooking → paused → completed. +enum OvenState { + /// Waiting for the start button. + standby, + + /// Actively cooking (timer running). + cooking, + + /// Cooking paused (timer held). + paused, + + /// Cooking finished (timer expired). + completed, +} + +/// One-hot encoded button inputs. +enum Button { + /// Start or restart cooking. + start(value: 0), + + /// Pause cooking. + pause(value: 1), + + /// Resume from pause. + resume(value: 2); + + /// Creates a button with the given encoded [value]. + const Button({required this.value}); + + /// The encoded value for this button. + final int value; +} + +/// One-hot encoded LED output colors. +enum LEDLight { + /// Yellow — cooking in progress. + yellow(value: 0), + + /// Blue — standby. + blue(value: 1), + + /// Red — paused. + red(value: 2), + + /// Green — cooking complete. + green(value: 3); + + /// Creates an LED color with the given encoded [value]. + const LEDLight({required this.value}); + + /// The encoded value for this LED color. + final int value; +} + +// ────────────────────────────────────────────────────────────────── +// OvenModule +// ────────────────────────────────────────────────────────────────── + +/// A microwave oven FSM with 4 states and an internal timer counter. +/// +/// Inputs: +/// - `button` (2-bit): start / pause / resume +/// - `reset`: active-high synchronous reset +/// - `clk`: clock +/// +/// Outputs: +/// - `led` (2-bit): blue (standby), yellow (cooking), +/// red (paused), green (completed) +class OvenModule extends Module { + late final FiniteStateMachine _oven; + + /// The LED output encoding the current state. + Logic get led => output('led'); + + /// The button input. + @protected + Logic get button => input('button'); + + /// The reset input. + @protected + Logic get resetPin => input('reset'); + + /// The clock input. + @protected + Logic get clkPin => input('clk'); + + /// Creates an [OvenModule] controlled by [button] with [clk] and [reset]. + OvenModule(Logic button, Logic reset, Logic clk) + : super(name: 'oven', definitionName: 'OvenModule') { + button = addInput('button', button, width: button.width); + reset = addInput('reset', reset); + clk = addInput('clk', clk); + final led = addOutput('led', width: button.width); + + final counterReset = Logic(name: 'counter_reset'); + final en = Logic(name: 'counter_en'); + + final counter = Counter(en, counterReset, clk, name: 'counter_module'); + + final states = [ + State(OvenState.standby, events: { + Logic(name: 'button_start') + ..gets(button.eq(Const(Button.start.value, width: button.width))): + OvenState.cooking, + }, actions: [ + led < LEDLight.blue.value, + counterReset < 1, + en < 0, + ]), + State(OvenState.cooking, events: { + Logic(name: 'button_pause') + ..gets(button.eq(Const(Button.pause.value, width: button.width))): + OvenState.paused, + Logic(name: 'counter_time_complete')..gets(counter.val.eq(4)): + OvenState.completed, + }, actions: [ + led < LEDLight.yellow.value, + counterReset < 0, + en < 1, + ]), + State(OvenState.paused, events: { + Logic(name: 'button_resume') + ..gets( + button.eq(Const(Button.resume.value, width: button.width))): + OvenState.cooking, + }, actions: [ + led < LEDLight.red.value, + counterReset < 0, + en < 0, + ]), + State(OvenState.completed, events: { + Logic(name: 'button_start') + ..gets(button.eq(Const(Button.start.value, width: button.width))): + OvenState.cooking, + }, actions: [ + led < LEDLight.green.value, + counterReset < 1, + en < 0, + ]), + ]; + + _oven = + FiniteStateMachine(clk, reset, OvenState.standby, states); + } + + /// The internal [FiniteStateMachine] driving the oven states. + FiniteStateMachine get ovenStateMachine => _oven; +} diff --git a/lib/src/examples/tree_modules.dart b/lib/src/examples/tree_modules.dart new file mode 100644 index 000000000..96fc7d283 --- /dev/null +++ b/lib/src/examples/tree_modules.dart @@ -0,0 +1,63 @@ +// Copyright (C) 2021-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// tree_modules.dart +// Web-safe module class definition for the Tree of Two-Input Modules example. +// +// Extracted from example/tree.dart so it can be imported in web-targeted code. +// +// 2026 April +// Original author: Max Korbel + +import 'package:meta/meta.dart'; +import 'package:rohd/rohd.dart'; + +// ────────────────────────────────────────────────────────────────── +// TreeOfTwoInputModules +// ────────────────────────────────────────────────────────────────── + +/// A logarithmic-height tree of arbitrary two-input/one-output modules. +/// +/// Recursively instantiates itself, splitting the input list in half at each +/// level. The operation [op] is applied to combine pairs of results. +class TreeOfTwoInputModules extends Module { + /// The combining operation (internal use only). + @protected + final Logic Function(Logic a, Logic b) op; + + final List _seq = []; + + /// The combined output of the tree. + Logic get out => output('out'); + + /// Creates a tree that reduces [seq] using [op]. + /// + /// Recursively splits [seq] in half until single elements remain, + /// then combines them pair-wise with the supplied operation. + TreeOfTwoInputModules(List seq, this.op) + : super( + name: 'tree_of_two_input_modules', + definitionName: 'TreeMax_N${seq.length}', + ) { + if (seq.isEmpty) { + throw Exception("Don't use TreeOfTwoInputModules with an empty sequence"); + } + + for (var i = 0; i < seq.length; i++) { + _seq.add(addInput('seq$i', seq[i], width: seq[i].width)); + } + addOutput('out', width: seq[0].width); + + if (_seq.length == 1) { + out <= _seq[0]; + } else { + final a = + TreeOfTwoInputModules(_seq.getRange(0, _seq.length ~/ 2).toList(), op) + .out; + final b = TreeOfTwoInputModules( + _seq.getRange(_seq.length ~/ 2, _seq.length).toList(), op) + .out; + out <= op(a, b); + } + } +} diff --git a/lib/src/module.dart b/lib/src/module.dart index 9f6ec634e..db0690871 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -13,10 +13,8 @@ import 'dart:collection'; import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/collections/traverseable_collection.dart'; -import 'package:rohd/src/utilities/config.dart'; import 'package:rohd/src/utilities/namer.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; -import 'package:rohd/src/utilities/timestamper.dart'; import 'package:rohd/src/utilities/uniquifier.dart'; /// Represents a synthesizable hardware entity with clearly defined interface @@ -303,8 +301,11 @@ abstract class Module { /// /// The hierarchy is built "bottom-up", so leaf-level [Module]s are built /// before the [Module]s which contain them. + /// + /// If [netlistOptions] is provided, a [NetlistService] is automatically + /// created and registered after the module hierarchy is constructed. @mustCallSuper - Future build() async { + Future build({NetlistOptions? netlistOptions}) async { if (hasBuilt) { throw Exception( 'This Module has already been built, and can only be built once.'); @@ -333,6 +334,11 @@ abstract class Module { _hasBuilt = true; ModuleServices.instance.rootModule = this; + + // Optionally synthesize a netlist and register the service. + if (netlistOptions != null) { + await NetlistService.create(this, options: netlistOptions); + } } /// Confirms that the post-[build] hierarchy is valid. @@ -1133,18 +1139,7 @@ abstract class Module { throw ModuleNotBuiltException(this); } - final synthHeader = ''' -/** - * Generated by ROHD - www.github.com/intel/rohd - * Generation time: ${Timestamper.stamp()} - * ROHD Version: ${Config.version} - */ - -'''; - return synthHeader + - SynthBuilder(this, SystemVerilogSynthesizer()) - .getSynthFileContents() - .join('\n\n////////////////////\n\n'); + return SvService(this, register: false).synthOutput; } } diff --git a/lib/src/synthesizers/netlist/leaf_cell_mapper.dart b/lib/src/synthesizers/netlist/leaf_cell_mapper.dart new file mode 100644 index 000000000..606747dee --- /dev/null +++ b/lib/src/synthesizers/netlist/leaf_cell_mapper.dart @@ -0,0 +1,486 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// leaf_cell_mapper.dart +// Maps ROHD leaf modules to Yosys-primitive cell representations. +// +// 2026 February 11 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; + +/// The result of mapping a leaf ROHD module to a Yosys-style cell. +typedef LeafCellMapping = ({ + String cellType, + Map portDirs, + Map> connections, + Map parameters, +}); + +/// Context provided to each leaf-cell mapping handler. +/// +/// Contains the module instance plus the raw ROHD port directions and +/// connections built by the synthesizer, so handlers can remap them to +/// Yosys-primitive port names. +class LeafCellContext { + /// The ROHD [Module] being mapped. + final Module module; + + /// Raw ROHD port-direction map (`{'portName': 'input'|'output'|'inout'}`). + final Map rawPortDirs; + + /// Raw ROHD connection map (`{'portName': [wireId, ...]}`). + final Map> rawConns; + + /// Creates a [LeafCellContext]. + const LeafCellContext(this.module, this.rawPortDirs, this.rawConns); + + // ── Shared helper methods ─────────────────────────────────────────── + + /// Find the first input port name matching [prefix]. + String? findInput(String prefix) { + for (final k in module.inputs.keys) { + if (k.startsWith(prefix)) { + return k; + } + } + return null; + } + + /// The first output port name, or `null` if there are none. + String? get firstOutput => + module.outputs.keys.isEmpty ? null : module.outputs.keys.first; + + /// The first input port name, or `null` if there are none. + String? get firstInput => + module.inputs.keys.isEmpty ? null : module.inputs.keys.first; + + /// Width (number of wire IDs) for a given ROHD port name. + int width(String portName) => rawConns[portName]?.length ?? 0; + + /// Build new port-direction and connection maps from a + /// `{rohdPortName: yosysPortName}` mapping. + ({ + Map portDirs, + Map> connections, + }) remap(Map nameMap) { + final pd = {}; + final cn = >{}; + for (final e in nameMap.entries) { + final rohdName = e.key; + final netlistPortName = e.value; + pd[netlistPortName] = rawPortDirs[rohdName] ?? 'output'; + cn[netlistPortName] = rawConns[rohdName] ?? []; + } + return (portDirs: pd, connections: cn); + } +} + +/// Signature for a leaf-cell mapping handler. +/// +/// Returns a [LeafCellMapping] if the handler recognises the module, +/// or `null` to let the next handler try. +typedef LeafCellHandler = LeafCellMapping? Function(LeafCellContext ctx); + +/// Maps ROHD leaf [Module]s to Yosys-primitive cell representations. +/// +/// Handlers are registered via [register] and tried in registration order. +/// A singleton instance with all built-in ROHD types pre-registered is +/// available via [LeafCellMapper.defaultMapper]. +/// +/// ```dart +/// final mapper = LeafCellMapper.defaultMapper; +/// final result = mapper.map(sub, rawPortDirs, rawConns); +/// ``` +class LeafCellMapper { + /// Ordered list of registered handlers. + final _handlers = []; + + /// Creates an empty [LeafCellMapper] with no registered handlers. + LeafCellMapper(); + + /// The default mapper with all built-in ROHD leaf types registered. + static final defaultMapper = LeafCellMapper._withDefaults(); + + /// Register a mapping [handler]. + /// + /// Handlers are tried in registration order; the first non-null result + /// wins. Register more-specific handlers before less-specific ones. + void register(LeafCellHandler handler) { + _handlers.add(handler); + } + + /// Try to map [module] to a Yosys-primitive cell. + /// + /// Returns `null` if no registered handler matches. + LeafCellMapping? map( + Module module, + Map rawPortDirs, + Map> rawConns, + ) { + final ctx = LeafCellContext(module, rawPortDirs, rawConns); + for (final handler in _handlers) { + final result = handler(ctx); + if (result != null) { + return result; + } + } + return null; + } + + // ══════════════════════════════════════════════════════════════════════ + // Reusable mapping patterns + // ══════════════════════════════════════════════════════════════════════ + + /// Map a single-input, single-output gate (e.g. `$not`, `$reduce_and`). + static LeafCellMapping? unaryAY( + LeafCellContext ctx, + String cellType, + ) { + final inN = ctx.firstInput; + final out = ctx.firstOutput; + if (inN == null || out == null) { + return null; + } + final r = ctx.remap({inN: 'A', out: 'Y'}); + return ( + cellType: cellType, + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'A_WIDTH': ctx.width(inN), + 'Y_WIDTH': ctx.width(out), + }, + ); + } + + /// Map a two-input gate with ports A, B, Y (e.g. `$and`, `$eq`, `$shl`). + static LeafCellMapping? binaryABY( + LeafCellContext ctx, + String cellType, { + required String inAPrefix, + required String inBPrefix, + }) { + final a = ctx.findInput(inAPrefix); + final b = ctx.findInput(inBPrefix); + final out = ctx.firstOutput; + if (a == null || b == null || out == null) { + return null; + } + final r = ctx.remap({a: 'A', b: 'B', out: 'Y'}); + return ( + cellType: cellType, + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'A_WIDTH': ctx.width(a), + 'B_WIDTH': ctx.width(b), + 'Y_WIDTH': ctx.width(out), + }, + ); + } + + // ══════════════════════════════════════════════════════════════════════ + // Built-in handler registration + // ══════════════════════════════════════════════════════════════════════ + + /// Creates a [LeafCellMapper] with built-in handlers for common ROHD leaf + /// types. + factory LeafCellMapper._withDefaults() { + final m = LeafCellMapper(); + + // Helper to reduce boilerplate for type-map-based handlers. + void registerByTypeMap( + Map typeMap, + LeafCellMapping? Function(LeafCellContext ctx, String cellType) handler, + ) { + m.register((ctx) { + final cellType = typeMap[ctx.module.runtimeType]; + return cellType == null ? null : handler(ctx, cellType); + }); + } + + m + // ── BusSubset → $slice ──────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! BusSubset) { + return null; + } + final sub = ctx.module as BusSubset; + final inName = sub.inputs.keys.first; + final outName = sub.outputs.keys.first; + final r = ctx.remap({inName: 'A', outName: 'Y'}); + return ( + cellType: r'$slice', + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'OFFSET': sub.startIndex, + 'A_WIDTH': ctx.width(inName), + 'Y_WIDTH': ctx.width(outName), + }, + ); + }) + + // ── Swizzle → $concat ───────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! Swizzle) { + return null; + } + final outName = ctx.firstOutput; + final inputKeys = ctx.module.inputs.keys.toList(); + + // Filter out zero-width inputs (degenerate concat operands). + final nonZeroKeys = inputKeys.where((k) => ctx.width(k) > 0).toList(); + + if (nonZeroKeys.length == 2 && outName != null) { + final r = ctx + .remap({nonZeroKeys[0]: 'A', nonZeroKeys[1]: 'B', outName: 'Y'}); + return ( + cellType: r'$concat', + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'A_WIDTH': ctx.width(nonZeroKeys[0]), + 'B_WIDTH': ctx.width(nonZeroKeys[1]), + }, + ); + } + + // Single non-zero input ⇒ emit as $buf. + if (nonZeroKeys.length == 1 && outName != null) { + final r = ctx.remap({nonZeroKeys[0]: 'A', outName: 'Y'}); + return ( + cellType: r'$buf', + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'WIDTH': ctx.width(nonZeroKeys[0]), + }, + ); + } + + if (nonZeroKeys.isEmpty) { + return null; + } + + // N-input concat: per-input range labels, output is Y. + final pd = {}; + final cn = >{}; + final params = {}; + var bitOffset = 0; + for (var i = 0; i < nonZeroKeys.length; i++) { + final ik = nonZeroKeys[i]; + final w = ctx.width(ik); + final label = + w == 1 ? '[$bitOffset]' : '[${bitOffset + w - 1}:$bitOffset]'; + pd[label] = 'input'; + cn[label] = ctx.rawConns[ik] ?? []; + params['IN${i}_WIDTH'] = w; + bitOffset += w; + } + if (outName != null) { + pd['Y'] = 'output'; + cn['Y'] = ctx.rawConns[outName] ?? []; + } + return ( + cellType: r'$concat', + portDirs: pd, + connections: cn, + parameters: params, + ); + }) + + // ── NOT gate ────────────────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! NotGate) { + return null; + } + return unaryAY(ctx, r'$not'); + }) + + // ── Mux ─────────────────────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! Mux) { + return null; + } + final ctrl = ctx.findInput('_control') ?? ctx.findInput('control'); + final d0 = ctx.findInput('_d0') ?? ctx.findInput('d0'); + final d1 = ctx.findInput('_d1') ?? ctx.findInput('d1'); + final out = ctx.firstOutput; + if (ctrl == null || d0 == null || d1 == null || out == null) { + return null; + } + // Yosys: S=select, A=d0 (when S=0), B=d1 (when S=1). + final r = ctx.remap({ctrl: 'S', d0: 'A', d1: 'B', out: 'Y'}); + return ( + cellType: r'$mux', + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'WIDTH': ctx.width(d0), + }, + ); + }) + + // ── Add ─────────────────────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! Add) { + return null; + } + final in0 = ctx.findInput('_in0') ?? ctx.findInput('in0'); + final in1 = ctx.findInput('_in1') ?? ctx.findInput('in1'); + final sumName = ctx.module.outputs.keys + .firstWhere((k) => !k.contains('carry'), orElse: () => ''); + final carryName = ctx.module.outputs.keys + .firstWhere((k) => k.contains('carry'), orElse: () => ''); + if (in0 == null || in1 == null || sumName.isEmpty) { + return null; + } + final pd = { + 'A': 'input', + 'B': 'input', + 'Y': 'output', + }; + final cn = >{ + 'A': ctx.rawConns[in0] ?? [], + 'B': ctx.rawConns[in1] ?? [], + 'Y': ctx.rawConns[sumName] ?? [], + }; + if (carryName.isNotEmpty) { + pd['CO'] = 'output'; + cn['CO'] = ctx.rawConns[carryName] ?? []; + } + return ( + cellType: r'$add', + portDirs: pd, + connections: cn, + parameters: { + 'A_WIDTH': ctx.width(in0), + 'B_WIDTH': ctx.width(in1), + 'Y_WIDTH': ctx.width(sumName), + }, + ); + }) + + // ── FlipFlop → $dff ─────────────────────────────────────────────── + ..register((ctx) { + if (ctx.module is! FlipFlop) { + return null; + } + final clk = ctx.findInput('_clk') ?? ctx.findInput('clk'); + final d = ctx.findInput('_d') ?? ctx.findInput('d'); + final en = ctx.findInput('_en') ?? ctx.findInput('en'); + final rst = ctx.findInput('_reset') ?? ctx.findInput('reset'); + final q = ctx.firstOutput; + if (clk == null || d == null || q == null) { + return null; + } + final pd = { + '_clk': 'input', + '_d': 'input', + '_q': 'output', + }; + final cn = >{ + '_clk': ctx.rawConns[clk] ?? [], + '_d': ctx.rawConns[d] ?? [], + '_q': ctx.rawConns[q] ?? [], + }; + if (en != null && ctx.rawConns.containsKey(en)) { + pd['_en'] = 'input'; + cn['_en'] = ctx.rawConns[en] ?? []; + } + if (rst != null && ctx.rawConns.containsKey(rst)) { + pd['_reset'] = 'input'; + cn['_reset'] = ctx.rawConns[rst] ?? []; + } + final rstVal = + ctx.findInput('_resetValue') ?? ctx.findInput('resetValue'); + if (rstVal != null && ctx.rawConns.containsKey(rstVal)) { + pd['_resetValue'] = 'input'; + cn['_resetValue'] = ctx.rawConns[rstVal] ?? []; + } + return ( + cellType: r'$dff', + portDirs: pd, + connections: cn, + parameters: { + 'WIDTH': ctx.width(d), + 'CLK_POLARITY': 1, + }, + ); + }); + + // ── Type-map-based gates ─────────────────────────────────────────── + final gateRegistrations = <( + Map, + LeafCellMapping? Function(LeafCellContext, String), + )>[ + ( + const { + And2Gate: r'$and', + Or2Gate: r'$or', + Xor2Gate: r'$xor', + }, + (ctx, type) => + binaryABY(ctx, type, inAPrefix: '_in0', inBPrefix: '_in1'), + ), + ( + const { + AndUnary: r'$reduce_and', + OrUnary: r'$reduce_or', + XorUnary: r'$reduce_xor', + }, + unaryAY, + ), + ( + const { + Multiply: r'$mul', + Subtract: r'$sub', + Equals: r'$eq', + NotEquals: r'$ne', + LessThan: r'$lt', + GreaterThan: r'$gt', + LessThanOrEqual: r'$le', + GreaterThanOrEqual: r'$ge', + }, + (ctx, type) => + binaryABY(ctx, type, inAPrefix: '_in0', inBPrefix: '_in1'), + ), + ( + const { + LShift: r'$shl', + RShift: r'$shr', + ARShift: r'$shiftx', + }, + (ctx, type) => + binaryABY(ctx, type, inAPrefix: '_in', inBPrefix: '_shiftAmount'), + ), + ]; + for (final (typeMap, handler) in gateRegistrations) { + registerByTypeMap(typeMap, handler); + } + + // ── TriStateBuffer → $tribuf ────────────────────────────────────── + m.register((ctx) { + if (ctx.module is! TriStateBuffer) { + return null; + } + final tsb = ctx.module as TriStateBuffer; + final inName = tsb.inputs.keys.first; // data input + final enName = tsb.inputs.keys.last; // enable + final outName = tsb.inOuts.keys.first; // inout output + final r = ctx.remap({inName: 'A', enName: 'EN', outName: 'Y'}); + return ( + cellType: r'$tribuf', + portDirs: r.portDirs, + connections: r.connections, + parameters: { + 'WIDTH': ctx.width(inName), + }, + ); + }); + + return m; + } +} diff --git a/lib/src/synthesizers/netlist/netlist.dart b/lib/src/synthesizers/netlist/netlist.dart new file mode 100644 index 000000000..8d335e812 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist.dart @@ -0,0 +1,16 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist.dart +// Barrel file for netlist synthesis library. +// +// 2026 February 11 +// Author: Desmond Kirkpatrick + +export 'leaf_cell_mapper.dart'; +export 'netlist_options.dart'; +export 'netlist_passes.dart'; +export 'netlist_service.dart'; +export 'netlist_synthesis_result.dart'; +export 'netlist_synthesizer.dart'; +export 'netlist_utils.dart'; diff --git a/lib/src/synthesizers/netlist/netlist_options.dart b/lib/src/synthesizers/netlist/netlist_options.dart new file mode 100644 index 000000000..95d0856c1 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_options.dart @@ -0,0 +1,138 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_options.dart +// Configuration for netlist synthesis. +// +// 2026 March 12 +// Author: Desmond Kirkpatrick + +import 'package:rohd/src/synthesizers/netlist/leaf_cell_mapper.dart'; + +/// Configuration options for netlist synthesis. +/// +/// The netlist synthesizer serves two main consumer flows, both configured +/// through these options: +/// +/// **Flow 1 — Slim JSON** (`NetlistService.slimJson`): +/// Batch synthesis of the entire design, producing a lightweight +/// representation with ports, signals, and cell stubs but **no cell +/// connections**. Used for the initial DevTools hierarchy load. +/// +/// **Flow 2 — Full JSON, incremental** (`NetlistService.moduleJson`): +/// Returns the complete netlist (with cell connections) for a single +/// module definition on demand. Results are cached; the first call +/// may trigger a lazy `SynthBuilder` run on the requested subtree. +/// +/// Both flows run the identical pipeline: `SynthBuilder` → +/// `collectModuleEntries` → `applyPostProcessingPasses`. Flow 1 +/// then strips cell connections from the cached data; Flow 2 returns +/// it verbatim. This guarantees cell keys and wire IDs are stable +/// across both flows. +/// +/// Bundles all parameters that control netlist generation into a single +/// object, making it easier to pass through call chains and to store +/// for incremental synthesis. +/// +/// Example usage: +/// ```dart +/// const options = NetlistOptions( +/// groupStructConversions: true, +/// collapseStructGroups: true, +/// ); +/// final synth = NetlistSynthesizer(options: options); +/// ``` +class NetlistOptions { + /// The leaf-cell mapper used to convert ROHD leaf modules to Yosys + /// primitive cell types. When `null`, [LeafCellMapper.defaultMapper] + /// is used. + final LeafCellMapper? leafCellMapper; + + /// When `true`, groups of `$slice` + `$concat` cells that represent + /// structure-to-structure signal conversions are collapsed into + /// synthetic child modules, reducing visual clutter in the netlist. + final bool groupStructConversions; + + /// When `true` (requires [groupStructConversions] to also be `true`), + /// the synthetic child modules created for struct conversions will have + /// all their internal `$slice`/`$concat` cells and intermediate nets + /// removed, leaving only a single `$buf` cell that directly connects + /// each input port to the corresponding output port. + final bool collapseStructGroups; + + /// When `true` (requires [groupStructConversions] to also be `true`), + /// enables an additional grouping pass that finds `$concat` cells whose + /// input bits all trace back through `$buf`/`$slice` chains to a + /// contiguous sub-range of a single source bus. + final bool groupMaximalSubsets; + + /// When `true` (requires [groupStructConversions] to also be `true`), + /// enables an additional pass that finds `$concat` cells where a + /// contiguous run of input ports trace back through `$buf`/`$slice` + /// chains to a contiguous sub-range of a single source bus. + final bool collapseConcats; + + /// When `true`, `$slice` cells whose outputs feed directly into a + /// `$struct_pack` input port are absorbed into the pack cell, which + /// already describes the field decomposition. The redundant slices + /// are removed. + final bool collapseSelectsIntoPack; + + /// When `true`, `$struct_unpack` output ports that feed directly into + /// `$concat` input ports are collapsed: the concat is replaced by a + /// `$buf` or `$slice` from the unpack's source bus when all inputs + /// trace back to it contiguously. + final bool collapseUnpackToConcat; + + /// When `true`, `$struct_unpack` output ports that feed (possibly + /// through `$buf`/`$slice` chains) into `$struct_pack` input ports + /// are collapsed: the intermediate cells are removed and the pack + /// input wires are rewired to the unpack output wires directly. + final bool collapseUnpackToPack; + + /// When `true`, dead-cell elimination is performed after aliasing to + /// remove cells whose inputs are entirely undriven or whose outputs + /// are entirely unconsumed. + final bool enableDCE; + + /// When `true`, the synthesizer produces "slim" output: the full + /// synthesis pipeline runs (including all post-processing passes), + /// but cell connection maps are stripped from the result. + /// Netnames and ports are still emitted with full wire-ID fidelity, + /// so a subsequent full-mode synthesis of the same module will + /// produce compatible wire IDs. + final bool slimMode; + + /// When `true`, contiguous ascending runs of ≥3 integer bit IDs in + /// `bits` arrays and cell `connections` arrays are replaced with + /// `"start:end"` range strings (e.g. `[52, 53, 54, 55]` → `["52:55"]`). + /// + /// This is backward-compatible: Yosys-format arrays already mix + /// integers with constant strings `"0"` and `"1"`. Parsers can + /// detect range strings by the presence of `:`. + final bool compressBitRanges; + + /// When `true`, the JSON output uses no indentation (compact form). + /// When `false` (default), the JSON is pretty-printed with two-space + /// indentation. + final bool compactJson; + + /// Creates a [NetlistOptions] with the given configuration. + /// + /// All parameters have sensible defaults matching the current + /// netlist synthesizer behaviour. + const NetlistOptions({ + this.leafCellMapper, + this.groupStructConversions = false, + this.collapseStructGroups = false, + this.groupMaximalSubsets = false, + this.collapseConcats = false, + this.collapseSelectsIntoPack = false, + this.collapseUnpackToConcat = false, + this.collapseUnpackToPack = false, + this.enableDCE = true, + this.slimMode = false, + this.compressBitRanges = false, + this.compactJson = false, + }); +} diff --git a/lib/src/synthesizers/netlist/netlist_passes.dart b/lib/src/synthesizers/netlist/netlist_passes.dart new file mode 100644 index 000000000..7fdcf57fb --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_passes.dart @@ -0,0 +1,2541 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_passes.dart +// Post-processing optimization passes for netlist synthesis. +// +// These passes operate on the modules map (definition name → module data) +// produced by [NetlistSynthesizer.synthesize]. They simplify the netlist +// by grouping struct conversions, collapsing redundant cells, and inserting +// buffer cells for cleaner schematic rendering. +// +// 2025 February 11 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; + +/// Post-processing optimization passes for netlist synthesis. +/// +/// All methods are static — no instances are created. +class NetlistPasses { + NetlistPasses._(); + + /// Collects a combined modules map from [SynthesisResult]s suitable for + /// JSON emission. + static Map> collectModuleEntries( + Iterable results, { + Module? topModule, + }) { + final allModules = >{}; + for (final result in results) { + if (result is NetlistSynthesisResult) { + final typeName = result.instanceTypeName; + final attrs = Map.from(result.attributes); + if (topModule != null && result.module == topModule) { + attrs['top'] = 1; + } + allModules[typeName] = { + 'attributes': attrs, + 'ports': result.ports, + 'cells': result.cells, + 'netnames': result.netnames, + }; + } + } + return allModules; + } + + // -- Maximal-subset grouping ------------------------------------------- + + /// Finds `$concat` cells whose input bits all trace back through + /// `$buf`/`$slice` chains to a contiguous sub-range of a single source + /// bus. Replaces the entire concat-tree (the concat itself plus the + /// intermediate `$buf` and `$slice` cells that exclusively serve it) + /// with a single `$slice` (or `$buf` when the sub-range covers the + /// full source width). + /// + /// This pass runs *before* the connected-component grouping so that + /// the simplified cells can be picked up by the standard struct-assign + /// grouping and collapse passes. + static void applyMaximalSubsetGrouping( + Map> allModules, + ) { + for (final moduleDef in allModules.values) { + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + // Build wire-driver, wire-consumer, and bit-to-net maps. + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); + + final cellsToRemove = {}; + final cellsToAdd = >{}; + var replIdx = 0; + + // Process each $concat cell. + for (final concatEntry in cells.entries.toList()) { + final concatName = concatEntry.key; + final concatCell = concatEntry.value; + if ((concatCell['type'] as String?) != r'$concat') { + continue; + } + if (cellsToRemove.contains(concatName)) { + continue; + } + + final conns = concatCell['connections'] as Map? ?? {}; + + // Gather the concat's input bits in LSB-first order. + final inputBits = []; + if (conns.containsKey('A')) { + // Standard 2-input concat: A (LSB), B (MSB). + for (final b in conns['A'] as List) { + if (b is int) { + inputBits.add(b); + } + } + for (final b in conns['B'] as List) { + if (b is int) { + inputBits.add(b); + } + } + } else { + // Multi-input concat: range-named ports [lo:hi]. + final rangePorts = >{}; + for (final portName in conns.keys) { + if (portName == 'Y') { + continue; + } + final m = NetlistUtils.rangePortRe.firstMatch(portName); + if (m != null) { + final hi = int.parse(m.group(1)!); + final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; + rangePorts[lo] = [ + for (final b in conns[portName] as List) + if (b is int) b, + ]; + } + } + for (final k in rangePorts.keys.toList()..sort()) { + inputBits.addAll(rangePorts[k]!); + } + } + + if (inputBits.isEmpty) { + continue; + } + + final outputBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; + + // Trace each input bit backward through $buf and $slice cells + // to find its ultimate source bit. Record the chain of + // intermediate cells visited. + final sourceBits = []; + final intermediateCells = {}; + var allFromOneBus = true; + String? sourceBusNet; + List? sourceBusBits; + + for (final inputBit in inputBits) { + final (traced, chain) = + NetlistUtils.traceBackward(inputBit, wireDriverCell, cells); + sourceBits.add(traced); + intermediateCells.addAll(chain); + + // Identify which named bus this bit belongs to. + final info = bitToNetInfo[traced]; + if (info == null) { + allFromOneBus = false; + break; + } + if (sourceBusNet == null) { + sourceBusNet = info.$1; + sourceBusBits = info.$2; + } else if (sourceBusNet != info.$1) { + allFromOneBus = false; + break; + } + } + + if (!allFromOneBus || sourceBusNet == null || sourceBusBits == null) { + continue; + } + + // Verify the traced source bits form a contiguous sub-range + // of the source bus. + if (sourceBits.length != inputBits.length) { + continue; + } + + // Find each source bit's index within the source bus. + final indices = []; + var contiguous = true; + for (final sb in sourceBits) { + final idx = sourceBusBits.indexOf(sb); + if (idx < 0) { + contiguous = false; + break; + } + indices.add(idx); + } + if (!contiguous || indices.isEmpty) { + continue; + } + + // Check that indices are sequential (contiguous ascending). + for (var i = 1; i < indices.length; i++) { + if (indices[i] != indices[i - 1] + 1) { + contiguous = false; + break; + } + } + if (!contiguous) { + continue; + } + + // Verify that every intermediate cell is used exclusively + // by this concat chain (no fanout to other consumers). + if (!NetlistUtils.isExclusiveChain( + intermediates: intermediateCells, + ownerCell: concatName, + cells: cells, + wireConsumerCells: wireConsumerCells, + allowPortConsumers: true, + )) { + continue; + } + + // Build the source bus bits list (the full bus from the module). + // We need the A connection to be the full source bus. + final sourceBusParentBits = sourceBusBits.cast().toList(); + + final offset = indices.first; + final yWidth = outputBits.length; + final aWidth = sourceBusBits.length; + + // Mark intermediate cells and the concat for removal. + cellsToRemove + ..addAll(intermediateCells) + ..add(concatName); + + if (yWidth == aWidth) { + cellsToAdd['maxsub_buf_$replIdx'] = NetlistUtils.makeBufCell( + aWidth, sourceBusParentBits, outputBits.cast()); + } else { + cellsToAdd['maxsub_slice_$replIdx'] = NetlistUtils.makeSliceCell( + offset, + aWidth, + yWidth, + sourceBusParentBits, + outputBits.cast()); + } + replIdx++; + } + + // Apply removals and additions. + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); + } + } + + // -- Partial concat collapsing ----------------------------------------- + + /// Scans every module in [allModules] for `$concat` cells where a + /// contiguous run of input ports (≥ 2) all trace back through + /// `$buf`/`$slice` chains to a contiguous sub-range of a single source + /// bus with exclusive fan-out. Each such run is replaced by a single + /// `$slice` and the concat is rebuilt with fewer input ports. + /// + /// If *all* ports of a concat qualify as a single run, the concat is + /// eliminated entirely and replaced with a `$slice` (or `$buf` for + /// full-width). + static void applyCollapseConcats( + Map> allModules, + ) { + for (final entry in allModules.entries) { + final moduleDef = entry.value; + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + final concatCount = cells.values + .where((c) => (c['type'] as String?) == r'$concat') + .length; + if (concatCount == 0) { + continue; + } + + // Iterate until no more collapses are possible. Nested concat chains + // (e.g. swizzle_4 feeding swizzle_3) require multiple passes because + // traceBackward only traces through $slice/$buf, not $concat. + var changed = true; + var replIdx = 0; + var iteration = 0; + while (changed && iteration < 20) { + changed = false; + iteration++; + + // --- Build wire-driver, wire-consumer, and bit-to-net maps ------- + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); + + final cellsToRemove = {}; + final cellsToAdd = >{}; + + // --- Process each $concat cell ------------------------------------ + for (final concatEntry in cells.entries.toList()) { + final concatName = concatEntry.key; + final concatCell = concatEntry.value; + if ((concatCell['type'] as String?) != r'$concat') { + continue; + } + if (cellsToRemove.contains(concatName)) { + continue; + } + + final conns = + concatCell['connections'] as Map? ?? {}; + + // Parse input ports into an ordered list. + // Supports both range-named ports [hi:lo] and A/B form. + final inputPorts = <(int lo, String portName, List bits)>[]; + var hasRangePorts = false; + for (final portName in conns.keys) { + if (portName == 'Y') { + continue; + } + final m = NetlistUtils.rangePortRe.firstMatch(portName); + if (m != null) { + hasRangePorts = true; + final hi = int.parse(m.group(1)!); + final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; + inputPorts.add(( + lo, + portName, + [ + for (final b in conns[portName] as List) + if (b is int) b, + ], + )); + } + } + if (!hasRangePorts) { + // A/B form: convert to ordered list. + if (conns.containsKey('A') && conns.containsKey('B')) { + final aBits = [ + for (final b in conns['A'] as List) + if (b is int) b, + ]; + final bBits = [ + for (final b in conns['B'] as List) + if (b is int) b, + ]; + inputPorts + ..add((0, 'A', aBits)) + ..add((aBits.length, 'B', bBits)); + } + } + inputPorts.sort((a, b) => a.$1.compareTo(b.$1)); + + if (inputPorts.length < 2) { + continue; + } + + // --- Trace each port's bits back to a source bus ---------------- + final portTraces = <({ + String? busName, + List? busBits, + List sourceIndices, + Set intermediates, + bool valid, + })>[]; + + for (final (_, _, bits) in inputPorts) { + final sourceIndices = []; + final intermediates = {}; + String? busName; + List? busBits; + var valid = true; + + for (final bit in bits) { + final (traced, chain) = + NetlistUtils.traceBackward(bit, wireDriverCell, cells); + intermediates.addAll(chain); + + // Identify source net. + final info = bitToNetInfo[traced]; + if (info == null) { + valid = false; + break; + } + if (busName == null) { + busName = info.$1; + busBits = info.$2; + } else if (busName != info.$1) { + valid = false; + break; + } + final idx = busBits!.indexOf(traced); + if (idx < 0) { + valid = false; + break; + } + sourceIndices.add(idx); + } + + // Check contiguous within this port. + if (valid && sourceIndices.length == bits.length) { + for (var i = 1; i < sourceIndices.length; i++) { + if (sourceIndices[i] != sourceIndices[i - 1] + 1) { + valid = false; + break; + } + } + } else { + valid = false; + } + + portTraces.add(( + busName: busName, + busBits: busBits, + sourceIndices: sourceIndices, + intermediates: intermediates, + valid: valid, + )); + } + + // --- Find maximal runs of consecutive traceable ports ----------- + final runs = <(int startIdx, int endIdx)>[]; + var runStart = 0; + while (runStart < inputPorts.length) { + final t = portTraces[runStart]; + if (!t.valid || t.busName == null) { + runStart++; + continue; + } + var runEnd = runStart; + while (runEnd + 1 < inputPorts.length) { + final nextT = portTraces[runEnd + 1]; + if (!nextT.valid) { + break; + } + if (nextT.busName != t.busName) { + break; + } + // Check contiguity across port boundary. + final curLast = portTraces[runEnd].sourceIndices.last; + final nextFirst = nextT.sourceIndices.first; + if (nextFirst != curLast + 1) { + break; + } + runEnd++; + } + if (runEnd > runStart) { + runs.add((runStart, runEnd)); + } + runStart = runEnd + 1; + } + + if (runs.isEmpty) { + continue; + } + + // --- Verify exclusivity of intermediate cells for each run ------ + final validRuns = + <(int startIdx, int endIdx, Set intermediates)>[]; + for (final (startIdx, endIdx) in runs) { + final allIntermediates = {}; + for (var i = startIdx; i <= endIdx; i++) { + allIntermediates.addAll(portTraces[i].intermediates); + } + if (NetlistUtils.isExclusiveChain( + intermediates: allIntermediates, + ownerCell: concatName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + validRuns.add((startIdx, endIdx, allIntermediates)); + } + } + + if (validRuns.isEmpty) { + continue; + } + + // --- Check whether ALL ports form a single valid run ------------ + final allCollapsed = validRuns.length == 1 && + validRuns.first.$1 == 0 && + validRuns.first.$2 == inputPorts.length - 1; + + // Remove exclusive intermediate cells for all valid runs. + for (final (_, _, intermediates) in validRuns) { + cellsToRemove.addAll(intermediates); + } + + if (allCollapsed) { + // Full collapse — replace concat with a single $slice or $buf. + final t0 = portTraces.first; + final srcOffset = t0.sourceIndices.first; + final yWidth = (conns['Y'] as List).whereType().length; + final aWidth = t0.busBits!.length; + final sourceBusParentBits = t0.busBits!.cast().toList(); + final outputBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; + + cellsToRemove.add(concatName); + if (yWidth == aWidth) { + cellsToAdd['collapse_buf_$replIdx'] = NetlistUtils.makeBufCell( + aWidth, sourceBusParentBits, outputBits); + } else { + cellsToAdd['collapse_slice_$replIdx'] = + NetlistUtils.makeSliceCell(srcOffset, aWidth, yWidth, + sourceBusParentBits, outputBits); + } + replIdx++; + continue; + } + + // --- Partial collapse — rebuild concat with fewer ports --------- + cellsToRemove.add(concatName); + + final newConns = >{}; + final newDirs = {}; + var outBitOffset = 0; + + var portIdx = 0; + while (portIdx < inputPorts.length) { + // Check if this port starts a valid run. + (int, int, Set)? activeRun; + for (final run in validRuns) { + if (run.$1 == portIdx) { + activeRun = run; + break; + } + } + + if (activeRun != null) { + final (startIdx, endIdx, _) = activeRun; + // Compute combined width and collect original input wire bits. + final originalBits = []; + for (var i = startIdx; i <= endIdx; i++) { + originalBits.addAll(inputPorts[i].$3.cast()); + } + final width = originalBits.length; + final t0 = portTraces[startIdx]; + final srcOffset = t0.sourceIndices.first; + final sourceBusBits = t0.busBits!.cast().toList(); + + // Reuse the original concat-input wire bits as the $slice + // output so that existing netname associations are preserved. + cellsToAdd['collapse_slice_$replIdx'] = + NetlistUtils.makeSliceCell(srcOffset, t0.busBits!.length, + width, sourceBusBits, originalBits); + replIdx++; + + // Add the combined port to the rebuilt concat. + final hi = outBitOffset + width - 1; + final portName = + hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; + newConns[portName] = originalBits; + newDirs[portName] = 'input'; + outBitOffset += width; + + portIdx = endIdx + 1; + } else { + // Keep this port as-is. + final port = inputPorts[portIdx]; + final width = port.$3.length; + final hi = outBitOffset + width - 1; + final portName = + hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; + newConns[portName] = port.$3.cast(); + newDirs[portName] = 'input'; + outBitOffset += width; + portIdx++; + } + } + + // Preserve Y. + newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; + newDirs['Y'] = 'output'; + + // Remove original concat, add collapsed replacement. + cellsToRemove.add(concatName); + cellsToAdd['${concatName}_collapsed'] = { + 'hide_name': concatCell['hide_name'], + 'type': r'$concat', + 'parameters': {}, + 'attributes': concatCell['attributes'] ?? {}, + 'port_directions': newDirs, + 'connections': newConns, + }; + } + + // Apply removals and additions. + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); + if (cellsToRemove.isNotEmpty || cellsToAdd.isNotEmpty) { + changed = true; + } + } // end while (changed) + } + } + + // -- Struct-conversion grouping ---------------------------------------- + + /// Scans every module in [allModules] for connected components of `$slice` + /// and `$concat` cells that form reconvergent struct-conversion trees. + /// Such trees arise from `LogicStructure.gets()` when a flat bus is + /// assigned to a struct (or vice-versa): leaf fields are sliced out and + /// re-packed through potentially multiple levels of concats. + /// + /// Each connected component is extracted into a new synthetic module + /// definition (added to [allModules]) and replaced in the parent with a + /// single hierarchical cell. This collapses the visual noise in the + /// netlist into a tidy "struct_assign_*" box. + static void applyStructConversionGrouping( + Map> allModules, + ) { + // Collect new module definitions to add (avoid modifying map during + // iteration). + final newModuleDefs = >{}; + + // Process each existing module definition. + for (final moduleName in allModules.keys.toList()) { + final moduleDef = allModules[moduleName]!; + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + // Identify all $slice and $concat cells. + final sliceConcat = {}; + for (final entry in cells.entries) { + final type = entry.value['type'] as String?; + if (type == r'$slice' || type == r'$concat') { + sliceConcat.add(entry.key); + } + } + if (sliceConcat.length < 2) { + continue; + } + + // Build wire-ID → driver cell and wire-ID → consumer cells maps. + final ( + :wireDriverCell, + wireConsumerCells: wireConsumerSets, + :bitToNetInfo, + ) = NetlistUtils.buildWireMaps(cells, moduleDef); + // Convert Set consumers to List for iteration. + final wireConsumerCells = >{ + for (final e in wireConsumerSets.entries) e.key: e.value.toList(), + }; + final modPorts = moduleDef['ports'] as Map>?; + + // Build adjacency among sliceConcat cells: two are adjacent if one's + // output feeds the other's input. + final adj = >{ + for (final cn in sliceConcat) cn: {}, + }; + for (final cn in sliceConcat) { + final cell = cells[cn]!; + final conns = cell['connections'] as Map? ?? {}; + final pdirs = cell['port_directions'] as Map? ?? {}; + for (final pe in conns.entries) { + final d = pdirs[pe.key] as String? ?? ''; + for (final b in pe.value as List) { + if (b is! int) { + continue; + } + if (d == 'output') { + // Find consumers in sliceConcat. + for (final consumer in wireConsumerCells[b] ?? []) { + if (consumer != cn && sliceConcat.contains(consumer)) { + adj[cn]!.add(consumer); + adj[consumer]!.add(cn); + } + } + } else if (d == 'input') { + // Find driver in sliceConcat. + final drv = wireDriverCell[b]; + if (drv != null && drv != cn && sliceConcat.contains(drv)) { + adj[cn]!.add(drv); + adj[drv]!.add(cn); + } + } + } + } + } + + // Find connected components via BFS. + final visited = {}; + final components = >[]; + for (final start in sliceConcat) { + if (visited.contains(start)) { + continue; + } + final comp = {}; + final queue = [start]; + while (queue.isNotEmpty) { + final node = queue.removeLast(); + if (!comp.add(node)) { + continue; + } + visited.add(node); + for (final nb in adj[node]!) { + if (!comp.contains(nb)) { + queue.add(nb); + } + } + } + if (comp.length >= 2) { + components.add(comp); + } + } + + // For each connected component, extract it into a synthetic module. + var groupIdx = 0; + final groupQueue = [...components]; + var gqi = 0; + final claimedCells = {}; + while (gqi < groupQueue.length) { + final comp = groupQueue[gqi++]..removeAll(claimedCells); + if (comp.length < 2) { + continue; + } + + // Collect all wire IDs used inside the component and classify them + // as internal-only (driven AND consumed within comp) or external + // (boundary ports of the synthetic module). + // + // External inputs = wire IDs consumed by comp cells but driven + // outside the component. + // External outputs = wire IDs produced by comp cells but consumed + // outside the component (or by module ports). + final compOutputIds = {}; // driven by comp + final compInputIds = {}; // consumed by comp + + for (final cn in comp) { + final cell = cells[cn]!; + final conns = cell['connections'] as Map? ?? {}; + final pdirs = cell['port_directions'] as Map? ?? {}; + for (final pe in conns.entries) { + final d = pdirs[pe.key] as String? ?? ''; + for (final b in pe.value as List) { + if (b is! int) { + continue; + } + if (d == 'output') { + compOutputIds.add(b); + } else if (d == 'input') { + compInputIds.add(b); + } + } + } + } + + // External input bits: consumed by comp but NOT driven by comp. + final extInputBits = compInputIds.difference(compOutputIds); + // External output bits: driven by comp but consumed outside comp + // (by non-comp cells or by module output ports). + final extOutputBits = {}; + for (final b in compOutputIds) { + // Check non-comp cell consumers. + for (final consumer in wireConsumerCells[b] ?? []) { + if (!comp.contains(consumer)) { + extOutputBits.add(b); + break; + } + } + // Check module output ports. + if (!extOutputBits.contains(b) && modPorts != null) { + for (final portEntry in modPorts.values) { + final dir = portEntry['direction'] as String?; + if (dir != 'output') { + continue; + } + final bits = portEntry['bits'] as List?; + if (bits != null && bits.contains(b)) { + extOutputBits.add(b); + break; + } + } + } + } + + if (extInputBits.isEmpty || extOutputBits.isEmpty) { + continue; // degenerate component, skip + } + + // Group external bits by netname to form named ports. + // Build a net-name → sorted bit IDs mapping for inputs and outputs. + final netnames = moduleDef['netnames'] as Map? ?? {}; + + // Wire → netname map (for bits in this component). + final wireToNet = {}; + for (final nnEntry in netnames.entries) { + final nd = nnEntry.value! as Map; + final bits = nd['bits'] as List? ?? []; + for (final b in bits) { + if (b is int) { + wireToNet[b] = nnEntry.key; + } + } + } + + // Group external input bits by their netname, preserving order. + final inputGroups = >{}; + for (final b in extInputBits) { + final nn = wireToNet[b] ?? 'in_$b'; + (inputGroups[nn] ??= []).add(b); + } + for (final v in inputGroups.values) { + v.sort(); + } + + // Group external output bits by their netname, preserving order. + final outputGroups = >{}; + for (final b in extOutputBits) { + final nn = wireToNet[b] ?? 'out_$b'; + (outputGroups[nn] ??= []).add(b); + } + for (final v in outputGroups.values) { + v.sort(); + } + + // Guard: only group when the component is a true struct + // assignment — one signal split into selections then re-assembled + // into one signal. The input may be wider than the output when + // fields are dropped (e.g. a nonCacheable bit unused in the + // destination struct). Multi-source concats (e.g. swizzles + // combining independent signals) and simple bit-range selections + // must remain as standalone cells. + if (inputGroups.length != 1 || + outputGroups.length != 1 || + extInputBits.length < extOutputBits.length) { + // Try sub-component extraction: for each $concat cell in the + // component, backward-BFS to find the subset of cells that + // transitively feed it. If that subset is strictly smaller + // than the full component it may pass the guard on its own. + for (final cn in comp.toList()) { + final cell = cells[cn]; + if (cell == null) { + continue; + } + if ((cell['type'] as String?) != r'$concat') { + continue; + } + + final subComp = {cn}; + final bfsQueue = [cn]; + while (bfsQueue.isNotEmpty) { + final cur = bfsQueue.removeLast(); + final curCell = cells[cur]; + if (curCell == null) { + continue; + } + final cConns = + curCell['connections'] as Map? ?? {}; + final cDirs = + curCell['port_directions'] as Map? ?? {}; + for (final pe in cConns.entries) { + if ((cDirs[pe.key] as String?) != 'input') { + continue; + } + for (final b in pe.value as List) { + if (b is! int) { + continue; + } + final drv = wireDriverCell[b]; + if (drv != null && + comp.contains(drv) && + !subComp.contains(drv)) { + subComp.add(drv); + bfsQueue.add(drv); + } + } + } + } + + if (subComp.length >= 2 && subComp.length < comp.length) { + groupQueue.add(subComp); + } + } + continue; + } + + // Build the synthetic module's internal wire-ID space. + final usedIds = {}; + for (final cn in comp) { + final cell = cells[cn]; + if (cell == null) { + continue; + } + final conns = cell['connections'] as Map? ?? {}; + for (final bits in conns.values) { + for (final b in bits as List) { + if (b is int) { + usedIds.add(b); + } + } + } + } + + var nextLocalId = 2; + final idRemap = {}; + for (final id in usedIds) { + idRemap[id] = nextLocalId++; + } + + List remapBits(List bits) => + bits.map((b) => b is int ? (idRemap[b] ?? b) : b).toList(); + + // Build ports: one input port per input group, one output port per + // output group. + final childPorts = >{}; + final instanceConns = >{}; + final instancePortDirs = {}; + + for (final entry in inputGroups.entries) { + final portName = 'in_${entry.key}'; + final parentBits = entry.value.cast(); + childPorts[portName] = { + 'direction': 'input', + 'bits': remapBits(parentBits), + }; + instanceConns[portName] = parentBits; + instancePortDirs[portName] = 'input'; + } + + for (final entry in outputGroups.entries) { + final portName = 'out_${entry.key}'; + final parentBits = entry.value.cast(); + childPorts[portName] = { + 'direction': 'output', + 'bits': remapBits(parentBits), + }; + instanceConns[portName] = parentBits; + instancePortDirs[portName] = 'output'; + } + + // Re-map cells into the child's local ID space. + final childCells = >{}; + for (final cn in comp) { + final cell = Map.from(cells[cn]!); + final conns = Map.from( + cell['connections']! as Map); + for (final key in conns.keys.toList()) { + conns[key] = remapBits((conns[key] as List).cast()); + } + cell['connections'] = conns; + childCells[cn] = cell; + } + + // Build netnames for the child module. + final childNetnames = {}; + for (final pe in childPorts.entries) { + childNetnames[pe.key] = { + 'bits': pe.value['bits'], + 'attributes': {}, + }; + } + + final coveredIds = {}; + for (final nn in childNetnames.values) { + final bits = (nn! as Map)['bits']! as List; + for (final b in bits) { + if (b is int) { + coveredIds.add(b); + } + } + } + for (final cellEntry in childCells.entries) { + final cellName = cellEntry.key; + final conns = + cellEntry.value['connections'] as Map? ?? {}; + for (final connEntry in conns.entries) { + final portName = connEntry.key; + final bits = connEntry.value as List; + final missingBits = []; + for (final b in bits) { + if (b is int && !coveredIds.contains(b)) { + missingBits.add(b); + coveredIds.add(b); + } + } + if (missingBits.isNotEmpty) { + childNetnames['${cellName}_$portName'] = { + 'bits': missingBits, + 'hide_name': 1, + 'attributes': {}, + }; + } + } + } + + // Choose a name for the synthetic module type. + final syntheticTypeName = 'struct_assign_${moduleName}_$groupIdx'; + final syntheticInstanceName = 'struct_assign_$groupIdx'; + groupIdx++; + + // Register the synthetic module definition. + newModuleDefs[syntheticTypeName] = { + 'attributes': {'src': 'generated'}, + 'ports': childPorts, + 'cells': childCells, + 'netnames': childNetnames, + }; + + // Remove the grouped cells from the parent. + claimedCells.addAll(comp); + comp.forEach(cells.remove); + + // Add a hierarchical cell referencing the synthetic module. + cells[syntheticInstanceName] = { + 'hide_name': 0, + 'type': syntheticTypeName, + 'parameters': {}, + 'attributes': {}, + 'port_directions': instancePortDirs, + 'connections': instanceConns, + }; + } + } + + // Add all new synthetic module definitions. + allModules.addAll(newModuleDefs); + } + + /// Replace groups of `$slice` cells that share the same input bus and + /// whose outputs all feed into the same destination cell+port with a + /// single `$buf` cell. + /// + /// This eliminates visual noise from struct-to-flat-bus decomposition + /// when the destination consumes the full struct value unchanged. + /// Both signal names (source struct and destination port) are preserved + /// as separate netnames connected through the buffer. + static void applyStructBufferInsertion( + Map> allModules, + ) { + for (final moduleName in allModules.keys.toList()) { + final moduleDef = allModules[moduleName]!; + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + // Group $slice cells by their input bus (A bits). + final slicesByInput = >{}; + for (final entry in cells.entries) { + final cell = entry.value; + if (cell['type'] != r'$slice') { + continue; + } + final conns = cell['connections'] as Map?; + if (conns == null) { + continue; + } + final aBits = conns['A'] as List?; + if (aBits == null) { + continue; + } + final key = aBits.join(','); + (slicesByInput[key] ??= []).add(entry.key); + } + + var bufIdx = 0; + for (final sliceGroup in slicesByInput.values) { + if (sliceGroup.length < 2) { + continue; + } + + // Collect all Y output bit IDs from the group. + final allYBitIds = {}; + for (final sliceName in sliceGroup) { + final cell = cells[sliceName]!; + final conns = cell['connections']! as Map; + for (final b in conns['Y']! as List) { + if (b is int) { + allYBitIds.add(b); + } + } + } + + // Check: do all Y bits go to the same destination cell+port + // (or a single module output port)? + String? destId; // unique identifier for the destination + var allSameDest = true; + + // Check cell port destinations. + for (final otherEntry in cells.entries) { + if (sliceGroup.contains(otherEntry.key)) { + continue; + } + final otherConns = + otherEntry.value['connections'] as Map? ?? {}; + for (final portEntry in otherConns.entries) { + final bits = portEntry.value as List; + if (bits.any((b) => b is int && allYBitIds.contains(b))) { + final id = '${otherEntry.key}.${portEntry.key}'; + if (destId == null) { + destId = id; + } else if (destId != id) { + allSameDest = false; + break; + } + } + } + if (!allSameDest) { + break; + } + } + + // Also check module output ports as potential destinations. + final modPorts = + moduleDef['ports'] as Map>?; + if (allSameDest && modPorts != null) { + for (final portEntry in modPorts.entries) { + final port = portEntry.value; + final dir = port['direction'] as String?; + if (dir != 'output') { + continue; + } + final bits = port['bits'] as List?; + if (bits != null && + bits.any((b) => b is int && allYBitIds.contains(b))) { + final id = '__port_${portEntry.key}'; + if (destId == null) { + destId = id; + } else if (destId != id) { + allSameDest = false; + break; + } + } + } + } + + if (!allSameDest || destId == null) { + continue; + } + + // Verify slices contiguously cover the full A bus. + final firstSlice = cells[sliceGroup.first]!; + final params0 = firstSlice['parameters'] as Map?; + final aWidth = params0?['A_WIDTH'] as int?; + if (aWidth == null) { + continue; + } + + // Map offset → Y bits list, and validate. + final coverageYBits = >{}; + var totalYBits = 0; + var valid = true; + for (final sliceName in sliceGroup) { + final cell = cells[sliceName]!; + final params = cell['parameters'] as Map?; + final offset = params?['OFFSET'] as int?; + final yWidth = params?['Y_WIDTH'] as int?; + if (offset == null || yWidth == null) { + valid = false; + break; + } + final conns = cell['connections']! as Map; + final yBits = (conns['Y']! as List).cast(); + if (yBits.length != yWidth) { + valid = false; + break; + } + coverageYBits[offset] = yBits; + totalYBits += yWidth; + } + if (!valid || totalYBits != aWidth) { + continue; + } + + // Verify contiguous coverage (no gaps or overlaps). + final sortedOffsets = coverageYBits.keys.toList()..sort(); + var expectedOffset = 0; + for (final off in sortedOffsets) { + if (off != expectedOffset) { + valid = false; + break; + } + expectedOffset += coverageYBits[off]!.length; + } + if (!valid || expectedOffset != aWidth) { + continue; + } + + // Build the buffer cell. + final firstConns = firstSlice['connections']! as Map; + final aBus = (firstConns['A']! as List).cast(); + + // Construct Y by concatenating slice outputs in offset order. + final yBus = []; + for (final off in sortedOffsets) { + yBus.addAll(coverageYBits[off]!); + } + + // Remove slice cells. + sliceGroup.forEach(cells.remove); + + // Insert $buf cell. + cells['struct_buf_$bufIdx'] = + NetlistUtils.makeBufCell(aWidth, aBus, yBus); + bufIdx++; + } + } + } + + /// Replaces each `struct_assign_*` hierarchical instance in parent modules + /// with one `$buf` cell per output port and removes the synthetic module + /// definition. + /// + /// For each output port the internal `$slice`/`$concat` routing is traced + /// back to the corresponding input-port bits so that each `$buf` connects + /// only the bits belonging to that specific net. This keeps distinct + /// signal paths (e.g. `sum_0 → sumRpath` vs `sumP1 → sumPlusOneRpath`) + /// as separate cells so the schematic viewer can route them independently. + static void collapseStructGroupModules( + Map> allModules, + ) { + // Collect the names of all struct_assign module definitions to remove. + final structAssignTypes = { + for (final name in allModules.keys) + if (name.startsWith('struct_assign_')) name, + }; + + if (structAssignTypes.isEmpty) { + return; + } + + // Track which struct_assign types were fully collapsed (all instances + // replaced). Only those will have their definitions removed. + final collapsedTypes = {}; + final keptTypes = {}; + + // In each module, replace cells that instantiate a struct_assign type + // with a $buf cell. + for (final moduleDef in allModules.values) { + final cells = + moduleDef['cells'] as Map>? ?? {}; + + final replacements = >{}; + final removals = []; + + for (final entry in cells.entries) { + final cellName = entry.key; + final cell = entry.value; + final type = cell['type'] as String?; + if (type == null || !structAssignTypes.contains(type)) { + continue; + } + + final conns = cell['connections'] as Map? ?? {}; + + // Look up the synthetic module definition so we can trace the + // actual per-bit routing through its internal $slice/$concat cells. + final synthDef = allModules[type]; + if (synthDef == null) { + continue; + } + + final synthPorts = + synthDef['ports'] as Map>? ?? {}; + final synthCells = + synthDef['cells'] as Map>? ?? {}; + + // Map local (module-internal) input port bits → parent bit IDs, + // and also record which input port name each local bit belongs to + // plus its index within that port. + final localToParent = {}; + final localBitToInputPort = {}; + final localBitToIndex = {}; + final inputPortWidths = {}; + for (final pEntry in synthPorts.entries) { + final dir = pEntry.value['direction'] as String?; + if (dir != 'input' && dir != 'inout') { + continue; + } + final localBits = + (pEntry.value['bits'] as List?)?.cast() ?? []; + final parentBits = (conns[pEntry.key] as List?)?.cast() ?? []; + inputPortWidths[pEntry.key] = localBits.length; + for (var i = 0; i < localBits.length && i < parentBits.length; i++) { + if (localBits[i] is int) { + localToParent[localBits[i] as int] = parentBits[i]; + localBitToInputPort[localBits[i] as int] = pEntry.key; + localBitToIndex[localBits[i] as int] = i; + } + } + } + + final inputPortBits = localToParent.keys.toSet(); + + // Build a net-driver map inside the synthetic module by + // processing its $slice, $concat, and $buf cells. + final driver = {}; + + for (final sc in synthCells.values) { + final ct = sc['type'] as String?; + final cc = sc['connections'] as Map? ?? {}; + final cp = sc['parameters'] as Map? ?? {}; + + if (ct == r'$slice') { + final aBits = (cc['A'] as List?)?.cast() ?? []; + final yBits = (cc['Y'] as List?)?.cast() ?? []; + final offset = cp['OFFSET'] as int? ?? 0; + final yWidth = yBits.length; + final aWidth = aBits.length; + final reversed = offset + yWidth > aWidth; + for (var i = 0; i < yBits.length; i++) { + if (yBits[i] is int) { + final srcIdx = reversed ? (offset - i) : (offset + i); + if (srcIdx >= 0 && srcIdx < aBits.length) { + driver[yBits[i] as int] = aBits[srcIdx]; + } + } + } + } else if (ct == r'$concat') { + final yBits = (cc['Y'] as List?)?.cast() ?? []; + + // Gather input bits in LSB-first order. Two formats: + // 1. Standard 2-input: ports A (LSB) and B (MSB). + // 2. Multi-input: range-named ports [lo:hi] with + // INx_WIDTH parameters — ordered by range start. + final inputBits = []; + if (cc.containsKey('A')) { + inputBits + ..addAll((cc['A'] as List?)?.cast() ?? []) + ..addAll((cc['B'] as List?)?.cast() ?? []); + } else { + // Multi-input concat: collect range-named ports ordered + // by their starting bit position (LSB first). + final rangePorts = >{}; + for (final portName in cc.keys) { + if (portName == 'Y') { + continue; + } + final m = NetlistUtils.rangePortRe.firstMatch(portName); + if (m != null) { + final hi = int.parse(m.group(1)!); + final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; + rangePorts[lo] = + (cc[portName] as List?)?.cast() ?? []; + } + } + final sortedKeys = rangePorts.keys.toList()..sort(); + for (final k in sortedKeys) { + inputBits.addAll(rangePorts[k]!); + } + } + + for (var i = 0; i < yBits.length; i++) { + if (yBits[i] is int && i < inputBits.length) { + driver[yBits[i] as int] = inputBits[i]; + } + } + } else if (ct == r'$buf') { + final aBits = (cc['A'] as List?)?.cast() ?? []; + final yBits = (cc['Y'] as List?)?.cast() ?? []; + for (var i = 0; i < yBits.length && i < aBits.length; i++) { + if (yBits[i] is int) { + driver[yBits[i] as int] = aBits[i]; + } + } + } + } + + // Trace a local bit backwards through the driver map until we + // reach an input port bit or a string constant. + Object traceToSource(Object bit) { + final visited = {}; + var current = bit; + while (current is int && !inputPortBits.contains(current)) { + if (visited.contains(current)) { + break; + } + visited.add(current); + final next = driver[current]; + if (next == null) { + break; + } + current = next; + } + return current; + } + + // For each output port, trace its bits to their source and build + // the appropriate cell type: + // $buf – output has same width as its single source input port + // $slice – output is a contiguous sub-range of one input port + // $concat – output combines bits from multiple input ports + final perPortCells = >{}; + var anyUnresolved = false; + + for (final pEntry in synthPorts.entries) { + final dir = pEntry.value['direction'] as String?; + if (dir != 'output') { + continue; + } + final localBits = + (pEntry.value['bits'] as List?)?.cast() ?? []; + final parentBits = (conns[pEntry.key] as List?)?.cast() ?? []; + + final portOutputBits = []; + final portInputBits = []; + // Track per-bit source: local input-port bit ID (int) or null. + final sourceBitIds = []; + + for (var i = 0; i < parentBits.length; i++) { + portOutputBits.add(parentBits[i]); + if (i < localBits.length) { + final source = traceToSource(localBits[i]); + if (source is int && localToParent.containsKey(source)) { + portInputBits.add(localToParent[source]!); + sourceBitIds.add(source); + } else if (source is String) { + portInputBits.add(source); + sourceBitIds.add(null); + } else { + portInputBits.add('x'); + sourceBitIds.add(null); + } + } else { + portInputBits.add('x'); + sourceBitIds.add(null); + } + } + + if (portInputBits.contains('x')) { + anyUnresolved = true; + break; + } + + if (portOutputBits.isEmpty) { + continue; + } + + // Determine which input port(s) source this output port. + final sourcePortNames = {}; + for (final sid in sourceBitIds) { + if (sid != null && localBitToInputPort.containsKey(sid)) { + sourcePortNames.add(localBitToInputPort[sid]!); + } + } + + final cellKey = '${cellName}_${pEntry.key}'; + + if (sourcePortNames.length == 1) { + final srcPort = sourcePortNames.first; + final srcWidth = inputPortWidths[srcPort] ?? 0; + if (portOutputBits.length == srcWidth) { + // Same width → $buf + perPortCells['${cellKey}_buf'] = NetlistUtils.makeBufCell( + portOutputBits.length, portInputBits, portOutputBits); + } else { + // Subset of one input port → $slice. Determine the offset + // from the first traced bit's index within its input port. + final firstIdx = sourceBitIds.first; + final offset = + firstIdx != null ? (localBitToIndex[firstIdx] ?? 0) : 0; + perPortCells['${cellKey}_slice'] = NetlistUtils.makeSliceCell( + offset, + srcWidth, + portOutputBits.length, + (conns[srcPort] as List?)?.cast() ?? [], + portOutputBits); + } + } else { + // Multiple source ports – should be rare after the grouping + // guard excludes multi-source concats. Fall back to $buf. + perPortCells['${cellKey}_buf'] = NetlistUtils.makeBufCell( + portOutputBits.length, portInputBits, portOutputBits); + } + } + + if (perPortCells.isEmpty) { + continue; + } + + // Only collapse pure passthroughs: every output bit must trace + // back to an input-port bit or a string constant. If any bit + // fell through as 'x' the module is doing real computation + // (e.g. addition, muxing) and should be kept as a hierarchy. + if (anyUnresolved) { + keptTypes.add(type); + continue; + } + + collapsedTypes.add(type); + removals.add(cellName); + replacements.addAll(perPortCells); + } + + removals.forEach(cells.remove); + cells.addAll(replacements); + } + + // Remove only the synthetic module definitions whose instances were all + // successfully collapsed. Types that had at least one non-passthrough + // instance must keep their definition so the hierarchy is preserved. + collapsedTypes.difference(keptTypes).forEach(allModules.remove); + } + + /// Replace standalone `$concat` cells whose input bits all originate + /// from a single module input (or inout) port and cover its full width + /// with a simple `$buf` cell. + /// + /// This eliminates the visual noise of struct-to-bitvector reassembly + /// when an input [LogicStructure] port is decomposed into fields and + /// immediately re-packed via a [Swizzle]. + static void applyConcatToBufferReplacement( + Map> allModules, + ) { + for (final moduleDef in allModules.values) { + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + final modPorts = moduleDef['ports'] as Map>?; + if (modPorts == null) { + continue; + } + + // Build bit → port-name map for input / inout ports. + final bitToPort = {}; + for (final portEntry in modPorts.entries) { + final dir = portEntry.value['direction'] as String?; + if (dir != 'input' && dir != 'inout') { + continue; + } + final bits = portEntry.value['bits'] as List? ?? []; + for (final b in bits) { + if (b is int) { + bitToPort[b] = portEntry.key; + } + } + } + + final removals = []; + final additions = >{}; + var bufIdx = 0; + + // Avoid name collisions with existing concat_buf_* cells. + for (final name in cells.keys) { + if (name.startsWith('concat_buf_')) { + final idx = int.tryParse(name.substring('concat_buf_'.length)); + if (idx != null && idx >= bufIdx) { + bufIdx = idx + 1; + } + } + } + + for (final entry in cells.entries) { + final cell = entry.value; + if ((cell['type'] as String?) != r'$concat') { + continue; + } + + final conns = cell['connections'] as Map? ?? {}; + final pdirs = cell['port_directions'] as Map? ?? {}; + + // Collect input ranges and the Y output. + // Port names follow the pattern "[upper:lower]" or "[bit]". + final rangedInputs = >{}; // lower → bits + List? yBits; + + for (final pe in conns.entries) { + final dir = pdirs[pe.key] as String? ?? ''; + final bits = (pe.value as List).cast(); + if (dir == 'output' && pe.key == 'Y') { + yBits = bits; + continue; + } + if (dir != 'input') { + continue; + } + // Parse "[upper:lower]" or "[bit]". + final match = NetlistUtils.rangePortRe.firstMatch(pe.key); + if (match == null) { + // Also accept the 2-input A/B form. + if (pe.key == 'A') { + rangedInputs[0] = bits; + } else if (pe.key == 'B') { + // Determine A width to set the offset. + final aBits = conns['A'] as List?; + if (aBits != null) { + rangedInputs[aBits.length] = bits; + } + } + continue; + } + final upper = int.parse(match.group(1)!); + final lower = + match.group(2) != null ? int.parse(match.group(2)!) : upper; + rangedInputs[lower] = bits; + } + + if (yBits == null || rangedInputs.isEmpty) { + continue; + } + + // Assemble input bits in LSB-to-MSB order. + final sortedLowers = rangedInputs.keys.toList()..sort(); + final allInputBits = []; + for (final lower in sortedLowers) { + allInputBits.addAll(rangedInputs[lower]!); + } + + // Check that every input bit belongs to the same module port. + String? sourcePort; + var allFromSamePort = true; + for (final b in allInputBits) { + if (b is! int) { + allFromSamePort = false; + break; + } + final port = bitToPort[b]; + if (port == null) { + allFromSamePort = false; + break; + } + sourcePort ??= port; + if (port != sourcePort) { + allFromSamePort = false; + break; + } + } + + if (!allFromSamePort || sourcePort == null) { + continue; + } + + // Verify full-width coverage of the source port. + final portBits = modPorts[sourcePort]!['bits']! as List; + if (allInputBits.length != portBits.length) { + continue; + } + + // Replace $concat with $buf. + removals.add(entry.key); + additions['concat_buf_$bufIdx'] = + NetlistUtils.makeBufCell(allInputBits.length, allInputBits, yBits); + bufIdx++; + } + + removals.forEach(cells.remove); + cells.addAll(additions); + } + } + + // -- Collapse selects into struct_pack --------------------------------- + + /// Finds `$slice` cells whose outputs feed exclusively into a + /// `$struct_pack` input port. The slice is absorbed: the pack input + /// port is rewired to the slice's source bits directly and the + /// now-redundant slice is removed. + /// + /// This is the "selects into a pack" optimization: when a flat bus is + /// decomposed through individual slices and then repacked into a struct, + /// the intermediate slice cells add visual noise beyond what the + /// struct_pack field metadata already provides. + static void applyCollapseSelectsIntoPack( + Map> allModules, + ) { + for (final moduleDef in allModules.values) { + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); + + final cellsToRemove = {}; + + for (final packEntry in cells.entries.toList()) { + final packName = packEntry.key; + final packCell = packEntry.value; + if ((packCell['type'] as String?) != r'$struct_pack') { + continue; + } + + final conns = packCell['connections'] as Map? ?? {}; + final dirs = packCell['port_directions'] as Map? ?? {}; + + for (final portName in conns.keys.toList()) { + if (dirs[portName] != 'input') { + continue; + } + final bits = [ + for (final b in conns[portName] as List) + if (b is int) b, + ]; + if (bits.isEmpty) { + continue; + } + + // All bits must be driven by the same $slice cell. + final firstDriver = wireDriverCell[bits.first]; + if (firstDriver == null) { + continue; + } + final driverCell = cells[firstDriver]; + if (driverCell == null) { + continue; + } + if ((driverCell['type'] as String?) != r'$slice') { + continue; + } + if (cellsToRemove.contains(firstDriver)) { + continue; + } + + final allFromSameSlice = bits.every( + (b) => wireDriverCell[b] == firstDriver, + ); + if (!allFromSameSlice) { + continue; + } + + // The slice must exclusively feed this pack. + final sliceConns = + driverCell['connections'] as Map? ?? {}; + final sliceYBits = [ + for (final b in sliceConns['Y'] as List) + if (b is int) b, + ]; + final exclusive = sliceYBits.every((b) { + final consumers = wireConsumerCells[b]; + if (consumers == null) { + return true; + } + return consumers.every((c) => c == packName || c == '__port__'); + }); + if (!exclusive) { + continue; + } + + // Rewire: replace the pack's input bits with the slice's + // source bits (A port) at the correct offset. + final sliceABits = sliceConns['A'] as List; + final params = + driverCell['parameters'] as Map? ?? {}; + final offset = params['OFFSET'] as int? ?? 0; + final yWidth = sliceYBits.length; + + final newBits = [ + for (var i = 0; i < yWidth; i++) sliceABits[offset + i] as Object, + ]; + conns[portName] = newBits; + + cellsToRemove.add(firstDriver); + } + } + + cellsToRemove.forEach(cells.remove); + + // Second pass: collapse struct_pack → $buf when all field inputs + // form a contiguous ascending sequence (identity pack). + for (final packEntry in cells.entries.toList()) { + final packName = packEntry.key; + final packCell = packEntry.value; + if ((packCell['type'] as String?) != r'$struct_pack') { + continue; + } + + final conns = packCell['connections'] as Map? ?? {}; + final dirs = packCell['port_directions'] as Map? ?? {}; + + // Collect all input bits in field declaration order. + final allInputBits = []; + for (final portName in conns.keys) { + if (dirs[portName] != 'input') { + continue; + } + for (final b in conns[portName] as List) { + if (b is int) { + allInputBits.add(b); + } + } + } + if (allInputBits.length < 2) { + continue; + } + + // Check: input bits must form a contiguous ascending sequence. + var contiguous = true; + for (var i = 1; i < allInputBits.length; i++) { + if (allInputBits[i] != allInputBits[i - 1] + 1) { + contiguous = false; + break; + } + } + if (!contiguous) { + continue; + } + + final yBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; + if (yBits.length != allInputBits.length) { + continue; + } + + // Replace struct_pack with $buf. + cells[packName] = { + 'type': r'$buf', + 'parameters': {'WIDTH': allInputBits.length}, + 'port_directions': {'A': 'input', 'Y': 'output'}, + 'connections': >{ + 'A': allInputBits.cast(), + 'Y': yBits, + }, + }; + } + } + } + + // -- Collapse struct_unpack to concat ---------------------------------- + + /// Finds `$concat` cells whose input ports are driven (directly or + /// through exclusive `$buf`/`$slice` chains) by output ports of + /// `$struct_unpack` cells. When all inputs trace back through a single + /// unpack to its source bus, the concat and intermediate cells are + /// replaced by a `$buf` or `$slice` from the unpack's A bus. + /// + /// Partial collapse is also supported: contiguous runs of concat ports + /// that trace to the same unpack are collapsed individually. + static void applyCollapseUnpackToConcat( + Map> allModules, + ) { + for (final moduleEntry in allModules.entries) { + final moduleDef = moduleEntry.value; + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + // Iterate until convergence: each pass may create bufs that enable + // the next outer concat/unpack to collapse. + var globalReplIdx = 0; + var anyChanged = true; + var iteration = 0; + while (anyChanged && iteration < 20) { + anyChanged = false; + iteration++; + + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); + + final cellsToRemove = {}; + final cellsToAdd = >{}; + var replIdx = globalReplIdx; + + for (final concatEntry in cells.entries.toList()) { + final concatName = concatEntry.key; + final concatCell = concatEntry.value; + if ((concatCell['type'] as String?) != r'$concat') { + continue; + } + if (cellsToRemove.contains(concatName)) { + continue; + } + + final conns = + concatCell['connections'] as Map? ?? {}; + + // Parse input ports into ordered list. + final inputPorts = <(int lo, String portName, List bits)>[]; + var hasRangePorts = false; + for (final portName in conns.keys) { + if (portName == 'Y') { + continue; + } + final m = NetlistUtils.rangePortRe.firstMatch(portName); + if (m != null) { + hasRangePorts = true; + final hi = int.parse(m.group(1)!); + final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; + inputPorts.add(( + lo, + portName, + [ + for (final b in conns[portName] as List) + if (b is int) b + ], + )); + } + } + if (!hasRangePorts) { + if (conns.containsKey('A') && conns.containsKey('B')) { + final aBits = [ + for (final b in conns['A'] as List) + if (b is int) b, + ]; + final bBits = [ + for (final b in conns['B'] as List) + if (b is int) b, + ]; + inputPorts + ..add((0, 'A', aBits)) + ..add((aBits.length, 'B', bBits)); + } + } + inputPorts.sort((a, b) => a.$1.compareTo(b.$1)); + if (inputPorts.length < 2) { + continue; + } + + // --- Extended trace: through $buf/$slice AND $struct_unpack ------ + final portTraces = <({ + String? unpackName, + List? unpackABits, + List sourceIndices, + Set intermediates, + bool valid, + })>[]; + + for (final (_, _, bits) in inputPorts) { + final sourceIndices = []; + final intermediates = {}; + String? unpackName; + List? unpackABits; + var valid = true; + + for (final bit in bits) { + final (traced, chain) = + NetlistUtils.traceBackward(bit, wireDriverCell, cells); + intermediates.addAll(chain); + + // Check if traced bit is driven by a $struct_unpack output. + final driverName = wireDriverCell[traced]; + if (driverName == null) { + valid = false; + break; + } + final driverCell = cells[driverName]; + if (driverCell == null || + (driverCell['type'] as String?) != r'$struct_unpack') { + valid = false; + break; + } + + final uConns = + driverCell['connections'] as Map? ?? {}; + final uDirs = + driverCell['port_directions'] as Map? ?? {}; + final aBits = [ + for (final b in uConns['A'] as List) + if (b is int) b, + ]; + + // Find which output port contains this bit and its index + // within that port. + String? outPort; + int? bitIdx; + for (final pe in uConns.entries) { + if (pe.key == 'A') { + continue; + } + if (uDirs[pe.key] != 'output') { + continue; + } + final pBits = [ + for (final b in pe.value as List) + if (b is int) b, + ]; + final idx = pBits.indexOf(traced); + if (idx >= 0) { + outPort = pe.key; + bitIdx = idx; + break; + } + } + + if (outPort == null || bitIdx == null) { + valid = false; + break; + } + + // Find the field offset for this output port. + final params = + driverCell['parameters'] as Map? ?? {}; + final fc = params['FIELD_COUNT'] as int? ?? 0; + int? fieldOffset; + for (var fi = 0; fi < fc; fi++) { + final fname = params['FIELD_${fi}_NAME'] as String? ?? ''; + if (fname == outPort || outPort == '${fname}_$fi') { + fieldOffset = params['FIELD_${fi}_OFFSET'] as int? ?? 0; + break; + } + } + + if (fieldOffset == null) { + valid = false; + break; + } + + final aIdx = fieldOffset + bitIdx; + if (aIdx >= aBits.length) { + valid = false; + break; + } + + intermediates.add(driverName); + + if (unpackName == null) { + unpackName = driverName; + unpackABits = aBits; + } else if (unpackName != driverName) { + valid = false; + break; + } + sourceIndices.add(aIdx); + } + + portTraces.add(( + unpackName: unpackName, + unpackABits: unpackABits, + sourceIndices: sourceIndices, + intermediates: intermediates, + valid: valid, + )); + } + + // --- Find runs of consecutive ports tracing to the same unpack --- + final runs = <(int startIdx, int endIdx)>[]; + var runStart = 0; + while (runStart < inputPorts.length) { + final t = portTraces[runStart]; + if (!t.valid || t.unpackName == null) { + runStart++; + continue; + } + var runEnd = runStart; + while (runEnd + 1 < inputPorts.length) { + final nextT = portTraces[runEnd + 1]; + if (!nextT.valid || nextT.unpackName != t.unpackName) { + break; + } + final curLast = portTraces[runEnd].sourceIndices.last; + final nextFirst = nextT.sourceIndices.first; + if (nextFirst != curLast + 1) { + break; + } + runEnd++; + } + if (runEnd > runStart) { + runs.add((runStart, runEnd)); + } + runStart = runEnd + 1; + } + + if (runs.isEmpty) { + // No contiguous ascending runs, but check if ALL ports trace + // to the same unpack (general reorder / swizzle case). + final allValid = portTraces.every((t) => t.valid); + if (!allValid) { + continue; + } + final unpackNames = portTraces.map((t) => t.unpackName).toSet(); + if (unpackNames.length != 1 || unpackNames.first == null) { + continue; + } + final uName = unpackNames.first!; + final uABits = portTraces.first.unpackABits!; + + // Gather all intermediates and verify exclusivity. + final allIntermediates = {}; + for (final t in portTraces) { + allIntermediates.addAll(t.intermediates); + } + final removable = allIntermediates.where((c) { + final ct = cells[c]?['type'] as String?; + return ct == r'$buf' || ct == r'$slice'; + }).toSet(); + if (removable.isNotEmpty && + !NetlistUtils.isExclusiveChain( + intermediates: removable, + ownerCell: concatName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + continue; + } + + // Build reordered A bits: for each concat input port (in + // order), map the source indices back to the unpack's A bus. + final reorderedA = []; + for (final t in portTraces) { + for (final aIdx in t.sourceIndices) { + reorderedA.add(uABits[aIdx] as Object); + } + } + final outputBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; + if (reorderedA.length != outputBits.length) { + continue; + } + + cellsToRemove + ..addAll(removable) + ..add(uName) + ..add(concatName); + cellsToAdd['unpack_concat_buf_$replIdx'] = NetlistUtils.makeBufCell( + reorderedA.length, reorderedA, outputBits); + replIdx++; + continue; + } + + // --- Verify exclusivity of non-unpack intermediates ------ + final validRuns = + <(int startIdx, int endIdx, Set intermediates)>[]; + for (final (startIdx, endIdx) in runs) { + final allIntermediates = {}; + for (var i = startIdx; i <= endIdx; i++) { + allIntermediates.addAll(portTraces[i].intermediates); + } + // Only remove $buf/$slice intermediates, not the unpack itself. + final removable = allIntermediates.where((c) { + final ct = cells[c]?['type'] as String?; + return ct == r'$buf' || ct == r'$slice'; + }).toSet(); + if (removable.isEmpty || + NetlistUtils.isExclusiveChain( + intermediates: removable, + ownerCell: concatName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + validRuns.add((startIdx, endIdx, removable)); + } + } + + if (validRuns.isEmpty) { + continue; + } + + final allCollapsed = validRuns.length == 1 && + validRuns.first.$1 == 0 && + validRuns.first.$2 == inputPorts.length - 1; + + for (final (_, _, intermediates) in validRuns) { + cellsToRemove.addAll(intermediates); + } + + if (allCollapsed) { + // Full collapse — replace concat with $buf or $slice. + // Since we remove intermediates (buf/slice chains between the + // unpack outputs and the concat inputs), we must source the + // replacement buf from the unpack's A bus, not the concat's + // input bits which may reference wires driven by the removed + // intermediates. + final t0 = portTraces.first; + final srcOffset = t0.sourceIndices.first; + final yWidth = (conns['Y'] as List).whereType().length; + final aWidth = t0.unpackABits!.length; + final sourceBits = t0.unpackABits!.cast().toList(); + final outputBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; + + cellsToRemove + ..add(concatName) + // Also remove the unpack itself — all its outputs are consumed + // exclusively through intermediates into this concat. + ..add(t0.unpackName!); + if (yWidth == aWidth) { + cellsToAdd['unpack_concat_buf_$replIdx'] = + NetlistUtils.makeBufCell(aWidth, sourceBits, outputBits); + } else { + cellsToAdd['unpack_concat_buf_$replIdx'] = + NetlistUtils.makeSliceCell( + srcOffset, aWidth, yWidth, sourceBits, outputBits); + } + replIdx++; + continue; + } + + // --- Partial collapse — rebuild concat with fewer ports --------- + cellsToRemove.add(concatName); + + final newConns = >{}; + final newDirs = {}; + var outBitOffset = 0; + + var portIdx = 0; + while (portIdx < inputPorts.length) { + (int, int, Set)? activeRun; + for (final run in validRuns) { + if (run.$1 == portIdx) { + activeRun = run; + break; + } + } + + if (activeRun != null) { + final (startIdx, endIdx, _) = activeRun; + // Collect the traced source bits — the unpack output bits + // that traceBackward found. We cannot use the concat's raw + // input bits because intermediates (buf/slice chains) between + // the unpack outputs and the concat are being removed. + final tracedBits = []; + final t0 = portTraces[startIdx]; + final uConns = cells[t0.unpackName!]!['connections'] + as Map? ?? + {}; + final uDirs = cells[t0.unpackName!]!['port_directions'] + as Map? ?? + {}; + // Rebuild the unpack's output bits in field declaration order + // to create a mapping from A-index to wire ID. + final unpackOutBitList = []; + for (final pe in uConns.entries) { + if (pe.key == 'A') { + continue; + } + if (uDirs[pe.key] != 'output') { + continue; + } + for (final b in pe.value as List) { + if (b is int) { + unpackOutBitList.add(b); + } + } + } + // Build A-index -> output wire ID map. + final aToOutBit = {}; + final uParams = cells[t0.unpackName!]!['parameters'] + as Map? ?? + {}; + final fc = uParams['FIELD_COUNT'] as int? ?? 0; + var outIdx = 0; + for (var fi = 0; fi < fc; fi++) { + final fw = uParams['FIELD_${fi}_WIDTH'] as int? ?? 0; + final fo = uParams['FIELD_${fi}_OFFSET'] as int? ?? 0; + for (var bi = 0; bi < fw; bi++) { + if (outIdx < unpackOutBitList.length) { + aToOutBit[fo + bi] = unpackOutBitList[outIdx]; + } + outIdx++; + } + } + for (var i = startIdx; i <= endIdx; i++) { + for (final aIdx in portTraces[i].sourceIndices) { + final outBit = aToOutBit[aIdx]; + if (outBit != null) { + tracedBits.add(outBit); + } + } + } + final width = tracedBits.length; + + final hi = outBitOffset + width - 1; + final portName = + hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; + newConns[portName] = tracedBits; + newDirs[portName] = 'input'; + outBitOffset += width; + + portIdx = endIdx + 1; + } else { + final port = inputPorts[portIdx]; + final width = port.$3.length; + final hi = outBitOffset + width - 1; + final portName = + hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; + newConns[portName] = port.$3.cast(); + newDirs[portName] = 'input'; + outBitOffset += width; + portIdx++; + } + } + + newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; + newDirs['Y'] = 'output'; + + cellsToAdd['${concatName}_collapsed'] = { + 'hide_name': concatCell['hide_name'], + 'type': r'$concat', + 'parameters': {}, + 'attributes': concatCell['attributes'] ?? {}, + 'port_directions': newDirs, + 'connections': newConns, + }; + } + + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); + if (cellsToRemove.isNotEmpty || cellsToAdd.isNotEmpty) { + anyChanged = true; + } + globalReplIdx = replIdx; + + // Second pass: collapse identity struct_unpack → $buf chains. + // If ALL outputs of a struct_unpack go exclusively to one $buf whose + // A bits are exactly those outputs in order, replace both with a + // single $buf from the unpack's A to the buf's Y. + final unpacksToRemove = {}; + final bufsToRemove = {}; + final bufsToAdd = >{}; + var identBufIdx = 0; + + final wireMaps2 = NetlistUtils.buildWireMaps(cells, moduleDef); + final wireConsumerCells2 = wireMaps2.wireConsumerCells; + + for (final entry in cells.entries.toList()) { + final unpackName = entry.key; + final unpackCell = entry.value; + if ((unpackCell['type'] as String?) != r'$struct_unpack') { + continue; + } + if (unpacksToRemove.contains(unpackName)) { + continue; + } + + final uConns = + unpackCell['connections'] as Map? ?? {}; + final uDirs = + unpackCell['port_directions'] as Map? ?? {}; + + // Collect all output bits in field declaration order. + final allOutputBits = []; + for (final pname in uConns.keys) { + if (uDirs[pname] != 'output') { + continue; + } + for (final b in uConns[pname] as List) { + if (b is int) { + allOutputBits.add(b); + } + } + } + if (allOutputBits.isEmpty) { + continue; + } + + // Every output bit must be consumed by exactly one $buf cell + // (the same one). + String? targetBufName; + var allToOneBuf = true; + for (final bit in allOutputBits) { + final consumers = wireConsumerCells2[bit]; + if (consumers == null || consumers.length != 1) { + allToOneBuf = false; + break; + } + final consumer = consumers.first; + if (consumer == '__port__') { + allToOneBuf = false; + break; + } + final consumerCell = cells[consumer]; + if (consumerCell == null || + (consumerCell['type'] as String?) != r'$buf') { + allToOneBuf = false; + break; + } + if (targetBufName == null) { + targetBufName = consumer; + } else if (consumer != targetBufName) { + allToOneBuf = false; + break; + } + } + if (!allToOneBuf || targetBufName == null) { + continue; + } + if (bufsToRemove.contains(targetBufName)) { + continue; + } + + final bufCell = cells[targetBufName]!; + final bufConns = + bufCell['connections'] as Map? ?? {}; + final bufABits = [ + for (final b in bufConns['A'] as List) + if (b is int) b, + ]; + + // The buf's A bits must be exactly the unpack's output bits. + if (bufABits.length != allOutputBits.length) { + continue; + } + var bitsMatch = true; + for (var i = 0; i < bufABits.length; i++) { + if (bufABits[i] != allOutputBits[i]) { + bitsMatch = false; + break; + } + } + if (!bitsMatch) { + continue; + } + + // Collapse: single buf from unpack.A → buf.Y + final unpackABits = [ + for (final b in uConns['A'] as List) + if (b is int) b, + ]; + final bufYBits = [ + for (final b in bufConns['Y'] as List) + if (b is int) b, + ]; + + if (unpackABits.length != bufYBits.length) { + continue; + } + + bufsToAdd['${unpackName}_buf_$identBufIdx'] = { + 'type': r'$buf', + 'parameters': {'WIDTH': unpackABits.length}, + 'port_directions': {'A': 'input', 'Y': 'output'}, + 'connections': >{ + 'A': unpackABits, + 'Y': bufYBits, + }, + }; + identBufIdx++; + unpacksToRemove.add(unpackName); + bufsToRemove.add(targetBufName); + } + + unpacksToRemove.forEach(cells.remove); + bufsToRemove.forEach(cells.remove); + cells.addAll(bufsToAdd); + if (unpacksToRemove.isNotEmpty || bufsToRemove.isNotEmpty) { + anyChanged = true; + } + } // end while (anyChanged) + } + } + + // -- Collapse struct_unpack to struct_pack ----------------------------- + + /// Finds `$struct_pack` cells whose input ports are driven (directly + /// or through exclusive `$buf`/`$slice` chains) by output ports of + /// `$struct_unpack` cells. The exclusive intermediate `$buf`/`$slice` + /// cells are removed, and the pack input ports are rewired to the + /// unpack output bits directly. + /// + /// The unpack cell itself is preserved (it may have other consumers). + /// Only the intermediate routing cells are removed. + static void applyCollapseUnpackToPack( + Map> allModules, + ) { + for (final moduleDef in allModules.values) { + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; + } + + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); + + final cellsToRemove = {}; + + for (final packEntry in cells.entries.toList()) { + final packName = packEntry.key; + final packCell = packEntry.value; + if ((packCell['type'] as String?) != r'$struct_pack') { + continue; + } + + final conns = packCell['connections'] as Map? ?? {}; + final dirs = packCell['port_directions'] as Map? ?? {}; + + for (final portName in conns.keys.toList()) { + if (dirs[portName] != 'input') { + continue; + } + final bits = [ + for (final b in conns[portName] as List) + if (b is int) b, + ]; + if (bits.isEmpty) { + continue; + } + + // Trace each bit backward through $buf/$slice chains. + final tracedBits = []; + final intermediates = {}; + var allTraceToUnpack = true; + String? unpackName; + + for (final bit in bits) { + final (traced, chain) = + NetlistUtils.traceBackward(bit, wireDriverCell, cells); + intermediates.addAll(chain); + + // Check if traced bit is driven by a $struct_unpack. + final driverName = wireDriverCell[traced]; + if (driverName == null) { + allTraceToUnpack = false; + break; + } + final driverCell = cells[driverName]; + if (driverCell == null || + (driverCell['type'] as String?) != r'$struct_unpack') { + allTraceToUnpack = false; + break; + } + + if (unpackName == null) { + unpackName = driverName; + } else if (unpackName != driverName) { + allTraceToUnpack = false; + break; + } + + tracedBits.add(traced); + } + + if (!allTraceToUnpack || intermediates.isEmpty) { + continue; + } + + // Only remove $buf/$slice intermediates (not the unpack itself). + final removable = intermediates.where((c) { + final ct = cells[c]?['type'] as String?; + return ct == r'$buf' || ct == r'$slice'; + }).toSet(); + + if (removable.isEmpty) { + continue; + } + + // Verify exclusivity. + if (!NetlistUtils.isExclusiveChain( + intermediates: removable, + ownerCell: packName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + continue; + } + + // Rewire: replace the pack's input port with the traced bits. + conns[portName] = tracedBits.cast().toList(); + cellsToRemove.addAll(removable); + } + } + + cellsToRemove.forEach(cells.remove); + } + } +} diff --git a/lib/src/synthesizers/netlist/netlist_service.dart b/lib/src/synthesizers/netlist/netlist_service.dart new file mode 100644 index 000000000..07aec9105 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_service.dart @@ -0,0 +1,224 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_service.dart +// Service wrapper for netlist synthesis. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; + +/// A service that wraps netlist (Yosys JSON) synthesis of a [Module] +/// hierarchy. +/// +/// Provides access to the full hierarchy JSON and per-module JSON with +/// lazy caching, and optionally registers with [ModuleServices] for +/// DevTools inspection. +/// +/// Example: +/// ```dart +/// final dut = MyModule(...); +/// await dut.build(); +/// final netlist = await NetlistService.create(dut); +/// +/// // Full hierarchy JSON: +/// print(netlist.toJson()); +/// +/// // Single module (lazy, cached): +/// print(netlist.moduleJson('FilterChannel')); +/// ``` +class NetlistService { + /// The top-level [Module] being synthesized. + final Module module; + + /// The [NetlistSynthesizer] used for synthesis. + final NetlistSynthesizer synthesizer; + + /// The underlying [SynthBuilder]. + late final SynthBuilder synthBuilder; + + /// The combined JSON string for the full hierarchy. + late final String _fullJson; + + /// Cached per-module JSON, keyed by definition name. + final Map _moduleJsonCache = {}; + + /// The parsed modules map from the combined JSON. + late final Map _modulesMap; + + NetlistService._(this.module, this.synthesizer, this._fullJson) { + final decoded = jsonDecode(_fullJson) as Map; + _modulesMap = + (decoded['modules'] as Map?) ?? {}; + } + + /// Creates a [NetlistService] for [module]. + /// + /// [module] must already be built. Set [register] to `true` (the + /// default) to register this service with [ModuleServices] for + /// DevTools access. + /// + /// The [options] parameter controls netlist synthesis behaviour; + /// see [NetlistOptions] for details. + static Future create( + Module module, { + NetlistOptions options = const NetlistOptions(), + bool register = true, + }) async { + if (!module.hasBuilt) { + throw Exception('Module must be built before creating NetlistService. ' + 'Call build() first.'); + } + + final synthesizer = NetlistSynthesizer(options: options); + final json = await synthesizer.synthesizeToJson(module); + + final service = NetlistService._(module, synthesizer, json); + + if (register) { + ModuleServices.instance.netlistService = service; + } + + return service; + } + + /// Returns the full netlist hierarchy as a JSON string. + String toJson() => _fullJson; + + /// Returns the netlist JSON for a single module [definitionName]. + /// + /// If the module is not found, returns a JSON error object. + String moduleJson(String definitionName) => + _moduleJsonCache.putIfAbsent(definitionName, () { + final modData = _modulesMap[definitionName]; + if (modData == null) { + return jsonEncode({ + 'status': 'not_found', + 'reason': 'module "$definitionName" not in netlist', + }); + } + return jsonEncode({ + 'creator': 'ROHD netlist synthesizer', + 'modules': {definitionName: modData}, + }); + }); + + /// Returns the set of module definition names in the netlist. + Set get moduleNames => _modulesMap.keys.toSet(); + + /// Read-only access to the parsed modules map. + /// + /// Each key is a definition name and each value is the Yosys-style + /// module descriptor containing `ports`, `cells`, and `netnames`. + Map get synthesizedModules => + Map.unmodifiable(_modulesMap); + + /// Cached slim JSON (lazy). + String? _slimJsonCache; + + /// Returns a slim netlist JSON string — same structure as [toJson] but + /// with cell `connections` stripped. + /// + /// The slim representation preserves ports, cells (type + port_directions + /// + port_widths), and netnames so the DevTools extension can render the + /// hierarchy and signal tree without the full connectivity payload. + /// Full per-module connectivity is fetched on demand via [moduleJson]. + String get slimJson => _slimJsonCache ??= _buildSlimJson(); + + String _buildSlimJson() { + final slimModules = {}; + for (final entry in _modulesMap.entries) { + final mod = entry.value as Map; + final cells = mod['cells'] as Map? ?? {}; + final slimCells = {}; + for (final cellEntry in cells.entries) { + final cell = cellEntry.value as Map; + // Compute per-port widths from connections (bit-array lengths). + final conns = cell['connections'] as Map?; + final portWidths = {}; + if (conns != null) { + for (final c in conns.entries) { + final bits = c.value; + if (bits is List) { + portWidths[c.key] = bits.length; + } + } + } + slimCells[cellEntry.key] = { + 'hide_name': cell['hide_name'] ?? 0, + 'type': cell['type'], + 'parameters': cell['parameters'] ?? {}, + 'attributes': cell['attributes'] ?? {}, + 'port_directions': cell['port_directions'] ?? {}, + if (portWidths.isNotEmpty) 'port_widths': portWidths, + // connections intentionally omitted → slim + }; + } + + // Determine which module-level ports have internal connectivity. + final ports = mod['ports'] as Map? ?? {}; + final slimPorts = {}; + final cellConnectedBits = {}; + for (final cellEntry in cells.values) { + final cell = cellEntry as Map; + final conns = cell['connections'] as Map?; + if (conns == null) { + continue; + } + for (final bits in conns.values) { + if (bits is List) { + for (final b in bits) { + if (b is int) { + cellConnectedBits.add(b); + } + } + } + } + } + for (final portEntry in ports.entries) { + final portData = portEntry.value as Map; + final bits = portData['bits'] as List?; + var connected = false; + if (bits != null) { + for (final b in bits) { + if (b is int && cellConnectedBits.contains(b)) { + connected = true; + break; + } + } + } + slimPorts[portEntry.key] = { + ...portData, + if (connected) 'connected': true, + }; + } + + final netnames = mod['netnames'] as Map? ?? {}; + + slimModules[entry.key] = { + 'attributes': { + ...(mod['attributes'] as Map? ?? {}), + 'original_signal_count': netnames.length, + 'original_cell_count': slimCells.length, + }, + 'ports': slimPorts, + 'cells': slimCells, + 'netnames': netnames, + }; + } + + final rootName = + module.hasBuilt ? module.uniqueInstanceName : module.name; + + return jsonEncode({ + 'netlist': { + 'creator': 'ROHD NetlistService (slim)', + 'rootInstanceName': rootName, + 'modules': slimModules, + }, + }); + } +} diff --git a/lib/src/synthesizers/netlist/netlist_synthesis_result.dart b/lib/src/synthesizers/netlist/netlist_synthesis_result.dart new file mode 100644 index 000000000..aaa7d8f6e --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_synthesis_result.dart @@ -0,0 +1,84 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_synthesis_result.dart +// A simple SynthesisResult that holds netlist data for one module. +// +// 2026 February 11 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; + +/// A [SynthesisResult] that holds the netlist representation of a single +/// module level: its ports, cells, and netnames. +class NetlistSynthesisResult extends SynthesisResult { + /// The ports map: name → {direction, bits}. + final Map> ports; + + /// The cells map: instance name → cell data. + final Map> cells; + + /// The netnames map: net name → {bits, attributes}. + final Map netnames; + + /// Attributes for this module (e.g., top marker). + final Map attributes; + + /// Cached JSON string for comparison and output. + late final String _cachedJson = _buildJson(); + + /// Creates a [NetlistSynthesisResult] for [module]. + NetlistSynthesisResult( + super.module, + super.getInstanceTypeOfModule, { + required this.ports, + required this.cells, + required this.netnames, + this.attributes = const {}, + }); + + String _buildJson() { + final moduleEntry = { + 'attributes': attributes, + 'ports': ports, + 'cells': cells, + 'netnames': netnames, + }; + return const JsonEncoder().convert(moduleEntry); + } + + @override + bool matchesImplementation(SynthesisResult other) => + other is NetlistSynthesisResult && _cachedJson == other._cachedJson; + + @override + int get matchHashCode => _cachedJson.hashCode; + + @override + @Deprecated('Use `toSynthFileContents()` instead.') + String toFileContents() => toSynthFileContents().first.contents; + + @override + List toSynthFileContents() { + final typeName = instanceTypeName; + final moduleEntry = { + 'attributes': attributes, + 'ports': ports, + 'cells': cells, + 'netnames': netnames, + }; + final contents = const JsonEncoder.withIndent(' ').convert({ + 'creator': 'NetlistSynthesizer (rohd)', + 'modules': {typeName: moduleEntry}, + }); + return [ + SynthFileContents( + name: '$typeName.rohd.json', + description: 'netlist for $typeName', + contents: contents, + ), + ]; + } +} diff --git a/lib/src/synthesizers/netlist/netlist_synthesizer.dart b/lib/src/synthesizers/netlist/netlist_synthesizer.dart new file mode 100644 index 000000000..a21760b15 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_synthesizer.dart @@ -0,0 +1,1788 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_synthesizer.dart +// A netlist synthesizer built on [SynthModuleDefinition]. +// +// 2026 February 11 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:rohd/src/utilities/sanitizer.dart'; + +/// +/// Skips SystemVerilog-specific processing (chain collapsing, net connects, +/// inOut inline replacement) since netlist represents all sub-modules as +/// cells rather than inline assignment expressions. +class _NetlistSynthModuleDefinition extends SynthModuleDefinition { + _NetlistSynthModuleDefinition(Module module) : super(module) { + // Create explicit $slice cells for LogicArray input ports so the + // netlist shows select gates for element extraction rather than + // flat bit aliasing. + module.inputs.values + .whereType() + .forEach(_subsetReceiveArrayPort); + + // Same for LogicArray outputs on submodules (received into this scope). + module.subModules + .expand((sub) => sub.outputs.values) + .whereType() + .forEach(_subsetReceiveArrayPort); + + // Create explicit $concat cells for internal LogicArrays whose elements + // are driven independently (e.g. by constants) and then consumed by + // submodule input ports. This parallels what _subsetReceiveArrayPort does + // on the decomposition side. + // + // Skip arrays that were merged with a port array's SynthLogic — those + // are already structurally decomposed by the $slice cells created above + // and reassembling them would create a circular driver on the port bus. + final portArrays = { + ...module.inputs.values.whereType(), + ...module.outputs.values.whereType(), + ...module.inOuts.values.whereType(), + }; + final portArraySynthLogics = {}; + for (final pa in portArrays) { + final sl = logicToSynthMap[pa]; + if (sl != null) { + portArraySynthLogics.add(sl.replacement ?? sl); + } + } + module.internalSignals.whereType().where((sig) { + if (portArrays.contains(sig)) { + return false; + } + final sl = logicToSynthMap[sig]; + if (sl == null) { + return false; + } + final resolved = sl.replacement ?? sl; + return !portArraySynthLogics.contains(resolved); + }).forEach(_concatAssembleArray); + } + + /// Creates explicit `$slice` cells for each element of a [LogicArray] port. + /// + /// Each element gets a [_BusSubsetForArraySlice] that extracts its bit range + /// from the packed parent bus. This produces explicit select gates in the + /// netlist, making array decomposition visible and traceable. + void _subsetReceiveArrayPort(LogicArray port) { + final portSynth = getSynthLogic(port)!; + + var idx = 0; + for (final element in port.elements) { + final elemSynth = getSynthLogic(element)!; + internalSignals.add(elemSynth); + + final subsetMod = _BusSubsetForArraySlice( + Logic(width: port.width, name: 'DUMMY'), + idx, + idx + element.width - 1, + ); + + getSynthSubModuleInstantiation(subsetMod) + ..setOutputMapping(subsetMod.subset.name, elemSynth) + ..setInputMapping(subsetMod.original.name, portSynth) + + // Pick a name now — this may be called after _pickNames() has run. + ..pickName(module); + + idx += element.width; + } + } + + /// Creates an explicit `$concat` cell that assembles a [LogicArray]'s + /// elements into the full packed array bus. + /// + /// This is the assembly counterpart to [_subsetReceiveArrayPort]: when + /// individual array elements are driven independently (e.g. by constants), + /// this makes the concatenation explicit as a visible gate in the netlist. + void _concatAssembleArray(LogicArray array) { + final arraySynth = getSynthLogic(array)!; + + // Build dummy signals matching each element's width. + final dummyElements = []; + for (final element in array.elements) { + dummyElements.add(Logic(width: element.width, name: 'DUMMY')); + } + + // Pass reversed dummies so that Swizzle's internal reversal cancels out, + // leaving in0 aligned with element[0] (LSB) and inN with element[N]. + final concatMod = _SwizzleForArrayConcat(dummyElements.reversed.toList()); + + final ssmi = getSynthSubModuleInstantiation(concatMod) + // Map the concat output to the full array. + ..setOutputMapping(concatMod.out.name, arraySynth); + + // Map each element input. + // Because we reversed dummies above, in0 corresponds to element[0], + // in1 to element[1], etc. + for (var i = 0; i < array.elements.length; i++) { + final elemSynth = getSynthLogic(array.elements[i])!; + internalSignals.add(elemSynth); + final inputName = concatMod.inputs.keys.elementAt(i); + ssmi.setInputMapping(inputName, elemSynth); + } + + // Pick a name now — this may be called after _pickNames() has run. + ssmi.pickName(module); + } + + @override + void process() { + // No SV-specific transformations -- we want every sub-module to remain + // as a cell in the JSON. + } +} + +/// A simple [Synthesizer] that produces netlist-compatible JSON. +/// +/// Leverages [SynthModuleDefinition] for signal tracing, naming, and +/// constant resolution, then maps the resulting [SynthLogic]s to integer +/// wire-bit IDs for netlist JSON output. +/// +/// Leaf modules (those with no sub-modules, or special cases like [FlipFlop]) +/// do *not* get their own module definition -- they appear only as cells +/// inside their parent. +/// +/// Usage: +/// ```dart +/// const options = NetlistOptions(groupStructConversions: true); +/// final synth = NetlistSynthesizer(options: options); +/// final builder = SynthBuilder(topModule, synth); +/// final json = await synth.synthesizeToJson(topModule); +/// ``` +class NetlistSynthesizer extends Synthesizer { + /// The configuration options controlling netlist synthesis. + /// + /// See [NetlistOptions] for documentation on individual fields. + final NetlistOptions options; + + /// Convenience accessor for the leaf-cell mapper. + LeafCellMapper get leafCellMapper => + options.leafCellMapper ?? LeafCellMapper.defaultMapper; + + /// Creates a [NetlistSynthesizer]. + /// + /// All synthesis parameters are bundled in [options]; see + /// [NetlistOptions] for documentation on each field. + NetlistSynthesizer({this.options = const NetlistOptions()}); + + @override + bool generatesDefinition(Module module) => + // Only modules with sub-modules generate their own module definition. + // Leaf modules (no children) become cells inside their parent. + // FlipFlop has internal Sequential sub-modules but should be emitted as + // a flat Yosys $dff primitive, not as a hierarchical module. + module is! FlipFlop && module.subModules.isNotEmpty; + + @override + SynthesisResult synthesize( + Module module, + String Function(Module module) getInstanceTypeOfModule, { + SynthesisResult? Function(Module module)? lookupExistingResult, + Map? existingResults, + }) { + final isTop = module.parent == null; + final attr = {'src': 'generated'}; + if (isTop) { + attr['top'] = 1; + } + + // -- Build SynthModuleDefinition ------------------------------------ + // This does all signal tracing, naming, constant handling, + // assignment collapsing, and unused signal pruning. + final canBuildSynthDef = !(module is SystemVerilog && + module.generatedDefinitionType == DefinitionGenerationType.none); + final synthDef = + canBuildSynthDef ? _NetlistSynthModuleDefinition(module) : null; + + // -- Wire-ID allocation --------------------------------------------- + // Start wire IDs at 2 to avoid collision with Yosys constant string + // bits "0" and "1". JavaScript viewers coerce object keys to strings, + // so integer wire ID 0 becomes "0", clashing with the constant-bit + // string "0". + var nextId = 2; + + // Map from SynthLogic -> assigned wire-bit IDs. + final synthLogicIds = >{}; + + /// Allocate or retrieve wire IDs for a [SynthLogic]. + /// For constants, do NOT follow the replacement chain to ensure each + /// constant usage gets its own separate driver cell in netlist. + List getIds(SynthLogic sl) { + var resolved = sl; + // For non-constants, follow replacement chain to resolve merged logics. + // For constants, keep them separate to create distinct const drivers. + if (!sl.isConstant) { + resolved = NetlistUtils.resolveReplacement(resolved); + } + final ids = synthLogicIds.putIfAbsent( + resolved, () => List.generate(resolved.width, (_) => nextId++)); + return ids; + } + + // -- Ports ----------------------------------------------------------- + final ports = >{}; + + final portGroups = [ + ('input', synthDef?.inputs, module.inputs), + ('output', synthDef?.outputs, module.outputs), + ('inout', synthDef?.inOuts, module.inOuts), + ]; + for (final (direction, synthLogics, modulePorts) in portGroups) { + if (synthLogics != null) { + for (final sl in synthLogics) { + final ids = getIds(sl); + final portName = NetlistUtils.portNameForSynthLogic(sl, modulePorts); + if (portName != null) { + ports[portName] = {'direction': direction, 'bits': ids}; + } + } + } else { + for (final entry in modulePorts.entries) { + final ids = List.generate(entry.value.width, (_) => nextId++); + ports[entry.key] = {'direction': direction, 'bits': ids}; + } + } + } + + // -- Pre-allocate IDs for internal signals in Module order ----------- + // This ensures that internals get IDs in the same order as + // Module.internalSignals, matching WaveformService._collectSignals. + // Signals already allocated during the port phase are skipped by + // putIfAbsent. Synthesis-generated wires get IDs later (during cell + // emission), so they are naturally appended after internals. + // + // Three-tier ordering guarantee: + // Tier 0 (ports): inputs → outputs → inOuts [above] + // Tier 1 (internals): module.internalSignals [here] + // Tier 2 (synth): cell emission wires [below] + if (synthDef != null) { + module.internalSignals + .map((sig) => synthDef.logicToSynthMap[sig]) + .whereType() + .where((sl) => !sl.isConstant) + .forEach(getIds); + } + + // -- Cell emission --------------------------------------------------- + final cells = >{}; + + // Track constant SynthLogics consumed exclusively by + // Combinational/Sequential so we can suppress their driver cells. + final blockedConstSynthLogics = {}; + + // Track emitted cell keys per instance for purging later. + final emittedCellKeys = {}; + + if (synthDef != null) { + for (final instance in synthDef.subModuleInstantiations) { + if (!instance.needsInstantiation) { + continue; + } + + final sub = instance.module; + + final isLeaf = !generatesDefinition(sub); + final defaultCellType = + isLeaf ? sub.definitionName : getInstanceTypeOfModule(sub); + + // Build port directions and connections from instance mappings. + final rawPortDirs = {}; + final rawConnections = >{}; + + for (final (dir, mapping) in [ + ('input', instance.inputMapping), + ('output', instance.outputMapping), + ('inout', instance.inOutMapping), + ]) { + for (final e in mapping.entries) { + rawPortDirs[e.key] = dir; + final ids = getIds(e.value); + rawConnections[e.key] = ids.cast(); + } + } + + // Map leaf cells to Yosys primitive types where possible. + final mapped = isLeaf + ? leafCellMapper.map(sub, rawPortDirs, rawConnections) + : null; + + final cellPortDirs = mapped?.portDirs ?? rawPortDirs; + final cellConns = mapped?.connections ?? rawConnections; + + // Use the SSMI's uniquified name as cell key to avoid + // collisions between identically-named modules (e.g. multiple + // struct_slice instances that share the same Module.name). + final cellKey = instance.name; + emittedCellKeys[instance] = cellKey; + + // -- Collapse bit-slice ports on Combinational / Sequential ---- + if (sub is Combinational || sub is Sequential) { + NetlistUtils.collapseAlwaysBlockPorts( + synthDef, + instance, + cellPortDirs, + cellConns, + getIds, + ); + } + + // -- Filter constant inputs from Combinational / Sequential ---- + if (sub is Combinational || sub is Sequential) { + final portsToRemove = []; + for (final pe in cellConns.entries) { + final portName = pe.key; + final synthLogic = instance.inputMapping[portName] ?? + instance.inOutMapping[portName]; + if (synthLogic != null && + NetlistUtils.isConstantSynthLogic(synthLogic)) { + portsToRemove.add(portName); + blockedConstSynthLogics.add(synthLogic.replacement ?? synthLogic); + } + } + for (final p in portsToRemove) { + cellConns.remove(p); + cellPortDirs.remove(p); + } + } + + // -- Rename Seq/Comb ports to Namer wire names ----------------- + // The port names from _Always.addInput/addOutput are internal + // (e.g. `_out`, `_enable`). Replace them with the Namer's + // resolved wire name so they match SystemVerilog and WaveDumper. + if (sub is Combinational || sub is Sequential) { + final renames = {}; + for (final portName in cellConns.keys.toList()) { + final sl = instance.inputMapping[portName] ?? + instance.outputMapping[portName] ?? + instance.inOutMapping[portName]; + if (sl == null) { + continue; // aggregated port, already renamed + } + final resolved = NetlistUtils.resolveReplacement(sl); + final namerName = NetlistUtils.tryGetSynthLogicName(resolved); + if (namerName != null && namerName != portName) { + renames[portName] = namerName; + } + } + for (final entry in renames.entries) { + final bits = cellConns.remove(entry.key)!; + final dir = cellPortDirs.remove(entry.key)!; + var newName = entry.value; + // Avoid collision with existing port names. + if (cellConns.containsKey(newName)) { + newName = '${entry.value}_${entry.key}'; + } + cellConns[newName] = bits; + cellPortDirs[newName] = dir; + } + } + + cells[cellKey] = { + 'hide_name': 0, + 'type': mapped?.cellType ?? defaultCellType, + 'parameters': mapped?.parameters ?? {}, + 'attributes': {}, + 'port_directions': cellPortDirs, + 'connections': cellConns, + }; + } + } + + // -- Remove cells that were cleared by collapseAlwaysBlockPorts ------ + // Because the iteration order may process a Swizzle/BusSubset cell + // BEFORE the Combinational/Sequential that clears it, we need to purge + // stale cells after all collapsing has been applied. + if (synthDef != null) { + synthDef.subModuleInstantiations + .where((i) => !i.needsInstantiation) + .map((i) => emittedCellKeys[i]) + .whereType() + .forEach(cells.remove); + } + + // -- Wire-ID aliasing from remaining assignments ------------------- + // SynthModuleDefinition._collapseAssignments may leave assignments + // between non-mergeable SynthLogics (e.g., reserved port + + // renameable internal signal). In SV synthesis these become + // `assign` statements. In netlist we need the two sides to + // share wire IDs so that the netlist is properly connected. + // + // Similarly, PartialSynthAssignments for output struct ports tell + // us which leaf-field IDs should compose the port's bits, and + // input-struct BusSubsets (which may be pruned) tell us which + // leaf-field IDs should be carved from the port's bits. + final idAlias = {}; + + // Pending $struct_field cells collected during Step 3. + // Each entry records a single field extraction from a parent struct. + // The `parentLogic` and `fullParentIds` fields are used to group + // entries from the same LogicStructure into a single multi-port + // `$struct_unpack` cell. + final structFieldCells = <({ + List parentIds, + List elemIds, + int offset, + int width, + Logic elemLogic, + Logic parentLogic, + List fullParentIds, + })>[]; + + // Pending $struct_compose cells: for output struct ports, instead of + // aliasing port bits to leaf bits (which causes "shorting"), we + // collect composition operations and emit explicit cells later. + // Each entry records: field (src) → port sub-range [lower:upper]. + final structComposeCells = <({ + List srcIds, + List dstIds, + int dstLowerIndex, + int dstUpperIndex, + SynthLogic srcSynthLogic, + SynthLogic dstSynthLogic, + })>[]; + + // Track struct ports (both output ports of the current module AND + // sub-module input struct ports) so Step 3 can skip $struct_field + // collection for them ($struct_pack handles these instead). + final outputStructPortLogics = {}; + + if (synthDef != null) { + // 1. Non-partial assignments: src drives dst → dst IDs become + // src IDs (the driver's IDs are canonical). + for (final assignment + in synthDef.assignments.where((a) => a is! PartialSynthAssignment)) { + final srcIds = getIds(assignment.src); + final dstIds = getIds(assignment.dst); + final len = + srcIds.length < dstIds.length ? srcIds.length : dstIds.length; + for (var i = 0; i < len; i++) { + if (dstIds[i] != srcIds[i]) { + idAlias[dstIds[i]] = srcIds[i]; + } + } + } + + // 2. Partial assignments (output / sub-module struct ports): + // src → dst[lower:upper]. The port-slice IDs become the + // leaf's IDs so that the port is composed from its fields. + // + // For struct ports (both output ports of the current module + // AND sub-module input struct ports), we keep distinct port + // and field IDs and instead collect pending $struct_pack + // cells. This avoids "shorting" where field wires are + // aliased directly to port bits, which creates multi-driver + // conflicts with $struct_unpack cells emitted in Step 3. + // + // For non-struct sub-module input ports, we alias as before. + + /// Recursively add [struct] and all its nested [LogicStructure] + /// descendants (excluding [LogicArray]) to [set]. + void addStructAndDescendants(LogicStructure struct, Set set) { + set.add(struct); + for (final elem in struct.elements) { + if (elem is LogicStructure && elem is! LogicArray) { + addStructAndDescendants(elem, set); + } + } + } + + for (final pa + in synthDef.assignments.whereType()) { + final srcIds = getIds(pa.src); + final dstIds = getIds(pa.dst); + + // Detect: is pa.dst an output struct port of the current module? + final isCurrentModuleOutputPort = + pa.dst.isPort(module) && pa.dst.logics.any((l) => l.isOutput); + + // Detect: is pa.dst a sub-module input struct port? + // (LogicStructure but not LogicArray, and not an output of the + // current module.) + final isSubModuleInputStructPort = !isCurrentModuleOutputPort && + pa.dst.logics.any((l) => l is LogicStructure && l is! LogicArray); + + if (isCurrentModuleOutputPort || isSubModuleInputStructPort) { + // Record as pending compose cell instead of aliasing. + structComposeCells.add(( + srcIds: srcIds, + dstIds: dstIds, + dstLowerIndex: pa.dstLowerIndex, + dstUpperIndex: pa.dstUpperIndex, + srcSynthLogic: pa.src, + dstSynthLogic: pa.dst, + )); + // Track the Logic (and nested structs) so Step 3 skips + // $struct_unpack for them. + for (final l in pa.dst.logics) { + if (l is LogicStructure && l is! LogicArray) { + addStructAndDescendants(l, outputStructPortLogics); + } + } + } else { + // Non-struct sub-module input port: alias as before. + for (var i = 0; i < srcIds.length; i++) { + final dstIdx = pa.dstLowerIndex + i; + if (dstIdx < dstIds.length && dstIds[dstIdx] != srcIds[i]) { + idAlias[dstIds[dstIdx]] = srcIds[i]; + } + } + } + } + + // 3. LogicStructure and LogicArray: child IDs → parent-slice IDs. + // + // LogicArray elements alias their IDs to matching parent bits + // so array connectivity works. + // + // Non-array LogicStructure elements are NOT aliased. Instead, + // their parent→element mappings are collected in + // [structFieldCells] and emitted as explicit $struct_field + // cells after alias resolution. This preserves element signals + // (e.g. "a_mantissa") as distinct named wires visible in the + // schematic, rather than collapsing them into parent bit ranges. + // + // For arrays with explicit $slice/$concat cells (from + // _BusSubsetForArraySlice / _SwizzleForArrayConcat), aliasing + // is skipped entirely — the cells provide the structural link. + // + // Applied to ALL instances (ports AND internal signals) since + // internal arrays/structs (e.g. constant-driven coefficients) + // also need child→parent aliasing. + // + // - LogicStructure (non-array): walks leafElements (recursive) + // - LogicArray: walks elements (direct children only, since + // each element is already a flat bitvector). + // For input array ports that have _BusSubsetForArraySlice + // cells, we skip aliasing so the $slice cells provide the + // structural connection (see _subsetReceiveArrayPort). + // + // When a child ID was already aliased (e.g. by step 1 to a + // constant driver), we also redirect that prior target to the + // parent ID so the transitive chain resolves correctly: + // constId → childId → parentId. + void aliasChildToParent(int childId, int parentId) { + if (childId == parentId) { + return; + } + // If childId already aliases somewhere (e.g. constId → childId + // was set in step 1 as childId → constId), redirect that old + // target to parentId as well, so constId → parentId. + final existing = idAlias[childId]; + if (existing != null && existing != parentId) { + idAlias[existing] = parentId; + } + idAlias[childId] = parentId; + } + + // Collect LogicArray ports that have explicit array_slice or + // array_concat submodules so we can skip aliasing them (the + // $slice/$concat cells provide the structural link). + final arraysWithExplicitCells = {}; + for (final inst in synthDef.subModuleInstantiations) { + if (inst.module is _BusSubsetForArraySlice) { + // The input of the BusSubset is the array port. + for (final inputSL in inst.inputMapping.values) { + final logic = synthDef.logicToSynthMap.entries + .where( + (e) => e.value == inputSL || e.value.replacement == inputSL) + .map((e) => e.key) + .firstOrNull; + if (logic != null && logic is LogicArray) { + arraysWithExplicitCells.add(logic); + } + // Also check the resolved replacement chain. + final resolved = NetlistUtils.resolveReplacement(inputSL); + final logic2 = synthDef.logicToSynthMap.entries + .where((e) => e.value == resolved) + .map((e) => e.key) + .firstOrNull; + if (logic2 != null && logic2 is LogicArray) { + arraysWithExplicitCells.add(logic2); + } + } + } + if (inst.module is _SwizzleForArrayConcat) { + // The output of the Swizzle is the array signal. + for (final outputSL in inst.outputMapping.values) { + final logic = synthDef.logicToSynthMap.entries + .where((e) => + e.value == outputSL || e.value.replacement == outputSL) + .map((e) => e.key) + .firstOrNull; + if (logic != null && logic is LogicArray) { + arraysWithExplicitCells.add(logic); + } + } + } + } + + for (final entry in synthDef.logicToSynthMap.entries) { + final logic = entry.key; + if (logic is! LogicStructure) { + continue; + } + final parentSL = entry.value; + final parentIds = getIds(parentSL); + + if (logic is LogicArray) { + // Skip aliasing for arrays that have explicit $slice/$concat cells. + if (arraysWithExplicitCells.contains(logic)) { + continue; + } + // Array: alias each element's IDs to matching parent slice. + var idx = 0; + for (final element in logic.elements) { + final elemSL = synthDef.logicToSynthMap[element]; + if (elemSL != null) { + final elemIds = getIds(elemSL); + for (var i = 0; + i < elemIds.length && idx + i < parentIds.length; + i++) { + aliasChildToParent(elemIds[i], parentIds[idx + i]); + } + } + idx += element.width; + } + } else { + // Struct: collect element→parent mappings for $struct_field + // cell emission instead of aliasing. This preserves named + // field signals as distinct wires connected through explicit + // cells, making them visible in the schematic and evaluable + // by the netlist evaluator. + // + // Skip output struct ports of the current module — those are + // handled by $struct_compose cells (from Step 2). + if (outputStructPortLogics.contains(logic)) { + continue; + } + var idx = 0; + for (final elem in logic.elements) { + final elemSL = synthDef.logicToSynthMap[elem]; + if (elemSL != null) { + final elemIds = getIds(elemSL); + final sliceLen = elemIds.length < parentIds.length - idx + ? elemIds.length + : parentIds.length - idx; + if (sliceLen > 0) { + structFieldCells.add(( + parentIds: parentIds.sublist(idx, idx + sliceLen), + elemIds: elemIds.sublist(0, sliceLen), + offset: idx, + width: sliceLen, + elemLogic: elem, + parentLogic: logic, + fullParentIds: parentIds, + )); + } + } + idx += elem.width; + } + } + } + } + + // Transitively resolve an alias chain to its canonical ID. + // Uses a visited set to detect cycles created by conflicting + // child→parent and assignment aliasing directions. + int resolveAlias(int id) { + var resolved = id; + final visited = {}; + while (idAlias.containsKey(resolved)) { + if (!visited.add(resolved)) { + // Cycle detected — break the cycle by removing this entry. + idAlias.remove(resolved); + break; + } + resolved = idAlias[resolved]!; + } + return resolved; + } + + // Apply aliases to a list of bit IDs / string constants. + List applyAlias(List bits) => idAlias.isEmpty + ? bits + : bits.map((b) => b is int ? resolveAlias(b) : b).toList(); + + // Alias port bits. + if (idAlias.isNotEmpty) { + for (final p in ports.values) { + p['bits'] = applyAlias((p['bits']! as List).cast()); + } + // Alias cell connections. + for (final c in cells.values) { + final conns = c['connections']! as Map; + for (final key in conns.keys.toList()) { + conns[key] = applyAlias((conns[key] as List).cast()); + } + } + + // -- Elide trivial $slice cells ---------------------------------- + // Also elide struct_slice cells (`_BusSubsetForStructSlice` + // instances from `_subsetReceiveStructPort`) because the new + // `$struct_unpack` cells emitted below supersede them with + // better-named field-level connections. + cells.removeWhere((cellKey, cell) { + if (cell['type'] != r'$slice') { + return false; + } + // Unconditionally remove struct_slice cells — they are + // duplicated by $struct_unpack cells which carry field names. + if (cellKey.startsWith('struct_slice')) { + return true; + } + final params = cell['parameters'] as Map?; + final offset = params?['OFFSET']; + if (offset is! int) { + return false; + } + final conns = cell['connections']! as Map; + final aBits = conns['A'] as List?; + final yBits = conns['Y'] as List?; + if (aBits == null || yBits == null) { + return false; + } + return yBits.indexed.every((e) => + offset + e.$1 < aBits.length && e.$2 == aBits[offset + e.$1]); + }); + } + + // -- Emit $struct_unpack cells for LogicStructure elements ---------- + // Group per-field entries by their parent LogicStructure and emit a + // single multi-port cell per group. Each group has: + // • input port A: the full parent bus (packed bitvector) + // • one output port per non-trivial field: bits for that field + // This replaces the old per-field $struct_field cells. + if (synthDef != null && structFieldCells.isNotEmpty) { + // Group by parent Logic identity. + final groups = parentIds, + List elemIds, + int offset, + int width, + Logic elemLogic, + Logic parentLogic, + List fullParentIds, + })>>{}; + for (final sf in structFieldCells) { + (groups[sf.parentLogic] ??= []).add(sf); + } + + var suIdx = 0; + for (final entry in groups.entries) { + final parentLogic = entry.key; + final fields = entry.value; + final fullParentIds = fields.first.fullParentIds; + final resolvedParentBits = applyAlias(fullParentIds.cast()); + + // Filter out trivial fields (input slice == output after aliasing). + final nonTrivialFields = fields + .map((sf) { + final resolvedElemBits = applyAlias(sf.elemIds.cast()); + return ( + resolvedElemBits: resolvedElemBits, + offset: sf.offset, + width: sf.width, + elemLogic: sf.elemLogic, + ); + }) + .where((f) => !f.resolvedElemBits.indexed.every((e) { + final (i, bit) = e; + return f.offset + i < resolvedParentBits.length && + bit == resolvedParentBits[f.offset + i]; + })) + .toList(); + + if (nonTrivialFields.isEmpty) { + continue; + } + + // Derive struct name for the cell key. + final structName = Sanitizer.sanitizeSV(parentLogic.name); + + // Build element range table for the parent struct so we can + // derive proper field names even when the leaf Logic objects + // have unpreferred names like `_swizzled`. + // Same strategy as $struct_pack: walk the hierarchy collecting + // (start, end, name, path, indexInParent) and look up the + // narrowest non-unpreferred range for each field offset. + final suElementRanges = <({ + int start, + int end, + String name, + String path, + int indexInParent, + })>[]; + if (parentLogic is LogicStructure) { + void walkStruct( + LogicStructure struct, int baseOffset, String parentPath) { + var offset = baseOffset; + for (var idx = 0; idx < struct.elements.length; idx++) { + final elem = struct.elements[idx]; + final elemEnd = offset + elem.width; + final elemPath = + parentPath.isEmpty ? elem.name : '${parentPath}_${elem.name}'; + suElementRanges.add(( + start: offset, + end: elemEnd, + name: elem.name, + path: elemPath, + indexInParent: idx, + )); + if (elem is LogicStructure && elem is! LogicArray) { + walkStruct(elem, offset, elemPath); + } + offset = elemEnd; + } + } + + walkStruct(parentLogic, 0, ''); + } + + String suFieldNameFor(int fieldOffset, String fallbackName) { + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? bestNamed; + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? bestAny; + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? narrowest; + + for (final r in suElementRanges) { + if (fieldOffset >= r.start && fieldOffset < r.end) { + final span = r.end - r.start; + if (narrowest == null || + span < (narrowest.end - narrowest.start)) { + narrowest = r; + } + if (bestAny == null || span < (bestAny.end - bestAny.start)) { + bestAny = r; + } + if (!Naming.isUnpreferred(r.name)) { + if (bestNamed == null || + span < (bestNamed.end - bestNamed.start)) { + bestNamed = r; + } + } + } + } + + if (bestNamed != null) { + if (narrowest != null && + (narrowest.end - narrowest.start) < + (bestNamed.end - bestNamed.start)) { + final bestNamedPrefix = bestNamed.path; + if (narrowest.path.length > bestNamedPrefix.length && + narrowest.path.startsWith(bestNamedPrefix)) { + final suffix = + narrowest.path.substring(bestNamedPrefix.length + 1); + if (!Naming.isUnpreferred(suffix)) { + return '${bestNamed.name}_$suffix'; + } + } + return '${bestNamed.name}_${narrowest.indexInParent}'; + } + return bestNamed.name; + } + // All matching elements have unpreferred names — use the + // narrowest element's positional index as discriminator. + if (narrowest != null && Naming.isUnpreferred(narrowest.name)) { + return 'anonymous_${narrowest.indexInParent}'; + } + return bestAny?.name ?? fallbackName; + } + + // Build port_directions and connections with one output per field. + final portDirs = {'A': 'input'}; + final conns = >{'A': resolvedParentBits}; + + for (var i = 0; i < nonTrivialFields.length; i++) { + final f = nonTrivialFields[i]; + final fieldName = suFieldNameFor(f.offset, f.elemLogic.name); + // Disambiguate duplicate field names with index suffix. + var portName = fieldName; + if (portDirs.containsKey(portName)) { + portName = '${fieldName}_$i'; + } + portDirs[portName] = 'output'; + conns[portName] = f.resolvedElemBits; + } + + // Parameters list field metadata for the schematic viewer. + final params = { + 'STRUCT_NAME': parentLogic.name, + 'FIELD_COUNT': nonTrivialFields.length, + }; + for (var i = 0; i < nonTrivialFields.length; i++) { + final f = nonTrivialFields[i]; + params['FIELD_${i}_NAME'] = + suFieldNameFor(f.offset, f.elemLogic.name); + params['FIELD_${i}_OFFSET'] = f.offset; + params['FIELD_${i}_WIDTH'] = f.width; + } + + cells['struct_unpack_${suIdx}_$structName'] = { + 'hide_name': 0, + 'type': r'$struct_unpack', + 'parameters': params, + 'attributes': {}, + 'port_directions': portDirs, + 'connections': conns, + }; + suIdx++; + } + } + + // -- Emit $struct_pack cells for output struct ports ------------------ + // Group compose entries by destination port and emit a single + // multi-port cell per group. Each group has: + // • one input port per non-trivial field + // • output port Y: the full packed output bus + // This replaces the old per-field $struct_compose cells. + if (structComposeCells.isNotEmpty) { + // Group by destination SynthLogic identity. + final composeGroups = srcIds, + List dstIds, + int dstLowerIndex, + int dstUpperIndex, + SynthLogic srcSynthLogic, + SynthLogic dstSynthLogic, + })>>{}; + for (final sc in structComposeCells) { + (composeGroups[sc.dstSynthLogic] ??= []).add(sc); + } + + var spIdx = 0; + for (final entry in composeGroups.entries) { + final dstSynthLogic = entry.key; + final fields = entry.value; + final resolvedDstBits = applyAlias(fields.first.dstIds.cast()); + + // Filter out trivial fields. + final nonTrivialFields = fields + .map((sc) { + final resolvedSrcBits = applyAlias(sc.srcIds.cast()); + final yBits = resolvedDstBits.sublist( + sc.dstLowerIndex, sc.dstUpperIndex + 1); + return ( + resolvedSrcBits: resolvedSrcBits, + yBits: yBits, + dstLowerIndex: sc.dstLowerIndex, + dstUpperIndex: sc.dstUpperIndex, + srcSynthLogic: sc.srcSynthLogic, + ); + }) + .where((f) => !f.resolvedSrcBits + .take(f.yBits.length) + .indexed + .every((e) => e.$2 == f.yBits[e.$1])) + .toList(); + + if (nonTrivialFields.isEmpty) { + continue; + } + + // Derive struct name from the destination Logic. + final dstLogic = dstSynthLogic.logics.firstOrNull; + final structName = dstLogic != null + ? Sanitizer.sanitizeSV(dstLogic.name) + : 'struct_$spIdx'; + + // Build a lookup from bit offset to the best struct element + // name, so that field names come from the struct definition + // (e.g. "data", "last", "poison") rather than the source + // signal name (which may be an internal like "_swizzled"). + // + // Elements pack LSB-first via `rswizzle`, so element[0] + // starts at offset 0, element[1] at element[0].width, etc. + // + // We collect (start, end, name, path, parentElementIndex) + // ranges for every element at every nesting level. The + // `path` carries the chain of parent struct names so we can + // produce qualified names like "mmu_info_mmuSid". When + // leaf names are unpreferred, `parentElementIndex` provides + // a fallback discriminator like "mmu_info_0". + final dstElementRanges = <({ + int start, + int end, + String name, + String path, + int indexInParent, + })>[]; + if (dstLogic is LogicStructure) { + void walkStruct( + LogicStructure struct, int baseOffset, String parentPath) { + var offset = baseOffset; + for (var idx = 0; idx < struct.elements.length; idx++) { + final elem = struct.elements[idx]; + final elemEnd = offset + elem.width; + final elemPath = + parentPath.isEmpty ? elem.name : '${parentPath}_${elem.name}'; + dstElementRanges.add(( + start: offset, + end: elemEnd, + name: elem.name, + path: elemPath, + indexInParent: idx, + )); + if (elem is LogicStructure && elem is! LogicArray) { + walkStruct(elem, offset, elemPath); + } + offset = elemEnd; + } + } + + walkStruct(dstLogic, 0, ''); + } + + /// Look up the field name for a compose entry by finding the + /// best struct element whose range contains [dstLowerIndex]. + /// + /// Strategy (deepest-first): + /// 1. Find the narrowest element with a non-unpreferred name. + /// 2. If a narrower unpreferred leaf exists under a named + /// parent, try to qualify with the leaf's proper name + /// (e.g. `mmu_info_mmuSid`). + /// 3. If the leaf name is also unpreferred, fall back to the + /// parent name qualified by the leaf's positional index + /// (e.g. `mmu_info_0`, `mmu_info_1`). + /// 4. Falls back to the resolved source SynthLogic name. + String fieldNameFor( + int dstLowerIndex, + SynthLogic srcSynthLogic, + ) { + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? bestNamed; + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? bestAny; + ({ + int start, + int end, + String name, + String path, + int indexInParent, + })? narrowest; + + for (final r in dstElementRanges) { + if (dstLowerIndex >= r.start && dstLowerIndex < r.end) { + final span = r.end - r.start; + if (narrowest == null || + span < (narrowest.end - narrowest.start)) { + narrowest = r; + } + if (bestAny == null || span < (bestAny.end - bestAny.start)) { + bestAny = r; + } + if (!Naming.isUnpreferred(r.name)) { + if (bestNamed == null || + span < (bestNamed.end - bestNamed.start)) { + bestNamed = r; + } + } + } + } + + if (bestNamed != null) { + // Check if there's a narrower child element under + // bestNamed that we can use to discriminate. + if (narrowest != null && + (narrowest.end - narrowest.start) < + (bestNamed.end - bestNamed.start)) { + final bestNamedPrefix = bestNamed.path; + // Try using the child's proper name as qualifier. + if (narrowest.path.length > bestNamedPrefix.length && + narrowest.path.startsWith(bestNamedPrefix)) { + final suffix = + narrowest.path.substring(bestNamedPrefix.length + 1); + if (!Naming.isUnpreferred(suffix)) { + return '${bestNamed.name}_$suffix'; + } + } + // Child has unpreferred name — use positional index. + return '${bestNamed.name}_${narrowest.indexInParent}'; + } + return bestNamed.name; + } + return bestAny?.name ?? + NetlistUtils.resolveReplacement(srcSynthLogic).name; + } + + // Build port_directions and connections. + final portDirs = {}; + final conns = >{}; + + for (var i = 0; i < nonTrivialFields.length; i++) { + final f = nonTrivialFields[i]; + final fieldName = fieldNameFor(f.dstLowerIndex, f.srcSynthLogic); + var portName = fieldName; + if (portDirs.containsKey(portName)) { + portName = '${fieldName}_$i'; + } + portDirs[portName] = 'input'; + conns[portName] = f.resolvedSrcBits; + } + + // Output port Y: full destination bus. + portDirs['Y'] = 'output'; + conns['Y'] = resolvedDstBits; + + // Parameters list field metadata for the schematic viewer. + final params = { + 'STRUCT_NAME': dstLogic?.name ?? 'struct', + 'FIELD_COUNT': nonTrivialFields.length, + }; + for (var i = 0; i < nonTrivialFields.length; i++) { + final f = nonTrivialFields[i]; + params['FIELD_${i}_NAME'] = + fieldNameFor(f.dstLowerIndex, f.srcSynthLogic); + params['FIELD_${i}_OFFSET'] = f.dstLowerIndex; + params['FIELD_${i}_WIDTH'] = f.dstUpperIndex - f.dstLowerIndex + 1; + } + + cells['struct_pack_${spIdx}_$structName'] = { + 'hide_name': 0, + 'type': r'$struct_pack', + 'parameters': params, + 'attributes': {}, + 'port_directions': portDirs, + 'connections': conns, + }; + spIdx++; + } + } + + // -- Passthrough buffer insertion ------------------------------------ + // When a signal passes directly from an input port to an output port, + // they share the same wire IDs after aliasing. This causes the signal + // to appear routed *around* the module in the netlist rather than + // *through* it. Insert a `$buf` cell to break the wire-ID sharing, + // giving the output port fresh IDs driven by the buffer. + { + final inputBitIds = ports.values + .where((p) => p['direction'] == 'input' || p['direction'] == 'inout') + .expand((p) => p['bits']! as List) + .whereType() + .toSet(); + + // Check each output port for overlap with input bits. + var bufIdx = 0; + for (final p + in ports.entries.where((p) => p.value['direction'] == 'output')) { + final outBits = (p.value['bits']! as List).cast(); + if (!outBits.any((b) => b is int && inputBitIds.contains(b))) { + continue; + } + + // Allocate fresh wire IDs for the output side of the buffer. + final freshBits = + List.generate(outBits.length, (_) => nextId++); + + // Insert a $buf cell: input = original (shared) IDs, + // output = fresh IDs. + cells['passthrough_buf_$bufIdx'] = + NetlistUtils.makeBufCell(outBits.length, outBits, freshBits); + + // Update the output port to use the fresh IDs. + p.value['bits'] = freshBits; + bufIdx++; + } + } + + // -- Dead-cell elimination (DCE) ------------------------------------- + // After aliasing and elision, some cells may have inputs whose wire + // IDs are not driven by any cell output or module input port. This + // typically happens when a LogicStructure's `packed` representation + // creates a Swizzle chain whose inputs reference sub-module-internal + // signals that are not accessible from the synthesised module's + // scope. Iteratively remove such dead cells using both forward + // (all-inputs-undriven) and backward (all-outputs-unconsumed) DCE. + if (options.enableDCE) { + var dceChanged = true; + while (dceChanged) { + dceChanged = false; + + // Build set of driven wire IDs (from input/inout ports and cell + // outputs). + final drivenIds = { + ...ports.values + .where( + (p) => p['direction'] == 'input' || p['direction'] == 'inout') + .expand((p) => p['bits']! as List) + .whereType(), + ...cells.values.expand((c) { + final cell = c as Map; + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + return conns.entries + .where((pe) => pdirs[pe.key] == 'output') + .expand((pe) => pe.value as List) + .whereType(); + }), + }; + + // Build set of consumed wire IDs (from output/inout ports and + // cell inputs). + final consumedIds = { + ...ports.values + .where((p) => + p['direction'] == 'output' || p['direction'] == 'inout') + .expand((p) => p['bits']! as List) + .whereType(), + ...cells.values.expand((c) { + final cell = c as Map; + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + return conns.entries + .where((pe) => pdirs[pe.key] == 'input') + .expand((pe) => pe.value as List) + .whereType(); + }), + }; + + // Forward DCE: remove cells whose inputs are ALL undriven. + cells + ..removeWhere((cellKey, cellVal) { + final cell = cellVal as Map; + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + final inputPorts = + conns.entries.where((pe) => pdirs[pe.key] == 'input'); + if (inputPorts.isEmpty) { + return false; + } + final allUndriven = !inputPorts + .expand((pe) => pe.value as List) + .any((b) => (b is int && drivenIds.contains(b)) || b is String); + if (allUndriven) { + dceChanged = true; + return true; + } + return false; + }) + + // Backward DCE: remove cells whose outputs are ALL unconsumed. + // Preserve non-leaf cells (user module instances) — their type + // does not start with '$' (Yosys primitive convention). Users + // expect to see all instantiated modules in the schematic even + // when outputs are unconnected. + ..removeWhere((cellKey, cellVal) { + final cell = cellVal as Map; + final cellType = cell['type'] as String? ?? ''; + if (!cellType.startsWith(r'$')) { + return false; + } + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + final outputPorts = + conns.entries.where((pe) => pdirs[pe.key] == 'output'); + if (outputPorts.isEmpty) { + return false; + } + final allUnconsumed = !outputPorts + .expand((pe) => pe.value as List) + .whereType() + .any(consumedIds.contains); + if (allUnconsumed) { + dceChanged = true; + return true; + } + return false; + }); + } + } + + // -- Constant driver cells ------------------------------------------- + // Generated AFTER the aliasing pass so that constants discovered + // during aliasing (via getIds(assignment.src)) are included. + // Constant IDs may have been redirected by step 3 (struct/array + // child→parent aliasing), so apply alias resolution to their + // connection bits. + { + var constIdx = 0; + final emittedConstWires = {}; + for (final entry in synthLogicIds.entries + .where((e) => e.key.isConstant) + .where((e) => !blockedConstSynthLogics.contains(e.key)) + .where((e) => e.value.isNotEmpty)) { + final sl = entry.key; + final constValue = NetlistUtils.constValueFromSynthLogic(sl); + if (constValue == null) { + continue; + } + final ids = entry.value; + + // Resolve aliases and skip if these wires are already driven + // by a previously emitted $const cell (can happen when aliasing + // merges two SynthLogic constants onto the same wire IDs). + final resolvedIds = applyAlias(ids.cast()); + final firstWire = + resolvedIds.firstWhere((b) => b is int, orElse: () => -1); + if (firstWire is int && firstWire >= 0) { + if (emittedConstWires.contains(firstWire)) { + continue; + } + emittedConstWires.addAll(resolvedIds.whereType()); + } + + final valuePart = NetlistUtils.constValuePart(constValue); + final cellName = 'const_${constIdx}_$valuePart'; + final valueLiteral = valuePart.replaceFirst('_', "'"); + + cells[cellName] = { + 'hide_name': 0, + 'type': r'$const', + 'parameters': {}, + 'attributes': {}, + 'port_directions': {valueLiteral: 'output'}, + 'connections': >{ + valueLiteral: resolvedIds, + }, + }; + constIdx++; + } + } + + // -- Remove floating $const cells ------------------------------------ + // The $const cells were emitted after the main DCE pass, so they + // may reference wire IDs that no cell input or output port consumes. + if (options.enableDCE) { + final consumedByInputs = { + ...ports.values + .where( + (p) => p['direction'] == 'output' || p['direction'] == 'inout') + .expand((p) => p['bits']! as List) + .whereType(), + ...cells.values.expand((c) { + final cell = c as Map; + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + return conns.entries + .where((pe) => pdirs[pe.key] == 'input') + .expand((pe) => pe.value as List) + .whereType(); + }), + }; + + cells.removeWhere((cellKey, cellVal) { + final cell = cellVal as Map; + if (cell['type'] != r'$const') { + return false; + } + final conns = cell['connections']! as Map; + final pdirs = cell['port_directions']! as Map; + return !conns.entries + .where((pe) => pdirs[pe.key] == 'output') + .expand((pe) => pe.value as List) + .whereType() + .any(consumedByInputs.contains); + }); + } + + // -- Break shared wire IDs for array_concat cells -------------------- + // After aliasing, the concat inputs share the same wire IDs as the + // concat Y output (because LogicArray elements share the parent's + // bit storage). This makes the concat transparent -- constants + // appear to drive the parent array directly. + // + // To fix: allocate fresh wire IDs for each concat input port, + // then redirect all other cells whose outputs used those old IDs + // to drive the fresh IDs instead. The concat Y output keeps the + // original parent-array IDs, so the data flow becomes: + // const → fresh_IDs → concat input → concat Y (= parent IDs) + final arrayConcatOldToNew = {}; + + for (final cellEntry in cells.entries) { + if (!cellEntry.key.startsWith('array_concat')) { + continue; + } + final cell = cellEntry.value as Map; + final conns = cell['connections'] as Map; + final dirs = cell['port_directions'] as Map; + + for (final portEntry in conns.entries.toList()) { + if (dirs[portEntry.key] != 'input') { + continue; + } + final oldBits = (portEntry.value as List).cast(); + conns[portEntry.key] = [ + for (final b in oldBits) + b is int ? arrayConcatOldToNew.putIfAbsent(b, () => nextId++) : b, + ]; + } + } + + // Redirect other cells: any output port bit that matches an old ID + // gets replaced with the corresponding fresh ID. + if (arrayConcatOldToNew.isNotEmpty) { + for (final cellEntry in cells.entries) { + if (cellEntry.key.startsWith('array_concat')) { + continue; // skip the concat cells themselves + } + final cell = cellEntry.value as Map; + final conns = cell['connections'] as Map; + final dirs = cell['port_directions'] as Map; + + for (final portEntry in conns.entries.toList()) { + if (dirs[portEntry.key] != 'output') { + continue; + } + final bits = (portEntry.value as List).cast(); + final newBits = [ + for (final b in bits) b is int ? (arrayConcatOldToNew[b] ?? b) : b, + ]; + if (bits.indexed.any((e) => e.$2 != newBits[e.$1])) { + conns[portEntry.key] = newBits; + } + } + } + } + + // -- Netnames -------------------------------------------------------- + final netnames = {}; + final emittedNames = {}; + + // InlineSystemVerilog modules are pure combinational — all their + // signals are derivable from the gate netlist. + final isInlineSV = module is InlineSystemVerilog; + + void addNetname(String name, List bits, + {bool hideName = false, bool computed = false}) { + if (emittedNames.contains(name)) { + return; + } + emittedNames.add(name); + netnames[name] = { + 'bits': bits, + if (hideName) 'hide_name': 1, + 'attributes': { + if (computed || isInlineSV) 'computed': 1, + }, + }; + } + + // Port nets (already aliased above). + for (final p in ports.entries) { + addNetname(Sanitizer.sanitizeSV(p.key), + (p.value['bits']! as List).cast()); + } + + // Named signals from SynthModuleDefinition. + if (synthDef != null) { + for (final entry in synthLogicIds.entries + .where((e) => !e.key.isConstant && !e.key.declarationCleared)) { + final sl = entry.key; + final name = NetlistUtils.tryGetSynthLogicName(sl); + if (name != null) { + var bits = applyAlias(entry.value.cast()); + // For element signals whose IDs were remapped by the + // array_concat fresh-ID pass, apply that mapping so the + // element netname matches the concat input (fresh) IDs. + if (arrayConcatOldToNew.isNotEmpty && sl is SynthLogicArrayElement) { + bits = bits + .map((b) => b is int ? (arrayConcatOldToNew[b] ?? b) : b) + .toList(); + } + addNetname(Sanitizer.sanitizeSV(name), bits); + } + } + } + + // Constant netnames for non-blocked constants (already aliased via + // cell connections above). + for (final cellEntry + in cells.entries.where((e) => e.value['type'] == r'$const')) { + final conns = + cellEntry.value['connections'] as Map>?; + if (conns != null && conns.isNotEmpty) { + addNetname(cellEntry.key, conns.values.first, computed: true); + } + } + + // -- Ensure every bit ID in cell connections has a netname ------------ + { + final coveredIds = netnames.values + .expand( + (nn) => ((nn! as Map)['bits'] as List?) ?? []) + .whereType() + .toSet(); + + for (final cellEntry in cells.entries) { + final cellName = cellEntry.key; + final conns = + cellEntry.value['connections'] as Map? ?? {}; + for (final connEntry in conns.entries) { + final portName = connEntry.key; + final bits = connEntry.value as List; + final missingBits = []; + for (final b in bits) { + if (b is int && !coveredIds.contains(b)) { + missingBits.add(b); + coveredIds.add(b); + } + } + if (missingBits.isNotEmpty) { + addNetname( + Sanitizer.sanitizeSV('${cellName}_$portName'), missingBits, + hideName: true); + } + } + } + } + + // -- Slim: strip cell connections ------------------------------------ + // The full pipeline ran identically, so the cell set (keys, ordering) + // is canonical. Now drop the connection maps to reduce the output + // size. This is the ONLY difference between slim and full output. + if (options.slimMode) { + for (final cell in cells.values) { + cell.remove('connections'); + } + } + + return NetlistSynthesisResult( + module, + getInstanceTypeOfModule, + ports: ports, + cells: cells, + netnames: netnames, + attributes: attr, + ); + } + + /// Apply all post-processing passes to the modules map. + /// + /// This is the canonical pass ordering used by both netlist flows: + /// **Flow 1** (slim batch via `_synthesizeSlimModules`) and + /// **Flow 2** (incremental full via `moduleNetlistJson`). + /// Also used internally by [buildModulesMap] / [synthesizeToJson]. + void applyPostProcessingPasses( + Map> modules, + ) { + if (options.groupStructConversions) { + if (options.groupMaximalSubsets) { + NetlistPasses.applyMaximalSubsetGrouping(modules); + } + if (options.collapseConcats) { + NetlistPasses.applyCollapseConcats(modules); + } + if (options.collapseSelectsIntoPack) { + NetlistPasses.applyCollapseSelectsIntoPack(modules); + } + if (options.collapseUnpackToConcat) { + NetlistPasses.applyCollapseUnpackToConcat(modules); + } + if (options.collapseUnpackToPack) { + NetlistPasses.applyCollapseUnpackToPack(modules); + } + NetlistPasses.applyStructConversionGrouping(modules); + if (options.collapseStructGroups) { + NetlistPasses.collapseStructGroupModules(modules); + } + NetlistPasses.applyStructBufferInsertion(modules); + NetlistPasses.applyConcatToBufferReplacement(modules); + } + } + + /// Build the processed modules map from a [SynthBuilder]'s results. + /// + /// Returns the intermediate module map (definition name → module data) + /// after all post-processing passes have been applied. This allows + /// callers to retain per-module results for incremental serving while + /// avoiding redundant re-synthesis. + Future>> buildModulesMap( + SynthBuilder synth, Module top) async { + final swEntries = Stopwatch()..start(); + final modules = NetlistPasses.collectModuleEntries(synth.synthesisResults, + topModule: top); + swEntries.stop(); + + final swPasses = Stopwatch()..start(); + applyPostProcessingPasses(modules); + swPasses.stop(); + + return modules; + } + + /// Generate the combined netlist JSON from a [SynthBuilder]'s results. + Future generateCombinedJson(SynthBuilder synth, Module top) async { + final swCollect = Stopwatch()..start(); + final modules = await buildModulesMap(synth, top); + swCollect.stop(); + + final swCompress = Stopwatch()..start(); + if (options.compressBitRanges) { + _compressModulesMap(modules); + } + swCompress.stop(); + + final combined = { + 'creator': 'NetlistSynthesizer (rohd)', + 'modules': modules, + }; + + final swEncode = Stopwatch()..start(); + final encoder = options.compactJson + ? const JsonEncoder() + : const JsonEncoder.withIndent(' '); + final result = encoder.convert(combined); + swEncode.stop(); + + return result; + } + + /// Compresses a list of bit IDs by replacing contiguous ascending runs of + /// 3 or more integers with `"start:end"` range strings. + static List _compressBits(List bits) { + final result = []; + final pending = []; + + void flushPending() { + if (pending.isEmpty) { + return; + } + var i = 0; + while (i < pending.length) { + var j = i; + while (j + 1 < pending.length && pending[j + 1] == pending[j] + 1) { + j++; + } + final runLen = j - i + 1; + if (runLen >= 3) { + result.add('${pending[i]}:${pending[j]}'); + } else { + for (var k = i; k <= j; k++) { + result.add(pending[k]); + } + } + i = j + 1; + } + pending.clear(); + } + + for (final element in bits) { + if (element is int) { + pending.add(element); + } else { + flushPending(); + result.add(element); + } + } + flushPending(); + return result; + } + + /// Applies [_compressBits] to all `bits` arrays and cell `connections` + /// arrays in a modules map. + static void _compressModulesMap( + Map> modules, + ) { + for (final moduleDef in modules.values) { + final ports = moduleDef['ports'] as Map>?; + if (ports != null) { + for (final port in ports.values) { + final bits = port['bits']; + if (bits is List) { + port['bits'] = _compressBits(bits.cast()); + } + } + } + + final cells = moduleDef['cells'] as Map>?; + if (cells != null) { + for (final cell in cells.values) { + final conns = cell['connections'] as Map>?; + if (conns != null) { + for (final key in conns.keys.toList()) { + conns[key] = _compressBits(conns[key]!); + } + } + } + } + + final netnames = moduleDef['netnames'] as Map?; + if (netnames != null) { + for (final entry in netnames.values) { + if (entry is Map) { + final bits = entry['bits']; + if (bits is List) { + entry['bits'] = _compressBits(bits.cast()); + } + } + } + } + } + } + + /// Convenience: synthesize [top] into a combined netlist JSON string. + /// + /// Builds a [SynthBuilder] internally and returns the full JSON. + Future synthesizeToJson(Module top) async { + final sb = SynthBuilder(top, this); + return generateCombinedJson(sb, top); + } +} + +/// A version of [BusSubset] that creates explicit `$slice` cells for +/// [LogicArray] element extraction in the netlist. +/// +/// When a [LogicArray] port is decomposed into its elements, each element +/// gets its own [_BusSubsetForArraySlice] so the netlist shows explicit +/// select gates rather than flat bit aliasing. +class _BusSubsetForArraySlice extends BusSubset { + _BusSubsetForArraySlice( + super.bus, + super.startIndex, + super.endIndex, + ) : super(name: 'array_slice'); + + @override + bool get hasBuilt => true; +} + +/// A version of [Swizzle] that creates explicit `$concat` cells for +/// [LogicArray] element assembly in the netlist. +/// +/// When a [LogicArray]'s elements are driven independently (e.g. by +/// constants), this creates a visible concat gate in the netlist that +/// assembles the element signals into the full packed array bus. +class _SwizzleForArrayConcat extends Swizzle { + _SwizzleForArrayConcat(super.signals) : super(name: 'array_concat'); + + @override + bool get hasBuilt => true; +} diff --git a/lib/src/synthesizers/netlist/netlist_utils.dart b/lib/src/synthesizers/netlist/netlist_utils.dart new file mode 100644 index 000000000..258fe56a3 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_utils.dart @@ -0,0 +1,531 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_utils.dart +// Shared utility functions for netlist synthesis and post-processing passes. +// +// 2026 February 11 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/synthesizers/utilities/utilities.dart'; + +/// Shared utility functions for netlist synthesis and post-processing passes. +/// +/// All methods are static — no instances are created. +class NetlistUtils { + NetlistUtils._(); + + /// Find the port name in [portMap] that corresponds to [sl]. + static String? portNameForSynthLogic( + SynthLogic sl, Map portMap) { + for (final e in portMap.entries) { + if (sl.logics.contains(e.value)) { + return e.key; + } + } + return null; + } + + /// Safely retrieve the name from a [SynthLogic], returning null if + /// retrieval fails (e.g. name not yet picked, or the SynthLogic has + /// been replaced). + static String? tryGetSynthLogicName(SynthLogic sl) { + try { + return sl.name; + // ignore: avoid_catches_without_on_clauses + } catch (_) { + return null; + } + } + + /// Resolves [sl] to the end of its replacement chain. + static SynthLogic resolveReplacement(SynthLogic sl) { + var r = sl; + while (r.replacement != null) { + r = r.replacement!; + } + return r; + } + + /// Anchored regex for range-named concat port labels like `[7:0]` or `[3]`. + static final rangePortRe = RegExp(r'^\[(\d+)(?::(\d+))?\]$'); + + /// Create a `$buf` cell map. + static Map makeBufCell( + int width, + List aBits, + List yBits, + ) => + { + 'hide_name': 0, + 'type': r'$buf', + 'parameters': {'WIDTH': width}, + 'attributes': {}, + 'port_directions': {'A': 'input', 'Y': 'output'}, + 'connections': >{'A': aBits, 'Y': yBits}, + }; + + /// Create a `$slice` cell map. + static Map makeSliceCell( + int offset, + int aWidth, + int yWidth, + List aBits, + List yBits, + ) => + { + 'hide_name': 0, + 'type': r'$slice', + 'parameters': { + 'OFFSET': offset, + 'A_WIDTH': aWidth, + 'Y_WIDTH': yWidth, + }, + 'attributes': {}, + 'port_directions': {'A': 'input', 'Y': 'output'}, + 'connections': >{'A': aBits, 'Y': yBits}, + }; + + /// Build wire-driver, wire-consumer, and bit-to-net maps for a module. + /// + /// Scans every cell's connections to find which cell drives each wire bit + /// (output direction) and which cells consume it (input direction). + /// Module output-port bits are registered as pseudo-consumers (`__port__`) + /// so that cells feeding module ports are never accidentally removed. + static ({ + Map wireDriverCell, + Map> wireConsumerCells, + Map)> bitToNetInfo, + }) buildWireMaps( + Map> cells, + Map moduleDef, + ) { + final wireDriverCell = {}; + final wireConsumerCells = >{}; + for (final entry in cells.entries) { + final cell = entry.value; + final conns = cell['connections'] as Map? ?? {}; + final pdirs = cell['port_directions'] as Map? ?? {}; + for (final pe in conns.entries) { + final d = pdirs[pe.key] as String? ?? ''; + for (final b in pe.value as List) { + if (b is int) { + if (d == 'output') { + wireDriverCell[b] = entry.key; + } else if (d == 'input') { + (wireConsumerCells[b] ??= {}).add(entry.key); + } + } + } + } + } + + final modPorts = moduleDef['ports'] as Map>?; + if (modPorts != null) { + for (final port in modPorts.values) { + if ((port['direction'] as String?) == 'output') { + for (final b in port['bits'] as List? ?? []) { + if (b is int) { + (wireConsumerCells[b] ??= {}).add('__port__'); + } + } + } + } + } + + final netnames = moduleDef['netnames'] as Map? ?? {}; + final bitToNetInfo = )>{}; + for (final nnEntry in netnames.entries) { + final nd = nnEntry.value! as Map; + final bits = (nd['bits'] as List?)?.cast() ?? []; + for (final b in bits) { + bitToNetInfo[b] = (nnEntry.key, bits); + } + } + + return ( + wireDriverCell: wireDriverCell, + wireConsumerCells: wireConsumerCells, + bitToNetInfo: bitToNetInfo, + ); + } + + /// Trace a single wire bit backward through `$buf`/`$slice` cells, + /// returning the ultimate source bit and the set of intermediate cell + /// names visited along the chain. + static (int sourceBit, Set intermediates) traceBackward( + int startBit, + Map wireDriverCell, + Map> cells, + ) { + var current = startBit; + final chain = {}; + while (true) { + final driverName = wireDriverCell[current]; + if (driverName == null) { + break; + } + final driverCell = cells[driverName]; + if (driverCell == null) { + break; + } + final dt = driverCell['type'] as String?; + if (dt != r'$buf' && dt != r'$slice') { + break; + } + if (chain.contains(driverName)) { + break; // Cycle detected — stop tracing. + } + chain.add(driverName); + final dc = driverCell['connections'] as Map? ?? {}; + if (dt == r'$buf') { + final yBits = dc['Y'] as List; + final aBits = dc['A'] as List; + final idx = yBits.indexOf(current); + if (idx < 0 || idx >= aBits.length || aBits[idx] is! int) { + break; + } + current = aBits[idx] as int; + } else { + final yBits = dc['Y'] as List; + final aBits = dc['A'] as List; + final dp = driverCell['parameters'] as Map? ?? {}; + final offset = dp['OFFSET'] as int? ?? 0; + final idx = yBits.indexOf(current); + if (idx < 0) { + break; + } + final srcIdx = offset + idx; + if (srcIdx < 0 || srcIdx >= aBits.length || aBits[srcIdx] is! int) { + break; + } + current = aBits[srcIdx] as int; + } + } + return (current, chain); + } + + /// Whether every intermediate cell in [intermediates] exclusively feeds + /// [ownerCell] or other cells in [intermediates]. + /// + /// When [allowPortConsumers] is true, `'__port__'` pseudo-consumers are + /// also accepted (used when module-output ports registered as consumers). + static bool isExclusiveChain({ + required Set intermediates, + required String ownerCell, + required Map> cells, + required Map> wireConsumerCells, + bool allowPortConsumers = false, + }) { + for (final ic in intermediates) { + final icCell = cells[ic]; + if (icCell == null) { + return false; + } + final icConns = icCell['connections'] as Map? ?? {}; + final icDirs = icCell['port_directions'] as Map? ?? {}; + for (final pe in icConns.entries) { + if ((icDirs[pe.key] as String?) != 'output') { + continue; + } + for (final b in pe.value as List) { + if (b is! int) { + continue; + } + final consumers = wireConsumerCells[b]; + if (consumers == null) { + continue; + } + for (final cn in consumers) { + if (cn != ownerCell && !intermediates.contains(cn)) { + if (allowPortConsumers && cn == '__port__') { + continue; + } + return false; + } + } + } + } + } + return true; + } + + /// Collapses bit-slice ports of a Combinational/Sequential cell into + /// aggregate ports. + /// + /// **Input side**: When a Combinational references individual struct fields, + /// each field creates a BusSubset in the parent scope, and each slice + /// becomes a separate input port. This method detects groups of input + /// ports whose SynthLogics are outputs of BusSubset submodule + /// instantiations that slice the same root signal. For each group + /// forming a contiguous bit range, the N individual ports are replaced + /// with a single aggregate port connected to the corresponding sub-range + /// of the root signal's wire IDs. + /// + /// **Output side**: Similarly, Combinational output ports that feed into + /// the inputs of the same Swizzle submodule are collapsed into a single + /// aggregate port connected to the Swizzle's output wire IDs. + static void collapseAlwaysBlockPorts( + SynthModuleDefinition synthDef, + SynthSubModuleInstantiation instance, + Map portDirs, + Map> connections, + List Function(SynthLogic) getIds, + ) { + // ── Input-side collapsing (BusSubset → Combinational) ────────────── + + // Build reverse lookup: resolved BusSubset output SynthLogic → + // (BusSubset module, resolved root input SynthLogic, + // SynthSubModuleInstantiation). + final busSubsetLookup = + {}; + for (final bsInst in synthDef.subModuleInstantiations) { + if (bsInst.module is! BusSubset) { + continue; + } + final bsMod = bsInst.module as BusSubset; + + // BusSubset has input 'original' and output 'subset' + final outputSL = bsInst.outputMapping.values.firstOrNull; + final inputSL = bsInst.inputMapping.values.firstOrNull; + if (outputSL == null || inputSL == null) { + continue; + } + + final resolvedOutput = resolveReplacement(outputSL); + final resolvedInput = resolveReplacement(inputSL); + + busSubsetLookup[resolvedOutput] = (bsMod, resolvedInput, bsInst); + } + + // Group input ports by root signal, also tracking the BusSubset + // instantiations that produced each port. + final inputGroups = >{}; + + for (final e in instance.inputMapping.entries) { + final portName = e.key; + if (!connections.containsKey(portName)) { + continue; // already filtered + } + + final resolved = resolveReplacement(e.value); + final info = busSubsetLookup[resolved]; + if (info != null) { + final (bsMod, rootSL, bsInst) = info; + final width = bsMod.endIndex - bsMod.startIndex + 1; + inputGroups + .putIfAbsent(rootSL, () => []) + .add((portName, bsMod.startIndex, width, bsInst)); + } + } + + // Collapse each group with > 1 contiguous member. + for (final entry in inputGroups.entries) { + if (entry.value.length <= 1) { + continue; + } + + final rootSL = entry.key; + final ports = entry.value..sort((a, b) => a.$2.compareTo(b.$2)); + + // Verify contiguous non-overlapping coverage. + var expectedBit = ports.first.$2; + var contiguous = true; + for (final (_, startIdx, width, _) in ports) { + if (startIdx != expectedBit) { + contiguous = false; + break; + } + expectedBit += width; + } + if (!contiguous) { + continue; + } + + final minBit = ports.first.$2; + final maxBit = ports.last.$2 + ports.last.$3 - 1; + + // Get the root signal's full wire IDs and extract the sub-range. + final rootIds = getIds(rootSL); + if (maxBit >= rootIds.length) { + continue; // safety check + } + final aggBits = rootIds.sublist(minBit, maxBit + 1).cast(); + + // Choose a name for the aggregate port. + final rootName = tryGetSynthLogicName(rootSL) ?? 'agg_${minBit}_$maxBit'; + + // Replace individual ports with the aggregate. The bypassed + // BusSubset cells are left in place; the post-synthesis DCE pass + // will remove them if their outputs are no longer consumed. + for (final (portName, _, _, _) in ports) { + connections.remove(portName); + portDirs.remove(portName); + } + connections[rootName] = aggBits; + portDirs[rootName] = 'input'; + } + + // ── Output-side collapsing (Combinational → Swizzle) ─────────────── + + // Build reverse lookup: resolved Swizzle input SynthLogic → + // (Swizzle port name, bit offset within the Swizzle output, + // port width, resolved Swizzle output SynthLogic, + // SynthSubModuleInstantiation). + final swizzleLookup = {}; + for (final szInst in synthDef.subModuleInstantiations) { + if (szInst.module is! Swizzle) { + continue; + } + final outputSL = szInst.outputMapping.values.firstOrNull; + if (outputSL == null) { + continue; + } + final resolvedOutput = resolveReplacement(outputSL); + + // Swizzle inputs are in0, in1, ... with bit-0 first. + var offset = 0; + for (final inEntry in szInst.inputMapping.entries) { + final resolvedInput = resolveReplacement(inEntry.value); + final w = resolvedInput.width; + swizzleLookup[resolvedInput] = + (inEntry.key, offset, w, resolvedOutput, szInst); + offset += w; + } + } + + // Group output ports by Swizzle output signal. + final outputGroups = >{}; + + for (final e in instance.outputMapping.entries) { + final portName = e.key; + if (!connections.containsKey(portName)) { + continue; + } + + final resolved = resolveReplacement(e.value); + final info = swizzleLookup[resolved]; + if (info != null) { + final (_, offset, width, swizzleOutputSL, szInst) = info; + outputGroups + .putIfAbsent(swizzleOutputSL, () => []) + .add((portName, offset, width, szInst)); + } + } + + // Collapse each group with > 1 contiguous member. + for (final entry in outputGroups.entries) { + if (entry.value.length <= 1) { + continue; + } + + final swizOutSL = entry.key; + final ports = entry.value..sort((a, b) => a.$2.compareTo(b.$2)); + + // Verify contiguous. + var expectedBit = ports.first.$2; + var contiguous = true; + for (final (_, offset, width, _) in ports) { + if (offset != expectedBit) { + contiguous = false; + break; + } + expectedBit += width; + } + if (!contiguous) { + continue; + } + + final minBit = ports.first.$2; + final maxBit = ports.last.$2 + ports.last.$3 - 1; + + final outIds = getIds(swizOutSL); + if (maxBit >= outIds.length) { + continue; + } + final aggBits = outIds.sublist(minBit, maxBit + 1).cast(); + + final outName = + tryGetSynthLogicName(swizOutSL) ?? 'agg_out_${minBit}_$maxBit'; + + // Replace individual ports with the aggregate. The bypassed + // Swizzle cells are left in place; the post-synthesis DCE pass + // will remove them if their outputs are no longer consumed. + for (final (portName, _, _, _) in ports) { + connections.remove(portName); + portDirs.remove(portName); + } + connections[outName] = aggBits; + portDirs[outName] = 'output'; + } + } + + /// Check if a SynthLogic is a constant (following replacement chain). + static bool isConstantSynthLogic(SynthLogic sl) => + resolveReplacement(sl).isConstant; + + /// Extract the Const value from a constant SynthLogic. + static Const? constValueFromSynthLogic(SynthLogic sl) { + final resolved = resolveReplacement(sl); + for (final logic in resolved.logics) { + if (logic is Const) { + return logic; + } + } + return null; + } + + /// Value portion of a constant name: `_h` or `_b`. + static String constValuePart(Const c) { + final bitChars = []; + var hasXZ = false; + for (var i = c.width - 1; i >= 0; i--) { + final v = c.value[i]; + switch (v) { + case LogicValue.zero: + bitChars.add('0'); + case LogicValue.one: + bitChars.add('1'); + case LogicValue.x: + bitChars.add('x'); + hasXZ = true; + case LogicValue.z: + bitChars.add('z'); + hasXZ = true; + } + } + if (hasXZ) { + return '${c.width}_b${bitChars.join()}'; + } + var value = BigInt.zero; + for (var i = c.width - 1; i >= 0; i--) { + value = value << 1; + if (c.value[i] == LogicValue.one) { + value = value | BigInt.one; + } + } + return '${c.width}_h${value.toRadixString(16)}'; + } +} diff --git a/lib/src/synthesizers/synthesizers.dart b/lib/src/synthesizers/synthesizers.dart index b8c8523ec..da5d76586 100644 --- a/lib/src/synthesizers/synthesizers.dart +++ b/lib/src/synthesizers/synthesizers.dart @@ -1,6 +1,7 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'netlist/netlist.dart'; export 'synth_builder.dart'; export 'synth_file_contents.dart'; export 'synthesis_result.dart'; diff --git a/lib/src/synthesizers/systemverilog/sv_service.dart b/lib/src/synthesizers/systemverilog/sv_service.dart index e1adf43cd..d2d9f359e 100644 --- a/lib/src/synthesizers/systemverilog/sv_service.dart +++ b/lib/src/synthesizers/systemverilog/sv_service.dart @@ -11,6 +11,8 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/config.dart'; +import 'package:rohd/src/utilities/timestamper.dart'; /// A service that wraps SystemVerilog synthesis of a [Module] hierarchy. /// @@ -31,6 +33,12 @@ import 'package:rohd/rohd.dart'; /// print(sv.allContents); /// ``` class SvService { + /// The separator inserted between module definitions in the + /// concatenated single-file output from [allContents]. + /// + /// Matches the format historically produced by `Module.generateSynth()`. + static const moduleSeparator = '\n\n////////////////////\n\n'; + /// The top-level [Module] being synthesized. final Module module; @@ -45,7 +53,11 @@ class SvService { /// [module] must already be built. Set [register] to `true` (the /// default) to register this service with [ModuleServices] for /// DevTools access. - SvService(this.module, {bool register = true}) { + /// + /// If [outputPath] is provided, the concatenated SV output (with + /// header) is written to that file. The parent directory is created + /// if needed. + SvService(this.module, {bool register = true, String? outputPath}) { if (!module.hasBuilt) { throw Exception('Module must be built before creating SvService. ' 'Call build() first.'); @@ -54,6 +66,12 @@ class SvService { synthBuilder = SynthBuilder(module, SystemVerilogSynthesizer()); fileContents = synthBuilder.getSynthFileContents(); + if (outputPath != null) { + final file = File(outputPath); + file.parent.createSync(recursive: true); + file.writeAsStringSync(synthOutput); + } + if (register) { ModuleServices.instance.svService = this; } @@ -62,9 +80,29 @@ class SvService { /// All [SynthesisResult]s produced by synthesis. Set get synthesisResults => synthBuilder.synthesisResults; - /// Returns the concatenated SystemVerilog output as a single string, - /// matching the format of [Module.generateSynth]. - String get allContents => fileContents.map((fc) => fc.contents).join('\n\n'); + // ─── Single-file output ─────────────────────────────────────── + + /// Returns the concatenated SystemVerilog module definitions as a + /// single string, without the generation header. + /// + /// For the full output with header (matching `Module.generateSynth()`), + /// use [synthOutput]. + String get allContents => + fileContents.map((fc) => fc.contents).join(moduleSeparator); + + /// The ROHD generation header prepended to single-file output. + String get synthHeader => ''' +/** + * Generated by ROHD - www.github.com/intel/rohd + * Generation time: ${Timestamper.stamp()} + * ROHD Version: ${Config.version} + */ + +'''; + + /// Returns the full single-file SystemVerilog output with header, + /// identical to `Module.generateSynth()`. + String get synthOutput => synthHeader + allContents; /// Returns a map from module definition name to its SV file contents. /// diff --git a/test/incremental_expansion_test.dart b/test/incremental_expansion_test.dart new file mode 100644 index 000000000..d5bdeb121 --- /dev/null +++ b/test/incremental_expansion_test.dart @@ -0,0 +1,116 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// incremental_expansion_test.dart +// Tests for the incremental expansion protocol: +// - original_signal_count / original_cell_count attributes in slim JSON +// - HierarchyNode.extendSignals / extendChildren + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/example.dart' as ex; +import '../example/filter_bank.dart'; + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + // ─────── original_signal_count / original_cell_count ──────────────── + + group('original_signal_count / original_cell_count', () { + test('Counter slim JSON has counts in attributes', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final counter = ex.Counter(en, reset, clk); + await counter.build(); + final netSvc = await NetlistService.create(counter); + + final slimStr = netSvc.slimJson; + final unified = jsonDecode(slimStr) as Map; + final netlist = unified['netlist'] as Map; + final modules = netlist['modules'] as Map; + + for (final entry in modules.entries) { + final mod = entry.value as Map; + final attrs = mod['attributes'] as Map; + expect( + attrs.containsKey('original_signal_count'), + isTrue, + reason: '${entry.key} missing original_signal_count', + ); + expect( + attrs.containsKey('original_cell_count'), + isTrue, + reason: '${entry.key} missing original_cell_count', + ); + + final sigCount = attrs['original_signal_count'] as int; + final cellCount = attrs['original_cell_count'] as int; + final netnames = mod['netnames'] as Map? ?? {}; + final cells = mod['cells'] as Map? ?? {}; + + // Counts must match the actual number of entries in slim JSON. + expect( + sigCount, + equals(netnames.length), + reason: '${entry.key}: original_signal_count mismatch', + ); + expect( + cellCount, + equals(cells.length), + reason: '${entry.key}: original_cell_count mismatch', + ); + } + }); + + test('FilterBank slim JSON has counts in attributes', () async { + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], 16, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + final filterBank = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: 4, + dataWidth: 16, + coefficients: [List.filled(4, 1), List.filled(4, 1)], + ); + await filterBank.build(); + final netSvc = await NetlistService.create(filterBank); + + final slimStr = netSvc.slimJson; + final unified = jsonDecode(slimStr) as Map; + final netlist = unified['netlist'] as Map; + final modules = netlist['modules'] as Map; + + // At least the root module should have counts. + expect(modules.isNotEmpty, isTrue); + for (final entry in modules.entries) { + final mod = entry.value as Map; + final attrs = mod['attributes'] as Map; + expect( + attrs['original_signal_count'], + isA(), + reason: '${entry.key}: original_signal_count not int', + ); + expect( + attrs['original_cell_count'], + isA(), + reason: '${entry.key}: original_cell_count not int', + ); + } + }); + }); +} diff --git a/test/module_services_test.dart b/test/module_services_test.dart index f9b0ac075..38350dd14 100644 --- a/test/module_services_test.dart +++ b/test/module_services_test.dart @@ -2,133 +2,319 @@ // SPDX-License-Identifier: BSD-3-Clause // // module_services_test.dart -// Unit tests for ModuleServices and SvService. -// -// 2026 April 25 -// Author: Desmond Kirkpatrick +// Tests for ModuleServices, SvService, and NetlistService. + +@TestOn('vm') +library; import 'dart:convert'; -import 'dart:io'; import 'package:rohd/rohd.dart'; import 'package:test/test.dart'; -class SimpleModule extends Module { - SimpleModule(Logic a) : super(name: 'simple') { +// --------------------------------------------------------------------------- +// Simple test modules +// --------------------------------------------------------------------------- + +class _InverterModule extends Module { + Logic get out => output('out'); + + _InverterModule(Logic inp) : super(name: 'inverter') { + inp = addInput('inp', inp); + final out = addOutput('out'); + out <= ~inp; + } +} + +class _TopModule extends Module { + Logic get out => output('out'); + + _TopModule(Logic a, Logic b) : super(name: 'top') { a = addInput('a', a); - addOutput('b') <= ~a; + b = addInput('b', b); + final out = addOutput('out'); + + final inv = _InverterModule(a); + out <= inv.out & b; } } void main() { - tearDown(() { + tearDown(() async { + await Simulator.reset(); ModuleServices.instance.reset(); }); group('ModuleServices', () { - test('rootModule is set after build', () async { - final mod = SimpleModule(Logic()); + test('rootModule is set by Module.build', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); + expect(ModuleServices.instance.rootModule, equals(mod)); }); - test('hierarchyJSON returns valid JSON', () async { - final mod = SimpleModule(Logic()); + test('hierarchyJSON returns valid JSON after build', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); + final json = ModuleServices.instance.hierarchyJSON; - expect(() => jsonDecode(json), returnsNormally); + final decoded = jsonDecode(json) as Map; + expect(decoded['name'], equals('top')); }); - test('inspectorJSON matches hierarchyJSON', () async { - final mod = SimpleModule(Logic()); - await mod.build(); - expect(ModuleServices.instance.inspectorJSON, - equals(ModuleServices.instance.hierarchyJSON)); + test('svJSON returns unavailable when no SvService registered', () { + final json = ModuleServices.instance.svJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['status'], equals('unavailable')); }); - test('svJSON returns unavailable when no service registered', () async { - final mod = SimpleModule(Logic()); - await mod.build(); - final result = - jsonDecode(ModuleServices.instance.svJSON) as Map; - expect(result['status'], equals('unavailable')); - }); + test( + 'netlistJSON returns unavailable when no NetlistService registered', + () { + final json = ModuleServices.instance.netlistJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['status'], equals('unavailable')); + }, + ); test('reset clears all services', () async { - final mod = SimpleModule(Logic()); + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); + expect(ModuleServices.instance.rootModule, isNotNull); ModuleServices.instance.reset(); expect(ModuleServices.instance.rootModule, isNull); - expect(ModuleServices.instance.svService, isNull); + }); + + test('inspectorJSON returns slim netlist when service registered', + () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + await NetlistService.create(mod); + + final json = ModuleServices.instance.inspectorJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['netlist'], isA>()); + final netlist = decoded['netlist'] as Map; + expect(netlist['modules'], isA>()); + }); + + test('inspectorJSON falls back to hierarchy when no netlist', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + + final json = ModuleServices.instance.inspectorJSON; + final decoded = jsonDecode(json) as Map; + // Falls back to hierarchy JSON format. + expect(decoded['name'], equals('top')); + }); + + test('inspectorModuleJSON returns per-module netlist', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + final netSvc = await NetlistService.create(mod); + + for (final name in netSvc.moduleNames) { + final json = ModuleServices.instance.inspectorModuleJSON(name); + final decoded = jsonDecode(json) as Map; + expect(decoded['modules'], isA>()); + } }); }); group('SvService', () { - test('registers with ModuleServices on creation', () async { - final mod = SimpleModule(Logic()); + test('generates SV for a module hierarchy', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); + final sv = SvService(mod); - expect(ModuleServices.instance.svService, equals(sv)); + + expect(sv.fileContents, isNotEmpty); + expect(sv.allContents, contains('module')); + expect(sv.allContents, contains('endmodule')); }); - test('allContents is non-empty', () async { - final mod = SimpleModule(Logic()); + test('registers with ModuleServices by default', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - final sv = SvService(mod); - expect(sv.allContents, isNotEmpty); + + SvService(mod); + + expect(ModuleServices.instance.svService, isNotNull); + final json = ModuleServices.instance.svJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['modules'], isA>()); }); - test('contentsByName has entries', () async { - final mod = SimpleModule(Logic()); + test('register: false does not register', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - final sv = SvService(mod); - expect(sv.contentsByName, isNotEmpty); + + SvService(mod, register: false); + + expect(ModuleServices.instance.svService, isNull); }); - test('contentsByDefinitionName has entries', () async { - final mod = SimpleModule(Logic()); + test('contentsByName returns per-module SV', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - final sv = SvService(mod); - expect(sv.contentsByDefinitionName, isNotEmpty); - expect(sv.contentsByDefinitionName.containsKey('SimpleModule'), isTrue); + + final sv = SvService(mod, register: false); + final byName = sv.contentsByName; + + // Should have at least the top module and the inverter. + expect(byName.length, greaterThanOrEqualTo(2)); + for (final content in byName.values) { + expect(content, contains('module')); + } }); - test('svJSON returns valid JSON after registration', () async { - final mod = SimpleModule(Logic()); + test('synthOutput includes header', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - SvService(mod); - final result = - jsonDecode(ModuleServices.instance.svJSON) as Map; - expect(result['modules'], isList); + + final sv = SvService(mod, register: false); + expect(sv.synthOutput, contains('Generated by ROHD')); + expect(sv.synthOutput, contains(sv.allContents)); }); + }); - test('writeFiles creates SV files', () async { - final mod = SimpleModule(Logic()); + group('NetlistService', () { + test('generates netlist JSON for a module hierarchy', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - final sv = SvService(mod); - final dir = Directory.systemTemp.createTempSync('sv_test_'); - try { - sv.writeFiles(dir.path); - final files = dir.listSync().whereType().toList(); - expect(files, isNotEmpty); - expect(files.any((f) => f.path.endsWith('.sv')), isTrue); - } finally { - dir.deleteSync(recursive: true); + + final netlist = await NetlistService.create(mod); + final json = netlist.toJson(); + final decoded = jsonDecode(json) as Map; + + expect(decoded['modules'], isA>()); + expect(netlist.moduleNames, isNotEmpty); + }); + + test('registers with ModuleServices by default', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + + await NetlistService.create(mod); + + expect(ModuleServices.instance.netlistService, isNotNull); + final json = ModuleServices.instance.netlistJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['modules'], isA>()); + }); + + test('register: false does not register', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + + await NetlistService.create(mod, register: false); + + expect(ModuleServices.instance.netlistService, isNull); + }); + + test('moduleJson returns single module data', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + + final netlist = await NetlistService.create(mod, register: false); + + // Query for a module that exists. + for (final name in netlist.moduleNames) { + final moduleJson = netlist.moduleJson(name); + final decoded = jsonDecode(moduleJson) as Map; + expect(decoded['modules'], isA>()); + expect((decoded['modules'] as Map).containsKey(name), isTrue); } + + // Query for a module that doesn't exist. + final missing = netlist.moduleJson('nonexistent'); + final decoded = jsonDecode(missing) as Map; + expect(decoded['status'], equals('not_found')); }); - test('register false does not register', () async { - final mod = SimpleModule(Logic()); + test('slimJson returns netlist envelope without connections', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); await mod.build(); - ModuleServices.instance.reset(); - SvService(mod, register: false); - expect(ModuleServices.instance.svService, isNull); + + final netlist = await NetlistService.create(mod, register: false); + final slim = netlist.slimJson; + final decoded = jsonDecode(slim) as Map; + + expect(decoded['netlist'], isA>()); + final netlistSection = decoded['netlist'] as Map; + expect(netlistSection['rootInstanceName'], isNotNull); + expect(netlistSection['modules'], isA>()); + + // Verify cells have no connections + final modules = netlistSection['modules'] as Map; + for (final modEntry in modules.values) { + final cells = + (modEntry as Map)['cells'] as Map; + for (final cellEntry in cells.values) { + final cell = cellEntry as Map; + expect(cell.containsKey('connections'), isFalse, + reason: 'Slim cells should not have connections'); + } + } }); - test('throws if module not built', () { - final mod = SimpleModule(Logic()); - expect(() => SvService(mod), throwsException); + test('synthesizedModules provides read-only access', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(); + + final netlist = await NetlistService.create(mod, register: false); + final modules = netlist.synthesizedModules; + expect(modules, isNotEmpty); + expect(modules.keys, equals(netlist.moduleNames)); + }); + }); + + group('Module.build netlistOptions integration', () { + test('netlistOptions creates and registers NetlistService', () async { + final a = Logic(name: 'a'); + final b = Logic(name: 'b'); + final mod = _TopModule(a, b); + await mod.build(netlistOptions: const NetlistOptions()); + + expect(ModuleServices.instance.netlistService, isNotNull); + final json = ModuleServices.instance.netlistJSON; + final decoded = jsonDecode(json) as Map; + expect(decoded['modules'], isA>()); }); }); } diff --git a/test/netlist_example_test.dart b/test/netlist_example_test.dart new file mode 100644 index 000000000..e5d7b7cbe --- /dev/null +++ b/test/netlist_example_test.dart @@ -0,0 +1,285 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_example_test.dart +// Convert examples to netlist JSON and check the produced output. + +// 2026 March 31 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; +import 'dart:io'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/example.dart'; +import '../example/fir_filter.dart'; +import '../example/logic_array.dart'; +import '../example/oven_fsm.dart'; +import '../example/tree.dart'; + +void main() { + // Detect whether running in JS (dart2js) environment. In JS many + // `dart:io` APIs are unsupported; when running tests with + // `--platform node` we skip filesystem and loader assertions. + const isJS = identical(0, 0.0); + + // Helper used by the tests to synthesize `top` and optionally write the + // produced JSON to `outPath` when running on VM. Returns the decoded + // modules map from the Yosys-format JSON. + Future> convertTestWriteNetlist( + Module top, + String outPath, + ) async { + final synth = SynthBuilder(top, NetlistSynthesizer()); + final jsonStr = + await (synth.synthesizer as NetlistSynthesizer).synthesizeToJson(top); + if (!isJS) { + final file = File(outPath); + await file.create(recursive: true); + await file.writeAsString(jsonStr); + } + final decoded = jsonDecode(jsonStr) as Map; + return decoded['modules'] as Map; + } + + test('Netlist dump for example Counter', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + + final counter = Counter(en, reset, clk); + await counter.build(); + counter.generateSynth(); + + final modules = await convertTestWriteNetlist( + counter, + 'build/Counter.rohd.json', + ); + + expect( + modules, + isNotEmpty, + reason: 'Counter netlist should have module definitions', + ); + // The top module should have cells (sub-module instances or gates) + final topMod = modules[counter.definitionName] as Map; + final cells = topMod['cells'] as Map? ?? {}; + expect(cells, isNotEmpty, reason: 'Counter should have cells'); + }); + + group('SynthBuilder netlist generation for examples', () { + test('SynthBuilder netlist for Counter', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + + final counter = Counter(en, reset, clk); + await counter.build(); + + final modules = await convertTestWriteNetlist( + counter, + 'build/Counter.synth.rohd.json', + ); + expect( + modules, + isNotEmpty, + reason: 'Counter synth netlist should have modules', + ); + }); + + test('SynthBuilder netlist for FIR filter example', () async { + final en = Logic(name: 'en'); + final resetB = Logic(name: 'resetB'); + final clk = SimpleClockGenerator(10).clk; + final inputVal = Logic(name: 'inputVal', width: 8); + + final fir = + FirFilter(en, resetB, clk, inputVal, [0, 0, 0, 1], bitWidth: 8); + await fir.build(); + + final synth = SynthBuilder(fir, NetlistSynthesizer()); + expect(synth.synthesisResults.isNotEmpty, isTrue); + + final modules = await convertTestWriteNetlist( + fir, + 'build/FirFilter.synth.rohd.json', + ); + expect( + modules, + isNotEmpty, + reason: 'FirFilter synth netlist should have modules', + ); + }); + + test('SynthBuilder netlist for LogicArray example', () async { + final arrayA = LogicArray([4], 8, name: 'arrayA'); + final id = Logic(name: 'id', width: 3); + final selectIndexValue = Logic(name: 'selectIndexValue', width: 8); + final selectFromValue = Logic(name: 'selectFromValue', width: 8); + + final la = LogicArrayExample( + arrayA, + id, + selectIndexValue, + selectFromValue, + ); + await la.build(); + + final synth = SynthBuilder(la, NetlistSynthesizer()); + expect(synth.synthesisResults.isNotEmpty, isTrue); + + final modules = await convertTestWriteNetlist( + la, + 'build/LogicArrayExample.synth.rohd.json', + ); + expect( + modules, + isNotEmpty, + reason: 'LogicArrayExample synth netlist should have modules', + ); + }); + + test('SynthBuilder netlist for OvenModule example', () async { + final button = Logic(name: 'button', width: 2); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + + final oven = OvenModule(button, reset, clk); + await oven.build(); + + final synth = SynthBuilder(oven, NetlistSynthesizer()); + expect(synth.synthesisResults.isNotEmpty, isTrue); + + final modules = await convertTestWriteNetlist( + oven, + 'build/OvenModule.synth.rohd.json', + ); + expect( + modules, + isNotEmpty, + reason: 'OvenModule synth netlist should have modules', + ); + }); + + test('SynthBuilder netlist for TreeOfTwoInputModules example', () async { + final seq = List.generate(4, (_) => Logic(width: 8)); + final tree = TreeOfTwoInputModules(seq, (a, b) => mux(a > b, a, b)); + await tree.build(); + + final synth = SynthBuilder(tree, NetlistSynthesizer()); + expect(synth.synthesisResults.isNotEmpty, isTrue); + + // Only verify JSON generation succeeds; the deeply nested hierarchy + // causes a stack overflow in any recursive parser (pure Dart or JS). + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(tree); + expect( + json, + isNotEmpty, + reason: 'TreeOfTwoInputModules should produce non-empty JSON', + ); + if (!isJS) { + final file = File('build/TreeOfTwoInputModules.synth.rohd.json'); + await file.create(recursive: true); + await file.writeAsString(json); + } + }); + }); + + test('Netlist dump for FIR filter example', () async { + final en = Logic(name: 'en'); + final resetB = Logic(name: 'resetB'); + final clk = SimpleClockGenerator(10).clk; + final inputVal = Logic(name: 'inputVal', width: 8); + + final fir = FirFilter(en, resetB, clk, inputVal, [0, 0, 0, 1], bitWidth: 8); + await fir.build(); + + const outPath = 'build/FirFilter.rohd.json'; + final modules = await convertTestWriteNetlist(fir, outPath); + if (!isJS) { + final f = File(outPath); + expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); + final contents = await f.readAsString(); + expect(contents.trim().isNotEmpty, isTrue); + } + expect( + modules, + isNotEmpty, + reason: 'FirFilter netlist should have module definitions', + ); + }); + + test('Netlist dump for LogicArray example', () async { + final arrayA = LogicArray([4], 8, name: 'arrayA'); + final id = Logic(name: 'id', width: 3); + final selectIndexValue = Logic(name: 'selectIndexValue', width: 8); + final selectFromValue = Logic(name: 'selectFromValue', width: 8); + + final la = LogicArrayExample(arrayA, id, selectIndexValue, selectFromValue); + await la.build(); + + const outPath = 'build/LogicArrayExample.rohd.json'; + final modules = await convertTestWriteNetlist(la, outPath); + if (!isJS) { + final f = File(outPath); + expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); + final contents = await f.readAsString(); + expect(contents.trim().isNotEmpty, isTrue); + } + expect( + modules, + isNotEmpty, + reason: 'LogicArrayExample netlist should have module definitions', + ); + }); + + test('Netlist dump for OvenModule example', () async { + final button = Logic(name: 'button', width: 2); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + + final oven = OvenModule(button, reset, clk); + await oven.build(); + + const outPath = 'build/OvenModule.rohd.json'; + final modules = await convertTestWriteNetlist(oven, outPath); + if (!isJS) { + final f = File(outPath); + expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); + final contents = await f.readAsString(); + expect(contents.trim().isNotEmpty, isTrue); + } + expect( + modules, + isNotEmpty, + reason: 'OvenModule netlist should have module definitions', + ); + }); + + test('Netlist dump for TreeOfTwoInputModules example', () async { + final seq = List.generate(4, (_) => Logic(width: 8)); + final tree = TreeOfTwoInputModules(seq, (a, b) => mux(a > b, a, b)); + await tree.build(); + + // Only verify JSON generation succeeds; the deeply nested hierarchy + // causes a stack overflow in any recursive parser. + const outPath = 'build/TreeOfTwoInputModules.rohd.json'; + final synth = SynthBuilder(tree, NetlistSynthesizer()); + final json = + await (synth.synthesizer as NetlistSynthesizer).synthesizeToJson(tree); + expect( + json, + isNotEmpty, + reason: 'TreeOfTwoInputModules should produce non-empty JSON', + ); + if (!isJS) { + final file = File(outPath); + await file.create(recursive: true); + await file.writeAsString(json); + expect(file.existsSync(), isTrue, reason: 'ROHD JSON should be created'); + } + }); +} diff --git a/test/netlist_synthesizer_test.dart b/test/netlist_synthesizer_test.dart new file mode 100644 index 000000000..0c985f18d --- /dev/null +++ b/test/netlist_synthesizer_test.dart @@ -0,0 +1,1437 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_synthesizer_test.dart +// Comprehensive tests for the netlist synthesizer covering leaf cell +// mapping, structural validation, options permutations, and real +// example designs. +// +// 2026 April 13 +// Author: Auto-generated + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/examples/filter_bank_modules.dart'; +import 'package:test/test.dart'; + +import '../example/example.dart'; +import '../example/fir_filter.dart'; +import '../example/logic_array.dart'; +import '../example/oven_fsm.dart'; +import '../example/tree.dart'; + +// ──────────────────────────────────────────────────────────────────── +// Tiny helper modules for targeted gate-level tests +// ──────────────────────────────────────────────────────────────────── + +/// Exercises And2Gate. +class AndModule extends Module { + Logic get y => output('y'); + AndModule(Logic a, Logic b) : super(name: 'andmod') { + a = addInput('a', a); + b = addInput('b', b); + addOutput('y') <= a & b; + } +} + +/// Exercises Or2Gate. +class OrModule extends Module { + Logic get y => output('y'); + OrModule(Logic a, Logic b) : super(name: 'ormod') { + a = addInput('a', a); + b = addInput('b', b); + addOutput('y') <= a | b; + } +} + +/// Exercises Xor2Gate. +class XorModule extends Module { + Logic get y => output('y'); + XorModule(Logic a, Logic b) : super(name: 'xormod') { + a = addInput('a', a); + b = addInput('b', b); + addOutput('y') <= a ^ b; + } +} + +/// Exercises NotGate. +class NotModule extends Module { + Logic get y => output('y'); + NotModule(Logic a) : super(name: 'notmod') { + a = addInput('a', a); + addOutput('y') <= ~a; + } +} + +/// Exercises Mux. +class MuxModule extends Module { + Logic get y => output('y'); + MuxModule(Logic sel, Logic a, Logic b, {int width = 8}) : super(name: 'mux') { + sel = addInput('sel', sel); + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y', width: width) <= mux(sel, a, b); + } +} + +/// Exercises FlipFlop. +class FlopModule extends Module { + Logic get q => output('q'); + FlopModule(Logic clk, Logic d, {int width = 8}) : super(name: 'flopmod') { + clk = addInput('clk', clk); + d = addInput('d', d, width: width); + addOutput('q', width: width) <= flop(clk, d); + } +} + +/// Exercises Add. +class AddModule extends Module { + Logic get sum => output('sum'); + AddModule(Logic a, Logic b, {int width = 8}) : super(name: 'addmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('sum', width: width) <= a + b; + } +} + +/// Exercises Multiply. +class MulModule extends Module { + Logic get prod => output('prod'); + MulModule(Logic a, Logic b, {int width = 8}) : super(name: 'mulmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('prod', width: width) <= a * b; + } +} + +/// Exercises BusSubset ($slice). +class SliceModule extends Module { + Logic get y => output('y'); + SliceModule(Logic a) : super(name: 'slicemod') { + a = addInput('a', a, width: 8); + addOutput('y', width: 4) <= a.getRange(2, 6); + } +} + +/// Exercises comparison operators. +class CompareModule extends Module { + Logic get lt => output('lt'); + Logic get gt => output('gt'); + Logic get eq => output('eq'); + CompareModule(Logic a, Logic b, {int width = 8}) : super(name: 'cmpmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('lt') <= LessThan(a, b).out; + addOutput('gt') <= GreaterThan(a, b).out; + addOutput('eq') <= a.eq(b); + } +} + +/// Exercises shift operations. +class ShiftModule extends Module { + Logic get shl => output('shl'); + Logic get shr => output('shr'); + ShiftModule(Logic a, Logic amt, {int width = 8}) : super(name: 'shiftmod') { + a = addInput('a', a, width: width); + amt = addInput('amt', amt, width: width); + addOutput('shl', width: width) <= a << amt; + addOutput('shr', width: width) <= a >>> amt; + } +} + +/// Exercises Xor2Gate. +class XorGateModule extends Module { + Logic get y => output('y'); + XorGateModule(Logic a, Logic b) : super(name: 'xormod2') { + a = addInput('a', a); + b = addInput('b', b); + addOutput('y') <= a ^ b; + } +} + +/// Exercises Subtract. +class SubModule extends Module { + Logic get diff => output('diff'); + SubModule(Logic a, Logic b, {int width = 8}) : super(name: 'submod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('diff', width: width) <= a - b; + } +} + +/// Exercises Swizzle ($concat). +class SwizzleModule extends Module { + Logic get y => output('y'); + SwizzleModule(Logic a, Logic b, {int width = 4}) : super(name: 'swizmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y', width: width * 2) <= [a, b].swizzle(); + } +} + +/// Exercises arithmetic right shift (ARShift). +class ARShiftModule extends Module { + Logic get y => output('y'); + ARShiftModule(Logic a, Logic amt, {int width = 8}) + : super(name: 'arshiftmod') { + a = addInput('a', a, width: width); + amt = addInput('amt', amt, width: width); + addOutput('y', width: width) <= a >> amt; + } +} + +/// Exercises unary reduction ops. +class ReduceModule extends Module { + Logic get andR => output('andR'); + Logic get orR => output('orR'); + Logic get xorR => output('xorR'); + ReduceModule(Logic a, {int width = 8}) : super(name: 'reducemod') { + a = addInput('a', a, width: width); + addOutput('andR') <= a.and(); + addOutput('orR') <= a.or(); + addOutput('xorR') <= a.xor(); + } +} + +/// Exercises individual comparison ops for cell-type checking. +class LtModule extends Module { + Logic get y => output('y'); + LtModule(Logic a, Logic b, {int width = 8}) : super(name: 'ltmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.lt(b); + } +} + +class GtModule extends Module { + Logic get y => output('y'); + GtModule(Logic a, Logic b, {int width = 8}) : super(name: 'gtmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.gt(b); + } +} + +class EqModule extends Module { + Logic get y => output('y'); + EqModule(Logic a, Logic b, {int width = 8}) : super(name: 'eqmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.eq(b); + } +} + +class NeqModule extends Module { + Logic get y => output('y'); + NeqModule(Logic a, Logic b, {int width = 8}) : super(name: 'neqmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.neq(b); + } +} + +class LeqModule extends Module { + Logic get y => output('y'); + LeqModule(Logic a, Logic b, {int width = 8}) : super(name: 'leqmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.lte(b); + } +} + +class GeqModule extends Module { + Logic get y => output('y'); + GeqModule(Logic a, Logic b, {int width = 8}) : super(name: 'geqmod') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y') <= a.gte(b); + } +} + +/// Exercises TriStateBuffer. +class TriBufModule extends Module { + Logic get bus => inOut('bus'); + TriBufModule(LogicNet busNet, Logic data, Logic en) + : super(name: 'tribufmod') { + final bus = addInOut('bus', busNet, width: data.width); + data = addInput('data', data, width: data.width); + en = addInput('en', en); + TriStateBuffer(data, enable: en, name: 'tsb').out.gets(bus); + } +} + +/// Exercises Combinational with If. +class CombIfModule extends Module { + Logic get y => output('y'); + CombIfModule(Logic sel, Logic a, Logic b, {int width = 8}) + : super(name: 'combif') { + sel = addInput('sel', sel); + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + final y = addOutput('y', width: width); + Combinational([ + If(sel, then: [y < a], orElse: [y < b]), + ]); + } +} + +/// Exercises Sequential with If. +class SeqIfModule extends Module { + Logic get q => output('q'); + SeqIfModule(Logic clk, Logic en, Logic d, {int width = 8}) + : super(name: 'seqif') { + clk = addInput('clk', clk); + en = addInput('en', en); + d = addInput('d', d, width: width); + final q = addOutput('q', width: width); + Sequential(clk, [ + If(en, then: [q < d]), + ]); + } +} + +/// Module with multiple instances of the same sub-module (dedup test). +class DedupTop extends Module { + Logic get y0 => output('y0'); + Logic get y1 => output('y1'); + DedupTop(Logic a, Logic b, {int width = 8}) + : super(name: 'deduptop', definitionName: 'DedupTop') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + addOutput('y0', width: width) <= AddModule(a, b, width: width).sum; + addOutput('y1', width: width) <= AddModule(a, b, width: width).sum; + } +} + +/// Module with different-width instances (no dedup). +class NoDedupTop extends Module { + Logic get y0 => output('y0'); + Logic get y1 => output('y1'); + NoDedupTop(Logic a4, Logic b4, Logic a8, Logic b8) + : super(name: 'nodeduptop', definitionName: 'NoDedupTop') { + a4 = addInput('a4', a4, width: 4); + b4 = addInput('b4', b4, width: 4); + a8 = addInput('a8', a8, width: 8); + b8 = addInput('b8', b8, width: 8); + addOutput('y0', width: 4) <= AddModule(a4, b4, width: 4).sum; + addOutput('y1', width: 8) <= AddModule(a8, b8).sum; + } +} + +// ──────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────── + +/// Build a FilterBank module for testing (not yet built). +FilterBank _buildFilterBank() { + const dataWidth = 16; + const numTaps = 3; + const coeffs0 = [1, 2, 1]; + const coeffs1 = [1, -2, 1]; + + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + return FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: [coeffs0, coeffs1], + ); +} + +/// Build a module and synthesize to a parsed JSON map. +Future> _synthToMap( + Module mod, { + NetlistOptions options = const NetlistOptions(), +}) async { + await mod.build(); + final synth = SynthBuilder(mod, NetlistSynthesizer(options: options)); + final json = await (synth.synthesizer as NetlistSynthesizer).synthesizeToJson( + mod, + ); + return jsonDecode(json) as Map; +} + +/// Extract the `modules` map from a synthesized JSON map. +Map _modules(Map json) => + json['modules'] as Map; + +/// Get cells map from a module definition. +Map _cells(Map moduleDef) => + moduleDef['cells'] as Map? ?? {}; + +/// Get ports map from a module definition. +Map _ports(Map moduleDef) => + moduleDef['ports'] as Map? ?? {}; + +/// Get netnames map from a module definition. +Map _netnames(Map moduleDef) => + moduleDef['netnames'] as Map? ?? {}; + +/// Check that a module definition has a port with given name and direction. +void _expectPort( + Map moduleDef, + String portName, + String direction, +) { + final ports = _ports(moduleDef); + expect(ports, contains(portName), reason: 'Expected port "$portName"'); + final port = ports[portName] as Map; + expect( + port['direction'], + equals(direction), + reason: 'Port "$portName" should be "$direction"', + ); +} + +/// Returns true if any cell in any module definition has the given type. +bool _hasCellType(Map json, String cellType) { + final mod = _modules(json); + return mod.values.any((m) { + final def = m as Map; + return _cells(def).values.any((c) { + final cell = c as Map; + return (cell['type'] as String) == cellType; + }); + }); +} + +// ──────────────────────────────────────────────────────────────────── +// Tests +// ──────────────────────────────────────────────────────────────────── + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + // ── Group 1: Leaf cell mapper — individual gate mappings ─────────── + + group('leaf cell mapping', () { + test(r'And2Gate maps to $and cell', () async { + final json = await _synthToMap(AndModule(Logic(), Logic())); + expect(_hasCellType(json, r'$and'), isTrue); + }); + + test(r'Or2Gate maps to $or cell', () async { + final json = await _synthToMap(OrModule(Logic(), Logic())); + expect(_hasCellType(json, r'$or'), isTrue); + }); + + test(r'Xor2Gate maps to $xor cell', () async { + final json = await _synthToMap(XorGateModule(Logic(), Logic())); + expect(_hasCellType(json, r'$xor'), isTrue); + }); + + test(r'NotGate maps to $not cell', () async { + final json = await _synthToMap(NotModule(Logic())); + expect(_hasCellType(json, r'$not'), isTrue); + }); + + test(r'Mux maps to $mux cell', () async { + final json = await _synthToMap( + MuxModule(Logic(), Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$mux'), isTrue); + }); + + test(r'FlipFlop maps to $dff cell', () async { + final clk = SimpleClockGenerator(10).clk; + final json = await _synthToMap(FlopModule(clk, Logic(width: 8))); + expect(_hasCellType(json, r'$dff'), isTrue); + }); + + test(r'Add maps to $add cell', () async { + final json = await _synthToMap( + AddModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$add'), isTrue); + }); + + test(r'Subtract maps to $sub cell', () async { + final json = await _synthToMap( + SubModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$sub'), isTrue); + }); + + test(r'Multiply maps to $mul cell', () async { + final json = await _synthToMap( + MulModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$mul'), isTrue); + }); + + test(r'BusSubset maps to $slice cell', () async { + final json = await _synthToMap(SliceModule(Logic(width: 8))); + expect(_hasCellType(json, r'$slice'), isTrue); + }); + + test(r'Swizzle maps to $concat cell', () async { + final json = await _synthToMap( + SwizzleModule(Logic(width: 4), Logic(width: 4)), + ); + expect(_hasCellType(json, r'$concat'), isTrue); + }); + + test(r'LessThan maps to $lt cell', () async { + final json = await _synthToMap( + LtModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$lt'), isTrue); + }); + + test(r'GreaterThan maps to $gt cell', () async { + final json = await _synthToMap( + GtModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$gt'), isTrue); + }); + + test(r'Equals maps to $eq cell', () async { + final json = await _synthToMap( + EqModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$eq'), isTrue); + }); + + test(r'NotEquals maps to $ne cell', () async { + final json = await _synthToMap( + NeqModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$ne'), isTrue); + }); + + test(r'LessThanOrEqual maps to $le cell', () async { + final json = await _synthToMap( + LeqModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$le'), isTrue); + }); + + test(r'GreaterThanOrEqual maps to $ge cell', () async { + final json = await _synthToMap( + GeqModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$ge'), isTrue); + }); + + test(r'LShift maps to $shl cell', () async { + final json = await _synthToMap( + ShiftModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$shl'), isTrue); + }); + + test(r'RShift maps to $shr cell', () async { + final json = await _synthToMap( + ShiftModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$shr'), isTrue); + }); + + test(r'ARShift maps to $shiftx cell', () async { + final json = await _synthToMap( + ARShiftModule(Logic(width: 8), Logic(width: 8)), + ); + expect(_hasCellType(json, r'$shiftx'), isTrue); + }); + + test(r'AndUnary maps to $reduce_and cell', () async { + final json = await _synthToMap(ReduceModule(Logic(width: 8))); + expect(_hasCellType(json, r'$reduce_and'), isTrue); + }); + + test(r'OrUnary maps to $reduce_or cell', () async { + final json = await _synthToMap(ReduceModule(Logic(width: 8))); + expect(_hasCellType(json, r'$reduce_or'), isTrue); + }); + + test(r'XorUnary maps to $reduce_xor cell', () async { + final json = await _synthToMap(ReduceModule(Logic(width: 8))); + expect(_hasCellType(json, r'$reduce_xor'), isTrue); + }); + + test(r'TriStateBuffer maps to $tribuf cell', () async { + final busNet = LogicNet(width: 8); + final json = await _synthToMap( + TriBufModule(busNet, Logic(width: 8), Logic()), + ); + expect(_hasCellType(json, r'$tribuf'), isTrue); + }); + }); + + // ── Group 2: Structural content validation ───────────────────────── + + group('structural validation', () { + test('ports have correct direction', () async { + final json = await _synthToMap( + AddModule(Logic(width: 8), Logic(width: 8)), + ); + // Find the top-level or AddModule definition + final mod = _modules(json); + for (final def in mod.values) { + final d = def as Map; + final ports = _ports(d); + for (final port in ports.entries) { + final p = port.value as Map; + expect( + ['input', 'output', 'inout'].contains(p['direction']), + isTrue, + reason: 'Port ${port.key} should have valid direction', + ); + // Each port should have bits + expect( + p['bits'], + isNotNull, + reason: 'Port ${port.key} should have bits array', + ); + } + } + }); + + test('cells have type and connections', () async { + final json = await _synthToMap( + MuxModule(Logic(), Logic(width: 8), Logic(width: 8)), + ); + final mod = _modules(json); + for (final def in mod.values) { + final d = def as Map; + for (final cell in _cells(d).values) { + final c = cell as Map; + expect(c['type'], isNotNull, reason: 'Every cell should have a type'); + expect( + c['connections'], + isNotNull, + reason: 'Every cell should have connections', + ); + } + } + }); + + test('netnames have bits arrays', () async { + final json = await _synthToMap( + AddModule(Logic(width: 8), Logic(width: 8)), + ); + final mod = _modules(json); + for (final def in mod.values) { + final d = def as Map; + for (final nn in _netnames(d).values) { + final n = nn as Map; + expect( + n['bits'], + isA>(), + reason: 'Each netname should have a bits list', + ); + } + } + }); + + test('inOut ports have direction inout', () async { + final busNet = LogicNet(width: 8); + final json = await _synthToMap( + TriBufModule(busNet, Logic(width: 8), Logic()), + ); + final mod = _modules(json); + // Find the TriBufModule definition + final tribufDef = mod.values.firstWhere((m) { + final d = m as Map; + return _ports(d).values.any((p) { + final port = p as Map; + return port['direction'] == 'inout'; + }); + }, orElse: () => {}) as Map; + expect( + tribufDef, + isNotEmpty, + reason: 'Should have a module with inout ports', + ); + }); + + test('Combinational If produces Combinational cell', () async { + final json = await _synthToMap( + CombIfModule(Logic(), Logic(width: 8), Logic(width: 8)), + ); + // Combinational blocks become Combinational cell type + expect( + _hasCellType(json, 'Combinational'), + isTrue, + reason: 'Combinational If should produce a Combinational cell', + ); + }); + + test('Sequential If produces dff cells', () async { + final clk = SimpleClockGenerator(10).clk; + final json = await _synthToMap( + SeqIfModule(clk, Logic(), Logic(width: 8)), + ); + final mod = _modules(json); + final hasSeq = mod.values.any((m) { + final def = m as Map; + final cells = _cells(def); + return cells.values.any((c) { + final cell = c as Map; + return (cell['type'] as String).contains('Sequential'); + }); + }); + expect( + hasSeq, + isTrue, + reason: 'Sequential If should contain Sequential cells', + ); + }); + }); + + // ── Group 3: Module deduplication ────────────────────────────────── + + group('deduplication', () { + test('identical sub-modules are deduplicated', () async { + final json = await _synthToMap( + DedupTop(Logic(width: 8), Logic(width: 8)), + ); + final mod = _modules(json); + // AddModule should appear only once as a definition + final addDefs = mod.keys.where((k) => k.contains('Add')).toList(); + expect( + addDefs.length, + equals(1), + reason: 'Two identical AddModules should produce one definition', + ); + // But should be instantiated twice in the top-level cells + final topDef = mod.entries + .firstWhere((e) => e.key.contains('DedupTop')) + .value as Map; + final addCells = _cells(topDef).values.where((c) { + final cell = c as Map; + return (cell['type'] as String).contains('Add'); + }).toList(); + expect( + addCells.length, + equals(2), + reason: 'Top module should instantiate AddModule twice', + ); + }); + + test('different-width sub-modules are not deduplicated', () async { + final json = await _synthToMap( + NoDedupTop( + Logic(width: 4), + Logic(width: 4), + Logic(width: 8), + Logic(width: 8), + ), + ); + final mod = _modules(json); + // Should have two distinct AddModule definitions (different widths) + final addDefs = mod.keys.where((k) => k.contains('Add')).toList(); + expect( + addDefs.length, + greaterThanOrEqualTo(2), + reason: 'Different-width AddModules should NOT be deduplicated', + ); + }); + }); + + // ── Group 4: NetlistOptions permutations ───────────────────────── + + group('NetlistOptions', () { + late Module filterBank; + + setUp(() async { + await Simulator.reset(); + filterBank = _buildFilterBank(); + await filterBank.build(); + }); + + test('default options produce valid netlist', () async { + final synth = SynthBuilder(filterBank, NetlistSynthesizer()); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + expect(_modules(parsed), isNotEmpty); + }); + + test('slimMode omits connections', () async { + final synth = SynthBuilder( + filterBank, + NetlistSynthesizer(options: const NetlistOptions(slimMode: true)), + ); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + final mod = _modules(parsed); + expect(mod, isNotEmpty); + // In slim mode, cells should exist but connections should be empty + for (final def in mod.values) { + final d = def as Map; + for (final cell in _cells(d).values) { + final c = cell as Map; + final conns = c['connections'] as Map?; + if (conns != null) { + expect( + conns, + isEmpty, + reason: 'Slim mode cells should have empty connections', + ); + } + } + } + }); + + test('groupStructConversions produces valid netlist', () async { + final synth = SynthBuilder( + filterBank, + NetlistSynthesizer( + options: const NetlistOptions(groupStructConversions: true), + ), + ); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + expect(_modules(parsed), isNotEmpty); + }); + + test('collapseStructGroups with groupStructConversions', () async { + final synth = SynthBuilder( + filterBank, + NetlistSynthesizer( + options: const NetlistOptions( + groupStructConversions: true, + collapseStructGroups: true, + ), + ), + ); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + expect(_modules(parsed), isNotEmpty); + }); + + test('DCE disabled still produces valid netlist', () async { + final synth = SynthBuilder( + filterBank, + NetlistSynthesizer(options: const NetlistOptions(enableDCE: false)), + ); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + expect(_modules(parsed), isNotEmpty); + }); + + test('all optimizations disabled produces valid netlist', () async { + final synth = SynthBuilder( + filterBank, + NetlistSynthesizer(options: const NetlistOptions(enableDCE: false)), + ); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final parsed = jsonDecode(json) as Map; + expect(_modules(parsed), isNotEmpty); + }); + + test('slim and full produce same module definitions', () async { + final fullSynth = SynthBuilder(filterBank, NetlistSynthesizer()); + final fullJson = await (fullSynth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(filterBank); + final fullParsed = jsonDecode(fullJson) as Map; + + // Rebuild for slim + await Simulator.reset(); + final fb2 = _buildFilterBank(); + await fb2.build(); + final slimSynth = SynthBuilder( + fb2, + NetlistSynthesizer(options: const NetlistOptions(slimMode: true)), + ); + final slimJson = await (slimSynth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(fb2); + final slimParsed = jsonDecode(slimJson) as Map; + + // Same module definition names + expect( + _modules(slimParsed).keys.toSet(), + equals(_modules(fullParsed).keys.toSet()), + reason: 'Slim and full should have identical module definition names', + ); + }); + }); + + // ── Group 5: Example designs — structural checks ─────────────────── + + group('example designs', () { + test('Counter netlist has FlipFlop and FSM-related cells', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final counter = Counter(en, reset, clk); + final json = await _synthToMap(counter); + final mod = _modules(json); + + expect( + mod, + isNotEmpty, + reason: 'Counter should produce module definitions', + ); + // Should have a Counter definition + expect(mod.keys.any((k) => k.contains('Counter')), isTrue); + }); + + test('FirFilter netlist has pipeline and multiplier cells', () async { + final en = Logic(name: 'en'); + final resetB = Logic(name: 'resetB'); + final clk = SimpleClockGenerator(10).clk; + final inputVal = Logic(name: 'inputVal', width: 8); + final fir = FirFilter( + en, + resetB, + clk, + inputVal, + [ + 0, + 0, + 0, + 1, + ], + bitWidth: 8); + final json = await _synthToMap(fir); + final mod = _modules(json); + + expect( + mod, + isNotEmpty, + reason: 'FirFilter should produce module definitions', + ); + }); + + test('OvenModule netlist has FSM states', () async { + final button = Logic(name: 'button', width: 2); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final oven = OvenModule(button, reset, clk); + final json = await _synthToMap(oven); + final mod = _modules(json); + + expect(mod, isNotEmpty); + // Should have OvenModule definition + expect( + mod.keys.any((k) => k.contains('Oven') || k.contains('oven')), + isTrue, + ); + }); + + test('LogicArrayExample netlist has array-related cells', () async { + final arrayA = LogicArray([4], 8, name: 'arrayA'); + final id = Logic(name: 'id', width: 3); + final selectIndexValue = Logic(name: 'selectIndexValue', width: 8); + final selectFromValue = Logic(name: 'selectFromValue', width: 8); + final la = LogicArrayExample( + arrayA, + id, + selectIndexValue, + selectFromValue, + ); + final json = await _synthToMap(la); + final mod = _modules(json); + + expect(mod, isNotEmpty); + }); + + test('TreeOfTwoInputModules netlist has recursive hierarchy', () async { + final seq = List.generate(4, (_) => Logic(width: 8)); + final tree = TreeOfTwoInputModules(seq, (a, b) => mux(a > b, a, b)); + await tree.build(); + final synth = SynthBuilder(tree, NetlistSynthesizer()); + final json = await (synth.synthesizer as NetlistSynthesizer) + .synthesizeToJson(tree); + expect(json, isNotEmpty); + final parsed = jsonDecode(json) as Map; + final mod = _modules(parsed); + expect(mod, isNotEmpty, reason: 'Tree should have module definitions'); + }); + }); + + // ── Group 6: FilterBank deep structural checks ───────────────────── + + group('FilterBank netlist structure', () { + late Map json; + + setUpAll(() async { + final fb = _buildFilterBank(); + json = await _synthToMap(fb); + }); + + test('contains expected module definitions', () { + final mod = _modules(json); + final defNames = mod.keys.toSet(); + + // FilterBank, FilterChannel, CoeffBank, MacUnit, FilterController + // should all appear (possibly with parameterized suffixes) + expect( + defNames.any((k) => k.contains('FilterBank')), + isTrue, + reason: 'Should have FilterBank definition', + ); + expect( + defNames.any((k) => k.contains('FilterChannel')), + isTrue, + reason: 'Should have FilterChannel definition', + ); + expect( + defNames.any((k) => k.contains('CoeffBank')), + isTrue, + reason: 'Should have CoeffBank definition', + ); + expect( + defNames.any((k) => k.contains('MacUnit')), + isTrue, + reason: 'Should have MacUnit definition', + ); + expect( + defNames.any((k) => k.contains('FilterController')), + isTrue, + reason: 'Should have FilterController definition', + ); + }); + + test('FilterBank has array ports', () { + final mod = _modules(json); + final fbDef = mod.entries + .firstWhere((e) => e.key.contains('FilterBank')) + .value as Map; + final ports = _ports(fbDef); + + // Should have samplesIn and channelOut as array ports + expect( + ports.keys.any( + (k) => k.contains('samplesIn') || k.contains('channelOut'), + ), + isTrue, + reason: 'FilterBank should have array port signals', + ); + }); + + test('FilterBank top instantiates two FilterChannels', () { + final mod = _modules(json); + final fbDef = mod.entries + .firstWhere((e) => e.key.contains('FilterBank')) + .value as Map; + final cells = _cells(fbDef); + + final channelCells = cells.entries.where((e) { + final cell = e.value as Map; + return (cell['type'] as String).contains('FilterChannel'); + }).toList(); + + expect( + channelCells.length, + equals(2), + reason: 'FilterBank should instantiate 2 FilterChannels', + ); + }); + + test( + 'FilterChannels with different coefficients get separate definitions', + () { + final mod = _modules(json); + final channelDefs = + mod.keys.where((k) => k.contains('FilterChannel')).toList(); + + expect( + channelDefs.length, + equals(2), + reason: 'Two FilterChannels with different coefficients ' + 'should produce distinct definitions', + ); + }, + ); + + test('MacUnit definition contains Pipeline-generated cells', () { + final mod = _modules(json); + final macDef = mod.entries + .firstWhere((e) => e.key.contains('MacUnit')) + .value as Map; + final cells = _cells(macDef); + + // Pipeline generates Sequential cells for stage registers + final hasSeq = cells.values.any((c) { + final cell = c as Map; + final type = cell['type'] as String; + return type.contains('Sequential'); + }); + expect( + hasSeq, + isTrue, + reason: 'MacUnit Pipeline should produce Sequential cells', + ); + }); + + test('CoeffBank has coeffArray input port', () { + final mod = _modules(json); + final coeffDef = mod.entries + .firstWhere((e) => e.key.contains('CoeffBank')) + .value as Map; + final ports = _ports(coeffDef); + + // Should have coeffArray-related port names + expect( + ports.keys.any((k) => k.contains('coeffArray')), + isTrue, + reason: 'CoeffBank should have coeffArray port', + ); + + // tapIndex should be input + expect( + ports.keys.any((k) => k.contains('tapIndex')), + isTrue, + reason: 'CoeffBank should have tapIndex port', + ); + }); + + test('FilterController has FSM state output', () { + final mod = _modules(json); + final ctrlDef = mod.entries + .firstWhere((e) => e.key.contains('FilterController')) + .value as Map; + final ports = _ports(ctrlDef); + + _expectPort(ctrlDef, 'state', 'output'); + _expectPort(ctrlDef, 'filterEnable', 'output'); + _expectPort(ctrlDef, 'doneFlag', 'output'); + expect(ports.keys.any((k) => k.contains('clk')), isTrue); + expect(ports.keys.any((k) => k.contains('reset')), isTrue); + }); + + test('all module definitions have valid JSON structure', () { + final mod = _modules(json); + for (final entry in mod.entries) { + final defName = entry.key; + final def = entry.value as Map; + + // Every definition must have ports and cells + expect( + def.containsKey('ports'), + isTrue, + reason: '$defName should have ports', + ); + expect( + def.containsKey('cells'), + isTrue, + reason: '$defName should have cells', + ); + + // All ports must have direction and bits + for (final port in _ports(def).entries) { + final p = port.value as Map; + expect( + p.containsKey('direction'), + isTrue, + reason: '$defName.${port.key} should have direction', + ); + expect( + p.containsKey('bits'), + isTrue, + reason: '$defName.${port.key} should have bits', + ); + } + + // All cells must have type + for (final cell in _cells(def).entries) { + final c = cell.value as Map; + expect( + c.containsKey('type'), + isTrue, + reason: '$defName cell ${cell.key} should have type', + ); + } + } + }); + }); + + // ── Group 7: Design API path ─────────────────────────────────────── + + group('Design API path', () { + test('build with netlistOptions enables NetlistService', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final counter = Counter(en, reset, clk); + + await counter.build(); + final netSvc = await NetlistService.create(counter); + + final fullJson = netSvc.toJson(); + expect(fullJson, isNotNull); + + final parsed = jsonDecode(fullJson) as Map; + expect(parsed.containsKey('modules'), isTrue); + }); + + test('moduleJson returns per-module data', () async { + final fb = _buildFilterBank(); + await fb.build(); + final netSvc = await NetlistService.create(fb); + + // Fetch FilterBank definition specifically + final fbJson = netSvc.moduleJson(fb.definitionName); + final parsed = jsonDecode(fbJson) as Map; + expect(parsed.containsKey('modules'), isTrue); + final modules = parsed['modules'] as Map; + expect(modules.containsKey(fb.definitionName), isTrue); + }); + + test('slimJson produces slim output', () async { + final fb = _buildFilterBank(); + await fb.build(); + final netSvc = await NetlistService.create(fb); + + final slimJson = netSvc.slimJson; + + final parsed = jsonDecode(slimJson) as Map; + expect(parsed.containsKey('netlist'), isTrue); + final netlist = parsed['netlist'] as Map; + final modules = netlist['modules'] as Map; + expect(modules, isNotEmpty); + }); + }); + + // ── Group 8: Wire ID and structural invariants ───────────────────── + + group('wire ID and structural invariants', () { + test('all wire IDs are >= 2 (0 and 1 reserved for constants)', () async { + final json = await _synthToMap( + AddModule(Logic(width: 8), Logic(width: 8)), + ); + final mod = _modules(json); + for (final entry in mod.entries) { + final def = entry.value as Map; + // Check ports + for (final port in _ports(def).entries) { + final p = port.value as Map; + final bits = p['bits'] as List; + for (final bit in bits) { + if (bit is int) { + expect( + bit, + greaterThanOrEqualTo(2), + reason: 'Wire ID ${port.key} bit $bit should be >= 2', + ); + } + } + } + } + }); + + test(r'FilterBank contains $const cells for constant drivers', () async { + final json = await _synthToMap(_buildFilterBank()); + expect( + _hasCellType(json, r'$const'), + isTrue, + reason: r'FilterBank should have $const cells for constant values', + ); + }); + + test('passthrough buffers prevent input-output wire sharing', () async { + // A module whose output directly comes from an input should get a + // $buf for wire-ID isolation. + final json = await _synthToMap( + AddModule(Logic(width: 8), Logic(width: 8)), + ); + final mod = _modules(json); + // Verify input and output port bits don't overlap in any definition + for (final entry in mod.entries) { + final def = entry.value as Map; + final ports = _ports(def); + final inputBits = {}; + final outputBits = {}; + for (final port in ports.entries) { + final p = port.value as Map; + final bits = (p['bits'] as List).whereType().toSet(); + final dir = p['direction'] as String; + if (dir == 'input') { + inputBits.addAll(bits); + } else if (dir == 'output') { + outputBits.addAll(bits); + } + } + expect( + inputBits.intersection(outputBits), + isEmpty, + reason: '${entry.key}: input and output ports should not share wire ' + 'IDs (passthrough buffer should break sharing)', + ); + } + }); + }); + + // ── Group 9: DCE (dead-cell elimination) verification ────────────── + + group('dead-cell elimination', () { + test('DCE enabled produces fewer cells than DCE disabled', () async { + final fbDce = _buildFilterBank(); + final jsonDce = await _synthToMap(fbDce); + int countCells(Map j) { + var total = 0; + for (final def in _modules(j).values) { + total += _cells(def as Map).length; + } + return total; + } + + final fbNoDce = _buildFilterBank(); + final jsonNoDce = await _synthToMap( + fbNoDce, + options: const NetlistOptions(enableDCE: false), + ); + + final dceCells = countCells(jsonDce); + final noDceCells = countCells(jsonNoDce); + expect( + dceCells, + lessThanOrEqualTo(noDceCells), + reason: 'DCE should remove at least as many cells as no-DCE', + ); + }); + + test(r'DCE removes floating $const cells', () async { + // With DCE disabled, there may be more $const cells + final fbDce = _buildFilterBank(); + final jsonDce = await _synthToMap(fbDce); + int countConstCells(Map j) { + var total = 0; + for (final def in _modules(j).values) { + final d = def as Map; + for (final cell in _cells(d).values) { + final c = cell as Map; + if ((c['type'] as String) == r'$const') { + total++; + } + } + } + return total; + } + + final fbNoDce = _buildFilterBank(); + final jsonNoDce = await _synthToMap( + fbNoDce, + options: const NetlistOptions(enableDCE: false), + ); + + expect( + countConstCells(jsonDce), + lessThanOrEqualTo(countConstCells(jsonNoDce)), + reason: r'DCE should not produce more $const cells than no-DCE', + ); + }); + }); + + // ── Group 10: Post-processing option combinations ────────────────── + + group('post-processing options', () { + test('groupMaximalSubsets produces valid netlist', () async { + final fb = _buildFilterBank(); + final json = await _synthToMap( + fb, + options: const NetlistOptions( + groupStructConversions: true, + groupMaximalSubsets: true, + ), + ); + expect(_modules(json), isNotEmpty); + }); + + test('collapseConcats produces valid netlist', () async { + final fb = _buildFilterBank(); + final json = await _synthToMap( + fb, + options: const NetlistOptions( + groupStructConversions: true, + collapseConcats: true, + ), + ); + expect(_modules(json), isNotEmpty); + }); + + test( + 'all post-processing options enabled produces valid netlist', + () async { + final fb = _buildFilterBank(); + final json = await _synthToMap( + fb, + options: const NetlistOptions( + groupStructConversions: true, + collapseStructGroups: true, + groupMaximalSubsets: true, + collapseConcats: true, + ), + ); + expect(_modules(json), isNotEmpty); + }, + ); + + test('groupStructConversions adds synthetic module definitions', () async { + final fbPlain = _buildFilterBank(); + final jsonPlain = await _synthToMap(fbPlain); + final plainCount = _modules(jsonPlain).length; + + final fbGrouped = _buildFilterBank(); + final jsonGrouped = await _synthToMap( + fbGrouped, + options: const NetlistOptions(groupStructConversions: true), + ); + final groupedCount = _modules(jsonGrouped).length; + + // groupStructConversions should introduce additional synthetic modules + expect( + groupedCount, + greaterThanOrEqualTo(plainCount), + reason: 'Struct grouping should add module definitions', + ); + }); + + test( + 'collapseStructGroups reduces cells vs groupStructConversions only', + () async { + final fb1 = _buildFilterBank(); + final json1 = await _synthToMap( + fb1, + options: const NetlistOptions(groupStructConversions: true), + ); + int countCells(Map j) { + var total = 0; + for (final def in _modules(j).values) { + total += _cells(def as Map).length; + } + return total; + } + + final fb2 = _buildFilterBank(); + final json2 = await _synthToMap( + fb2, + options: const NetlistOptions( + groupStructConversions: true, + collapseStructGroups: true, + ), + ); + + expect( + countCells(json2), + lessThanOrEqualTo(countCells(json1)), + reason: 'Collapsing struct groups should not increase cell count', + ); + }, + ); + }); + +} diff --git a/test/netlist_test.dart b/test/netlist_test.dart new file mode 100644 index 000000000..43eb94cc4 --- /dev/null +++ b/test/netlist_test.dart @@ -0,0 +1,706 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_test.dart +// Tests for the netlist synthesizer: JSON structure, SynthBuilder, +// NetlistSynthesisResult, collectModuleEntries, NetlistOptions, +// and example-based smoke tests. +// +// 2026 March 31 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; +import 'dart:io'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/examples/filter_bank_modules.dart'; +import 'package:test/test.dart'; + +import '../example/example.dart'; +import '../example/fir_filter.dart'; +import '../example/logic_array.dart'; +import '../example/oven_fsm.dart'; +import '../example/tree.dart'; + +// --------------------------------------------------------------------------- +// Simple test modules (self-contained, no example imports needed) +// --------------------------------------------------------------------------- + +/// A trivial module that inverts a single-bit input. +class _InverterModule extends Module { + Logic get out => output('out'); + + _InverterModule(Logic inp) : super(name: 'inverter') { + inp = addInput('inp', inp); + final out = addOutput('out'); + out <= ~inp; + } +} + +/// A module that instantiates two sub-modules: an inverter and an AND gate. +class _CompositeModule extends Module { + Logic get out => output('out'); + + _CompositeModule(Logic a, Logic b) : super(name: 'composite') { + a = addInput('a', a); + b = addInput('b', b); + final out = addOutput('out'); + + final invA = _InverterModule(a); + out <= (_InverterModule(invA.out).out & b); + } +} + +/// A simple adder module with a configurable width. +class _AdderModule extends Module { + Logic get sum => output('sum'); + + _AdderModule(Logic a, Logic b, {int width = 8}) : super(name: 'adder') { + a = addInput('a', a, width: width); + b = addInput('b', b, width: width); + final sum = addOutput('sum', width: width); + sum <= a + b; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Detect whether running in JS (dart2js) environment. +const _isJS = identical(0, 0.0); + +/// Synthesize [top] and optionally write the produced JSON to [outPath]. +/// Returns the decoded modules map from the Yosys-format JSON. +Future> _synthesizeAndWrite( + Module top, + String outPath, +) async { + final synth = SynthBuilder(top, NetlistSynthesizer()); + final jsonStr = + await (synth.synthesizer as NetlistSynthesizer).synthesizeToJson(top); + if (!_isJS) { + final file = File(outPath); + await file.create(recursive: true); + await file.writeAsString(jsonStr); + } + final decoded = jsonDecode(jsonStr) as Map; + return decoded['modules'] as Map; +} + +/// Build a FilterBank with default test parameters. +FilterBank _buildFilterBank({ + int dataWidth = 16, + int numTaps = 3, + List> coefficients = const [ + [1, 2, 1], + [1, -2, 1], + ], +}) { + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = + LogicArray([coefficients.length], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + return FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: coefficients, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + tearDown(() async { + await Simulator.reset(); + }); + + // ── Example smoke tests ─────────────────────────────────────────────── + // + // Each example is synthesized once, verifying that the netlist is + // non-empty and (on VM) that the JSON file is written successfully. + + group('Example netlist smoke tests', () { + test('Counter', () async { + final counter = Counter(Logic(name: 'en'), Logic(name: 'reset'), + SimpleClockGenerator(10).clk); + await counter.build(); + + final modules = + await _synthesizeAndWrite(counter, 'build/Counter.rohd.json'); + expect(modules, isNotEmpty); + + final topMod = modules[counter.definitionName] as Map; + final cells = topMod['cells'] as Map? ?? {}; + expect(cells, isNotEmpty, reason: 'Counter should have cells'); + }); + + test('FIR filter', () async { + final fir = FirFilter( + Logic(name: 'en'), + Logic(name: 'resetB'), + SimpleClockGenerator(10).clk, + Logic(name: 'inputVal', width: 8), + [0, 0, 0, 1], + bitWidth: 8, + ); + await fir.build(); + + final modules = + await _synthesizeAndWrite(fir, 'build/FirFilter.rohd.json'); + expect(modules, isNotEmpty); + if (!_isJS) { + expect(File('build/FirFilter.rohd.json').existsSync(), isTrue); + } + }); + + test('LogicArray', () async { + final la = LogicArrayExample( + LogicArray([4], 8, name: 'arrayA'), + Logic(name: 'id', width: 3), + Logic(name: 'selectIndexValue', width: 8), + Logic(name: 'selectFromValue', width: 8), + ); + await la.build(); + + final modules = + await _synthesizeAndWrite(la, 'build/LogicArrayExample.rohd.json'); + expect(modules, isNotEmpty); + }); + + test('OvenModule', () async { + final oven = OvenModule( + Logic(name: 'button', width: 2), + Logic(name: 'reset'), + SimpleClockGenerator(10).clk, + ); + await oven.build(); + + final modules = + await _synthesizeAndWrite(oven, 'build/OvenModule.rohd.json'); + expect(modules, isNotEmpty); + }); + + test('TreeOfTwoInputModules', () async { + final seq = List.generate(4, (_) => Logic(width: 8)); + final tree = TreeOfTwoInputModules(seq, (a, b) => mux(a > b, a, b)); + await tree.build(); + + // Only verify JSON generation succeeds; the deeply nested hierarchy + // causes a stack overflow in any recursive parser. + final json = await NetlistSynthesizer().synthesizeToJson(tree); + expect(json, isNotEmpty); + if (!_isJS) { + final file = File('build/TreeOfTwoInputModules.rohd.json'); + await file.create(recursive: true); + await file.writeAsString(json); + } + }); + + test('FilterBank', () async { + final fb = _buildFilterBank(); + await fb.build(); + + final modules = + await _synthesizeAndWrite(fb, 'build/FilterBank.smoke.rohd.json'); + expect(modules, isNotEmpty); + expect(modules.length, greaterThan(1), + reason: 'FilterBank should have sub-module definitions'); + }); + }); + + // ── JSON structure ──────────────────────────────────────────────────── + + group('JSON structure', () { + test('synthesizeToJson returns valid JSON with modules key', () async { + final mod = _InverterModule(Logic(name: 'inp')); + await mod.build(); + + final json = await NetlistSynthesizer().synthesizeToJson(mod); + expect(json, isNotEmpty); + final decoded = jsonDecode(json) as Map; + expect(decoded, contains('modules')); + }); + + test('top module is present with correct ports and top attribute', + () async { + final mod = _InverterModule(Logic(name: 'inp')); + await mod.build(); + + final json = await NetlistSynthesizer().synthesizeToJson(mod); + final decoded = jsonDecode(json) as Map; + final modules = decoded['modules'] as Map; + expect(modules, contains(mod.definitionName)); + + final topMod = modules[mod.definitionName] as Map; + + // Port directions + final ports = topMod['ports'] as Map; + expect(ports, contains('inp')); + expect(ports, contains('out')); + expect((ports['inp'] as Map)['direction'], equals('input')); + expect((ports['out'] as Map)['direction'], equals('output')); + + // Top attribute + final attrs = topMod['attributes'] as Map?; + expect(attrs, isNotNull); + expect(attrs!['top'], equals(1)); + }); + + test('port bit widths match module interface', () async { + const width = 16; + final mod = _AdderModule( + Logic(name: 'a', width: width), Logic(name: 'b', width: width), + width: width); + await mod.build(); + + final json = await NetlistSynthesizer().synthesizeToJson(mod); + final decoded = jsonDecode(json) as Map; + final modules = decoded['modules'] as Map; + final topMod = modules[mod.definitionName] as Map; + final ports = topMod['ports'] as Map; + + expect((ports['a'] as Map)['bits'], hasLength(width)); + expect((ports['b'] as Map)['bits'], hasLength(width)); + expect((ports['sum'] as Map)['bits'], hasLength(width)); + }); + + test('cells have connections in default mode', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final json = await NetlistSynthesizer().synthesizeToJson(mod); + final decoded = jsonDecode(json) as Map; + final modules = decoded['modules'] as Map; + final topMod = modules[mod.definitionName] as Map; + final cells = topMod['cells'] as Map? ?? {}; + + final hasConnections = cells.values.any((cell) { + final c = cell as Map; + final conns = c['connections'] as Map?; + return conns != null && conns.isNotEmpty; + }); + expect(hasConnections, isTrue); + }); + + test('generateCombinedJson and synthesizeToJson produce same module keys', + () async { + final mod = _InverterModule(Logic(name: 'inp')); + await mod.build(); + + final synthesizer = NetlistSynthesizer(); + final synth = SynthBuilder(mod, synthesizer); + + final fromCombined = await synthesizer.generateCombinedJson(synth, mod); + final fromConvenience = await NetlistSynthesizer().synthesizeToJson(mod); + + final combinedModules = + (jsonDecode(fromCombined) as Map)['modules'] as Map; + final convenienceModules = + (jsonDecode(fromConvenience) as Map)['modules'] as Map; + expect(combinedModules.keys.toSet(), + equals(convenienceModules.keys.toSet())); + }); + }); + + // ── SynthBuilder ────────────────────────────────────────────────────── + + group('SynthBuilder', () { + test('synthesisResults are NetlistSynthesisResult instances', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final synth = SynthBuilder(mod, NetlistSynthesizer()); + expect(synth.synthesisResults, isNotEmpty); + for (final result in synth.synthesisResults) { + expect(result, isA()); + } + }); + + test('composite module includes sub-module definitions', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final synth = SynthBuilder(mod, NetlistSynthesizer()); + final names = + synth.synthesisResults.map((r) => r.instanceTypeName).toSet(); + expect(names, contains(mod.definitionName)); + expect(synth.synthesisResults.length, greaterThan(1)); + }); + + test('toSynthFileContents produces valid JSON per definition', () async { + final mod = _InverterModule(Logic(name: 'inp')); + await mod.build(); + + final fileContents = + SynthBuilder(mod, NetlistSynthesizer()).getSynthFileContents(); + expect(fileContents, isNotEmpty); + for (final fc in fileContents) { + expect(fc.name, isNotEmpty); + expect(jsonDecode(fc.contents), isA>()); + } + }); + }); + + // ── NetlistSynthesisResult maps ─────────────────────────────────────── + + group('NetlistSynthesisResult maps', () { + test('ports map has direction and bits for each port', () async { + final mod = + _AdderModule(Logic(name: 'a', width: 8), Logic(name: 'b', width: 8)); + await mod.build(); + + final result = SynthBuilder(mod, NetlistSynthesizer()) + .synthesisResults + .whereType() + .firstWhere((r) => r.module == mod); + + for (final portName in ['a', 'b', 'sum']) { + expect(result.ports, contains(portName)); + final port = result.ports[portName]!; + expect(port, contains('direction')); + expect(port, contains('bits')); + } + }); + + test('netnames map is populated', () async { + final mod = _InverterModule(Logic(name: 'inp')); + await mod.build(); + + final result = SynthBuilder(mod, NetlistSynthesizer()) + .synthesisResults + .whereType() + .firstWhere((r) => r.module == mod); + expect(result.netnames, isNotEmpty); + }); + }); + + // ── collectModuleEntries ────────────────────────────────────────────── + + group('collectModuleEntries', () { + test('gathers results with correct structure and top attribute', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final synth = SynthBuilder(mod, NetlistSynthesizer()); + final modulesMap = NetlistPasses.collectModuleEntries( + synth.synthesisResults, + topModule: mod); + + expect(modulesMap, contains(mod.definitionName)); + expect(modulesMap.length, greaterThan(1)); + + // Top attribute + final topAttrs = modulesMap[mod.definitionName]!['attributes']! + as Map; + expect(topAttrs['top'], equals(1)); + + // Every entry has the expected sections + for (final entry in modulesMap.values) { + expect(entry, contains('ports')); + expect(entry, contains('cells')); + expect(entry, contains('netnames')); + } + }); + }); + + // ── buildModulesMap ─────────────────────────────────────────────────── + + group('buildModulesMap', () { + test('returns map with all definitions and expected sections', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final synthesizer = NetlistSynthesizer(); + final synth = SynthBuilder(mod, synthesizer); + final modulesMap = await synthesizer.buildModulesMap(synth, mod); + + expect(modulesMap, contains(mod.definitionName)); + expect(modulesMap.length, greaterThan(1)); + for (final modEntry in modulesMap.entries) { + final data = modEntry.value; + expect(data, contains('ports'), reason: modEntry.key); + expect(data, contains('cells'), reason: modEntry.key); + expect(data, contains('netnames'), reason: modEntry.key); + } + }); + }); + + // ── NetlistOptions ─────────────────────────────────────────────────── + + group('NetlistOptions', () { + test('slimMode omits cell connections', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final slimSynth = + NetlistSynthesizer(options: const NetlistOptions(slimMode: true)); + final json = await slimSynth.synthesizeToJson(mod); + final decoded = jsonDecode(json) as Map; + final modules = decoded['modules'] as Map; + + for (final modEntry in modules.values) { + final data = modEntry as Map; + final cells = data['cells'] as Map? ?? {}; + for (final cell in cells.values) { + final c = cell as Map; + final conns = c['connections'] as Map?; + if (conns != null) { + expect(conns, isEmpty, reason: 'slim mode should omit connections'); + } + } + } + }); + }); + + // ── FilterBank (multi-channel, dedup, loopback) ─────────────────────── + + group('FilterBank netlist', () { + test('produces valid netlist with multiple module definitions', () async { + final mod = _buildFilterBank(); + await mod.build(); + + final modules = + await _synthesizeAndWrite(mod, 'build/FilterBank.rohd.json'); + expect(modules, isNotEmpty); + expect(modules.length, greaterThan(1), + reason: 'FilterBank should have sub-module definitions'); + + // Top module should have cells + final topMod = modules[mod.definitionName] as Map; + final cells = topMod['cells'] as Map? ?? {}; + expect(cells, isNotEmpty, reason: 'FilterBank should have cells'); + }); + + test('FilterChannel definitions are deduplicated', () async { + final mod = _buildFilterBank(); + await mod.build(); + + final json = await NetlistSynthesizer().synthesizeToJson(mod); + final parsed = jsonDecode(json) as Map; + final modules = parsed['modules'] as Map; + final channelDefs = + modules.keys.where((k) => k.contains('FilterChannel')).toList(); + // Two channels with different coefficients should produce + // separate definitions (not fully deduplicated). + expect(channelDefs, isNotEmpty, + reason: 'FilterChannel definitions should be present'); + }); + + test('all module entries have ports, cells, and netnames', () async { + final mod = _buildFilterBank(); + await mod.build(); + + final synthesizer = NetlistSynthesizer(); + final synth = SynthBuilder(mod, synthesizer); + final modulesMap = await synthesizer.buildModulesMap(synth, mod); + + for (final entry in modulesMap.entries) { + final data = entry.value; + expect(data, contains('ports'), reason: '${entry.key} missing ports'); + expect(data, contains('cells'), reason: '${entry.key} missing cells'); + expect(data, contains('netnames'), + reason: '${entry.key} missing netnames'); + } + }); + + test('ports have correct directions on sub-modules', () async { + final mod = _buildFilterBank(); + await mod.build(); + + final synthesizer = NetlistSynthesizer(); + final synth = SynthBuilder(mod, synthesizer); + + for (final result + in synth.synthesisResults.whereType()) { + for (final port in result.ports.entries) { + final dir = port.value['direction']! as String; + expect(['input', 'output', 'inout'], contains(dir), + reason: '${result.instanceTypeName}.${port.key} ' + 'has invalid direction'); + } + } + }); + }); + + // ----------------------------------------------------------------------- + // Bit-range compression & compact JSON + // ----------------------------------------------------------------------- + group('Bit-range compression', () { + test('compressBitRanges option produces range strings in JSON', () async { + final a = Logic(name: 'a', width: 8); + final mod = _AdderModule(a, Logic(name: 'b', width: 8)); + await mod.build(); + + final synthCompressed = NetlistSynthesizer( + options: const NetlistOptions(compressBitRanges: true), + ); + final jsonCompressed = await synthCompressed.synthesizeToJson(mod); + + final synthNormal = NetlistSynthesizer(); + final jsonNormal = await synthNormal.synthesizeToJson(mod); + + // Compressed should be shorter. + expect(jsonCompressed.length, lessThan(jsonNormal.length)); + + // Both should parse as valid JSON with the same module keys. + final decodedCompressed = jsonDecode(jsonCompressed) as Map; + final decodedNormal = jsonDecode(jsonNormal) as Map; + expect( + (decodedCompressed['modules'] as Map).keys.toSet(), + equals((decodedNormal['modules'] as Map).keys.toSet()), + ); + + // Compressed JSON should contain range strings like "2:9". + expect(jsonCompressed, contains(RegExp(r'"\d+:\d+"'))); + // Normal JSON should NOT contain range strings. + expect(jsonNormal, isNot(contains(RegExp(r'"\d+:\d+"')))); + }); + + test('compressed ranges preserve constant bit strings', () async { + // Use a module that produces constant "0"/"1" bits in the netlist. + final a = Logic(name: 'a'); + final mod = _InverterModule(a); + await mod.build(); + + final synth = NetlistSynthesizer( + options: const NetlistOptions(compressBitRanges: true), + ); + final json = await synth.synthesizeToJson(mod); + final decoded = jsonDecode(json) as Map; + + // Should still be valid JSON. + expect(decoded['modules'], isNotNull); + }); + + test('compactJson option removes indentation', () async { + final a = Logic(name: 'a', width: 8); + final mod = _AdderModule(a, Logic(name: 'b', width: 8)); + await mod.build(); + + final synthCompact = NetlistSynthesizer( + options: const NetlistOptions(compactJson: true), + ); + final jsonCompact = await synthCompact.synthesizeToJson(mod); + + final synthNormal = NetlistSynthesizer(); + final jsonNormal = await synthNormal.synthesizeToJson(mod); + + // Compact should be shorter. + expect(jsonCompact.length, lessThan(jsonNormal.length)); + // Compact should have no leading whitespace lines. + expect(jsonCompact, isNot(contains('\n '))); + // Both should be valid JSON with the same module keys. + final decodedCompact = jsonDecode(jsonCompact) as Map; + final decodedNormal = jsonDecode(jsonNormal) as Map; + expect( + (decodedCompact['modules'] as Map).keys.toSet(), + equals((decodedNormal['modules'] as Map).keys.toSet()), + ); + }); + + test('both options together produce smallest output', () async { + final a = Logic(name: 'a', width: 8); + final mod = _AdderModule(a, Logic(name: 'b', width: 8)); + await mod.build(); + + final synthBoth = NetlistSynthesizer( + options: const NetlistOptions( + compressBitRanges: true, + compactJson: true, + ), + ); + final jsonBoth = await synthBoth.synthesizeToJson(mod); + + final synthCompressOnly = NetlistSynthesizer( + options: const NetlistOptions(compressBitRanges: true), + ); + final jsonCompressOnly = await synthCompressOnly.synthesizeToJson(mod); + + final synthCompactOnly = NetlistSynthesizer( + options: const NetlistOptions(compactJson: true), + ); + final jsonCompactOnly = await synthCompactOnly.synthesizeToJson(mod); + + expect(jsonBoth.length, lessThan(jsonCompressOnly.length)); + expect(jsonBoth.length, lessThan(jsonCompactOnly.length)); + }); + + test( + 'compressed FilterBank round-trips: range strings expand to ' + 'same bit IDs as uncompressed', () async { + final mod = _buildFilterBank(); + await mod.build(); + + // Generate both compressed and uncompressed. + final synthNormal = NetlistSynthesizer(); + final jsonNormal = await synthNormal.synthesizeToJson(mod); + final normalModules = (jsonDecode(jsonNormal) + as Map)['modules'] as Map; + + final synthCompressed = NetlistSynthesizer( + options: const NetlistOptions(compressBitRanges: true), + ); + final jsonCompressed = await synthCompressed.synthesizeToJson(mod); + final compressedModules = (jsonDecode(jsonCompressed) + as Map)['modules'] as Map; + + // Compressed should be smaller. + expect(jsonCompressed.length, lessThan(jsonNormal.length)); + + // Same module keys. + expect(compressedModules.keys.toSet(), normalModules.keys.toSet()); + + // Verify compressed JSON contains range strings. + expect(jsonCompressed, contains(RegExp(r'"\d+:\d+"'))); + + // For each module, expand compressed port bits and compare to normal. + for (final modName in normalModules.keys) { + final normalPorts = (normalModules[modName] + as Map)['ports'] as Map?; + final compPorts = (compressedModules[modName] + as Map)['ports'] as Map?; + if (normalPorts == null || compPorts == null) { + continue; + } + + for (final portName in normalPorts.keys) { + final normalBits = + (normalPorts[portName] as Map)['bits'] as List; + final compBits = + (compPorts[portName] as Map)['bits'] as List; + + // Expand any range strings in the compressed bits. + final expanded = []; + for (final b in compBits) { + if (b is String && b.contains(':')) { + final parts = b.split(':'); + final start = int.parse(parts[0]); + final end = int.parse(parts[1]); + for (var i = start; i <= end; i++) { + expanded.add(i); + } + } else { + expanded.add(b); + } + } + + expect(expanded, normalBits, + reason: 'round-trip failed for $modName.$portName'); + } + } + }); + }); +} diff --git a/test/signal_registry_test.dart b/test/signal_registry_test.dart index d1719c85e..d51f1f0cd 100644 --- a/test/signal_registry_test.dart +++ b/test/signal_registry_test.dart @@ -10,6 +10,7 @@ import 'package:rohd/rohd.dart'; import 'package:test/test.dart'; +import '../example/filter_bank.dart'; // ──────────────────────────────────────────────────────────────── // Simple test modules // ──────────────────────────────────────────────────────────────── @@ -140,4 +141,43 @@ void main() { expect(names1, equals(names2)); }); }); + + group('filter_bank hierarchy', () { + test('submodule canonical names work independently', () async { + const dataWidth = 16; + const numTaps = 3; + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + final dut = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: [ + [1, 2, 1], + [1, -2, 1], + ], + ); + await dut.build(); + + expect(dut.namer.signalNameOf(dut.input('clk')), equals('clk')); + expect(dut.namer.signalNameOf(dut.output('done')), equals('done')); + + for (final sub in dut.subModules) { + for (final entry in sub.inputs.entries) { + final name = sub.namer.signalNameOf(entry.value); + expect(name, isNotEmpty); + } + } + }); + }); } diff --git a/test/slim_connected_port_test.dart b/test/slim_connected_port_test.dart new file mode 100644 index 000000000..1f2f642e0 --- /dev/null +++ b/test/slim_connected_port_test.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +class SimpleModule extends Module { + SimpleModule(Logic a) : super(name: 'SimpleTest') { + a = addInput('a', a, width: 8); + final b = addOutput('b', width: 8); + b <= ~a; + addOutput('unused_port', width: 4); // not connected internally + } +} + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + test('slim ports carry connected attribute', () async { + final a = Logic(width: 8, name: 'a'); + final mod = SimpleModule(a); + await mod.build(); + final netSvc = await NetlistService.create(mod); + + final slim = netSvc.slimJson; + + final parsed = json.decode(slim) as Map; + final netlist = parsed['netlist'] as Map; + final modules = netlist['modules'] as Map; + + // Find the SimpleTest module + // ignore: avoid_print + print('Module keys: ${modules.keys.toList()}'); + // The module name may be the type name or uniquified; find it + final simpleTestKey = modules.keys.firstWhere( + (k) => k.contains('SimpleTest'), + orElse: () => modules.keys.first, + ); + final simpleTest = modules[simpleTestKey] as Map; + // ignore: avoid_print + print('Using module: $simpleTestKey'); + + final ports = simpleTest['ports'] as Map; + + // Port 'a' is connected internally (feeds ~a) + final portA = ports['a'] as Map; + expect( + portA['connected'], + isTrue, + reason: 'Port a should be marked connected', + ); + + // Port 'b' is connected internally (output of ~a) + final portB = ports['b'] as Map; + expect( + portB['connected'], + isTrue, + reason: 'Port b should be marked connected', + ); + + // Port 'unused_port' is NOT connected internally + final portUnused = ports['unused_port'] as Map; + expect( + portUnused.containsKey('connected'), + isFalse, + reason: 'unused_port should not have connected attribute', + ); + + // Print for visibility + for (final p in ports.entries) { + final pd = p.value as Map; + // ignore: avoid_print + print('Port ${p.key}: connected=${pd["connected"]}'); + } + }); +} diff --git a/test/slim_full_canonical_test.dart b/test/slim_full_canonical_test.dart new file mode 100644 index 000000000..ce979b5fa --- /dev/null +++ b/test/slim_full_canonical_test.dart @@ -0,0 +1,180 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// slim_full_canonical_test.dart +// Validates that slim and full synthesis produce identical cell sets. + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/filter_bank.dart'; + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + test('slim and full produce identical cell keys for FilterBank', () async { + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], 16, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + final dut = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: 3, + dataWidth: 16, + coefficients: [ + [1, 2, 1], + [1, -2, 1], + ], + ); + await dut.build(); + final netSvc = await NetlistService.create(dut); + + // 1. Get slim JSON + final slimJsonStr = netSvc.slimJson; + + final slimUnified = jsonDecode(slimJsonStr) as Map; + final slimNetlist = slimUnified['netlist'] as Map; + final slimModules = slimNetlist['modules'] as Map; + + expect(slimModules, isNotEmpty, reason: 'No slim modules found'); + + // 2. For each slim module, fetch full and compare cell keys + var modulesTested = 0; + final mismatches = []; + + for (final moduleKey in slimModules.keys) { + final slimMod = slimModules[moduleKey] as Map; + final slimCells = slimMod['cells'] as Map? ?? {}; + + // Fetch full data + final fullJsonStr = netSvc.moduleJson(moduleKey); + final fullJson = jsonDecode(fullJsonStr) as Map; + if (fullJson.containsKey('status')) { + mismatches.add('$moduleKey: full fetch returned not_found'); + continue; + } + + final fullModules = fullJson['modules'] as Map?; + final fullMod = fullModules?[moduleKey] as Map?; + if (fullMod == null) { + // The full data might be under a different key (base definition). + // Try the first key. + final firstKey = fullModules?.keys.first; + final altMod = firstKey != null + ? fullModules![firstKey] as Map? + : null; + if (altMod == null) { + mismatches.add('$moduleKey: no module data in full response'); + continue; + } + // Use the alt module + _compareCells(moduleKey, slimCells, altMod, mismatches); + } else { + _compareCells(moduleKey, slimCells, fullMod, mismatches); + } + modulesTested++; + } + + // Report + if (mismatches.isNotEmpty) { + fail( + 'Cell key mismatches found in $modulesTested modules:\n' + '${mismatches.join('\n')}', + ); + } + + // Sanity: we tested a reasonable number of modules + expect(modulesTested, greaterThan(0), reason: 'No modules were tested'); + // ignore: avoid_print + print( + 'Validated $modulesTested modules — all cell keys match ' + 'between slim and full.', + ); + }); +} + +void _compareCells( + String moduleKey, + Map slimCells, + Map fullMod, + List mismatches, +) { + final fullCells = fullMod['cells'] as Map? ?? {}; + + final slimKeys = slimCells.keys.toList(); + final fullKeys = fullCells.keys.toList(); + + if (slimKeys.length != fullKeys.length) { + mismatches.add( + '$moduleKey: cell count differs — ' + 'slim=${slimKeys.length}, full=${fullKeys.length}', + ); + // Show which keys differ + final slimOnly = slimKeys.toSet().difference(fullKeys.toSet()); + final fullOnly = fullKeys.toSet().difference(slimKeys.toSet()); + if (slimOnly.isNotEmpty) { + mismatches.add(' slim-only: $slimOnly'); + } + if (fullOnly.isNotEmpty) { + mismatches.add(' full-only: $fullOnly'); + } + return; + } + + // Check ordering matches + for (var i = 0; i < slimKeys.length; i++) { + if (slimKeys[i] != fullKeys[i]) { + mismatches.add( + '$moduleKey: cell key ordering differs at index $i — ' + 'slim="${slimKeys[i]}", full="${fullKeys[i]}"', + ); + return; + } + } + + // Check cell types match + for (final key in slimKeys) { + final slimCell = slimCells[key] as Map; + final fullCell = fullCells[key] as Map; + final slimType = slimCell['type'] as String?; + final fullType = fullCell['type'] as String?; + if (slimType != fullType) { + mismatches.add( + '$moduleKey: cell "$key" type differs — ' + 'slim="$slimType", full="$fullType"', + ); + } + } + + // Verify slim cells DON'T have connections + for (final key in slimKeys) { + final slimCell = slimCells[key] as Map; + if (slimCell.containsKey('connections')) { + mismatches.add( + '$moduleKey: slim cell "$key" has connections ' + '(should be stripped)', + ); + } + } + + // Verify full cells DO have connections + for (final key in fullKeys) { + final fullCell = fullCells[key] as Map; + if (!fullCell.containsKey('connections')) { + mismatches.add('$moduleKey: full cell "$key" missing connections'); + } + } +} diff --git a/test/slim_incremental_equivalence_test.dart b/test/slim_incremental_equivalence_test.dart new file mode 100644 index 000000000..cd5c84f00 --- /dev/null +++ b/test/slim_incremental_equivalence_test.dart @@ -0,0 +1,310 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// slim_incremental_equivalence_test.dart +// Validates that assembling full data from slim + per-module fetches +// produces the same result as pulling the full netlist in one shot. + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/example.dart' as ex; +import '../example/filter_bank.dart'; +import '../example/fir_filter.dart'; +import '../example/oven_fsm.dart'; + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + /// Builds [module] with netlist synthesis, then verifies that + /// reassembling the full netlist from slim + per-module fetches + /// produces a result equivalent to [toFullJson]. + /// + /// For each module definition in the slim netlist: + /// 1. Cell keys, types, and ordering must match the full netlist. + /// 2. Port definitions (direction, bit indices) must match. + /// 3. Fetching full data via [moduleJson] adds connections + /// that exactly match those in the full netlist. + Future validateSlimIncrementalEquivalence(Module module) async { + await module.build(); + final netSvc = await NetlistService.create(module); + + // ── Pull full netlist in one shot ───────────────────────────── + final fullJsonStr = netSvc.toJson(); + final fullNetlist = jsonDecode(fullJsonStr) as Map; + final fullModules = fullNetlist['modules'] as Map; + + // ── Pull slim netlist ───────────────────────────────────────── + final slimJsonStr = netSvc.slimJson; + final slimUnified = jsonDecode(slimJsonStr) as Map; + final slimNetlist = slimUnified['netlist'] as Map; + final slimModules = slimNetlist['modules'] as Map; + + // ── Same set of module definition keys ──────────────────────── + expect( + slimModules.keys.toSet(), + equals(fullModules.keys.toSet()), + reason: 'Slim and full should have identical module keys', + ); + + // ── Per-module comparison ───────────────────────────────────── + final errors = []; + + for (final moduleKey in fullModules.keys) { + final fullMod = fullModules[moduleKey] as Map; + final slimMod = slimModules[moduleKey] as Map; + + final fullCells = fullMod['cells'] as Map? ?? {}; + final slimCells = slimMod['cells'] as Map? ?? {}; + + // ── Cell keys and ordering ────────────────────────────────── + final fullCellKeys = fullCells.keys.toList(); + final slimCellKeys = slimCells.keys.toList(); + if (!_listsEqual(fullCellKeys, slimCellKeys)) { + errors.add( + '$moduleKey: cell keys differ — ' + 'full=$fullCellKeys, slim=$slimCellKeys', + ); + continue; // Skip deeper checks for this module + } + + // ── Cell types match ──────────────────────────────────────── + for (final cellKey in fullCellKeys) { + final fullCell = fullCells[cellKey] as Map; + final slimCell = slimCells[cellKey] as Map; + if (fullCell['type'] != slimCell['type']) { + errors.add( + '$moduleKey.$cellKey: type mismatch — ' + 'full="${fullCell['type']}", slim="${slimCell['type']}"', + ); + } + } + + // ── Port definitions match ────────────────────────────────── + final fullPorts = fullMod['ports'] as Map? ?? {}; + final slimPorts = slimMod['ports'] as Map? ?? {}; + if (!_listsEqual(fullPorts.keys.toList(), slimPorts.keys.toList())) { + errors.add( + '$moduleKey: port keys differ — ' + 'full=${fullPorts.keys.toList()}, ' + 'slim=${slimPorts.keys.toList()}', + ); + } else { + for (final portKey in fullPorts.keys) { + final fullPort = fullPorts[portKey] as Map; + final slimPort = slimPorts[portKey] as Map; + if (fullPort['direction'] != slimPort['direction']) { + errors.add('$moduleKey port $portKey: direction mismatch'); + } + final fullBits = fullPort['bits'] as List?; + final slimBits = slimPort['bits'] as List?; + if (!_listsEqual(fullBits ?? [], slimBits ?? [])) { + errors.add( + '$moduleKey port $portKey: bits mismatch — ' + 'full=$fullBits, slim=$slimBits', + ); + } + } + } + + // ── Slim cells must NOT have connections ──────────────────── + for (final cellKey in slimCellKeys) { + final slimCell = slimCells[cellKey] as Map; + if (slimCell.containsKey('connections')) { + errors.add( + '$moduleKey.$cellKey: slim cell has connections ' + '(should be stripped)', + ); + } + } + + // ── Full cells must have connections ──────────────────────── + for (final cellKey in fullCellKeys) { + final fullCell = fullCells[cellKey] as Map; + if (!fullCell.containsKey('connections')) { + errors.add('$moduleKey.$cellKey: full cell missing connections'); + } + } + + // ── Fetch full data via moduleJson ───────────────────────── + // This is the incremental-loading contract: for EVERY module + // in the slim netlist, fetching full data must recover the + // exact connections present in the one-shot full netlist. + final fetchedStr = netSvc.moduleJson(moduleKey); + final fetchedJson = jsonDecode(fetchedStr) as Map; + if (fetchedJson.containsKey('status')) { + errors.add('$moduleKey: moduleJson returned not_found'); + continue; + } + + // The fetched result is {"creator":..., "modules": {key: data}}. + final fetchedModules = + fetchedJson['modules'] as Map? ?? fetchedJson; + final fetchedMod = + (fetchedModules[moduleKey] ?? fetchedModules.values.first) + as Map; + + final fetchedCells = fetchedMod['cells'] as Map? ?? {}; + + // ── Fetched cell keys must match full ─────────────────────── + if (!_listsEqual(fetchedCells.keys.toList(), fullCellKeys)) { + errors.add( + '$moduleKey: fetched cell keys differ from full — ' + 'fetched=${fetchedCells.keys.toList()}, ' + 'full=$fullCellKeys', + ); + continue; + } + + // ── Fetched connections must match full exactly ───────────── + for (final cellKey in fullCellKeys) { + final fullCell = fullCells[cellKey] as Map; + final fetchedCell = fetchedCells[cellKey] as Map; + + final fullConns = + fullCell['connections'] as Map? ?? {}; + final fetchedConns = + fetchedCell['connections'] as Map? ?? {}; + + if (!_connectionsEqual(fullConns, fetchedConns)) { + errors.add( + '$moduleKey.$cellKey: connections mismatch — ' + 'full=$fullConns, fetched=$fetchedConns', + ); + } + } + + // ── Fetched ports must match full ─────────────────────────── + final fetchedPorts = fetchedMod['ports'] as Map? ?? {}; + for (final portKey in fullPorts.keys) { + final fullPort = fullPorts[portKey] as Map; + final fetchedPort = fetchedPorts[portKey] as Map?; + if (fetchedPort == null) { + errors.add('$moduleKey port $portKey: missing in fetched data'); + continue; + } + if (fullPort['direction'] != fetchedPort['direction']) { + errors.add( + '$moduleKey port $portKey: direction mismatch ' + 'in fetched data', + ); + } + final fullBits = fullPort['bits'] as List?; + final fetchedBits = fetchedPort['bits'] as List?; + if (!_listsEqual(fullBits ?? [], fetchedBits ?? [])) { + errors.add( + '$moduleKey port $portKey: bits mismatch — ' + 'full=$fullBits, fetched=$fetchedBits', + ); + } + } + } + + // ── Report ──────────────────────────────────────────────────── + if (errors.isNotEmpty) { + fail('Slim incremental equivalence errors:\n${errors.join('\n')}'); + } + + // ignore: avoid_print + print( + 'Validated ${fullModules.length} modules — ' + 'slim + incremental fetch matches full.', + ); + } + + test('Counter: slim + incremental fetch == full', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final counter = ex.Counter(en, reset, clk); + await validateSlimIncrementalEquivalence(counter); + }); + + test('FIR filter: slim + incremental fetch == full', () async { + final en = Logic(name: 'en'); + final resetB = Logic(name: 'resetB'); + final clk = SimpleClockGenerator(10).clk; + final inputVal = Logic(name: 'inputVal', width: 8); + final fir = FirFilter(en, resetB, clk, inputVal, [0, 0, 0, 1], bitWidth: 8); + await validateSlimIncrementalEquivalence(fir); + }); + + test('OvenModule: slim + incremental fetch == full', () async { + final button = Logic(name: 'button', width: 2); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final oven = OvenModule(button, reset, clk); + await validateSlimIncrementalEquivalence(oven); + }); + + test('FilterBank: slim + incremental fetch == full', () async { + const dataWidth = 16; + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + final dut = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: 3, + dataWidth: dataWidth, + coefficients: [ + [1, 2, 1], + [1, -2, 1], + ], + ); + await validateSlimIncrementalEquivalence(dut); + }); +} + +/// Deep-compare two lists element by element. +bool _listsEqual(List a, List b) { + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; +} + +/// Compare two connection maps: {portName: [bit indices]}. +/// +/// All bit indices are numeric IDs; order within each port's list matters +/// because it encodes the wire mapping. +bool _connectionsEqual(Map a, Map b) { + if (a.length != b.length) { + return false; + } + for (final key in a.keys) { + if (!b.containsKey(key)) { + return false; + } + final aBits = a[key] as List?; + final bBits = b[key] as List?; + if (aBits == null && bBits == null) { + continue; + } + if (aBits == null || bBits == null) { + return false; + } + if (!_listsEqual(aBits, bBits)) { + return false; + } + } + return true; +} diff --git a/test/struct_port_pruning_test.dart b/test/struct_port_pruning_test.dart new file mode 100644 index 000000000..b13346ebe --- /dev/null +++ b/test/struct_port_pruning_test.dart @@ -0,0 +1,143 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// struct_port_pruning_test.dart +// Verifies that struct port elements on submodules are not incorrectly +// pruned during SV synthesis. Exercises the `submoduleOutputSynths` / +// `submoduleInputSynths` fix in `_pruneUnused`. +// +// 2026 April 17 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +// ── Struct definition ────────────────────────────────────────── + +class PairStruct extends LogicStructure { + PairStruct({Logic? a, Logic? b, super.name = 'pair'}) + : super([a ?? Logic(name: 'a'), b ?? Logic(name: 'b')]); + + @override + PairStruct clone({String? name}) => PairStruct(name: name); +} + +// ── Leaf submodule with a struct output port ─────────────────── + +class StructProducer extends Module { + Logic get out => PairStruct()..gets(output('out')); + + StructProducer(Logic x, Logic y) : super(name: 'struct_producer') { + x = addInput('x', x); + y = addInput('y', y); + + final s = PairStruct(a: x, b: y); + addOutput('out', width: s.width) <= s; + } +} + +// ── Leaf submodule with a struct input port ──────────────────── + +class StructConsumer extends Module { + Logic get sum => output('sum'); + + StructConsumer(Logic pair) : super(name: 'struct_consumer') { + pair = addInput('pair', pair, width: pair.width); + + final s = PairStruct()..gets(pair); + addOutput('sum') <= s.elements[0] ^ s.elements[1]; + } +} + +// ── Top module: struct output from submodule → struct input ─── + +class StructPipeTop extends Module { + Logic get result => output('result'); + + StructPipeTop(Logic x, Logic y) : super(name: 'struct_pipe_top') { + x = addInput('x', x); + y = addInput('y', y); + + final producer = StructProducer(x, y); + final consumer = StructConsumer(producer.out); + + addOutput('result') <= consumer.sum; + } +} + +void main() { + tearDown(() async { + await Simulator.reset(); + }); + + group('struct port pruning', () { + test('SV output retains struct element signals from submodule', () async { + final dut = StructPipeTop(Logic(), Logic()); + await dut.build(); + + final svStr = dut.generateSynth(); + + // The struct_producer submodule should appear in the SV. + expect( + svStr, + contains('struct_producer'), + reason: 'Submodule with struct output should not be pruned', + ); + + // The struct_consumer submodule should appear in the SV. + expect( + svStr, + contains('struct_consumer'), + reason: 'Submodule with struct input should not be pruned', + ); + + // The output port 'out' of struct_producer (width 2) must have a + // connection in the parent — it should not be pruned away. + expect( + svStr, + contains('.out('), + reason: 'Struct output port connection should not be pruned', + ); + + // The input port 'pair' of struct_consumer must be connected. + expect( + svStr, + contains('.pair('), + reason: 'Struct input port connection should not be pruned', + ); + }); + + test('struct element signals survive SV synthesis for producer', () async { + final dut = StructProducer(Logic(), Logic()); + await dut.build(); + + final svStr = dut.generateSynth(); + + // Inside StructProducer, the struct elements (a, b from PairStruct) + // drive the output via struct_slice decomposition. They must not + // be pruned. + expect(svStr, contains('out'), reason: 'Output port should appear in SV'); + expect( + svStr, + contains('input'), + reason: 'Input ports should appear in SV', + ); + }); + + test('struct element signals survive SV synthesis for consumer', () async { + final dut = StructConsumer(Logic(width: 2)); + await dut.build(); + + final svStr = dut.generateSynth(); + + // Inside StructConsumer, the struct elements are extracted from the + // packed input. The XOR of elements drives the output. + expect(svStr, contains('sum'), reason: 'Output port should appear in SV'); + expect( + svStr, + contains('pair'), + reason: 'Input struct port should appear in SV', + ); + }); + }); +} diff --git a/test/synth_name_parity_test.dart b/test/synth_name_parity_test.dart new file mode 100644 index 000000000..64b7dcc45 --- /dev/null +++ b/test/synth_name_parity_test.dart @@ -0,0 +1,125 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// synth_name_parity_test.dart +// Tests that verify canonicalNameOf works consistently across +// different synthesis paths (SV and netlist). +// +// 2026 April 14 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/filter_bank.dart'; + +class _Counter extends Module { + _Counter(Logic en, Logic reset, {int width = 8}) : super(name: 'counter') { + en = addInput('en', en); + reset = addInput('reset', reset); + final val = addOutput('val', width: width); + final nextVal = Logic(name: 'nextVal', width: width); + nextVal <= val + 1; + Sequential.multi( + [SimpleClockGenerator(10).clk, reset], + [ + If( + reset, + then: [val < 0], + orElse: [ + If(en, then: [val < nextVal]), + ], + ), + ], + ); + } +} + +void main() { + tearDown(() async { + await Simulator.reset(); + ModuleServices.instance.reset(); + }); + + group('canonicalNameOf after netlist synthesis', () { + test('counter — returns names after netlist synthesis', () async { + final mod = _Counter(Logic(), Logic()); + await mod.build(); + await NetlistService.create(mod); + + expect(mod.namer.signalNameOf(mod.input('en')), equals('en')); + expect(mod.namer.signalNameOf(mod.input('reset')), equals('reset')); + expect(mod.namer.signalNameOf(mod.output('val')), equals('val')); + }); + + test('filter_bank — returns names for sub-module signals', () async { + const dataWidth = 16; + const numTaps = 3; + final clk = SimpleClockGenerator(10).clk; + final reset = Logic(name: 'reset'); + final start = Logic(name: 'start'); + final samplesIn = LogicArray([2], dataWidth, name: 'samplesIn'); + final validIn = Logic(name: 'validIn'); + final inputDone = Logic(name: 'inputDone'); + + final dut = FilterBank( + clk, + reset, + start, + samplesIn, + validIn, + inputDone, + numTaps: numTaps, + dataWidth: dataWidth, + coefficients: [ + [1, 2, 1], + [1, -2, 1], + ], + ); + await dut.build(); + await NetlistService.create(dut); + + expect(dut.namer.signalNameOf(dut.input('clk')), equals('clk')); + expect(dut.namer.signalNameOf(dut.input('reset')), equals('reset')); + expect(dut.namer.signalNameOf(dut.output('done')), equals('done')); + }); + }); + + group('canonicalNameOf after SV synthesis', () { + test('counter — returns canonical name after SV synth', () async { + final mod = _Counter(Logic(), Logic()); + await mod.build(); + + mod.generateSynth(); + + expect(mod.namer.signalNameOf(mod.input('en')), equals('en')); + expect(mod.namer.signalNameOf(mod.input('reset')), equals('reset')); + }); + }); + + group('cross-synthesizer parity', () { + test( + 'counter — SV and netlist produce identical canonicalNameOf', + () async { + final modNetlist = _Counter(Logic(), Logic()); + await modNetlist.build(); + await NetlistService.create(modNetlist); + await Simulator.reset(); + + final modSv = _Counter(Logic(), Logic()); + await modSv.build(); + modSv.generateSynth(); + + // Both paths use the same Namer, so names must match. + final enNetlist = modNetlist.namer.signalNameOf(modNetlist.input('en')); + final enSv = modSv.namer.signalNameOf(modSv.input('en')); + + expect( + enSv, + equals(enNetlist), + reason: 'SV and netlist should produce identical canonical names', + ); + }, + ); + }); +} From 831e3da0afc1166f603789655fec0c316577bbb1 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 6 May 2026 15:16:16 -0700 Subject: [PATCH 19/20] formatting --- lib/src/synthesizers/netlist/netlist_service.dart | 3 +-- test/netlist_synthesizer_test.dart | 1 - test/slim_incremental_equivalence_test.dart | 5 ++--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/src/synthesizers/netlist/netlist_service.dart b/lib/src/synthesizers/netlist/netlist_service.dart index 07aec9105..32b164d94 100644 --- a/lib/src/synthesizers/netlist/netlist_service.dart +++ b/lib/src/synthesizers/netlist/netlist_service.dart @@ -210,8 +210,7 @@ class NetlistService { }; } - final rootName = - module.hasBuilt ? module.uniqueInstanceName : module.name; + final rootName = module.hasBuilt ? module.uniqueInstanceName : module.name; return jsonEncode({ 'netlist': { diff --git a/test/netlist_synthesizer_test.dart b/test/netlist_synthesizer_test.dart index 0c985f18d..8e08e2945 100644 --- a/test/netlist_synthesizer_test.dart +++ b/test/netlist_synthesizer_test.dart @@ -1433,5 +1433,4 @@ void main() { }, ); }); - } diff --git a/test/slim_incremental_equivalence_test.dart b/test/slim_incremental_equivalence_test.dart index cd5c84f00..09664a112 100644 --- a/test/slim_incremental_equivalence_test.dart +++ b/test/slim_incremental_equivalence_test.dart @@ -145,9 +145,8 @@ void main() { // The fetched result is {"creator":..., "modules": {key: data}}. final fetchedModules = fetchedJson['modules'] as Map? ?? fetchedJson; - final fetchedMod = - (fetchedModules[moduleKey] ?? fetchedModules.values.first) - as Map; + final fetchedMod = (fetchedModules[moduleKey] ?? + fetchedModules.values.first) as Map; final fetchedCells = fetchedMod['cells'] as Map? ?? {}; From be67018bde163b225c9c7f3fb53d1dc593688120 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Tue, 12 May 2026 08:47:40 -0700 Subject: [PATCH 20/20] streamlined netlist optimization passes --- .../synthesizers/netlist/netlist_options.dart | 59 +- .../synthesizers/netlist/netlist_passes.dart | 2609 ++--------------- .../netlist/netlist_synthesizer.dart | 54 +- .../synthesizers/netlist/netlist_utils.dart | 188 -- test/netlist_synthesizer_test.dart | 118 +- test/signal_registry_test.dart | 3 + test/slim_connected_port_test.dart | 11 - test/slim_full_canonical_test.dart | 5 - test/slim_incremental_equivalence_test.dart | 6 - 9 files changed, 258 insertions(+), 2795 deletions(-) diff --git a/lib/src/synthesizers/netlist/netlist_options.dart b/lib/src/synthesizers/netlist/netlist_options.dart index 95d0856c1..a92be2217 100644 --- a/lib/src/synthesizers/netlist/netlist_options.dart +++ b/lib/src/synthesizers/netlist/netlist_options.dart @@ -37,8 +37,7 @@ import 'package:rohd/src/synthesizers/netlist/leaf_cell_mapper.dart'; /// Example usage: /// ```dart /// const options = NetlistOptions( -/// groupStructConversions: true, -/// collapseStructGroups: true, +/// collapseTransparentClusters: true, /// ); /// final synth = NetlistSynthesizer(options: options); /// ``` @@ -48,47 +47,13 @@ class NetlistOptions { /// is used. final LeafCellMapper? leafCellMapper; - /// When `true`, groups of `$slice` + `$concat` cells that represent - /// structure-to-structure signal conversions are collapsed into - /// synthetic child modules, reducing visual clutter in the netlist. - final bool groupStructConversions; - - /// When `true` (requires [groupStructConversions] to also be `true`), - /// the synthetic child modules created for struct conversions will have - /// all their internal `$slice`/`$concat` cells and intermediate nets - /// removed, leaving only a single `$buf` cell that directly connects - /// each input port to the corresponding output port. - final bool collapseStructGroups; - - /// When `true` (requires [groupStructConversions] to also be `true`), - /// enables an additional grouping pass that finds `$concat` cells whose - /// input bits all trace back through `$buf`/`$slice` chains to a - /// contiguous sub-range of a single source bus. - final bool groupMaximalSubsets; - - /// When `true` (requires [groupStructConversions] to also be `true`), - /// enables an additional pass that finds `$concat` cells where a - /// contiguous run of input ports trace back through `$buf`/`$slice` - /// chains to a contiguous sub-range of a single source bus. - final bool collapseConcats; - - /// When `true`, `$slice` cells whose outputs feed directly into a - /// `$struct_pack` input port are absorbed into the pack cell, which - /// already describes the field decomposition. The redundant slices - /// are removed. - final bool collapseSelectsIntoPack; - - /// When `true`, `$struct_unpack` output ports that feed directly into - /// `$concat` input ports are collapsed: the concat is replaced by a - /// `$buf` or `$slice` from the unpack's source bus when all inputs - /// trace back to it contiguously. - final bool collapseUnpackToConcat; - - /// When `true`, `$struct_unpack` output ports that feed (possibly - /// through `$buf`/`$slice` chains) into `$struct_pack` input ports - /// are collapsed: the intermediate cells are removed and the pack - /// input wires are rewired to the unpack output wires directly. - final bool collapseUnpackToPack; + /// When `true`, a single unified pass finds connected components of + /// all transparent cells (`$buf`, `$slice`, `$concat`, + /// `$struct_unpack`, `$struct_pack`), traces each cluster's output + /// bits back to their ultimate source bits, and replaces every + /// multi-cell cluster with a direct `$buf`. This subsumes all of + /// the individual collapse passes above. + final bool collapseTransparentClusters; /// When `true`, dead-cell elimination is performed after aliasing to /// remove cells whose inputs are entirely undriven or whose outputs @@ -123,13 +88,7 @@ class NetlistOptions { /// netlist synthesizer behaviour. const NetlistOptions({ this.leafCellMapper, - this.groupStructConversions = false, - this.collapseStructGroups = false, - this.groupMaximalSubsets = false, - this.collapseConcats = false, - this.collapseSelectsIntoPack = false, - this.collapseUnpackToConcat = false, - this.collapseUnpackToPack = false, + this.collapseTransparentClusters = false, this.enableDCE = true, this.slimMode = false, this.compressBitRanges = false, diff --git a/lib/src/synthesizers/netlist/netlist_passes.dart b/lib/src/synthesizers/netlist/netlist_passes.dart index 7fdcf57fb..2e19b45b6 100644 --- a/lib/src/synthesizers/netlist/netlist_passes.dart +++ b/lib/src/synthesizers/netlist/netlist_passes.dart @@ -45,19 +45,33 @@ class NetlistPasses { return allModules; } - // -- Maximal-subset grouping ------------------------------------------- - - /// Finds `$concat` cells whose input bits all trace back through - /// `$buf`/`$slice` chains to a contiguous sub-range of a single source - /// bus. Replaces the entire concat-tree (the concat itself plus the - /// intermediate `$buf` and `$slice` cells that exclusively serve it) - /// with a single `$slice` (or `$buf` when the sub-range covers the - /// full source width). + // ════════════════════════════════════════════════════════════════════ + // Unified transparent-cell clustering + // ════════════════════════════════════════════════════════════════════ + + /// Transparent cell types that only reshuffle / rename bits. + static const _transparentTypes = { + r'$buf', + r'$slice', + r'$concat', + r'$struct_unpack', + r'$struct_pack', + }; + + /// Unified transparent-cell clustering pass. + /// + /// **Phase 1 — Cluster identification:** + /// Builds an undirected graph over transparent cells (two cells are + /// neighbours when one's output wire feeds the other's input) and + /// finds connected components via BFS. /// - /// This pass runs *before* the connected-component grouping so that - /// the simplified cells can be picked up by the standard struct-assign - /// grouping and collapse passes. - static void applyMaximalSubsetGrouping( + /// **Phase 2 — Cluster collapse:** + /// For every multi-cell component, traces each externally-consumed + /// output bit backward through the component's bit-level mapping + /// until reaching an external source bit, then replaces the entire + /// component with a single `$buf` wired from traced sources to + /// destinations. + static void applyTransparentClustering( Map> allModules, ) { for (final moduleDef in allModules.values) { @@ -66,633 +80,94 @@ class NetlistPasses { continue; } - // Build wire-driver, wire-consumer, and bit-to-net maps. - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - NetlistUtils.buildWireMaps(cells, moduleDef); - - final cellsToRemove = {}; - final cellsToAdd = >{}; - var replIdx = 0; - - // Process each $concat cell. - for (final concatEntry in cells.entries.toList()) { - final concatName = concatEntry.key; - final concatCell = concatEntry.value; - if ((concatCell['type'] as String?) != r'$concat') { - continue; - } - if (cellsToRemove.contains(concatName)) { - continue; - } - - final conns = concatCell['connections'] as Map? ?? {}; - - // Gather the concat's input bits in LSB-first order. - final inputBits = []; - if (conns.containsKey('A')) { - // Standard 2-input concat: A (LSB), B (MSB). - for (final b in conns['A'] as List) { - if (b is int) { - inputBits.add(b); - } - } - for (final b in conns['B'] as List) { - if (b is int) { - inputBits.add(b); - } - } - } else { - // Multi-input concat: range-named ports [lo:hi]. - final rangePorts = >{}; - for (final portName in conns.keys) { - if (portName == 'Y') { - continue; - } - final m = NetlistUtils.rangePortRe.firstMatch(portName); - if (m != null) { - final hi = int.parse(m.group(1)!); - final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; - rangePorts[lo] = [ - for (final b in conns[portName] as List) - if (b is int) b, - ]; - } - } - for (final k in rangePorts.keys.toList()..sort()) { - inputBits.addAll(rangePorts[k]!); - } - } - - if (inputBits.isEmpty) { - continue; - } - - final outputBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; - - // Trace each input bit backward through $buf and $slice cells - // to find its ultimate source bit. Record the chain of - // intermediate cells visited. - final sourceBits = []; - final intermediateCells = {}; - var allFromOneBus = true; - String? sourceBusNet; - List? sourceBusBits; - - for (final inputBit in inputBits) { - final (traced, chain) = - NetlistUtils.traceBackward(inputBit, wireDriverCell, cells); - sourceBits.add(traced); - intermediateCells.addAll(chain); - - // Identify which named bus this bit belongs to. - final info = bitToNetInfo[traced]; - if (info == null) { - allFromOneBus = false; - break; - } - if (sourceBusNet == null) { - sourceBusNet = info.$1; - sourceBusBits = info.$2; - } else if (sourceBusNet != info.$1) { - allFromOneBus = false; - break; - } - } - - if (!allFromOneBus || sourceBusNet == null || sourceBusBits == null) { - continue; - } - - // Verify the traced source bits form a contiguous sub-range - // of the source bus. - if (sourceBits.length != inputBits.length) { - continue; - } - - // Find each source bit's index within the source bus. - final indices = []; - var contiguous = true; - for (final sb in sourceBits) { - final idx = sourceBusBits.indexOf(sb); - if (idx < 0) { - contiguous = false; - break; - } - indices.add(idx); - } - if (!contiguous || indices.isEmpty) { - continue; - } - - // Check that indices are sequential (contiguous ascending). - for (var i = 1; i < indices.length; i++) { - if (indices[i] != indices[i - 1] + 1) { - contiguous = false; - break; - } - } - if (!contiguous) { - continue; - } - - // Verify that every intermediate cell is used exclusively - // by this concat chain (no fanout to other consumers). - if (!NetlistUtils.isExclusiveChain( - intermediates: intermediateCells, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - allowPortConsumers: true, - )) { - continue; - } - - // Build the source bus bits list (the full bus from the module). - // We need the A connection to be the full source bus. - final sourceBusParentBits = sourceBusBits.cast().toList(); - - final offset = indices.first; - final yWidth = outputBits.length; - final aWidth = sourceBusBits.length; - - // Mark intermediate cells and the concat for removal. - cellsToRemove - ..addAll(intermediateCells) - ..add(concatName); - - if (yWidth == aWidth) { - cellsToAdd['maxsub_buf_$replIdx'] = NetlistUtils.makeBufCell( - aWidth, sourceBusParentBits, outputBits.cast()); - } else { - cellsToAdd['maxsub_slice_$replIdx'] = NetlistUtils.makeSliceCell( - offset, - aWidth, - yWidth, - sourceBusParentBits, - outputBits.cast()); - } - replIdx++; - } - - // Apply removals and additions. - cellsToRemove.forEach(cells.remove); - cells.addAll(cellsToAdd); - } - } - - // -- Partial concat collapsing ----------------------------------------- + final ports = moduleDef['ports'] as Map? ?? {}; - /// Scans every module in [allModules] for `$concat` cells where a - /// contiguous run of input ports (≥ 2) all trace back through - /// `$buf`/`$slice` chains to a contiguous sub-range of a single source - /// bus with exclusive fan-out. Each such run is replaced by a single - /// `$slice` and the concat is rebuilt with fewer input ports. - /// - /// If *all* ports of a concat qualify as a single run, the concat is - /// eliminated entirely and replaced with a `$slice` (or `$buf` for - /// full-width). - static void applyCollapseConcats( - Map> allModules, - ) { - for (final entry in allModules.entries) { - final moduleDef = entry.value; - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } + // ── Gather transparent cells ── - final concatCount = cells.values - .where((c) => (c['type'] as String?) == r'$concat') - .length; - if (concatCount == 0) { + final tCells = { + for (final e in cells.entries) + if (_transparentTypes.contains(e.value['type'] as String?)) e.key, + }; + if (tCells.isEmpty) { continue; } - // Iterate until no more collapses are possible. Nested concat chains - // (e.g. swizzle_4 feeding swizzle_3) require multiple passes because - // traceBackward only traces through $slice/$buf, not $concat. - var changed = true; - var replIdx = 0; - var iteration = 0; - while (changed && iteration < 20) { - changed = false; - iteration++; - - // --- Build wire-driver, wire-consumer, and bit-to-net maps ------- - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - NetlistUtils.buildWireMaps(cells, moduleDef); - - final cellsToRemove = {}; - final cellsToAdd = >{}; - - // --- Process each $concat cell ------------------------------------ - for (final concatEntry in cells.entries.toList()) { - final concatName = concatEntry.key; - final concatCell = concatEntry.value; - if ((concatCell['type'] as String?) != r'$concat') { - continue; - } - if (cellsToRemove.contains(concatName)) { - continue; - } - - final conns = - concatCell['connections'] as Map? ?? {}; - - // Parse input ports into an ordered list. - // Supports both range-named ports [hi:lo] and A/B form. - final inputPorts = <(int lo, String portName, List bits)>[]; - var hasRangePorts = false; - for (final portName in conns.keys) { - if (portName == 'Y') { - continue; - } - final m = NetlistUtils.rangePortRe.firstMatch(portName); - if (m != null) { - hasRangePorts = true; - final hi = int.parse(m.group(1)!); - final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; - inputPorts.add(( - lo, - portName, - [ - for (final b in conns[portName] as List) - if (b is int) b, - ], - )); - } - } - if (!hasRangePorts) { - // A/B form: convert to ordered list. - if (conns.containsKey('A') && conns.containsKey('B')) { - final aBits = [ - for (final b in conns['A'] as List) - if (b is int) b, - ]; - final bBits = [ - for (final b in conns['B'] as List) - if (b is int) b, - ]; - inputPorts - ..add((0, 'A', aBits)) - ..add((aBits.length, 'B', bBits)); - } - } - inputPorts.sort((a, b) => a.$1.compareTo(b.$1)); - - if (inputPorts.length < 2) { - continue; - } - - // --- Trace each port's bits back to a source bus ---------------- - final portTraces = <({ - String? busName, - List? busBits, - List sourceIndices, - Set intermediates, - bool valid, - })>[]; - - for (final (_, _, bits) in inputPorts) { - final sourceIndices = []; - final intermediates = {}; - String? busName; - List? busBits; - var valid = true; - - for (final bit in bits) { - final (traced, chain) = - NetlistUtils.traceBackward(bit, wireDriverCell, cells); - intermediates.addAll(chain); - - // Identify source net. - final info = bitToNetInfo[traced]; - if (info == null) { - valid = false; - break; - } - if (busName == null) { - busName = info.$1; - busBits = info.$2; - } else if (busName != info.$1) { - valid = false; - break; - } - final idx = busBits!.indexOf(traced); - if (idx < 0) { - valid = false; - break; - } - sourceIndices.add(idx); - } - - // Check contiguous within this port. - if (valid && sourceIndices.length == bits.length) { - for (var i = 1; i < sourceIndices.length; i++) { - if (sourceIndices[i] != sourceIndices[i - 1] + 1) { - valid = false; - break; - } - } - } else { - valid = false; - } - - portTraces.add(( - busName: busName, - busBits: busBits, - sourceIndices: sourceIndices, - intermediates: intermediates, - valid: valid, - )); - } - - // --- Find maximal runs of consecutive traceable ports ----------- - final runs = <(int startIdx, int endIdx)>[]; - var runStart = 0; - while (runStart < inputPorts.length) { - final t = portTraces[runStart]; - if (!t.valid || t.busName == null) { - runStart++; - continue; - } - var runEnd = runStart; - while (runEnd + 1 < inputPorts.length) { - final nextT = portTraces[runEnd + 1]; - if (!nextT.valid) { - break; - } - if (nextT.busName != t.busName) { - break; - } - // Check contiguity across port boundary. - final curLast = portTraces[runEnd].sourceIndices.last; - final nextFirst = nextT.sourceIndices.first; - if (nextFirst != curLast + 1) { - break; - } - runEnd++; - } - if (runEnd > runStart) { - runs.add((runStart, runEnd)); - } - runStart = runEnd + 1; - } - - if (runs.isEmpty) { - continue; - } - - // --- Verify exclusivity of intermediate cells for each run ------ - final validRuns = - <(int startIdx, int endIdx, Set intermediates)>[]; - for (final (startIdx, endIdx) in runs) { - final allIntermediates = {}; - for (var i = startIdx; i <= endIdx; i++) { - allIntermediates.addAll(portTraces[i].intermediates); - } - if (NetlistUtils.isExclusiveChain( - intermediates: allIntermediates, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - validRuns.add((startIdx, endIdx, allIntermediates)); - } - } - - if (validRuns.isEmpty) { - continue; - } - - // --- Check whether ALL ports form a single valid run ------------ - final allCollapsed = validRuns.length == 1 && - validRuns.first.$1 == 0 && - validRuns.first.$2 == inputPorts.length - 1; - - // Remove exclusive intermediate cells for all valid runs. - for (final (_, _, intermediates) in validRuns) { - cellsToRemove.addAll(intermediates); - } + // ── Wire maps ── - if (allCollapsed) { - // Full collapse — replace concat with a single $slice or $buf. - final t0 = portTraces.first; - final srcOffset = t0.sourceIndices.first; - final yWidth = (conns['Y'] as List).whereType().length; - final aWidth = t0.busBits!.length; - final sourceBusParentBits = t0.busBits!.cast().toList(); - final outputBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; + final wireConsumers = >{}; - cellsToRemove.add(concatName); - if (yWidth == aWidth) { - cellsToAdd['collapse_buf_$replIdx'] = NetlistUtils.makeBufCell( - aWidth, sourceBusParentBits, outputBits); - } else { - cellsToAdd['collapse_slice_$replIdx'] = - NetlistUtils.makeSliceCell(srcOffset, aWidth, yWidth, - sourceBusParentBits, outputBits); - } - replIdx++; + for (final e in cells.entries) { + final dirs = e.value['port_directions'] as Map? ?? {}; + final conns = e.value['connections'] as Map? ?? {}; + for (final pe in conns.entries) { + if ((dirs[pe.key] as String?) == 'output') { continue; } - - // --- Partial collapse — rebuild concat with fewer ports --------- - cellsToRemove.add(concatName); - - final newConns = >{}; - final newDirs = {}; - var outBitOffset = 0; - - var portIdx = 0; - while (portIdx < inputPorts.length) { - // Check if this port starts a valid run. - (int, int, Set)? activeRun; - for (final run in validRuns) { - if (run.$1 == portIdx) { - activeRun = run; - break; - } - } - - if (activeRun != null) { - final (startIdx, endIdx, _) = activeRun; - // Compute combined width and collect original input wire bits. - final originalBits = []; - for (var i = startIdx; i <= endIdx; i++) { - originalBits.addAll(inputPorts[i].$3.cast()); - } - final width = originalBits.length; - final t0 = portTraces[startIdx]; - final srcOffset = t0.sourceIndices.first; - final sourceBusBits = t0.busBits!.cast().toList(); - - // Reuse the original concat-input wire bits as the $slice - // output so that existing netname associations are preserved. - cellsToAdd['collapse_slice_$replIdx'] = - NetlistUtils.makeSliceCell(srcOffset, t0.busBits!.length, - width, sourceBusBits, originalBits); - replIdx++; - - // Add the combined port to the rebuilt concat. - final hi = outBitOffset + width - 1; - final portName = - hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; - newConns[portName] = originalBits; - newDirs[portName] = 'input'; - outBitOffset += width; - - portIdx = endIdx + 1; - } else { - // Keep this port as-is. - final port = inputPorts[portIdx]; - final width = port.$3.length; - final hi = outBitOffset + width - 1; - final portName = - hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; - newConns[portName] = port.$3.cast(); - newDirs[portName] = 'input'; - outBitOffset += width; - portIdx++; + for (final b in pe.value as List) { + if (b is int) { + (wireConsumers[b] ??= {}).add(e.key); } } - - // Preserve Y. - newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; - newDirs['Y'] = 'output'; - - // Remove original concat, add collapsed replacement. - cellsToRemove.add(concatName); - cellsToAdd['${concatName}_collapsed'] = { - 'hide_name': concatCell['hide_name'], - 'type': r'$concat', - 'parameters': {}, - 'attributes': concatCell['attributes'] ?? {}, - 'port_directions': newDirs, - 'connections': newConns, - }; } - - // Apply removals and additions. - cellsToRemove.forEach(cells.remove); - cells.addAll(cellsToAdd); - if (cellsToRemove.isNotEmpty || cellsToAdd.isNotEmpty) { - changed = true; - } - } // end while (changed) - } - } - - // -- Struct-conversion grouping ---------------------------------------- - - /// Scans every module in [allModules] for connected components of `$slice` - /// and `$concat` cells that form reconvergent struct-conversion trees. - /// Such trees arise from `LogicStructure.gets()` when a flat bus is - /// assigned to a struct (or vice-versa): leaf fields are sliced out and - /// re-packed through potentially multiple levels of concats. - /// - /// Each connected component is extracted into a new synthetic module - /// definition (added to [allModules]) and replaced in the parent with a - /// single hierarchical cell. This collapses the visual noise in the - /// netlist into a tidy "struct_assign_*" box. - static void applyStructConversionGrouping( - Map> allModules, - ) { - // Collect new module definitions to add (avoid modifying map during - // iteration). - final newModuleDefs = >{}; - - // Process each existing module definition. - for (final moduleName in allModules.keys.toList()) { - final moduleDef = allModules[moduleName]!; - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; } - // Identify all $slice and $concat cells. - final sliceConcat = {}; - for (final entry in cells.entries) { - final type = entry.value['type'] as String?; - if (type == r'$slice' || type == r'$concat') { - sliceConcat.add(entry.key); + // Bits consumed by module output / inout ports. + final portOutBits = {}; + for (final pv in ports.values) { + final pm = pv as Map; + final dir = pm['direction'] as String?; + if (dir == 'output' || dir == 'inout') { + for (final b in pm['bits'] as List) { + if (b is int) { + portOutBits.add(b); + } + } } } - if (sliceConcat.length < 2) { - continue; - } - // Build wire-ID → driver cell and wire-ID → consumer cells maps. - final ( - :wireDriverCell, - wireConsumerCells: wireConsumerSets, - :bitToNetInfo, - ) = NetlistUtils.buildWireMaps(cells, moduleDef); - // Convert Set consumers to List for iteration. - final wireConsumerCells = >{ - for (final e in wireConsumerSets.entries) e.key: e.value.toList(), - }; - final modPorts = moduleDef['ports'] as Map>?; + // ── Phase 1: connected components ── - // Build adjacency among sliceConcat cells: two are adjacent if one's - // output feeds the other's input. final adj = >{ - for (final cn in sliceConcat) cn: {}, + for (final tc in tCells) tc: {}, }; - for (final cn in sliceConcat) { - final cell = cells[cn]!; - final conns = cell['connections'] as Map? ?? {}; - final pdirs = cell['port_directions'] as Map? ?? {}; + + for (final tc in tCells) { + final dirs = + cells[tc]!['port_directions'] as Map? ?? {}; + final conns = cells[tc]!['connections'] as Map? ?? {}; for (final pe in conns.entries) { - final d = pdirs[pe.key] as String? ?? ''; + if ((dirs[pe.key] as String?) != 'output') { + continue; + } for (final b in pe.value as List) { if (b is! int) { continue; } - if (d == 'output') { - // Find consumers in sliceConcat. - for (final consumer in wireConsumerCells[b] ?? []) { - if (consumer != cn && sliceConcat.contains(consumer)) { - adj[cn]!.add(consumer); - adj[consumer]!.add(cn); - } - } - } else if (d == 'input') { - // Find driver in sliceConcat. - final drv = wireDriverCell[b]; - if (drv != null && drv != cn && sliceConcat.contains(drv)) { - adj[cn]!.add(drv); - adj[drv]!.add(cn); + for (final c in wireConsumers[b] ?? const {}) { + if (c != tc && tCells.contains(c)) { + adj[tc]!.add(c); + adj[c]!.add(tc); } } } } } - // Find connected components via BFS. final visited = {}; final components = >[]; - for (final start in sliceConcat) { - if (visited.contains(start)) { + + for (final tc in tCells) { + if (!visited.add(tc)) { continue; } - final comp = {}; - final queue = [start]; - while (queue.isNotEmpty) { - final node = queue.removeLast(); - if (!comp.add(node)) { - continue; - } - visited.add(node); - for (final nb in adj[node]!) { - if (!comp.contains(nb)) { - queue.add(nb); + final comp = {tc}; + final stack = [tc]; + while (stack.isNotEmpty) { + final cur = stack.removeLast(); + for (final nb in adj[cur]!) { + if (visited.add(nb)) { + comp.add(nb); + stack.add(nb); } } } @@ -701,1841 +176,183 @@ class NetlistPasses { } } - // For each connected component, extract it into a synthetic module. - var groupIdx = 0; - final groupQueue = [...components]; - var gqi = 0; - final claimedCells = {}; - while (gqi < groupQueue.length) { - final comp = groupQueue[gqi++]..removeAll(claimedCells); - if (comp.length < 2) { - continue; - } + if (components.isEmpty) { + continue; + } - // Collect all wire IDs used inside the component and classify them - // as internal-only (driven AND consumed within comp) or external - // (boundary ports of the synthetic module). - // - // External inputs = wire IDs consumed by comp cells but driven - // outside the component. - // External outputs = wire IDs produced by comp cells but consumed - // outside the component (or by module ports). - final compOutputIds = {}; // driven by comp - final compInputIds = {}; // consumed by comp + // ── Phase 2: trace & replace ── + + final cellsToRemove = {}; + final cellsToAdd = >{}; + for (final comp in components) { + // Build output-bit → input-bit map for the whole cluster. + final bitMap = {}; for (final cn in comp) { - final cell = cells[cn]!; - final conns = cell['connections'] as Map? ?? {}; - final pdirs = cell['port_directions'] as Map? ?? {}; - for (final pe in conns.entries) { - final d = pdirs[pe.key] as String? ?? ''; - for (final b in pe.value as List) { - if (b is! int) { - continue; - } - if (d == 'output') { - compOutputIds.add(b); - } else if (d == 'input') { - compInputIds.add(b); - } - } - } + _mapCellBits(cells[cn]!, bitMap); } - // External input bits: consumed by comp but NOT driven by comp. - final extInputBits = compInputIds.difference(compOutputIds); - // External output bits: driven by comp but consumed outside comp - // (by non-comp cells or by module output ports). - final extOutputBits = {}; - for (final b in compOutputIds) { - // Check non-comp cell consumers. - for (final consumer in wireConsumerCells[b] ?? []) { - if (!comp.contains(consumer)) { - extOutputBits.add(b); - break; + // External output bits: produced by the cluster but consumed + // by something outside it (another cell or module output port). + final extOut = []; + for (final cn in comp) { + final dirs = + cells[cn]!['port_directions'] as Map? ?? {}; + final conns = + cells[cn]!['connections'] as Map? ?? {}; + for (final pe in conns.entries) { + if ((dirs[pe.key] as String?) != 'output') { + continue; } - } - // Check module output ports. - if (!extOutputBits.contains(b) && modPorts != null) { - for (final portEntry in modPorts.values) { - final dir = portEntry['direction'] as String?; - if (dir != 'output') { + for (final b in pe.value as List) { + if (b is! int) { continue; } - final bits = portEntry['bits'] as List?; - if (bits != null && bits.contains(b)) { - extOutputBits.add(b); - break; + if (portOutBits.contains(b) || + (wireConsumers[b]?.any((c) => !comp.contains(c)) ?? false)) { + extOut.add(b); } } } } - if (extInputBits.isEmpty || extOutputBits.isEmpty) { - continue; // degenerate component, skip + if (extOut.isEmpty) { + // Fully dead cluster — remove. + cellsToRemove.addAll(comp); + continue; } - // Group external bits by netname to form named ports. - // Build a net-name → sorted bit IDs mapping for inputs and outputs. - final netnames = moduleDef['netnames'] as Map? ?? {}; + // Trace each external output back through the cluster to an + // external source bit. + final aList = []; + final yList = []; + var ok = true; - // Wire → netname map (for bits in this component). - final wireToNet = {}; - for (final nnEntry in netnames.entries) { - final nd = nnEntry.value! as Map; - final bits = nd['bits'] as List? ?? []; - for (final b in bits) { - if (b is int) { - wireToNet[b] = nnEntry.key; + for (final ob in extOut) { + Object cur = ob; + final seen = {}; + while (cur is int && bitMap.containsKey(cur)) { + if (!seen.add(cur)) { + ok = false; + break; } + cur = bitMap[cur]!; } + if (!ok) { + break; + } + aList.add(cur); + yList.add(ob); } - // Group external input bits by their netname, preserving order. - final inputGroups = >{}; - for (final b in extInputBits) { - final nn = wireToNet[b] ?? 'in_$b'; - (inputGroups[nn] ??= []).add(b); - } - for (final v in inputGroups.values) { - v.sort(); + if (!ok) { + continue; } - // Group external output bits by their netname, preserving order. - final outputGroups = >{}; - for (final b in extOutputBits) { - final nn = wireToNet[b] ?? 'out_$b'; - (outputGroups[nn] ??= []).add(b); - } - for (final v in outputGroups.values) { - v.sort(); - } + cellsToAdd['cluster_buf_${comp.first}'] = + NetlistUtils.makeBufCell(aList.length, aList, yList); + cellsToRemove.addAll(comp); + } - // Guard: only group when the component is a true struct - // assignment — one signal split into selections then re-assembled - // into one signal. The input may be wider than the output when - // fields are dropped (e.g. a nonCacheable bit unused in the - // destination struct). Multi-source concats (e.g. swizzles - // combining independent signals) and simple bit-range selections - // must remain as standalone cells. - if (inputGroups.length != 1 || - outputGroups.length != 1 || - extInputBits.length < extOutputBits.length) { - // Try sub-component extraction: for each $concat cell in the - // component, backward-BFS to find the subset of cells that - // transitively feed it. If that subset is strictly smaller - // than the full component it may pass the guard on its own. - for (final cn in comp.toList()) { - final cell = cells[cn]; - if (cell == null) { - continue; - } - if ((cell['type'] as String?) != r'$concat') { - continue; - } + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); + } + } - final subComp = {cn}; - final bfsQueue = [cn]; - while (bfsQueue.isNotEmpty) { - final cur = bfsQueue.removeLast(); - final curCell = cells[cur]; - if (curCell == null) { - continue; - } - final cConns = - curCell['connections'] as Map? ?? {}; - final cDirs = - curCell['port_directions'] as Map? ?? {}; - for (final pe in cConns.entries) { - if ((cDirs[pe.key] as String?) != 'input') { - continue; - } - for (final b in pe.value as List) { - if (b is! int) { - continue; - } - final drv = wireDriverCell[b]; - if (drv != null && - comp.contains(drv) && - !subComp.contains(drv)) { - subComp.add(drv); - bfsQueue.add(drv); - } - } - } - } + /// Populates [bitMap] with output-wire-bit → input-wire-bit entries + /// for a single transparent cell. + static void _mapCellBits( + Map cell, + Map bitMap, + ) { + final type = cell['type']! as String; + final dirs = cell['port_directions'] as Map? ?? {}; + final conns = cell['connections'] as Map? ?? {}; + final params = cell['parameters'] as Map? ?? {}; + + switch (type) { + case r'$buf': + _mapPairwise(conns['A'] as List, conns['Y'] as List, bitMap); + + case r'$slice': + final a = conns['A'] as List; + final y = conns['Y'] as List; + final off = params['OFFSET'] as int? ?? 0; + for (var i = 0; i < y.length; i++) { + if (y[i] is int && (off + i) < a.length) { + bitMap[y[i] as int] = a[off + i] as Object; + } + } + + case r'$concat': + final y = conns['Y'] as List; + // Input ports are in connection-map order; their bits + // concatenate to form Y (first port at LSB). + final inBits = [ + for (final pe in conns.entries) + if ((dirs[pe.key] as String?) != 'output') + ...(pe.value as List).cast(), + ]; + _mapPairwise(inBits, y, bitMap); - if (subComp.length >= 2 && subComp.length < comp.length) { - groupQueue.add(subComp); - } + case r'$struct_unpack': + final a = conns['A'] as List; + final fc = params['FIELD_COUNT'] as int? ?? 0; + for (var f = 0; f < fc; f++) { + final fn = params['FIELD_${f}_NAME'] as String?; + final fo = params['FIELD_${f}_OFFSET'] as int? ?? 0; + if (fn == null) { + continue; } - continue; - } - - // Build the synthetic module's internal wire-ID space. - final usedIds = {}; - for (final cn in comp) { - final cell = cells[cn]; - if (cell == null) { + final fb = conns[fn] as List?; + if (fb == null) { continue; } - final conns = cell['connections'] as Map? ?? {}; - for (final bits in conns.values) { - for (final b in bits as List) { - if (b is int) { - usedIds.add(b); - } + for (var i = 0; i < fb.length; i++) { + if (fb[i] is int && (fo + i) < a.length) { + bitMap[fb[i] as int] = a[fo + i] as Object; } } } - var nextLocalId = 2; - final idRemap = {}; - for (final id in usedIds) { - idRemap[id] = nextLocalId++; - } - - List remapBits(List bits) => - bits.map((b) => b is int ? (idRemap[b] ?? b) : b).toList(); - - // Build ports: one input port per input group, one output port per - // output group. - final childPorts = >{}; - final instanceConns = >{}; - final instancePortDirs = {}; - - for (final entry in inputGroups.entries) { - final portName = 'in_${entry.key}'; - final parentBits = entry.value.cast(); - childPorts[portName] = { - 'direction': 'input', - 'bits': remapBits(parentBits), - }; - instanceConns[portName] = parentBits; - instancePortDirs[portName] = 'input'; - } - - for (final entry in outputGroups.entries) { - final portName = 'out_${entry.key}'; - final parentBits = entry.value.cast(); - childPorts[portName] = { - 'direction': 'output', - 'bits': remapBits(parentBits), - }; - instanceConns[portName] = parentBits; - instancePortDirs[portName] = 'output'; - } - - // Re-map cells into the child's local ID space. - final childCells = >{}; - for (final cn in comp) { - final cell = Map.from(cells[cn]!); - final conns = Map.from( - cell['connections']! as Map); - for (final key in conns.keys.toList()) { - conns[key] = remapBits((conns[key] as List).cast()); + case r'$struct_pack': + final y = conns['Y'] as List; + final fc = params['FIELD_COUNT'] as int? ?? 0; + final src = List.filled(y.length, null); + for (var f = 0; f < fc; f++) { + final fn = params['FIELD_${f}_NAME'] as String?; + final fo = params['FIELD_${f}_OFFSET'] as int? ?? 0; + if (fn == null) { + continue; } - cell['connections'] = conns; - childCells[cn] = cell; - } - - // Build netnames for the child module. - final childNetnames = {}; - for (final pe in childPorts.entries) { - childNetnames[pe.key] = { - 'bits': pe.value['bits'], - 'attributes': {}, - }; - } - - final coveredIds = {}; - for (final nn in childNetnames.values) { - final bits = (nn! as Map)['bits']! as List; - for (final b in bits) { - if (b is int) { - coveredIds.add(b); + final fb = conns[fn] as List?; + if (fb == null) { + continue; + } + for (var i = 0; i < fb.length; i++) { + if ((fo + i) < src.length) { + src[fo + i] = fb[i]; } } } - for (final cellEntry in childCells.entries) { - final cellName = cellEntry.key; - final conns = - cellEntry.value['connections'] as Map? ?? {}; - for (final connEntry in conns.entries) { - final portName = connEntry.key; - final bits = connEntry.value as List; - final missingBits = []; - for (final b in bits) { - if (b is int && !coveredIds.contains(b)) { - missingBits.add(b); - coveredIds.add(b); - } - } - if (missingBits.isNotEmpty) { - childNetnames['${cellName}_$portName'] = { - 'bits': missingBits, - 'hide_name': 1, - 'attributes': {}, - }; - } + for (var i = 0; i < y.length; i++) { + if (y[i] is int && src[i] != null) { + bitMap[y[i] as int] = src[i]!; } } - - // Choose a name for the synthetic module type. - final syntheticTypeName = 'struct_assign_${moduleName}_$groupIdx'; - final syntheticInstanceName = 'struct_assign_$groupIdx'; - groupIdx++; - - // Register the synthetic module definition. - newModuleDefs[syntheticTypeName] = { - 'attributes': {'src': 'generated'}, - 'ports': childPorts, - 'cells': childCells, - 'netnames': childNetnames, - }; - - // Remove the grouped cells from the parent. - claimedCells.addAll(comp); - comp.forEach(cells.remove); - - // Add a hierarchical cell referencing the synthetic module. - cells[syntheticInstanceName] = { - 'hide_name': 0, - 'type': syntheticTypeName, - 'parameters': {}, - 'attributes': {}, - 'port_directions': instancePortDirs, - 'connections': instanceConns, - }; - } } - - // Add all new synthetic module definitions. - allModules.addAll(newModuleDefs); } - /// Replace groups of `$slice` cells that share the same input bus and - /// whose outputs all feed into the same destination cell+port with a - /// single `$buf` cell. - /// - /// This eliminates visual noise from struct-to-flat-bus decomposition - /// when the destination consumes the full struct value unchanged. - /// Both signal names (source struct and destination port) are preserved - /// as separate netnames connected through the buffer. - static void applyStructBufferInsertion( - Map> allModules, + /// Maps `Y[i]` → `A[i]` for identity-shaped cells. + static void _mapPairwise( + List a, + List y, + Map bitMap, ) { - for (final moduleName in allModules.keys.toList()) { - final moduleDef = allModules[moduleName]!; - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } - - // Group $slice cells by their input bus (A bits). - final slicesByInput = >{}; - for (final entry in cells.entries) { - final cell = entry.value; - if (cell['type'] != r'$slice') { - continue; - } - final conns = cell['connections'] as Map?; - if (conns == null) { - continue; - } - final aBits = conns['A'] as List?; - if (aBits == null) { - continue; - } - final key = aBits.join(','); - (slicesByInput[key] ??= []).add(entry.key); - } - - var bufIdx = 0; - for (final sliceGroup in slicesByInput.values) { - if (sliceGroup.length < 2) { - continue; - } - - // Collect all Y output bit IDs from the group. - final allYBitIds = {}; - for (final sliceName in sliceGroup) { - final cell = cells[sliceName]!; - final conns = cell['connections']! as Map; - for (final b in conns['Y']! as List) { - if (b is int) { - allYBitIds.add(b); - } - } - } - - // Check: do all Y bits go to the same destination cell+port - // (or a single module output port)? - String? destId; // unique identifier for the destination - var allSameDest = true; - - // Check cell port destinations. - for (final otherEntry in cells.entries) { - if (sliceGroup.contains(otherEntry.key)) { - continue; - } - final otherConns = - otherEntry.value['connections'] as Map? ?? {}; - for (final portEntry in otherConns.entries) { - final bits = portEntry.value as List; - if (bits.any((b) => b is int && allYBitIds.contains(b))) { - final id = '${otherEntry.key}.${portEntry.key}'; - if (destId == null) { - destId = id; - } else if (destId != id) { - allSameDest = false; - break; - } - } - } - if (!allSameDest) { - break; - } - } - - // Also check module output ports as potential destinations. - final modPorts = - moduleDef['ports'] as Map>?; - if (allSameDest && modPorts != null) { - for (final portEntry in modPorts.entries) { - final port = portEntry.value; - final dir = port['direction'] as String?; - if (dir != 'output') { - continue; - } - final bits = port['bits'] as List?; - if (bits != null && - bits.any((b) => b is int && allYBitIds.contains(b))) { - final id = '__port_${portEntry.key}'; - if (destId == null) { - destId = id; - } else if (destId != id) { - allSameDest = false; - break; - } - } - } - } - - if (!allSameDest || destId == null) { - continue; - } - - // Verify slices contiguously cover the full A bus. - final firstSlice = cells[sliceGroup.first]!; - final params0 = firstSlice['parameters'] as Map?; - final aWidth = params0?['A_WIDTH'] as int?; - if (aWidth == null) { - continue; - } - - // Map offset → Y bits list, and validate. - final coverageYBits = >{}; - var totalYBits = 0; - var valid = true; - for (final sliceName in sliceGroup) { - final cell = cells[sliceName]!; - final params = cell['parameters'] as Map?; - final offset = params?['OFFSET'] as int?; - final yWidth = params?['Y_WIDTH'] as int?; - if (offset == null || yWidth == null) { - valid = false; - break; - } - final conns = cell['connections']! as Map; - final yBits = (conns['Y']! as List).cast(); - if (yBits.length != yWidth) { - valid = false; - break; - } - coverageYBits[offset] = yBits; - totalYBits += yWidth; - } - if (!valid || totalYBits != aWidth) { - continue; - } - - // Verify contiguous coverage (no gaps or overlaps). - final sortedOffsets = coverageYBits.keys.toList()..sort(); - var expectedOffset = 0; - for (final off in sortedOffsets) { - if (off != expectedOffset) { - valid = false; - break; - } - expectedOffset += coverageYBits[off]!.length; - } - if (!valid || expectedOffset != aWidth) { - continue; - } - - // Build the buffer cell. - final firstConns = firstSlice['connections']! as Map; - final aBus = (firstConns['A']! as List).cast(); - - // Construct Y by concatenating slice outputs in offset order. - final yBus = []; - for (final off in sortedOffsets) { - yBus.addAll(coverageYBits[off]!); - } - - // Remove slice cells. - sliceGroup.forEach(cells.remove); - - // Insert $buf cell. - cells['struct_buf_$bufIdx'] = - NetlistUtils.makeBufCell(aWidth, aBus, yBus); - bufIdx++; + for (var i = 0; i < y.length && i < a.length; i++) { + if (y[i] is int) { + bitMap[y[i] as int] = a[i] as Object; } } } - - /// Replaces each `struct_assign_*` hierarchical instance in parent modules - /// with one `$buf` cell per output port and removes the synthetic module - /// definition. - /// - /// For each output port the internal `$slice`/`$concat` routing is traced - /// back to the corresponding input-port bits so that each `$buf` connects - /// only the bits belonging to that specific net. This keeps distinct - /// signal paths (e.g. `sum_0 → sumRpath` vs `sumP1 → sumPlusOneRpath`) - /// as separate cells so the schematic viewer can route them independently. - static void collapseStructGroupModules( - Map> allModules, - ) { - // Collect the names of all struct_assign module definitions to remove. - final structAssignTypes = { - for (final name in allModules.keys) - if (name.startsWith('struct_assign_')) name, - }; - - if (structAssignTypes.isEmpty) { - return; - } - - // Track which struct_assign types were fully collapsed (all instances - // replaced). Only those will have their definitions removed. - final collapsedTypes = {}; - final keptTypes = {}; - - // In each module, replace cells that instantiate a struct_assign type - // with a $buf cell. - for (final moduleDef in allModules.values) { - final cells = - moduleDef['cells'] as Map>? ?? {}; - - final replacements = >{}; - final removals = []; - - for (final entry in cells.entries) { - final cellName = entry.key; - final cell = entry.value; - final type = cell['type'] as String?; - if (type == null || !structAssignTypes.contains(type)) { - continue; - } - - final conns = cell['connections'] as Map? ?? {}; - - // Look up the synthetic module definition so we can trace the - // actual per-bit routing through its internal $slice/$concat cells. - final synthDef = allModules[type]; - if (synthDef == null) { - continue; - } - - final synthPorts = - synthDef['ports'] as Map>? ?? {}; - final synthCells = - synthDef['cells'] as Map>? ?? {}; - - // Map local (module-internal) input port bits → parent bit IDs, - // and also record which input port name each local bit belongs to - // plus its index within that port. - final localToParent = {}; - final localBitToInputPort = {}; - final localBitToIndex = {}; - final inputPortWidths = {}; - for (final pEntry in synthPorts.entries) { - final dir = pEntry.value['direction'] as String?; - if (dir != 'input' && dir != 'inout') { - continue; - } - final localBits = - (pEntry.value['bits'] as List?)?.cast() ?? []; - final parentBits = (conns[pEntry.key] as List?)?.cast() ?? []; - inputPortWidths[pEntry.key] = localBits.length; - for (var i = 0; i < localBits.length && i < parentBits.length; i++) { - if (localBits[i] is int) { - localToParent[localBits[i] as int] = parentBits[i]; - localBitToInputPort[localBits[i] as int] = pEntry.key; - localBitToIndex[localBits[i] as int] = i; - } - } - } - - final inputPortBits = localToParent.keys.toSet(); - - // Build a net-driver map inside the synthetic module by - // processing its $slice, $concat, and $buf cells. - final driver = {}; - - for (final sc in synthCells.values) { - final ct = sc['type'] as String?; - final cc = sc['connections'] as Map? ?? {}; - final cp = sc['parameters'] as Map? ?? {}; - - if (ct == r'$slice') { - final aBits = (cc['A'] as List?)?.cast() ?? []; - final yBits = (cc['Y'] as List?)?.cast() ?? []; - final offset = cp['OFFSET'] as int? ?? 0; - final yWidth = yBits.length; - final aWidth = aBits.length; - final reversed = offset + yWidth > aWidth; - for (var i = 0; i < yBits.length; i++) { - if (yBits[i] is int) { - final srcIdx = reversed ? (offset - i) : (offset + i); - if (srcIdx >= 0 && srcIdx < aBits.length) { - driver[yBits[i] as int] = aBits[srcIdx]; - } - } - } - } else if (ct == r'$concat') { - final yBits = (cc['Y'] as List?)?.cast() ?? []; - - // Gather input bits in LSB-first order. Two formats: - // 1. Standard 2-input: ports A (LSB) and B (MSB). - // 2. Multi-input: range-named ports [lo:hi] with - // INx_WIDTH parameters — ordered by range start. - final inputBits = []; - if (cc.containsKey('A')) { - inputBits - ..addAll((cc['A'] as List?)?.cast() ?? []) - ..addAll((cc['B'] as List?)?.cast() ?? []); - } else { - // Multi-input concat: collect range-named ports ordered - // by their starting bit position (LSB first). - final rangePorts = >{}; - for (final portName in cc.keys) { - if (portName == 'Y') { - continue; - } - final m = NetlistUtils.rangePortRe.firstMatch(portName); - if (m != null) { - final hi = int.parse(m.group(1)!); - final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; - rangePorts[lo] = - (cc[portName] as List?)?.cast() ?? []; - } - } - final sortedKeys = rangePorts.keys.toList()..sort(); - for (final k in sortedKeys) { - inputBits.addAll(rangePorts[k]!); - } - } - - for (var i = 0; i < yBits.length; i++) { - if (yBits[i] is int && i < inputBits.length) { - driver[yBits[i] as int] = inputBits[i]; - } - } - } else if (ct == r'$buf') { - final aBits = (cc['A'] as List?)?.cast() ?? []; - final yBits = (cc['Y'] as List?)?.cast() ?? []; - for (var i = 0; i < yBits.length && i < aBits.length; i++) { - if (yBits[i] is int) { - driver[yBits[i] as int] = aBits[i]; - } - } - } - } - - // Trace a local bit backwards through the driver map until we - // reach an input port bit or a string constant. - Object traceToSource(Object bit) { - final visited = {}; - var current = bit; - while (current is int && !inputPortBits.contains(current)) { - if (visited.contains(current)) { - break; - } - visited.add(current); - final next = driver[current]; - if (next == null) { - break; - } - current = next; - } - return current; - } - - // For each output port, trace its bits to their source and build - // the appropriate cell type: - // $buf – output has same width as its single source input port - // $slice – output is a contiguous sub-range of one input port - // $concat – output combines bits from multiple input ports - final perPortCells = >{}; - var anyUnresolved = false; - - for (final pEntry in synthPorts.entries) { - final dir = pEntry.value['direction'] as String?; - if (dir != 'output') { - continue; - } - final localBits = - (pEntry.value['bits'] as List?)?.cast() ?? []; - final parentBits = (conns[pEntry.key] as List?)?.cast() ?? []; - - final portOutputBits = []; - final portInputBits = []; - // Track per-bit source: local input-port bit ID (int) or null. - final sourceBitIds = []; - - for (var i = 0; i < parentBits.length; i++) { - portOutputBits.add(parentBits[i]); - if (i < localBits.length) { - final source = traceToSource(localBits[i]); - if (source is int && localToParent.containsKey(source)) { - portInputBits.add(localToParent[source]!); - sourceBitIds.add(source); - } else if (source is String) { - portInputBits.add(source); - sourceBitIds.add(null); - } else { - portInputBits.add('x'); - sourceBitIds.add(null); - } - } else { - portInputBits.add('x'); - sourceBitIds.add(null); - } - } - - if (portInputBits.contains('x')) { - anyUnresolved = true; - break; - } - - if (portOutputBits.isEmpty) { - continue; - } - - // Determine which input port(s) source this output port. - final sourcePortNames = {}; - for (final sid in sourceBitIds) { - if (sid != null && localBitToInputPort.containsKey(sid)) { - sourcePortNames.add(localBitToInputPort[sid]!); - } - } - - final cellKey = '${cellName}_${pEntry.key}'; - - if (sourcePortNames.length == 1) { - final srcPort = sourcePortNames.first; - final srcWidth = inputPortWidths[srcPort] ?? 0; - if (portOutputBits.length == srcWidth) { - // Same width → $buf - perPortCells['${cellKey}_buf'] = NetlistUtils.makeBufCell( - portOutputBits.length, portInputBits, portOutputBits); - } else { - // Subset of one input port → $slice. Determine the offset - // from the first traced bit's index within its input port. - final firstIdx = sourceBitIds.first; - final offset = - firstIdx != null ? (localBitToIndex[firstIdx] ?? 0) : 0; - perPortCells['${cellKey}_slice'] = NetlistUtils.makeSliceCell( - offset, - srcWidth, - portOutputBits.length, - (conns[srcPort] as List?)?.cast() ?? [], - portOutputBits); - } - } else { - // Multiple source ports – should be rare after the grouping - // guard excludes multi-source concats. Fall back to $buf. - perPortCells['${cellKey}_buf'] = NetlistUtils.makeBufCell( - portOutputBits.length, portInputBits, portOutputBits); - } - } - - if (perPortCells.isEmpty) { - continue; - } - - // Only collapse pure passthroughs: every output bit must trace - // back to an input-port bit or a string constant. If any bit - // fell through as 'x' the module is doing real computation - // (e.g. addition, muxing) and should be kept as a hierarchy. - if (anyUnresolved) { - keptTypes.add(type); - continue; - } - - collapsedTypes.add(type); - removals.add(cellName); - replacements.addAll(perPortCells); - } - - removals.forEach(cells.remove); - cells.addAll(replacements); - } - - // Remove only the synthetic module definitions whose instances were all - // successfully collapsed. Types that had at least one non-passthrough - // instance must keep their definition so the hierarchy is preserved. - collapsedTypes.difference(keptTypes).forEach(allModules.remove); - } - - /// Replace standalone `$concat` cells whose input bits all originate - /// from a single module input (or inout) port and cover its full width - /// with a simple `$buf` cell. - /// - /// This eliminates the visual noise of struct-to-bitvector reassembly - /// when an input [LogicStructure] port is decomposed into fields and - /// immediately re-packed via a [Swizzle]. - static void applyConcatToBufferReplacement( - Map> allModules, - ) { - for (final moduleDef in allModules.values) { - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } - - final modPorts = moduleDef['ports'] as Map>?; - if (modPorts == null) { - continue; - } - - // Build bit → port-name map for input / inout ports. - final bitToPort = {}; - for (final portEntry in modPorts.entries) { - final dir = portEntry.value['direction'] as String?; - if (dir != 'input' && dir != 'inout') { - continue; - } - final bits = portEntry.value['bits'] as List? ?? []; - for (final b in bits) { - if (b is int) { - bitToPort[b] = portEntry.key; - } - } - } - - final removals = []; - final additions = >{}; - var bufIdx = 0; - - // Avoid name collisions with existing concat_buf_* cells. - for (final name in cells.keys) { - if (name.startsWith('concat_buf_')) { - final idx = int.tryParse(name.substring('concat_buf_'.length)); - if (idx != null && idx >= bufIdx) { - bufIdx = idx + 1; - } - } - } - - for (final entry in cells.entries) { - final cell = entry.value; - if ((cell['type'] as String?) != r'$concat') { - continue; - } - - final conns = cell['connections'] as Map? ?? {}; - final pdirs = cell['port_directions'] as Map? ?? {}; - - // Collect input ranges and the Y output. - // Port names follow the pattern "[upper:lower]" or "[bit]". - final rangedInputs = >{}; // lower → bits - List? yBits; - - for (final pe in conns.entries) { - final dir = pdirs[pe.key] as String? ?? ''; - final bits = (pe.value as List).cast(); - if (dir == 'output' && pe.key == 'Y') { - yBits = bits; - continue; - } - if (dir != 'input') { - continue; - } - // Parse "[upper:lower]" or "[bit]". - final match = NetlistUtils.rangePortRe.firstMatch(pe.key); - if (match == null) { - // Also accept the 2-input A/B form. - if (pe.key == 'A') { - rangedInputs[0] = bits; - } else if (pe.key == 'B') { - // Determine A width to set the offset. - final aBits = conns['A'] as List?; - if (aBits != null) { - rangedInputs[aBits.length] = bits; - } - } - continue; - } - final upper = int.parse(match.group(1)!); - final lower = - match.group(2) != null ? int.parse(match.group(2)!) : upper; - rangedInputs[lower] = bits; - } - - if (yBits == null || rangedInputs.isEmpty) { - continue; - } - - // Assemble input bits in LSB-to-MSB order. - final sortedLowers = rangedInputs.keys.toList()..sort(); - final allInputBits = []; - for (final lower in sortedLowers) { - allInputBits.addAll(rangedInputs[lower]!); - } - - // Check that every input bit belongs to the same module port. - String? sourcePort; - var allFromSamePort = true; - for (final b in allInputBits) { - if (b is! int) { - allFromSamePort = false; - break; - } - final port = bitToPort[b]; - if (port == null) { - allFromSamePort = false; - break; - } - sourcePort ??= port; - if (port != sourcePort) { - allFromSamePort = false; - break; - } - } - - if (!allFromSamePort || sourcePort == null) { - continue; - } - - // Verify full-width coverage of the source port. - final portBits = modPorts[sourcePort]!['bits']! as List; - if (allInputBits.length != portBits.length) { - continue; - } - - // Replace $concat with $buf. - removals.add(entry.key); - additions['concat_buf_$bufIdx'] = - NetlistUtils.makeBufCell(allInputBits.length, allInputBits, yBits); - bufIdx++; - } - - removals.forEach(cells.remove); - cells.addAll(additions); - } - } - - // -- Collapse selects into struct_pack --------------------------------- - - /// Finds `$slice` cells whose outputs feed exclusively into a - /// `$struct_pack` input port. The slice is absorbed: the pack input - /// port is rewired to the slice's source bits directly and the - /// now-redundant slice is removed. - /// - /// This is the "selects into a pack" optimization: when a flat bus is - /// decomposed through individual slices and then repacked into a struct, - /// the intermediate slice cells add visual noise beyond what the - /// struct_pack field metadata already provides. - static void applyCollapseSelectsIntoPack( - Map> allModules, - ) { - for (final moduleDef in allModules.values) { - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } - - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - NetlistUtils.buildWireMaps(cells, moduleDef); - - final cellsToRemove = {}; - - for (final packEntry in cells.entries.toList()) { - final packName = packEntry.key; - final packCell = packEntry.value; - if ((packCell['type'] as String?) != r'$struct_pack') { - continue; - } - - final conns = packCell['connections'] as Map? ?? {}; - final dirs = packCell['port_directions'] as Map? ?? {}; - - for (final portName in conns.keys.toList()) { - if (dirs[portName] != 'input') { - continue; - } - final bits = [ - for (final b in conns[portName] as List) - if (b is int) b, - ]; - if (bits.isEmpty) { - continue; - } - - // All bits must be driven by the same $slice cell. - final firstDriver = wireDriverCell[bits.first]; - if (firstDriver == null) { - continue; - } - final driverCell = cells[firstDriver]; - if (driverCell == null) { - continue; - } - if ((driverCell['type'] as String?) != r'$slice') { - continue; - } - if (cellsToRemove.contains(firstDriver)) { - continue; - } - - final allFromSameSlice = bits.every( - (b) => wireDriverCell[b] == firstDriver, - ); - if (!allFromSameSlice) { - continue; - } - - // The slice must exclusively feed this pack. - final sliceConns = - driverCell['connections'] as Map? ?? {}; - final sliceYBits = [ - for (final b in sliceConns['Y'] as List) - if (b is int) b, - ]; - final exclusive = sliceYBits.every((b) { - final consumers = wireConsumerCells[b]; - if (consumers == null) { - return true; - } - return consumers.every((c) => c == packName || c == '__port__'); - }); - if (!exclusive) { - continue; - } - - // Rewire: replace the pack's input bits with the slice's - // source bits (A port) at the correct offset. - final sliceABits = sliceConns['A'] as List; - final params = - driverCell['parameters'] as Map? ?? {}; - final offset = params['OFFSET'] as int? ?? 0; - final yWidth = sliceYBits.length; - - final newBits = [ - for (var i = 0; i < yWidth; i++) sliceABits[offset + i] as Object, - ]; - conns[portName] = newBits; - - cellsToRemove.add(firstDriver); - } - } - - cellsToRemove.forEach(cells.remove); - - // Second pass: collapse struct_pack → $buf when all field inputs - // form a contiguous ascending sequence (identity pack). - for (final packEntry in cells.entries.toList()) { - final packName = packEntry.key; - final packCell = packEntry.value; - if ((packCell['type'] as String?) != r'$struct_pack') { - continue; - } - - final conns = packCell['connections'] as Map? ?? {}; - final dirs = packCell['port_directions'] as Map? ?? {}; - - // Collect all input bits in field declaration order. - final allInputBits = []; - for (final portName in conns.keys) { - if (dirs[portName] != 'input') { - continue; - } - for (final b in conns[portName] as List) { - if (b is int) { - allInputBits.add(b); - } - } - } - if (allInputBits.length < 2) { - continue; - } - - // Check: input bits must form a contiguous ascending sequence. - var contiguous = true; - for (var i = 1; i < allInputBits.length; i++) { - if (allInputBits[i] != allInputBits[i - 1] + 1) { - contiguous = false; - break; - } - } - if (!contiguous) { - continue; - } - - final yBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; - if (yBits.length != allInputBits.length) { - continue; - } - - // Replace struct_pack with $buf. - cells[packName] = { - 'type': r'$buf', - 'parameters': {'WIDTH': allInputBits.length}, - 'port_directions': {'A': 'input', 'Y': 'output'}, - 'connections': >{ - 'A': allInputBits.cast(), - 'Y': yBits, - }, - }; - } - } - } - - // -- Collapse struct_unpack to concat ---------------------------------- - - /// Finds `$concat` cells whose input ports are driven (directly or - /// through exclusive `$buf`/`$slice` chains) by output ports of - /// `$struct_unpack` cells. When all inputs trace back through a single - /// unpack to its source bus, the concat and intermediate cells are - /// replaced by a `$buf` or `$slice` from the unpack's A bus. - /// - /// Partial collapse is also supported: contiguous runs of concat ports - /// that trace to the same unpack are collapsed individually. - static void applyCollapseUnpackToConcat( - Map> allModules, - ) { - for (final moduleEntry in allModules.entries) { - final moduleDef = moduleEntry.value; - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } - - // Iterate until convergence: each pass may create bufs that enable - // the next outer concat/unpack to collapse. - var globalReplIdx = 0; - var anyChanged = true; - var iteration = 0; - while (anyChanged && iteration < 20) { - anyChanged = false; - iteration++; - - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - NetlistUtils.buildWireMaps(cells, moduleDef); - - final cellsToRemove = {}; - final cellsToAdd = >{}; - var replIdx = globalReplIdx; - - for (final concatEntry in cells.entries.toList()) { - final concatName = concatEntry.key; - final concatCell = concatEntry.value; - if ((concatCell['type'] as String?) != r'$concat') { - continue; - } - if (cellsToRemove.contains(concatName)) { - continue; - } - - final conns = - concatCell['connections'] as Map? ?? {}; - - // Parse input ports into ordered list. - final inputPorts = <(int lo, String portName, List bits)>[]; - var hasRangePorts = false; - for (final portName in conns.keys) { - if (portName == 'Y') { - continue; - } - final m = NetlistUtils.rangePortRe.firstMatch(portName); - if (m != null) { - hasRangePorts = true; - final hi = int.parse(m.group(1)!); - final lo = m.group(2) != null ? int.parse(m.group(2)!) : hi; - inputPorts.add(( - lo, - portName, - [ - for (final b in conns[portName] as List) - if (b is int) b - ], - )); - } - } - if (!hasRangePorts) { - if (conns.containsKey('A') && conns.containsKey('B')) { - final aBits = [ - for (final b in conns['A'] as List) - if (b is int) b, - ]; - final bBits = [ - for (final b in conns['B'] as List) - if (b is int) b, - ]; - inputPorts - ..add((0, 'A', aBits)) - ..add((aBits.length, 'B', bBits)); - } - } - inputPorts.sort((a, b) => a.$1.compareTo(b.$1)); - if (inputPorts.length < 2) { - continue; - } - - // --- Extended trace: through $buf/$slice AND $struct_unpack ------ - final portTraces = <({ - String? unpackName, - List? unpackABits, - List sourceIndices, - Set intermediates, - bool valid, - })>[]; - - for (final (_, _, bits) in inputPorts) { - final sourceIndices = []; - final intermediates = {}; - String? unpackName; - List? unpackABits; - var valid = true; - - for (final bit in bits) { - final (traced, chain) = - NetlistUtils.traceBackward(bit, wireDriverCell, cells); - intermediates.addAll(chain); - - // Check if traced bit is driven by a $struct_unpack output. - final driverName = wireDriverCell[traced]; - if (driverName == null) { - valid = false; - break; - } - final driverCell = cells[driverName]; - if (driverCell == null || - (driverCell['type'] as String?) != r'$struct_unpack') { - valid = false; - break; - } - - final uConns = - driverCell['connections'] as Map? ?? {}; - final uDirs = - driverCell['port_directions'] as Map? ?? {}; - final aBits = [ - for (final b in uConns['A'] as List) - if (b is int) b, - ]; - - // Find which output port contains this bit and its index - // within that port. - String? outPort; - int? bitIdx; - for (final pe in uConns.entries) { - if (pe.key == 'A') { - continue; - } - if (uDirs[pe.key] != 'output') { - continue; - } - final pBits = [ - for (final b in pe.value as List) - if (b is int) b, - ]; - final idx = pBits.indexOf(traced); - if (idx >= 0) { - outPort = pe.key; - bitIdx = idx; - break; - } - } - - if (outPort == null || bitIdx == null) { - valid = false; - break; - } - - // Find the field offset for this output port. - final params = - driverCell['parameters'] as Map? ?? {}; - final fc = params['FIELD_COUNT'] as int? ?? 0; - int? fieldOffset; - for (var fi = 0; fi < fc; fi++) { - final fname = params['FIELD_${fi}_NAME'] as String? ?? ''; - if (fname == outPort || outPort == '${fname}_$fi') { - fieldOffset = params['FIELD_${fi}_OFFSET'] as int? ?? 0; - break; - } - } - - if (fieldOffset == null) { - valid = false; - break; - } - - final aIdx = fieldOffset + bitIdx; - if (aIdx >= aBits.length) { - valid = false; - break; - } - - intermediates.add(driverName); - - if (unpackName == null) { - unpackName = driverName; - unpackABits = aBits; - } else if (unpackName != driverName) { - valid = false; - break; - } - sourceIndices.add(aIdx); - } - - portTraces.add(( - unpackName: unpackName, - unpackABits: unpackABits, - sourceIndices: sourceIndices, - intermediates: intermediates, - valid: valid, - )); - } - - // --- Find runs of consecutive ports tracing to the same unpack --- - final runs = <(int startIdx, int endIdx)>[]; - var runStart = 0; - while (runStart < inputPorts.length) { - final t = portTraces[runStart]; - if (!t.valid || t.unpackName == null) { - runStart++; - continue; - } - var runEnd = runStart; - while (runEnd + 1 < inputPorts.length) { - final nextT = portTraces[runEnd + 1]; - if (!nextT.valid || nextT.unpackName != t.unpackName) { - break; - } - final curLast = portTraces[runEnd].sourceIndices.last; - final nextFirst = nextT.sourceIndices.first; - if (nextFirst != curLast + 1) { - break; - } - runEnd++; - } - if (runEnd > runStart) { - runs.add((runStart, runEnd)); - } - runStart = runEnd + 1; - } - - if (runs.isEmpty) { - // No contiguous ascending runs, but check if ALL ports trace - // to the same unpack (general reorder / swizzle case). - final allValid = portTraces.every((t) => t.valid); - if (!allValid) { - continue; - } - final unpackNames = portTraces.map((t) => t.unpackName).toSet(); - if (unpackNames.length != 1 || unpackNames.first == null) { - continue; - } - final uName = unpackNames.first!; - final uABits = portTraces.first.unpackABits!; - - // Gather all intermediates and verify exclusivity. - final allIntermediates = {}; - for (final t in portTraces) { - allIntermediates.addAll(t.intermediates); - } - final removable = allIntermediates.where((c) { - final ct = cells[c]?['type'] as String?; - return ct == r'$buf' || ct == r'$slice'; - }).toSet(); - if (removable.isNotEmpty && - !NetlistUtils.isExclusiveChain( - intermediates: removable, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - continue; - } - - // Build reordered A bits: for each concat input port (in - // order), map the source indices back to the unpack's A bus. - final reorderedA = []; - for (final t in portTraces) { - for (final aIdx in t.sourceIndices) { - reorderedA.add(uABits[aIdx] as Object); - } - } - final outputBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; - if (reorderedA.length != outputBits.length) { - continue; - } - - cellsToRemove - ..addAll(removable) - ..add(uName) - ..add(concatName); - cellsToAdd['unpack_concat_buf_$replIdx'] = NetlistUtils.makeBufCell( - reorderedA.length, reorderedA, outputBits); - replIdx++; - continue; - } - - // --- Verify exclusivity of non-unpack intermediates ------ - final validRuns = - <(int startIdx, int endIdx, Set intermediates)>[]; - for (final (startIdx, endIdx) in runs) { - final allIntermediates = {}; - for (var i = startIdx; i <= endIdx; i++) { - allIntermediates.addAll(portTraces[i].intermediates); - } - // Only remove $buf/$slice intermediates, not the unpack itself. - final removable = allIntermediates.where((c) { - final ct = cells[c]?['type'] as String?; - return ct == r'$buf' || ct == r'$slice'; - }).toSet(); - if (removable.isEmpty || - NetlistUtils.isExclusiveChain( - intermediates: removable, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - validRuns.add((startIdx, endIdx, removable)); - } - } - - if (validRuns.isEmpty) { - continue; - } - - final allCollapsed = validRuns.length == 1 && - validRuns.first.$1 == 0 && - validRuns.first.$2 == inputPorts.length - 1; - - for (final (_, _, intermediates) in validRuns) { - cellsToRemove.addAll(intermediates); - } - - if (allCollapsed) { - // Full collapse — replace concat with $buf or $slice. - // Since we remove intermediates (buf/slice chains between the - // unpack outputs and the concat inputs), we must source the - // replacement buf from the unpack's A bus, not the concat's - // input bits which may reference wires driven by the removed - // intermediates. - final t0 = portTraces.first; - final srcOffset = t0.sourceIndices.first; - final yWidth = (conns['Y'] as List).whereType().length; - final aWidth = t0.unpackABits!.length; - final sourceBits = t0.unpackABits!.cast().toList(); - final outputBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; - - cellsToRemove - ..add(concatName) - // Also remove the unpack itself — all its outputs are consumed - // exclusively through intermediates into this concat. - ..add(t0.unpackName!); - if (yWidth == aWidth) { - cellsToAdd['unpack_concat_buf_$replIdx'] = - NetlistUtils.makeBufCell(aWidth, sourceBits, outputBits); - } else { - cellsToAdd['unpack_concat_buf_$replIdx'] = - NetlistUtils.makeSliceCell( - srcOffset, aWidth, yWidth, sourceBits, outputBits); - } - replIdx++; - continue; - } - - // --- Partial collapse — rebuild concat with fewer ports --------- - cellsToRemove.add(concatName); - - final newConns = >{}; - final newDirs = {}; - var outBitOffset = 0; - - var portIdx = 0; - while (portIdx < inputPorts.length) { - (int, int, Set)? activeRun; - for (final run in validRuns) { - if (run.$1 == portIdx) { - activeRun = run; - break; - } - } - - if (activeRun != null) { - final (startIdx, endIdx, _) = activeRun; - // Collect the traced source bits — the unpack output bits - // that traceBackward found. We cannot use the concat's raw - // input bits because intermediates (buf/slice chains) between - // the unpack outputs and the concat are being removed. - final tracedBits = []; - final t0 = portTraces[startIdx]; - final uConns = cells[t0.unpackName!]!['connections'] - as Map? ?? - {}; - final uDirs = cells[t0.unpackName!]!['port_directions'] - as Map? ?? - {}; - // Rebuild the unpack's output bits in field declaration order - // to create a mapping from A-index to wire ID. - final unpackOutBitList = []; - for (final pe in uConns.entries) { - if (pe.key == 'A') { - continue; - } - if (uDirs[pe.key] != 'output') { - continue; - } - for (final b in pe.value as List) { - if (b is int) { - unpackOutBitList.add(b); - } - } - } - // Build A-index -> output wire ID map. - final aToOutBit = {}; - final uParams = cells[t0.unpackName!]!['parameters'] - as Map? ?? - {}; - final fc = uParams['FIELD_COUNT'] as int? ?? 0; - var outIdx = 0; - for (var fi = 0; fi < fc; fi++) { - final fw = uParams['FIELD_${fi}_WIDTH'] as int? ?? 0; - final fo = uParams['FIELD_${fi}_OFFSET'] as int? ?? 0; - for (var bi = 0; bi < fw; bi++) { - if (outIdx < unpackOutBitList.length) { - aToOutBit[fo + bi] = unpackOutBitList[outIdx]; - } - outIdx++; - } - } - for (var i = startIdx; i <= endIdx; i++) { - for (final aIdx in portTraces[i].sourceIndices) { - final outBit = aToOutBit[aIdx]; - if (outBit != null) { - tracedBits.add(outBit); - } - } - } - final width = tracedBits.length; - - final hi = outBitOffset + width - 1; - final portName = - hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; - newConns[portName] = tracedBits; - newDirs[portName] = 'input'; - outBitOffset += width; - - portIdx = endIdx + 1; - } else { - final port = inputPorts[portIdx]; - final width = port.$3.length; - final hi = outBitOffset + width - 1; - final portName = - hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; - newConns[portName] = port.$3.cast(); - newDirs[portName] = 'input'; - outBitOffset += width; - portIdx++; - } - } - - newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; - newDirs['Y'] = 'output'; - - cellsToAdd['${concatName}_collapsed'] = { - 'hide_name': concatCell['hide_name'], - 'type': r'$concat', - 'parameters': {}, - 'attributes': concatCell['attributes'] ?? {}, - 'port_directions': newDirs, - 'connections': newConns, - }; - } - - cellsToRemove.forEach(cells.remove); - cells.addAll(cellsToAdd); - if (cellsToRemove.isNotEmpty || cellsToAdd.isNotEmpty) { - anyChanged = true; - } - globalReplIdx = replIdx; - - // Second pass: collapse identity struct_unpack → $buf chains. - // If ALL outputs of a struct_unpack go exclusively to one $buf whose - // A bits are exactly those outputs in order, replace both with a - // single $buf from the unpack's A to the buf's Y. - final unpacksToRemove = {}; - final bufsToRemove = {}; - final bufsToAdd = >{}; - var identBufIdx = 0; - - final wireMaps2 = NetlistUtils.buildWireMaps(cells, moduleDef); - final wireConsumerCells2 = wireMaps2.wireConsumerCells; - - for (final entry in cells.entries.toList()) { - final unpackName = entry.key; - final unpackCell = entry.value; - if ((unpackCell['type'] as String?) != r'$struct_unpack') { - continue; - } - if (unpacksToRemove.contains(unpackName)) { - continue; - } - - final uConns = - unpackCell['connections'] as Map? ?? {}; - final uDirs = - unpackCell['port_directions'] as Map? ?? {}; - - // Collect all output bits in field declaration order. - final allOutputBits = []; - for (final pname in uConns.keys) { - if (uDirs[pname] != 'output') { - continue; - } - for (final b in uConns[pname] as List) { - if (b is int) { - allOutputBits.add(b); - } - } - } - if (allOutputBits.isEmpty) { - continue; - } - - // Every output bit must be consumed by exactly one $buf cell - // (the same one). - String? targetBufName; - var allToOneBuf = true; - for (final bit in allOutputBits) { - final consumers = wireConsumerCells2[bit]; - if (consumers == null || consumers.length != 1) { - allToOneBuf = false; - break; - } - final consumer = consumers.first; - if (consumer == '__port__') { - allToOneBuf = false; - break; - } - final consumerCell = cells[consumer]; - if (consumerCell == null || - (consumerCell['type'] as String?) != r'$buf') { - allToOneBuf = false; - break; - } - if (targetBufName == null) { - targetBufName = consumer; - } else if (consumer != targetBufName) { - allToOneBuf = false; - break; - } - } - if (!allToOneBuf || targetBufName == null) { - continue; - } - if (bufsToRemove.contains(targetBufName)) { - continue; - } - - final bufCell = cells[targetBufName]!; - final bufConns = - bufCell['connections'] as Map? ?? {}; - final bufABits = [ - for (final b in bufConns['A'] as List) - if (b is int) b, - ]; - - // The buf's A bits must be exactly the unpack's output bits. - if (bufABits.length != allOutputBits.length) { - continue; - } - var bitsMatch = true; - for (var i = 0; i < bufABits.length; i++) { - if (bufABits[i] != allOutputBits[i]) { - bitsMatch = false; - break; - } - } - if (!bitsMatch) { - continue; - } - - // Collapse: single buf from unpack.A → buf.Y - final unpackABits = [ - for (final b in uConns['A'] as List) - if (b is int) b, - ]; - final bufYBits = [ - for (final b in bufConns['Y'] as List) - if (b is int) b, - ]; - - if (unpackABits.length != bufYBits.length) { - continue; - } - - bufsToAdd['${unpackName}_buf_$identBufIdx'] = { - 'type': r'$buf', - 'parameters': {'WIDTH': unpackABits.length}, - 'port_directions': {'A': 'input', 'Y': 'output'}, - 'connections': >{ - 'A': unpackABits, - 'Y': bufYBits, - }, - }; - identBufIdx++; - unpacksToRemove.add(unpackName); - bufsToRemove.add(targetBufName); - } - - unpacksToRemove.forEach(cells.remove); - bufsToRemove.forEach(cells.remove); - cells.addAll(bufsToAdd); - if (unpacksToRemove.isNotEmpty || bufsToRemove.isNotEmpty) { - anyChanged = true; - } - } // end while (anyChanged) - } - } - - // -- Collapse struct_unpack to struct_pack ----------------------------- - - /// Finds `$struct_pack` cells whose input ports are driven (directly - /// or through exclusive `$buf`/`$slice` chains) by output ports of - /// `$struct_unpack` cells. The exclusive intermediate `$buf`/`$slice` - /// cells are removed, and the pack input ports are rewired to the - /// unpack output bits directly. - /// - /// The unpack cell itself is preserved (it may have other consumers). - /// Only the intermediate routing cells are removed. - static void applyCollapseUnpackToPack( - Map> allModules, - ) { - for (final moduleDef in allModules.values) { - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } - - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - NetlistUtils.buildWireMaps(cells, moduleDef); - - final cellsToRemove = {}; - - for (final packEntry in cells.entries.toList()) { - final packName = packEntry.key; - final packCell = packEntry.value; - if ((packCell['type'] as String?) != r'$struct_pack') { - continue; - } - - final conns = packCell['connections'] as Map? ?? {}; - final dirs = packCell['port_directions'] as Map? ?? {}; - - for (final portName in conns.keys.toList()) { - if (dirs[portName] != 'input') { - continue; - } - final bits = [ - for (final b in conns[portName] as List) - if (b is int) b, - ]; - if (bits.isEmpty) { - continue; - } - - // Trace each bit backward through $buf/$slice chains. - final tracedBits = []; - final intermediates = {}; - var allTraceToUnpack = true; - String? unpackName; - - for (final bit in bits) { - final (traced, chain) = - NetlistUtils.traceBackward(bit, wireDriverCell, cells); - intermediates.addAll(chain); - - // Check if traced bit is driven by a $struct_unpack. - final driverName = wireDriverCell[traced]; - if (driverName == null) { - allTraceToUnpack = false; - break; - } - final driverCell = cells[driverName]; - if (driverCell == null || - (driverCell['type'] as String?) != r'$struct_unpack') { - allTraceToUnpack = false; - break; - } - - if (unpackName == null) { - unpackName = driverName; - } else if (unpackName != driverName) { - allTraceToUnpack = false; - break; - } - - tracedBits.add(traced); - } - - if (!allTraceToUnpack || intermediates.isEmpty) { - continue; - } - - // Only remove $buf/$slice intermediates (not the unpack itself). - final removable = intermediates.where((c) { - final ct = cells[c]?['type'] as String?; - return ct == r'$buf' || ct == r'$slice'; - }).toSet(); - - if (removable.isEmpty) { - continue; - } - - // Verify exclusivity. - if (!NetlistUtils.isExclusiveChain( - intermediates: removable, - ownerCell: packName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - continue; - } - - // Rewire: replace the pack's input port with the traced bits. - conns[portName] = tracedBits.cast().toList(); - cellsToRemove.addAll(removable); - } - } - - cellsToRemove.forEach(cells.remove); - } - } } diff --git a/lib/src/synthesizers/netlist/netlist_synthesizer.dart b/lib/src/synthesizers/netlist/netlist_synthesizer.dart index a21760b15..59608f8ba 100644 --- a/lib/src/synthesizers/netlist/netlist_synthesizer.dart +++ b/lib/src/synthesizers/netlist/netlist_synthesizer.dart @@ -151,7 +151,7 @@ class _NetlistSynthModuleDefinition extends SynthModuleDefinition { /// /// Usage: /// ```dart -/// const options = NetlistOptions(groupStructConversions: true); +/// const options = NetlistOptions(collapseTransparentClusters: true); /// final synth = NetlistSynthesizer(options: options); /// final builder = SynthBuilder(topModule, synth); /// final json = await synth.synthesizeToJson(topModule); @@ -681,6 +681,34 @@ class NetlistSynthesizer extends Synthesizer { fullParentIds: parentIds, )); } + } else if (elem is LogicStructure && elem is! LogicArray) { + // Nested InterfaceStructure: the intermediate struct + // itself has no SynthLogic, but its leaf elements do + // (created by _subsetReceiveStructPort). Walk leaf + // elements and emit struct field entries for each, + // using the top-level parent as the parent Logic. + var leafIdx = idx; + for (final leaf in elem.leafElements) { + final leafSL = synthDef.logicToSynthMap[leaf]; + if (leafSL != null) { + final leafIds = getIds(leafSL); + final sliceLen = leafIds.length < parentIds.length - leafIdx + ? leafIds.length + : parentIds.length - leafIdx; + if (sliceLen > 0) { + structFieldCells.add(( + parentIds: parentIds.sublist(leafIdx, leafIdx + sliceLen), + elemIds: leafIds.sublist(0, sliceLen), + offset: leafIdx, + width: sliceLen, + elemLogic: leaf, + parentLogic: logic, + fullParentIds: parentIds, + )); + } + } + leafIdx += leaf.width; + } } idx += elem.width; } @@ -1593,28 +1621,8 @@ class NetlistSynthesizer extends Synthesizer { void applyPostProcessingPasses( Map> modules, ) { - if (options.groupStructConversions) { - if (options.groupMaximalSubsets) { - NetlistPasses.applyMaximalSubsetGrouping(modules); - } - if (options.collapseConcats) { - NetlistPasses.applyCollapseConcats(modules); - } - if (options.collapseSelectsIntoPack) { - NetlistPasses.applyCollapseSelectsIntoPack(modules); - } - if (options.collapseUnpackToConcat) { - NetlistPasses.applyCollapseUnpackToConcat(modules); - } - if (options.collapseUnpackToPack) { - NetlistPasses.applyCollapseUnpackToPack(modules); - } - NetlistPasses.applyStructConversionGrouping(modules); - if (options.collapseStructGroups) { - NetlistPasses.collapseStructGroupModules(modules); - } - NetlistPasses.applyStructBufferInsertion(modules); - NetlistPasses.applyConcatToBufferReplacement(modules); + if (options.collapseTransparentClusters) { + NetlistPasses.applyTransparentClustering(modules); } } diff --git a/lib/src/synthesizers/netlist/netlist_utils.dart b/lib/src/synthesizers/netlist/netlist_utils.dart index 258fe56a3..b6ee2c087 100644 --- a/lib/src/synthesizers/netlist/netlist_utils.dart +++ b/lib/src/synthesizers/netlist/netlist_utils.dart @@ -48,9 +48,6 @@ class NetlistUtils { return r; } - /// Anchored regex for range-named concat port labels like `[7:0]` or `[3]`. - static final rangePortRe = RegExp(r'^\[(\d+)(?::(\d+))?\]$'); - /// Create a `$buf` cell map. static Map makeBufCell( int width, @@ -66,191 +63,6 @@ class NetlistUtils { 'connections': >{'A': aBits, 'Y': yBits}, }; - /// Create a `$slice` cell map. - static Map makeSliceCell( - int offset, - int aWidth, - int yWidth, - List aBits, - List yBits, - ) => - { - 'hide_name': 0, - 'type': r'$slice', - 'parameters': { - 'OFFSET': offset, - 'A_WIDTH': aWidth, - 'Y_WIDTH': yWidth, - }, - 'attributes': {}, - 'port_directions': {'A': 'input', 'Y': 'output'}, - 'connections': >{'A': aBits, 'Y': yBits}, - }; - - /// Build wire-driver, wire-consumer, and bit-to-net maps for a module. - /// - /// Scans every cell's connections to find which cell drives each wire bit - /// (output direction) and which cells consume it (input direction). - /// Module output-port bits are registered as pseudo-consumers (`__port__`) - /// so that cells feeding module ports are never accidentally removed. - static ({ - Map wireDriverCell, - Map> wireConsumerCells, - Map)> bitToNetInfo, - }) buildWireMaps( - Map> cells, - Map moduleDef, - ) { - final wireDriverCell = {}; - final wireConsumerCells = >{}; - for (final entry in cells.entries) { - final cell = entry.value; - final conns = cell['connections'] as Map? ?? {}; - final pdirs = cell['port_directions'] as Map? ?? {}; - for (final pe in conns.entries) { - final d = pdirs[pe.key] as String? ?? ''; - for (final b in pe.value as List) { - if (b is int) { - if (d == 'output') { - wireDriverCell[b] = entry.key; - } else if (d == 'input') { - (wireConsumerCells[b] ??= {}).add(entry.key); - } - } - } - } - } - - final modPorts = moduleDef['ports'] as Map>?; - if (modPorts != null) { - for (final port in modPorts.values) { - if ((port['direction'] as String?) == 'output') { - for (final b in port['bits'] as List? ?? []) { - if (b is int) { - (wireConsumerCells[b] ??= {}).add('__port__'); - } - } - } - } - } - - final netnames = moduleDef['netnames'] as Map? ?? {}; - final bitToNetInfo = )>{}; - for (final nnEntry in netnames.entries) { - final nd = nnEntry.value! as Map; - final bits = (nd['bits'] as List?)?.cast() ?? []; - for (final b in bits) { - bitToNetInfo[b] = (nnEntry.key, bits); - } - } - - return ( - wireDriverCell: wireDriverCell, - wireConsumerCells: wireConsumerCells, - bitToNetInfo: bitToNetInfo, - ); - } - - /// Trace a single wire bit backward through `$buf`/`$slice` cells, - /// returning the ultimate source bit and the set of intermediate cell - /// names visited along the chain. - static (int sourceBit, Set intermediates) traceBackward( - int startBit, - Map wireDriverCell, - Map> cells, - ) { - var current = startBit; - final chain = {}; - while (true) { - final driverName = wireDriverCell[current]; - if (driverName == null) { - break; - } - final driverCell = cells[driverName]; - if (driverCell == null) { - break; - } - final dt = driverCell['type'] as String?; - if (dt != r'$buf' && dt != r'$slice') { - break; - } - if (chain.contains(driverName)) { - break; // Cycle detected — stop tracing. - } - chain.add(driverName); - final dc = driverCell['connections'] as Map? ?? {}; - if (dt == r'$buf') { - final yBits = dc['Y'] as List; - final aBits = dc['A'] as List; - final idx = yBits.indexOf(current); - if (idx < 0 || idx >= aBits.length || aBits[idx] is! int) { - break; - } - current = aBits[idx] as int; - } else { - final yBits = dc['Y'] as List; - final aBits = dc['A'] as List; - final dp = driverCell['parameters'] as Map? ?? {}; - final offset = dp['OFFSET'] as int? ?? 0; - final idx = yBits.indexOf(current); - if (idx < 0) { - break; - } - final srcIdx = offset + idx; - if (srcIdx < 0 || srcIdx >= aBits.length || aBits[srcIdx] is! int) { - break; - } - current = aBits[srcIdx] as int; - } - } - return (current, chain); - } - - /// Whether every intermediate cell in [intermediates] exclusively feeds - /// [ownerCell] or other cells in [intermediates]. - /// - /// When [allowPortConsumers] is true, `'__port__'` pseudo-consumers are - /// also accepted (used when module-output ports registered as consumers). - static bool isExclusiveChain({ - required Set intermediates, - required String ownerCell, - required Map> cells, - required Map> wireConsumerCells, - bool allowPortConsumers = false, - }) { - for (final ic in intermediates) { - final icCell = cells[ic]; - if (icCell == null) { - return false; - } - final icConns = icCell['connections'] as Map? ?? {}; - final icDirs = icCell['port_directions'] as Map? ?? {}; - for (final pe in icConns.entries) { - if ((icDirs[pe.key] as String?) != 'output') { - continue; - } - for (final b in pe.value as List) { - if (b is! int) { - continue; - } - final consumers = wireConsumerCells[b]; - if (consumers == null) { - continue; - } - for (final cn in consumers) { - if (cn != ownerCell && !intermediates.contains(cn)) { - if (allowPortConsumers && cn == '__port__') { - continue; - } - return false; - } - } - } - } - } - return true; - } - /// Collapses bit-slice ports of a Combinational/Sequential cell into /// aggregate ports. /// diff --git a/test/netlist_synthesizer_test.dart b/test/netlist_synthesizer_test.dart index 8e08e2945..1befef915 100644 --- a/test/netlist_synthesizer_test.dart +++ b/test/netlist_synthesizer_test.dart @@ -790,35 +790,6 @@ void main() { } }); - test('groupStructConversions produces valid netlist', () async { - final synth = SynthBuilder( - filterBank, - NetlistSynthesizer( - options: const NetlistOptions(groupStructConversions: true), - ), - ); - final json = await (synth.synthesizer as NetlistSynthesizer) - .synthesizeToJson(filterBank); - final parsed = jsonDecode(json) as Map; - expect(_modules(parsed), isNotEmpty); - }); - - test('collapseStructGroups with groupStructConversions', () async { - final synth = SynthBuilder( - filterBank, - NetlistSynthesizer( - options: const NetlistOptions( - groupStructConversions: true, - collapseStructGroups: true, - ), - ), - ); - final json = await (synth.synthesizer as NetlistSynthesizer) - .synthesizeToJson(filterBank); - final parsed = jsonDecode(json) as Map; - expect(_modules(parsed), isNotEmpty); - }); - test('DCE disabled still produces valid netlist', () async { final synth = SynthBuilder( filterBank, @@ -1339,98 +1310,13 @@ void main() { // ── Group 10: Post-processing option combinations ────────────────── group('post-processing options', () { - test('groupMaximalSubsets produces valid netlist', () async { + test('collapseTransparentClusters produces valid netlist', () async { final fb = _buildFilterBank(); final json = await _synthToMap( fb, - options: const NetlistOptions( - groupStructConversions: true, - groupMaximalSubsets: true, - ), + options: const NetlistOptions(collapseTransparentClusters: true), ); expect(_modules(json), isNotEmpty); }); - - test('collapseConcats produces valid netlist', () async { - final fb = _buildFilterBank(); - final json = await _synthToMap( - fb, - options: const NetlistOptions( - groupStructConversions: true, - collapseConcats: true, - ), - ); - expect(_modules(json), isNotEmpty); - }); - - test( - 'all post-processing options enabled produces valid netlist', - () async { - final fb = _buildFilterBank(); - final json = await _synthToMap( - fb, - options: const NetlistOptions( - groupStructConversions: true, - collapseStructGroups: true, - groupMaximalSubsets: true, - collapseConcats: true, - ), - ); - expect(_modules(json), isNotEmpty); - }, - ); - - test('groupStructConversions adds synthetic module definitions', () async { - final fbPlain = _buildFilterBank(); - final jsonPlain = await _synthToMap(fbPlain); - final plainCount = _modules(jsonPlain).length; - - final fbGrouped = _buildFilterBank(); - final jsonGrouped = await _synthToMap( - fbGrouped, - options: const NetlistOptions(groupStructConversions: true), - ); - final groupedCount = _modules(jsonGrouped).length; - - // groupStructConversions should introduce additional synthetic modules - expect( - groupedCount, - greaterThanOrEqualTo(plainCount), - reason: 'Struct grouping should add module definitions', - ); - }); - - test( - 'collapseStructGroups reduces cells vs groupStructConversions only', - () async { - final fb1 = _buildFilterBank(); - final json1 = await _synthToMap( - fb1, - options: const NetlistOptions(groupStructConversions: true), - ); - int countCells(Map j) { - var total = 0; - for (final def in _modules(j).values) { - total += _cells(def as Map).length; - } - return total; - } - - final fb2 = _buildFilterBank(); - final json2 = await _synthToMap( - fb2, - options: const NetlistOptions( - groupStructConversions: true, - collapseStructGroups: true, - ), - ); - - expect( - countCells(json2), - lessThanOrEqualTo(countCells(json1)), - reason: 'Collapsing struct groups should not increase cell count', - ); - }, - ); }); } diff --git a/test/signal_registry_test.dart b/test/signal_registry_test.dart index cfd8d1e36..13e36a2d7 100644 --- a/test/signal_registry_test.dart +++ b/test/signal_registry_test.dart @@ -179,6 +179,9 @@ void main() { expect(name, isNotEmpty); } } + }); + }); + group('isAvailable', () { test('port names are not available', () async { final mod = _GateMod(Logic(), Logic()); diff --git a/test/slim_connected_port_test.dart b/test/slim_connected_port_test.dart index 1f2f642e0..05c0b99d2 100644 --- a/test/slim_connected_port_test.dart +++ b/test/slim_connected_port_test.dart @@ -30,16 +30,12 @@ void main() { final modules = netlist['modules'] as Map; // Find the SimpleTest module - // ignore: avoid_print - print('Module keys: ${modules.keys.toList()}'); // The module name may be the type name or uniquified; find it final simpleTestKey = modules.keys.firstWhere( (k) => k.contains('SimpleTest'), orElse: () => modules.keys.first, ); final simpleTest = modules[simpleTestKey] as Map; - // ignore: avoid_print - print('Using module: $simpleTestKey'); final ports = simpleTest['ports'] as Map; @@ -66,12 +62,5 @@ void main() { isFalse, reason: 'unused_port should not have connected attribute', ); - - // Print for visibility - for (final p in ports.entries) { - final pd = p.value as Map; - // ignore: avoid_print - print('Port ${p.key}: connected=${pd["connected"]}'); - } }); } diff --git a/test/slim_full_canonical_test.dart b/test/slim_full_canonical_test.dart index ce979b5fa..b3f230f34 100644 --- a/test/slim_full_canonical_test.dart +++ b/test/slim_full_canonical_test.dart @@ -98,11 +98,6 @@ void main() { // Sanity: we tested a reasonable number of modules expect(modulesTested, greaterThan(0), reason: 'No modules were tested'); - // ignore: avoid_print - print( - 'Validated $modulesTested modules — all cell keys match ' - 'between slim and full.', - ); }); } diff --git a/test/slim_incremental_equivalence_test.dart b/test/slim_incremental_equivalence_test.dart index 09664a112..697a95377 100644 --- a/test/slim_incremental_equivalence_test.dart +++ b/test/slim_incremental_equivalence_test.dart @@ -208,12 +208,6 @@ void main() { if (errors.isNotEmpty) { fail('Slim incremental equivalence errors:\n${errors.join('\n')}'); } - - // ignore: avoid_print - print( - 'Validated ${fullModules.length} modules — ' - 'slim + incremental fetch matches full.', - ); } test('Counter: slim + incremental fetch == full', () async {