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/15] 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/15] 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 850b1fdf4de527b3b27d8c0af11773cb489857da Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Apr 2026 12:26:19 -0700 Subject: [PATCH 03/15] netlist synthesizer with new examples --- doc/architecture.md | 9 +- doc/user_guide/_docs/A21-generation.md | 82 + example/example.dart | 42 +- example/filter_bank.dart | 117 ++ example/oven_fsm.dart | 175 +- example/tree.dart | 39 +- lib/src/examples/filter_bank_modules.dart | 951 ++++++++++ lib/src/examples/oven_fsm_modules.dart | 210 +++ lib/src/examples/tree_modules.dart | 62 + .../netlist/leaf_cell_mapper.dart | 486 +++++ lib/src/synthesizers/netlist/netlist.dart | 9 + .../synthesizers/netlist/netlist_passes.dart | 1594 +++++++++++++++++ .../netlist/netlist_synthesis_result.dart | 84 + .../netlist/netlist_synthesizer.dart | 1373 ++++++++++++++ .../synthesizers/netlist/netlist_utils.dart | 519 ++++++ .../netlist/netlister_options.dart | 101 ++ lib/src/synthesizers/synthesizers.dart | 3 +- test/netlist_example_test.dart | 285 +++ test/netlist_test.dart | 536 ++++++ test/signal_registry_test.dart | 164 ++ 20 files changed, 6609 insertions(+), 232 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_passes.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 lib/src/synthesizers/netlist/netlister_options.dart create mode 100644 test/netlist_example_test.dart create mode 100644 test/netlist_test.dart create mode 100644 test/signal_registry_test.dart diff --git a/doc/architecture.md b/doc/architecture.md index cc1e775ae..aa0645c75 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -24,7 +24,12 @@ The `Simulator` acts as a statically accessible driver of the overall simulation ### Synthesizer -A separate type of object responsible for taking a `Module` and converting it to some output, such as SystemVerilog. +A separate type of object responsible for taking a `Module` and converting it to some output. ROHD includes two built-in synthesizers: + +- `SystemVerilogSynthesizer` — Converts a `Module` hierarchy into SystemVerilog files. Each `Module` with custom functionality must implement the `SystemVerilog` or `InlineSystemVerilog` mixin so the synthesizer knows how to emit it. +- `NetlistSynthesizer` — Converts a `Module` hierarchy into a JSON netlist following the [Yosys JSON format](https://yosyshq.readthedocs.io/projects/yosys/en/0.45/cmd/write_json.html). The output contains `modules` with `ports`, `cells`, and `netnames` sections, and is compatible with open-source EDA tools that consume `yosys write_json` output. + +Both synthesizers are driven by `SynthBuilder`, which walks the module hierarchy bottom-up, deduplicates identical module definitions, and produces a set of `SynthesisResult` objects (either `SynthesisResult` for SystemVerilog or `NetlistSynthesisResult` for netlist JSON). ## Organization @@ -44,7 +49,7 @@ Contains a collection of `Module` implementations that can be used as primitive ### Synthesizers -Contains logic for synthesizing `Module`s into some output. It is structured to maximize reusability across different output types (including those not yet supported). +Contains logic for synthesizing `Module`s into some output. It is structured to maximize reusability across different output types. The `SystemVerilogSynthesizer` generates `.sv` files, while the `NetlistSynthesizer` generates Yosys-compatible JSON netlists. `SynthBuilder` orchestrates the synthesis process for both, handling hierarchy traversal and definition deduplication. ### Utilities diff --git a/doc/user_guide/_docs/A21-generation.md b/doc/user_guide/_docs/A21-generation.md index 00d3d25bb..27663e29e 100644 --- a/doc/user_guide/_docs/A21-generation.md +++ b/doc/user_guide/_docs/A21-generation.md @@ -57,3 +57,85 @@ The `Naming.unpreferredName` function will modify a signal name to indicate to d ## More advanced generation Under the hood of `generateSynth`, it's actually using a [`SynthBuilder`](https://intel.github.io/rohd/rohd/SynthBuilder-class.html) which accepts a `Module` and a `Synthesizer` (usually a `SystemVerilogSynthesizer`) as arguments. This `SynthBuilder` can provide a collection of `String` file contents via `getFileContents`, or you can ask for the full set of `synthesisResults`, which contains `SynthesisResult`s which can each be converted `toSynthFileContents` but also has context about the `module` it refers to, the `instanceTypeName`, etc. With these APIs, you can easily generate named files, add file headers, ignore generation of some modules, generate file lists for other tools, etc. The `SynthBuilder.multi` constructor makes it convenient to generate outputs for multiple independent hierarchies. + +## Netlist Synthesis + +In addition to SystemVerilog, ROHD can synthesize a design to a JSON netlist that follows the [Yosys JSON format](https://yosyshq.readthedocs.io/projects/yosys/en/0.45/cmd/write_json.html). This is the same format produced by `yosys write_json` and consumed by many open-source EDA tools and viewers. + +### Basic usage + +```dart +void main() async { + final myModule = MyModule(); + await myModule.build(); + + final netlistJson = await NetlistSynthesizer().synthesizeToJson(myModule); + + // write it to a file + File('myDesign.rohd.json').writeAsStringSync(netlistJson); +} +``` + +### Output format + +The produced JSON has the following top-level structure: + +```json +{ + "creator": "ROHD ...", + "modules": { + "": { + "attributes": { "top": 1, ... }, + "ports": { + "": { "direction": "input"|"output"|"inout", "bits": [...] } + }, + "cells": { + "": { "type": "...", "connections": { ... } } + }, + "netnames": { + "": { "bits": [...], "hide_name": 0|1 } + } + } + } +} +``` + +Key sections per module: + +- **`ports`** — The module's input, output, and inout ports. Each port has a `direction` and a `bits` array of integer wire IDs. +- **`cells`** — Sub-module instances and primitive gate cells (e.g. `$and`, `$mux`, `$dff`, `$add`). Each cell has a `type`, and in full mode, `connections` mapping port names to wire ID vectors. +- **`netnames`** — Named signals (wires) internal to the module. Each entry maps a signal name to its `bits` vector. + +The top-level module is marked with `"top": 1` in its `attributes`. + +### Slim mode + +Passing `NetlisterOptions(slimMode: true)` produces a compact JSON that omits cell `connections`. This is useful for transmitting the design dictionary (module hierarchy, ports, signals) without the full connectivity — a remote agent can then fetch connection details per module on demand. + +```dart +final slimSynth = NetlistSynthesizer( + options: const NetlisterOptions(slimMode: true), +); +final slimJson = await slimSynth.synthesizeToJson(myModule); +``` + +### Using SynthBuilder directly + +Just like SystemVerilog synthesis, you can use `SynthBuilder` directly with a `NetlistSynthesizer` for more control: + +```dart +final synthesizer = NetlistSynthesizer(); +final synth = SynthBuilder(myModule, synthesizer); + +// Access individual NetlistSynthesisResult objects +for (final result in synth.synthesisResults) { + if (result is NetlistSynthesisResult) { + print('${result.instanceTypeName}: ' + '${result.ports.length} ports, ' + '${result.cells.length} cells'); + } +} + +// Or build the combined modules map directly +final modulesMap = await synthesizer.buildModulesMap(synth, myModule); +``` diff --git a/example/example.dart b/example/example.dart index 2ddbfc738..f1ef1e73e 100644 --- a/example/example.dart +++ b/example/example.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2026 Intel Corporation +// Copyright (C) 2021-2023 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // 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/src/examples/filter_bank_modules.dart b/lib/src/examples/filter_bank_modules.dart new file mode 100644 index 000000000..1b0f52344 --- /dev/null +++ b/lib/src/examples/filter_bank_modules.dart @@ -0,0 +1,951 @@ +// 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. +// +// 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..a4a3da134 --- /dev/null +++ b/lib/src/examples/oven_fsm_modules.dart @@ -0,0 +1,210 @@ +// 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). +// +// 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..45a405b0f --- /dev/null +++ b/lib/src/examples/tree_modules.dart @@ -0,0 +1,62 @@ +// 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. +// +// 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/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..3044f54fd --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist.dart @@ -0,0 +1,9 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause + +export 'leaf_cell_mapper.dart'; +export 'netlist_passes.dart'; +export 'netlist_synthesis_result.dart'; +export 'netlist_synthesizer.dart'; +export 'netlist_utils.dart'; +export 'netlister_options.dart'; diff --git a/lib/src/synthesizers/netlist/netlist_passes.dart b/lib/src/synthesizers/netlist/netlist_passes.dart new file mode 100644 index 000000000..91afb27d0 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_passes.dart @@ -0,0 +1,1594 @@ +// 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'; + +/// Collects a combined modules map from [SynthesisResult]s suitable for +/// JSON emission. +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. +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) = + 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 = 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) = 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 (!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'] = + makeBufCell(aWidth, sourceBusParentBits, outputBits.cast()); + } else { + cellsToAdd['maxsub_slice_$replIdx'] = 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). +void applyCollapseConcats( + 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) = + 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? ?? {}; + + // 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 = 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) = 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 (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'] = + makeBufCell(aWidth, sourceBusParentBits, outputBits); + } else { + cellsToAdd['collapse_slice_$replIdx'] = 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'] = 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'; + + 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); + } +} + +// -- 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. +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, + ) = 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. +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'] = 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. +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 = 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'] = 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'] = 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'] = + 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]. +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 = 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'] = + makeBufCell(allInputBits.length, allInputBits, yBits); + bufIdx++; + } + + removals.forEach(cells.remove); + cells.addAll(additions); + } +} 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..30f04f235 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_synthesizer.dart @@ -0,0 +1,1373 @@ +// 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. + final portArrays = { + ...module.inputs.values.whereType(), + ...module.outputs.values.whereType(), + ...module.inOuts.values.whereType(), + }; + module.internalSignals + .whereType() + .where((sig) => !portArrays.contains(sig)) + .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 = NetlisterOptions(groupStructConversions: true); +/// final synth = NetlistSynthesizer(options: options); +/// final builder = SynthBuilder(topModule, synth); +/// final json = await synth.synthesizeToJson(topModule); +/// ``` +class NetlistSynthesizer extends Synthesizer { + /// Top-level modules provided in [prepare]. + Set _topModules = {}; + + /// The configuration options controlling netlist synthesis. + /// + /// See [NetlisterOptions] for documentation on individual fields. + final NetlisterOptions 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 + /// [NetlisterOptions] for documentation on each field. + NetlistSynthesizer({this.options = const NetlisterOptions()}); + + @override + void prepare(List tops) { + _topModules = Set.from(tops); + } + + @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 = _topModules.contains(module); + 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 = 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 = 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) { + 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 && isConstantSynthLogic(synthLogic)) { + portsToRemove.add(portName); + blockedConstSynthLogics.add(synthLogic.replacement ?? synthLogic); + } + } + for (final p in portsToRemove) { + cellConns.remove(p); + cellPortDirs.remove(p); + } + } + + 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 output struct ports of the current module so Step 3 + // can skip $struct_field collection for them ($struct_compose + // 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. + // + // Exception: for output struct ports of the CURRENT module, + // we keep distinct port and field IDs and instead collect + // pending $struct_compose cells. This avoids "shorting" + // where port and field netnames share the same wire IDs. + 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); + + if (isCurrentModuleOutputPort) { + // 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 so Step 3 skips $struct_field for it. + for (final l in pa.dst.logics) { + if (l is LogicStructure) { + outputStructPortLogics.add(l); + } + } + } else { + // 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 = 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) => + 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 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 = 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'] = 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 port_directions and connections. + final portDirs = {}; + final conns = >{}; + + for (var i = 0; i < nonTrivialFields.length; i++) { + final f = nonTrivialFields[i]; + final srcLogic = f.srcSynthLogic.logics.firstOrNull; + final fieldName = srcLogic?.name ?? 'field_$i'; + 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]; + final srcLogic = f.srcSynthLogic.logics.firstOrNull; + params['FIELD_${i}_NAME'] = srcLogic?.name ?? 'field_$i'; + 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'] = + 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 = 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 = 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 = 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) { + applyMaximalSubsetGrouping(modules); + } + if (options.collapseConcats) { + applyCollapseConcats(modules); + } + applyStructConversionGrouping(modules); + if (options.collapseStructGroups) { + collapseStructGroupModules(modules); + } + applyStructBufferInsertion(modules); + 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 modules = + collectModuleEntries(synth.synthesisResults, topModule: top); + + applyPostProcessingPasses(modules); + + return modules; + } + + /// Generate the combined netlist JSON from a [SynthBuilder]'s results. + Future generateCombinedJson(SynthBuilder synth, Module top) async { + final modules = await buildModulesMap(synth, top); + final combined = { + 'creator': 'NetlistSynthesizer (rohd)', + 'modules': modules, + }; + return const JsonEncoder.withIndent(' ').convert(combined); + } + + /// 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..0bf1d49fe --- /dev/null +++ b/lib/src/synthesizers/netlist/netlist_utils.dart @@ -0,0 +1,519 @@ +// 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'; + +/// Find the port name in [portMap] that corresponds to [sl]. +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). +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. +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]`. +final rangePortRe = RegExp(r'^\[(\d+)(?::(\d+))?\]$'); + +/// Create a `$buf` cell map. +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. +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. +({ + 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. +(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; + } + 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). +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. +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). +bool isConstantSynthLogic(SynthLogic sl) => resolveReplacement(sl).isConstant; + +/// Extract the Const value from a constant SynthLogic. +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`. +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/netlist/netlister_options.dart b/lib/src/synthesizers/netlist/netlister_options.dart new file mode 100644 index 000000000..365092207 --- /dev/null +++ b/lib/src/synthesizers/netlist/netlister_options.dart @@ -0,0 +1,101 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlister_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** (`ModuleTree.toModuleSignalJson`): +/// 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** (`ModuleTree.moduleNetlistJson`): +/// 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 = NetlisterOptions( +/// groupStructConversions: true, +/// collapseStructGroups: true, +/// ); +/// final synth = NetlistSynthesizer(options: options); +/// ``` +class NetlisterOptions { + /// 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`, 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; + + /// Creates a [NetlisterOptions] with the given configuration. + /// + /// All parameters have sensible defaults matching the current + /// netlist synthesizer behaviour. + const NetlisterOptions({ + this.leafCellMapper, + this.groupStructConversions = false, + this.collapseStructGroups = false, + this.groupMaximalSubsets = false, + this.collapseConcats = false, + this.enableDCE = true, + this.slimMode = false, + }); +} 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/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_test.dart b/test/netlist_test.dart new file mode 100644 index 000000000..af6f4d388 --- /dev/null +++ b/test/netlist_test.dart @@ -0,0 +1,536 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_test.dart +// Tests for the netlist synthesizer: JSON structure, SynthBuilder, +// NetlistSynthesisResult, collectModuleEntries, NetlisterOptions, +// 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 = + 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); + } + }); + }); + + // ── NetlisterOptions ────────────────────────────────────────────────── + + group('NetlisterOptions', () { + test('slimMode omits cell connections', () async { + final mod = _CompositeModule(Logic(name: 'a'), Logic(name: 'b')); + await mod.build(); + + final slimSynth = + NetlistSynthesizer(options: const NetlisterOptions(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'); + } + } + }); + }); +} diff --git a/test/signal_registry_test.dart b/test/signal_registry_test.dart new file mode 100644 index 000000000..cc1f6b411 --- /dev/null +++ b/test/signal_registry_test.dart @@ -0,0 +1,164 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_registry_test.dart +// Tests for Module canonical naming (SignalNamer / signalName / allocateSignalName). +// +// 2026 April 14 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:test/test.dart'; + +import '../example/filter_bank.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.signalName(mod.input('a')), equals('a')); + expect(mod.signalName(mod.input('b')), equals('b')); + expect(mod.signalName(mod.output('a_bar')), equals('a_bar')); + expect(mod.signalName(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.signalName(mod.input('en')), equals('en')); + expect(mod.signalName(mod.input('reset')), equals('reset')); + expect(mod.signalName(mod.output('val')), equals('val')); + }); + }); + + group('allocateSignalName', () { + test('avoids collision with existing names', () async { + final mod = _Counter(Logic(), Logic()); + await mod.build(); + + final allocated = mod.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.allocateSignalName('wire'); + final b = mod.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.signalName(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.signalName(sig), + }; + } + + final names1 = await buildAndGetNames(); + await Simulator.reset(); + final names2 = await buildAndGetNames(); + + 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.signalName(dut.input('clk')), equals('clk')); + expect(dut.signalName(dut.output('done')), equals('done')); + + for (final sub in dut.subModules) { + for (final entry in sub.inputs.entries) { + final name = sub.signalName(entry.value); + expect(name, isNotEmpty); + } + } + }); + }); +} From 5f3adcc6cfd3ee23e440292b801a490324f27600 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Apr 2026 12:33:24 -0700 Subject: [PATCH 04/15] netlist synthesizer with new examples, forgot barrel file --- example/example.dart | 2 +- lib/src/synthesizers/synthesizers.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/example.dart b/example/example.dart index f1ef1e73e..7715ffb34 100644 --- a/example/example.dart +++ b/example/example.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2023 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // example.dart diff --git a/lib/src/synthesizers/synthesizers.dart b/lib/src/synthesizers/synthesizers.dart index da5d76586..784071942 100644 --- a/lib/src/synthesizers/synthesizers.dart +++ b/lib/src/synthesizers/synthesizers.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2026 Intel Corporation +// Copyright (C) 2021-2025 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause export 'netlist/netlist.dart'; From b7087c40467389ae38be40e2d4c599c0d532ebe7 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Apr 2026 13:03:20 -0700 Subject: [PATCH 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 0a88f865f617d051e57b74787654ddef49e9156f Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 19 Apr 2026 08:31:21 -0700 Subject: [PATCH 10/15] bug fix for struct_pack, filename cleanup --- doc/user_guide/_docs/A21-generation.md | 4 +- lib/src/synthesizers/netlist/netlist.dart | 2 +- ...ster_options.dart => netlist_options.dart} | 10 +-- .../netlist/netlist_synthesizer.dart | 63 +++++++++++++------ test/netlist_test.dart | 8 +-- 5 files changed, 56 insertions(+), 31 deletions(-) rename lib/src/synthesizers/netlist/{netlister_options.dart => netlist_options.dart} (95%) diff --git a/doc/user_guide/_docs/A21-generation.md b/doc/user_guide/_docs/A21-generation.md index 27663e29e..d1960b5a0 100644 --- a/doc/user_guide/_docs/A21-generation.md +++ b/doc/user_guide/_docs/A21-generation.md @@ -110,11 +110,11 @@ The top-level module is marked with `"top": 1` in its `attributes`. ### Slim mode -Passing `NetlisterOptions(slimMode: true)` produces a compact JSON that omits cell `connections`. This is useful for transmitting the design dictionary (module hierarchy, ports, signals) without the full connectivity — a remote agent can then fetch connection details per module on demand. +Passing `NetlistOptions(slimMode: true)` produces a compact JSON that omits cell `connections`. This is useful for transmitting the design dictionary (module hierarchy, ports, signals) without the full connectivity — a remote agent can then fetch connection details per module on demand. ```dart final slimSynth = NetlistSynthesizer( - options: const NetlisterOptions(slimMode: true), + options: const NetlistOptions(slimMode: true), ); final slimJson = await slimSynth.synthesizeToJson(myModule); ``` diff --git a/lib/src/synthesizers/netlist/netlist.dart b/lib/src/synthesizers/netlist/netlist.dart index 3044f54fd..91280b779 100644 --- a/lib/src/synthesizers/netlist/netlist.dart +++ b/lib/src/synthesizers/netlist/netlist.dart @@ -2,8 +2,8 @@ // SPDX-License-Identifier: BSD-3-Clause export 'leaf_cell_mapper.dart'; +export 'netlist_options.dart'; export 'netlist_passes.dart'; export 'netlist_synthesis_result.dart'; export 'netlist_synthesizer.dart'; export 'netlist_utils.dart'; -export 'netlister_options.dart'; diff --git a/lib/src/synthesizers/netlist/netlister_options.dart b/lib/src/synthesizers/netlist/netlist_options.dart similarity index 95% rename from lib/src/synthesizers/netlist/netlister_options.dart rename to lib/src/synthesizers/netlist/netlist_options.dart index 365092207..525e5b819 100644 --- a/lib/src/synthesizers/netlist/netlister_options.dart +++ b/lib/src/synthesizers/netlist/netlist_options.dart @@ -1,7 +1,7 @@ // Copyright (C) 2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // -// netlister_options.dart +// netlist_options.dart // Configuration for netlist synthesis. // // 2026 March 12 @@ -36,13 +36,13 @@ import 'package:rohd/src/synthesizers/netlist/leaf_cell_mapper.dart'; /// /// Example usage: /// ```dart -/// const options = NetlisterOptions( +/// const options = NetlistOptions( /// groupStructConversions: true, /// collapseStructGroups: true, /// ); /// final synth = NetlistSynthesizer(options: options); /// ``` -class NetlisterOptions { +class NetlistOptions { /// The leaf-cell mapper used to convert ROHD leaf modules to Yosys /// primitive cell types. When `null`, [LeafCellMapper.defaultMapper] /// is used. @@ -85,11 +85,11 @@ class NetlisterOptions { /// produce compatible wire IDs. final bool slimMode; - /// Creates a [NetlisterOptions] with the given configuration. + /// Creates a [NetlistOptions] with the given configuration. /// /// All parameters have sensible defaults matching the current /// netlist synthesizer behaviour. - const NetlisterOptions({ + const NetlistOptions({ this.leafCellMapper, this.groupStructConversions = false, this.collapseStructGroups = false, diff --git a/lib/src/synthesizers/netlist/netlist_synthesizer.dart b/lib/src/synthesizers/netlist/netlist_synthesizer.dart index a8cc1657c..bad18165a 100644 --- a/lib/src/synthesizers/netlist/netlist_synthesizer.dart +++ b/lib/src/synthesizers/netlist/netlist_synthesizer.dart @@ -133,7 +133,7 @@ class _NetlistSynthModuleDefinition extends SynthModuleDefinition { /// /// Usage: /// ```dart -/// const options = NetlisterOptions(groupStructConversions: true); +/// const options = NetlistOptions(groupStructConversions: true); /// final synth = NetlistSynthesizer(options: options); /// final builder = SynthBuilder(topModule, synth); /// final json = await synth.synthesizeToJson(topModule); @@ -141,8 +141,8 @@ class _NetlistSynthModuleDefinition extends SynthModuleDefinition { class NetlistSynthesizer extends Synthesizer { /// The configuration options controlling netlist synthesis. /// - /// See [NetlisterOptions] for documentation on individual fields. - final NetlisterOptions options; + /// See [NetlistOptions] for documentation on individual fields. + final NetlistOptions options; /// Convenience accessor for the leaf-cell mapper. LeafCellMapper get leafCellMapper => @@ -151,8 +151,8 @@ class NetlistSynthesizer extends Synthesizer { /// Creates a [NetlistSynthesizer]. /// /// All synthesis parameters are bundled in [options]; see - /// [NetlisterOptions] for documentation on each field. - NetlistSynthesizer({this.options = const NetlisterOptions()}); + /// [NetlistOptions] for documentation on each field. + NetlistSynthesizer({this.options = const NetlistOptions()}); @override bool generatesDefinition(Module module) => @@ -165,8 +165,10 @@ class NetlistSynthesizer extends Synthesizer { @override SynthesisResult synthesize( Module module, - String Function(Module module) getInstanceTypeOfModule, - ) { + String Function(Module module) getInstanceTypeOfModule, { + SynthesisResult? Function(Module module)? lookupExistingResult, + Map? existingResults, + }) { final isTop = module.parent == null; final attr = {'src': 'generated'}; if (isTop) { @@ -395,9 +397,9 @@ class NetlistSynthesizer extends Synthesizer { SynthLogic dstSynthLogic, })>[]; - // Track output struct ports of the current module so Step 3 - // can skip $struct_field collection for them ($struct_compose - // handles these instead). + // 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) { @@ -420,10 +422,26 @@ class NetlistSynthesizer extends Synthesizer { // src → dst[lower:upper]. The port-slice IDs become the // leaf's IDs so that the port is composed from its fields. // - // Exception: for output struct ports of the CURRENT module, - // we keep distinct port and field IDs and instead collect - // pending $struct_compose cells. This avoids "shorting" - // where port and field netnames share the same wire IDs. + // 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); @@ -433,7 +451,13 @@ class NetlistSynthesizer extends Synthesizer { final isCurrentModuleOutputPort = pa.dst.isPort(module) && pa.dst.logics.any((l) => l.isOutput); - if (isCurrentModuleOutputPort) { + // 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, @@ -443,14 +467,15 @@ class NetlistSynthesizer extends Synthesizer { srcSynthLogic: pa.src, dstSynthLogic: pa.dst, )); - // Track the Logic so Step 3 skips $struct_field for it. + // 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) { - outputStructPortLogics.add(l); + if (l is LogicStructure && l is! LogicArray) { + addStructAndDescendants(l, outputStructPortLogics); } } } else { - // Sub-module input port: alias as before. + // 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]) { diff --git a/test/netlist_test.dart b/test/netlist_test.dart index af6f4d388..ffbfbe6b7 100644 --- a/test/netlist_test.dart +++ b/test/netlist_test.dart @@ -3,7 +3,7 @@ // // netlist_test.dart // Tests for the netlist synthesizer: JSON structure, SynthBuilder, -// NetlistSynthesisResult, collectModuleEntries, NetlisterOptions, +// NetlistSynthesisResult, collectModuleEntries, NetlistOptions, // and example-based smoke tests. // // 2026 March 31 @@ -437,15 +437,15 @@ void main() { }); }); - // ── NetlisterOptions ────────────────────────────────────────────────── + // ── NetlistOptions ─────────────────────────────────────────────────── - group('NetlisterOptions', () { + 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 NetlisterOptions(slimMode: true)); + NetlistSynthesizer(options: const NetlistOptions(slimMode: true)); final json = await slimSynth.synthesizeToJson(mod); final decoded = jsonDecode(json) as Map; final modules = decoded['modules'] as Map; From cf4590eef0183db90f2af8c3b7cb19dd1791cce7 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 19 Apr 2026 20:09:21 -0700 Subject: [PATCH 11/15] struct_unpack/pack reduction --- .../synthesizers/netlist/netlist_options.dart | 21 + .../synthesizers/netlist/netlist_passes.dart | 794 ++++++++++++++++++ .../netlist/netlist_synthesizer.dart | 292 ++++++- test/netlist_example_test.dart | 249 +++++- 4 files changed, 1313 insertions(+), 43 deletions(-) diff --git a/lib/src/synthesizers/netlist/netlist_options.dart b/lib/src/synthesizers/netlist/netlist_options.dart index 525e5b819..7dcbbe029 100644 --- a/lib/src/synthesizers/netlist/netlist_options.dart +++ b/lib/src/synthesizers/netlist/netlist_options.dart @@ -72,6 +72,24 @@ class NetlistOptions { /// 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. @@ -95,6 +113,9 @@ class NetlistOptions { this.collapseStructGroups = false, this.groupMaximalSubsets = false, this.collapseConcats = false, + this.collapseSelectsIntoPack = false, + this.collapseUnpackToConcat = false, + this.collapseUnpackToPack = false, this.enableDCE = true, this.slimMode = false, }); diff --git a/lib/src/synthesizers/netlist/netlist_passes.dart b/lib/src/synthesizers/netlist/netlist_passes.dart index 91afb27d0..da616b2bb 100644 --- a/lib/src/synthesizers/netlist/netlist_passes.dart +++ b/lib/src/synthesizers/netlist/netlist_passes.dart @@ -1592,3 +1592,797 @@ void applyConcatToBufferReplacement( 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. +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) = + 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. +void applyCollapseUnpackToConcat( + Map> allModules, +) { + for (final moduleDef in allModules.values) { + 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; + while (anyChanged) { + anyChanged = false; + + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + 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 = 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) { + var (traced, chain) = 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 && + !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'] = + 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 || + 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. + cellsToRemove.add(t0.unpackName!); + if (yWidth == aWidth) { + cellsToAdd['unpack_concat_buf_$replIdx'] = + makeBufCell(aWidth, sourceBits, outputBits); + } else { + cellsToAdd['unpack_concat_buf_$replIdx'] = 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 uABits = [ + for (final b in uConns['A'] as List) if (b is int) b, + ]; + 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; + + cellsToAdd['unpack_concat_buf_$replIdx'] = + makeBufCell(width, tracedBits, tracedBits); + replIdx++; + + 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 = 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. +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) = + 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) = + 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 (!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 bad18165a..2ca58edc1 100644 --- a/lib/src/synthesizers/netlist/netlist_synthesizer.dart +++ b/lib/src/synthesizers/netlist/netlist_synthesizer.dart @@ -333,6 +333,38 @@ class NetlistSynthesizer extends Synthesizer { } } + // -- 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 = resolveReplacement(sl); + final namerName = 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, @@ -756,13 +788,121 @@ class NetlistSynthesizer extends Synthesizer { // 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 as LogicStructure, 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 = f.elemLogic.name; + final fieldName = suFieldNameFor(f.offset, f.elemLogic.name); // Disambiguate duplicate field names with index suffix. var portName = fieldName; if (portDirs.containsKey(portName)) { @@ -779,7 +919,8 @@ class NetlistSynthesizer extends Synthesizer { }; for (var i = 0; i < nonTrivialFields.length; i++) { final f = nonTrivialFields[i]; - params['FIELD_${i}_NAME'] = f.elemLogic.name; + params['FIELD_${i}_NAME'] = + suFieldNameFor(f.offset, f.elemLogic.name); params['FIELD_${i}_OFFSET'] = f.offset; params['FIELD_${i}_WIDTH'] = f.width; } @@ -854,14 +995,144 @@ class NetlistSynthesizer extends Synthesizer { ? 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 ?? + 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 srcLogic = f.srcSynthLogic.logics.firstOrNull; - final fieldName = srcLogic?.name ?? 'field_$i'; + final fieldName = fieldNameFor(f.dstLowerIndex, f.srcSynthLogic); var portName = fieldName; if (portDirs.containsKey(portName)) { portName = '${fieldName}_$i'; @@ -881,8 +1152,8 @@ class NetlistSynthesizer extends Synthesizer { }; for (var i = 0; i < nonTrivialFields.length; i++) { final f = nonTrivialFields[i]; - final srcLogic = f.srcSynthLogic.logics.firstOrNull; - params['FIELD_${i}_NAME'] = srcLogic?.name ?? 'field_$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; } @@ -1313,6 +1584,15 @@ class NetlistSynthesizer extends Synthesizer { if (options.collapseConcats) { applyCollapseConcats(modules); } + if (options.collapseSelectsIntoPack) { + applyCollapseSelectsIntoPack(modules); + } + if (options.collapseUnpackToConcat) { + applyCollapseUnpackToConcat(modules); + } + if (options.collapseUnpackToPack) { + applyCollapseUnpackToPack(modules); + } applyStructConversionGrouping(modules); if (options.collapseStructGroups) { collapseStructGroupModules(modules); diff --git a/test/netlist_example_test.dart b/test/netlist_example_test.dart index e5d7b7cbe..f93bb582c 100644 --- a/test/netlist_example_test.dart +++ b/test/netlist_example_test.dart @@ -1,16 +1,17 @@ -// Copyright (C) 2026 Intel Corporation +// Copyright (C) 2025 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 +// 2025 December 18 // Author: Desmond Kirkpatrick import 'dart:convert'; import 'dart:io'; import 'package:rohd/rohd.dart'; +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; import 'package:test/test.dart'; import '../example/example.dart'; @@ -25,23 +26,25 @@ void main() { // `--platform node` we skip filesystem and loader assertions. const isJS = identical(0, 0.0); + // Tests should call the synthesizer API directly to obtain the final + // combined JSON string and perform VM-only writes themselves. + // 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( + // produced JSON to `outPath` when running on VM. Validates the JSON by + // parsing it through the pure-Dart NetlistHierarchyAdapter. + Future convertTestWriteNetlist( Module top, String outPath, ) async { final synth = SynthBuilder(top, NetlistSynthesizer()); - final jsonStr = + final json = await (synth.synthesizer as NetlistSynthesizer).synthesizeToJson(top); if (!isJS) { final file = File(outPath); await file.create(recursive: true); - await file.writeAsString(jsonStr); + await file.writeAsString(json); } - final decoded = jsonDecode(jsonStr) as Map; - return decoded['modules'] as Map; + return NetlistHierarchyAdapter.fromJson(json); } test('Netlist dump for example Counter', () async { @@ -53,20 +56,16 @@ void main() { await counter.build(); counter.generateSynth(); - final modules = await convertTestWriteNetlist( + final adapter = await convertTestWriteNetlist( counter, 'build/Counter.rohd.json', ); expect( - modules, + adapter.root.children, isNotEmpty, - reason: 'Counter netlist should have module definitions', + reason: 'Counter hierarchy should have children', ); - // 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', () { @@ -78,14 +77,14 @@ void main() { final counter = Counter(en, reset, clk); await counter.build(); - final modules = await convertTestWriteNetlist( + final adapter = await convertTestWriteNetlist( counter, 'build/Counter.synth.rohd.json', ); expect( - modules, + adapter.root.name, isNotEmpty, - reason: 'Counter synth netlist should have modules', + reason: 'Counter synth hierarchy should have a root name', ); }); @@ -102,14 +101,14 @@ void main() { final synth = SynthBuilder(fir, NetlistSynthesizer()); expect(synth.synthesisResults.isNotEmpty, isTrue); - final modules = await convertTestWriteNetlist( + final adapter = await convertTestWriteNetlist( fir, 'build/FirFilter.synth.rohd.json', ); expect( - modules, + adapter.root.name, isNotEmpty, - reason: 'FirFilter synth netlist should have modules', + reason: 'FirFilter synth hierarchy should have a root name', ); }); @@ -130,14 +129,14 @@ void main() { final synth = SynthBuilder(la, NetlistSynthesizer()); expect(synth.synthesisResults.isNotEmpty, isTrue); - final modules = await convertTestWriteNetlist( + final adapter = await convertTestWriteNetlist( la, 'build/LogicArrayExample.synth.rohd.json', ); expect( - modules, + adapter.root.name, isNotEmpty, - reason: 'LogicArrayExample synth netlist should have modules', + reason: 'LogicArrayExample synth hierarchy should have a root name', ); }); @@ -152,14 +151,14 @@ void main() { final synth = SynthBuilder(oven, NetlistSynthesizer()); expect(synth.synthesisResults.isNotEmpty, isTrue); - final modules = await convertTestWriteNetlist( + final adapter = await convertTestWriteNetlist( oven, 'build/OvenModule.synth.rohd.json', ); expect( - modules, + adapter.root.name, isNotEmpty, - reason: 'OvenModule synth netlist should have modules', + reason: 'OvenModule synth hierarchy should have a root name', ); }); @@ -198,7 +197,7 @@ void main() { await fir.build(); const outPath = 'build/FirFilter.rohd.json'; - final modules = await convertTestWriteNetlist(fir, outPath); + final adapter = await convertTestWriteNetlist(fir, outPath); if (!isJS) { final f = File(outPath); expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); @@ -206,9 +205,9 @@ void main() { expect(contents.trim().isNotEmpty, isTrue); } expect( - modules, + adapter.root.children, isNotEmpty, - reason: 'FirFilter netlist should have module definitions', + reason: 'FirFilter hierarchy should have children', ); }); @@ -222,7 +221,7 @@ void main() { await la.build(); const outPath = 'build/LogicArrayExample.rohd.json'; - final modules = await convertTestWriteNetlist(la, outPath); + final adapter = await convertTestWriteNetlist(la, outPath); if (!isJS) { final f = File(outPath); expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); @@ -230,9 +229,9 @@ void main() { expect(contents.trim().isNotEmpty, isTrue); } expect( - modules, + adapter.root.children, isNotEmpty, - reason: 'LogicArrayExample netlist should have module definitions', + reason: 'LogicArrayExample hierarchy should have children', ); }); @@ -245,7 +244,7 @@ void main() { await oven.build(); const outPath = 'build/OvenModule.rohd.json'; - final modules = await convertTestWriteNetlist(oven, outPath); + final adapter = await convertTestWriteNetlist(oven, outPath); if (!isJS) { final f = File(outPath); expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); @@ -253,9 +252,9 @@ void main() { expect(contents.trim().isNotEmpty, isTrue); } expect( - modules, + adapter.root.children, isNotEmpty, - reason: 'OvenModule netlist should have module definitions', + reason: 'OvenModule hierarchy should have children', ); }); @@ -282,4 +281,180 @@ void main() { expect(file.existsSync(), isTrue, reason: 'ROHD JSON should be created'); } }); + + // ── Design API tests ───────────────────────────────────────────────── + // + // These exercise the production path: build(netlistOptions: ...) → + // ModuleTree.instance.toJson() which produces the unified JSON format + // {"hierarchy": {...}, "netlist": {"modules": {...}}} consumed by DevTools. + + group('Design API unified netlist generation', () { + tearDown(() async { + await Simulator.reset(); + ModuleTree.clearCustomNetlistJson(); + }); + + /// Build [module] with synthesis, write the unified JSON, and validate + /// that both hierarchy and netlist sections are present and parseable. + Future validateDesignApi( + Module module, + String outPath, { + NetlistOptions? options, + }) async { + await module.build(netlistOptions: options ?? const NetlistOptions()); + + // ── Full netlist (with connections) ──────────────────────────── + final fullJson = ModuleTree.instance.toFullNetlistJson(); + expect(fullJson, isNotNull, + reason: 'toFullNetlistJson should not be null'); + final full = jsonDecode(fullJson!) as Map; + + expect( + full.containsKey('netlist'), + isTrue, + reason: 'Full JSON should have netlist section', + ); + + final netlistSection = full['netlist'] as Map; + expect( + netlistSection.containsKey('modules'), + isTrue, + reason: 'Netlist section should have modules', + ); + final modules = netlistSection['modules'] as Map; + expect( + modules, + isNotEmpty, + reason: 'Netlist should contain at least one module', + ); + + // Verify cells have connections (the key difference from slim) + final topModule = modules.values.first as Map; + final cells = topModule['cells'] as Map? ?? {}; + if (cells.isNotEmpty) { + 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, + reason: 'Full netlist cells should have connections', + ); + } + + // Verify the netlist section parses through hierarchy adapter + final adapter = NetlistHierarchyAdapter.fromMap(netlistSection); + expect( + adapter.root.children, + isNotEmpty, + reason: '${module.definitionName} should have hierarchy children', + ); + + // ── Slim netlist (no connections, for incremental loading) ───── + final slimJson = ModuleTree.instance.toModuleSignalJson(); + expect(slimJson, isNotNull, + reason: 'toModuleSignalJson should not be null'); + final slim = jsonDecode(slimJson!) as Map; + expect( + slim.containsKey('netlist'), + isTrue, + reason: 'Slim JSON should have netlist section', + ); + + // ── Module count parity between full and slim ───────────────── + final slimNetlist = slim['netlist'] as Map; + final slimModules = slimNetlist['modules'] as Map; + expect( + slimModules.length, + equals(modules.length), + reason: 'Slim and full should have same number of modules', + ); + + // Write the full unified JSON for inspection + if (!isJS) { + final file = File(outPath); + await file.create(recursive: true); + await file.writeAsString(fullJson); + } + } + + test('Counter via design API', () async { + final en = Logic(name: 'en'); + final reset = Logic(name: 'reset'); + final clk = SimpleClockGenerator(10).clk; + final counter = Counter(en, reset, clk); + + await validateDesignApi(counter, 'build/Counter.design.rohd.json'); + }); + + test('FIR filter via design API', () 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 validateDesignApi(fir, 'build/FirFilter.design.rohd.json'); + }); + + test('LogicArray via design API', () 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 validateDesignApi(la, 'build/LogicArrayExample.design.rohd.json'); + }); + + test('OvenModule via design API', () 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 validateDesignApi(oven, 'build/OvenModule.design.rohd.json'); + }); + + test('TreeOfTwoInputModules via design API', () async { + final seq = List.generate(4, (_) => Logic(width: 8)); + final tree = TreeOfTwoInputModules(seq, (a, b) => mux(a > b, a, b)); + + await tree.build(netlistOptions: const NetlistOptions()); + + final fullJson = ModuleTree.instance.toFullNetlistJson(); + expect(fullJson, isNotNull); + final unified = jsonDecode(fullJson!) as Map; + + expect(unified.containsKey('netlist'), isTrue); + + final netlistSection = unified['netlist'] as Map; + final modules = netlistSection['modules'] as Map; + expect(modules, isNotEmpty, reason: 'Tree netlist should have modules'); + + if (!isJS) { + final file = File('build/TreeOfTwoInputModules.design.rohd.json'); + await file.create(recursive: true); + await file.writeAsString(fullJson); + } + }); + }); } From a6a40e1a4eb8e2c67d45928ef7500eaff3b4f162 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 19 Apr 2026 20:31:42 -0700 Subject: [PATCH 12/15] format cleanup --- .../synthesizers/netlist/netlist_passes.dart | 1093 +++++++++-------- .../netlist/netlist_synthesizer.dart | 45 +- 2 files changed, 617 insertions(+), 521 deletions(-) diff --git a/lib/src/synthesizers/netlist/netlist_passes.dart b/lib/src/synthesizers/netlist/netlist_passes.dart index da616b2bb..33a41dcd2 100644 --- a/lib/src/synthesizers/netlist/netlist_passes.dart +++ b/lib/src/synthesizers/netlist/netlist_passes.dart @@ -1609,7 +1609,9 @@ void applyCollapseSelectsIntoPack( ) { for (final moduleDef in allModules.values) { final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) continue; + if (cells == null || cells.isEmpty) { + continue; + } final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = buildWireMaps(cells, moduleDef); @@ -1619,32 +1621,47 @@ void applyCollapseSelectsIntoPack( for (final packEntry in cells.entries.toList()) { final packName = packEntry.key; final packCell = packEntry.value; - if ((packCell['type'] as String?) != r'$struct_pack') continue; + if ((packCell['type'] as String?) != r'$struct_pack') { + continue; + } final conns = packCell['connections'] as Map? ?? {}; - final dirs = - packCell['port_directions'] as Map? ?? {}; + final dirs = packCell['port_directions'] as Map? ?? {}; for (final portName in conns.keys.toList()) { - if (dirs[portName] != 'input') continue; + if (dirs[portName] != 'input') { + continue; + } final bits = [ for (final b in conns[portName] as List) if (b is int) b, ]; - if (bits.isEmpty) continue; + if (bits.isEmpty) { + continue; + } // All bits must be driven by the same $slice cell. final firstDriver = wireDriverCell[bits.first]; - if (firstDriver == null) continue; + 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; + 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; + if (!allFromSameSlice) { + continue; + } // The slice must exclusively feed this pack. final sliceConns = @@ -1655,16 +1672,19 @@ void applyCollapseSelectsIntoPack( ]; final exclusive = sliceYBits.every((b) { final consumers = wireConsumerCells[b]; - if (consumers == null) return true; + if (consumers == null) { + return true; + } return consumers.every((c) => c == packName || c == '__port__'); }); - if (!exclusive) continue; + 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 params = driverCell['parameters'] as Map? ?? {}; final offset = params['OFFSET'] as int? ?? 0; final yWidth = sliceYBits.length; @@ -1684,21 +1704,28 @@ void applyCollapseSelectsIntoPack( for (final packEntry in cells.entries.toList()) { final packName = packEntry.key; final packCell = packEntry.value; - if ((packCell['type'] as String?) != r'$struct_pack') continue; + if ((packCell['type'] as String?) != r'$struct_pack') { + continue; + } final conns = packCell['connections'] as Map? ?? {}; - final dirs = - packCell['port_directions'] 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; + if (dirs[portName] != 'input') { + continue; + } for (final b in conns[portName] as List) { - if (b is int) allInputBits.add(b); + if (b is int) { + allInputBits.add(b); + } } } - if (allInputBits.length < 2) continue; + if (allInputBits.length < 2) { + continue; + } // Check: input bits must form a contiguous ascending sequence. var contiguous = true; @@ -1708,12 +1735,17 @@ void applyCollapseSelectsIntoPack( break; } } - if (!contiguous) continue; + if (!contiguous) { + continue; + } final yBits = [ - for (final b in conns['Y'] as List) if (b is int) b, + for (final b in conns['Y'] as List) + if (b is int) b, ]; - if (yBits.length != allInputBits.length) continue; + if (yBits.length != allInputBits.length) { + continue; + } // Replace struct_pack with $buf. cells[packName] = { @@ -1744,7 +1776,9 @@ void applyCollapseUnpackToConcat( ) { for (final moduleDef in allModules.values) { final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) continue; + if (cells == null || cells.isEmpty) { + continue; + } // Iterate until convergence: each pass may create bufs that enable // the next outer concat/unpack to collapse. @@ -1753,534 +1787,590 @@ void applyCollapseUnpackToConcat( while (anyChanged) { anyChanged = false; - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - 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 (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + buildWireMaps(cells, moduleDef); - final conns = concatCell['connections'] as Map? ?? {}; + final cellsToRemove = {}; + final cellsToAdd = >{}; + var replIdx = globalReplIdx; - // 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 = 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], - )); + for (final concatEntry in cells.entries.toList()) { + final concatName = concatEntry.key; + final concatCell = concatEntry.value; + if ((concatCell['type'] as String?) != r'$concat') { + continue; } - } - 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)); + if (cellsToRemove.contains(concatName)) { + continue; } - } - 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, - })>[]; + final conns = concatCell['connections'] as Map? ?? {}; - for (final (_, _, bits) in inputPorts) { - final sourceIndices = []; - final intermediates = {}; - String? unpackName; - List? unpackABits; - var valid = true; - - for (final bit in bits) { - var (traced, chain) = 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; + // 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 driverCell = cells[driverName]; - if (driverCell == null || - (driverCell['type'] as String?) != r'$struct_unpack') { - valid = false; - break; + final m = 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; + } - 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, - ]; + // --- 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) = 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; + } - // 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 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, ]; - final idx = pBits.indexOf(traced); - if (idx >= 0) { - outPort = pe.key; - bitIdx = idx; + + // 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; } - } - 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; + } + } - // 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; + if (fieldOffset == null) { + valid = false; break; } - } - if (fieldOffset == null) { - valid = false; - break; - } + final aIdx = fieldOffset + bitIdx; + if (aIdx >= aBits.length) { + 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); } - intermediates.add(driverName); + portTraces.add(( + unpackName: unpackName, + unpackABits: unpackABits, + sourceIndices: sourceIndices, + intermediates: intermediates, + valid: valid, + )); + } - if (unpackName == null) { - unpackName = driverName; - unpackABits = aBits; - } else if (unpackName != driverName) { - valid = false; - break; + // --- 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; } - sourceIndices.add(aIdx); + 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; } - portTraces.add(( - unpackName: unpackName, - unpackABits: unpackABits, - sourceIndices: sourceIndices, - intermediates: intermediates, - valid: valid, - )); - } + 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!; - // --- 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; - } + // 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 && + !isExclusiveChain( + intermediates: removable, + ownerCell: concatName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + continue; + } - 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 && - !isExclusiveChain( - intermediates: removable, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { + // 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'] = + makeBufCell(reorderedA.length, reorderedA, outputBits); + replIdx++; 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); + // --- 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 || + isExclusiveChain( + intermediates: removable, + ownerCell: concatName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + validRuns.add((startIdx, endIdx, removable)); } } - 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'] = - 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 || - isExclusiveChain( - intermediates: removable, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - validRuns.add((startIdx, endIdx, removable)); + if (validRuns.isEmpty) { + continue; } - } - if (validRuns.isEmpty) continue; + final allCollapsed = validRuns.length == 1 && + validRuns.first.$1 == 0 && + validRuns.first.$2 == inputPorts.length - 1; - final allCollapsed = validRuns.length == 1 && - validRuns.first.$1 == 0 && - validRuns.first.$2 == inputPorts.length - 1; - - for (final (_, _, intermediates) in validRuns) { - cellsToRemove.addAll(intermediates); - } + 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, - ]; + 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. - cellsToRemove.add(t0.unpackName!); - if (yWidth == aWidth) { - cellsToAdd['unpack_concat_buf_$replIdx'] = - makeBufCell(aWidth, sourceBits, outputBits); - } else { - cellsToAdd['unpack_concat_buf_$replIdx'] = makeSliceCell( - srcOffset, aWidth, yWidth, sourceBits, outputBits); + 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'] = + makeBufCell(aWidth, sourceBits, outputBits); + } else { + cellsToAdd['unpack_concat_buf_$replIdx'] = makeSliceCell( + srcOffset, aWidth, yWidth, sourceBits, outputBits); + } + replIdx++; + continue; } - replIdx++; - continue; - } - // --- Partial collapse — rebuild concat with fewer ports --------- - cellsToRemove.add(concatName); + // --- Partial collapse — rebuild concat with fewer ports --------- + cellsToRemove.add(concatName); - final newConns = >{}; - final newDirs = {}; - var outBitOffset = 0; + 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; + 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); + 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 uABits = [ - for (final b in uConns['A'] as List) if (b is int) b, - ]; - 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]; + // 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++; } - 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); + 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 width = tracedBits.length; - cellsToAdd['unpack_concat_buf_$replIdx'] = - makeBufCell(width, tracedBits, tracedBits); - replIdx++; + cellsToAdd['unpack_concat_buf_$replIdx'] = + makeBufCell(width, tracedBits, tracedBits); + replIdx++; - final hi = outBitOffset + width - 1; - final portName = - hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; - newConns[portName] = tracedBits; - newDirs[portName] = 'input'; - outBitOffset += width; + 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++; + 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, + }; } - newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; - newDirs['Y'] = 'output'; + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); + if (cellsToRemove.isNotEmpty || cellsToAdd.isNotEmpty) { + anyChanged = true; + } + globalReplIdx = replIdx; - cellsToAdd['${concatName}_collapsed'] = { - 'hide_name': concatCell['hide_name'], - 'type': r'$concat', - 'parameters': {}, - 'attributes': concatCell['attributes'] ?? {}, - 'port_directions': newDirs, - 'connections': newConns, - }; - } + // 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; - 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 = 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 wireMaps2 = 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; } - final consumer = consumers.first; - if (consumer == '__port__') { - allToOneBuf = false; - break; + if (unpacksToRemove.contains(unpackName)) { + continue; } - final consumerCell = cells[consumer]; - if (consumerCell == null || - (consumerCell['type'] as String?) != r'$buf') { - allToOneBuf = false; - break; + + 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 (targetBufName == null) { - targetBufName = consumer; - } else if (consumer != targetBufName) { - allToOneBuf = false; - break; + if (allOutputBits.isEmpty) { + continue; } - } - 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; + // 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; } - } - 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, - ]; + 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, + ]; - if (unpackABits.length != bufYBits.length) continue; + // 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; + } - 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); - } + // 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, + ]; - unpacksToRemove.forEach(cells.remove); - bufsToRemove.forEach(cells.remove); - cells.addAll(bufsToAdd); - if (unpacksToRemove.isNotEmpty || bufsToRemove.isNotEmpty) { - anyChanged = true; - } + 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) } } @@ -2300,7 +2390,9 @@ void applyCollapseUnpackToPack( ) { for (final moduleDef in allModules.values) { final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) continue; + if (cells == null || cells.isEmpty) { + continue; + } final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = buildWireMaps(cells, moduleDef); @@ -2310,18 +2402,24 @@ void applyCollapseUnpackToPack( for (final packEntry in cells.entries.toList()) { final packName = packEntry.key; final packCell = packEntry.value; - if ((packCell['type'] as String?) != r'$struct_pack') continue; + if ((packCell['type'] as String?) != r'$struct_pack') { + continue; + } final conns = packCell['connections'] as Map? ?? {}; - final dirs = - packCell['port_directions'] as Map? ?? {}; + final dirs = packCell['port_directions'] as Map? ?? {}; for (final portName in conns.keys.toList()) { - if (dirs[portName] != 'input') continue; + if (dirs[portName] != 'input') { + continue; + } final bits = [ - for (final b in conns[portName] as List) if (b is int) b, + for (final b in conns[portName] as List) + if (b is int) b, ]; - if (bits.isEmpty) continue; + if (bits.isEmpty) { + continue; + } // Trace each bit backward through $buf/$slice chains. final tracedBits = []; @@ -2330,8 +2428,7 @@ void applyCollapseUnpackToPack( String? unpackName; for (final bit in bits) { - final (traced, chain) = - traceBackward(bit, wireDriverCell, cells); + final (traced, chain) = traceBackward(bit, wireDriverCell, cells); intermediates.addAll(chain); // Check if traced bit is driven by a $struct_unpack. @@ -2357,7 +2454,9 @@ void applyCollapseUnpackToPack( tracedBits.add(traced); } - if (!allTraceToUnpack || intermediates.isEmpty) continue; + if (!allTraceToUnpack || intermediates.isEmpty) { + continue; + } // Only remove $buf/$slice intermediates (not the unpack itself). final removable = intermediates.where((c) { @@ -2365,7 +2464,9 @@ void applyCollapseUnpackToPack( return ct == r'$buf' || ct == r'$slice'; }).toSet(); - if (removable.isEmpty) continue; + if (removable.isEmpty) { + continue; + } // Verify exclusivity. if (!isExclusiveChain( diff --git a/lib/src/synthesizers/netlist/netlist_synthesizer.dart b/lib/src/synthesizers/netlist/netlist_synthesizer.dart index 2ca58edc1..2815d6111 100644 --- a/lib/src/synthesizers/netlist/netlist_synthesizer.dart +++ b/lib/src/synthesizers/netlist/netlist_synthesizer.dart @@ -794,14 +794,13 @@ class NetlistSynthesizer extends Synthesizer { // 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, - })>[]; + final suElementRanges = <({ + int start, + int end, + String name, + String path, + int indexInParent, + })>[]; if (parentLogic is LogicStructure) { void walkStruct( LogicStructure struct, int baseOffset, String parentPath) { @@ -809,9 +808,8 @@ class NetlistSynthesizer extends Synthesizer { 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}'; + final elemPath = + parentPath.isEmpty ? elem.name : '${parentPath}_${elem.name}'; suElementRanges.add(( start: offset, end: elemEnd, @@ -826,7 +824,7 @@ class NetlistSynthesizer extends Synthesizer { } } - walkStruct(parentLogic as LogicStructure, 0, ''); + walkStruct(parentLogic, 0, ''); } String suFieldNameFor(int fieldOffset, String fallbackName) { @@ -1009,14 +1007,13 @@ class NetlistSynthesizer extends Synthesizer { // 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, - })>[]; + final dstElementRanges = <({ + int start, + int end, + String name, + String path, + int indexInParent, + })>[]; if (dstLogic is LogicStructure) { void walkStruct( LogicStructure struct, int baseOffset, String parentPath) { @@ -1024,9 +1021,8 @@ class NetlistSynthesizer extends Synthesizer { 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}'; + final elemPath = + parentPath.isEmpty ? elem.name : '${parentPath}_${elem.name}'; dstElementRanges.add(( start: offset, end: elemEnd, @@ -1122,8 +1118,7 @@ class NetlistSynthesizer extends Synthesizer { } return bestNamed.name; } - return bestAny?.name ?? - resolveReplacement(srcSynthLogic).name; + return bestAny?.name ?? resolveReplacement(srcSynthLogic).name; } // Build port_directions and connections. From 558f5ff12308d87d52bac8e337393b17bea0807d Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 19 Apr 2026 22:30:56 -0700 Subject: [PATCH 13/15] reverted netlist_example_test.dart limited to existing apis --- test/netlist_example_test.dart | 249 +++++---------------------------- 1 file changed, 37 insertions(+), 212 deletions(-) diff --git a/test/netlist_example_test.dart b/test/netlist_example_test.dart index f93bb582c..e5d7b7cbe 100644 --- a/test/netlist_example_test.dart +++ b/test/netlist_example_test.dart @@ -1,17 +1,16 @@ -// Copyright (C) 2025 Intel Corporation +// 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. -// 2025 December 18 +// 2026 March 31 // Author: Desmond Kirkpatrick import 'dart:convert'; import 'dart:io'; import 'package:rohd/rohd.dart'; -import 'package:rohd_hierarchy/rohd_hierarchy.dart'; import 'package:test/test.dart'; import '../example/example.dart'; @@ -26,25 +25,23 @@ void main() { // `--platform node` we skip filesystem and loader assertions. const isJS = identical(0, 0.0); - // Tests should call the synthesizer API directly to obtain the final - // combined JSON string and perform VM-only writes themselves. - // Helper used by the tests to synthesize `top` and optionally write the - // produced JSON to `outPath` when running on VM. Validates the JSON by - // parsing it through the pure-Dart NetlistHierarchyAdapter. - Future convertTestWriteNetlist( + // 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 json = + final jsonStr = await (synth.synthesizer as NetlistSynthesizer).synthesizeToJson(top); if (!isJS) { final file = File(outPath); await file.create(recursive: true); - await file.writeAsString(json); + await file.writeAsString(jsonStr); } - return NetlistHierarchyAdapter.fromJson(json); + final decoded = jsonDecode(jsonStr) as Map; + return decoded['modules'] as Map; } test('Netlist dump for example Counter', () async { @@ -56,16 +53,20 @@ void main() { await counter.build(); counter.generateSynth(); - final adapter = await convertTestWriteNetlist( + final modules = await convertTestWriteNetlist( counter, 'build/Counter.rohd.json', ); expect( - adapter.root.children, + modules, isNotEmpty, - reason: 'Counter hierarchy should have children', + 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', () { @@ -77,14 +78,14 @@ void main() { final counter = Counter(en, reset, clk); await counter.build(); - final adapter = await convertTestWriteNetlist( + final modules = await convertTestWriteNetlist( counter, 'build/Counter.synth.rohd.json', ); expect( - adapter.root.name, + modules, isNotEmpty, - reason: 'Counter synth hierarchy should have a root name', + reason: 'Counter synth netlist should have modules', ); }); @@ -101,14 +102,14 @@ void main() { final synth = SynthBuilder(fir, NetlistSynthesizer()); expect(synth.synthesisResults.isNotEmpty, isTrue); - final adapter = await convertTestWriteNetlist( + final modules = await convertTestWriteNetlist( fir, 'build/FirFilter.synth.rohd.json', ); expect( - adapter.root.name, + modules, isNotEmpty, - reason: 'FirFilter synth hierarchy should have a root name', + reason: 'FirFilter synth netlist should have modules', ); }); @@ -129,14 +130,14 @@ void main() { final synth = SynthBuilder(la, NetlistSynthesizer()); expect(synth.synthesisResults.isNotEmpty, isTrue); - final adapter = await convertTestWriteNetlist( + final modules = await convertTestWriteNetlist( la, 'build/LogicArrayExample.synth.rohd.json', ); expect( - adapter.root.name, + modules, isNotEmpty, - reason: 'LogicArrayExample synth hierarchy should have a root name', + reason: 'LogicArrayExample synth netlist should have modules', ); }); @@ -151,14 +152,14 @@ void main() { final synth = SynthBuilder(oven, NetlistSynthesizer()); expect(synth.synthesisResults.isNotEmpty, isTrue); - final adapter = await convertTestWriteNetlist( + final modules = await convertTestWriteNetlist( oven, 'build/OvenModule.synth.rohd.json', ); expect( - adapter.root.name, + modules, isNotEmpty, - reason: 'OvenModule synth hierarchy should have a root name', + reason: 'OvenModule synth netlist should have modules', ); }); @@ -197,7 +198,7 @@ void main() { await fir.build(); const outPath = 'build/FirFilter.rohd.json'; - final adapter = await convertTestWriteNetlist(fir, outPath); + final modules = await convertTestWriteNetlist(fir, outPath); if (!isJS) { final f = File(outPath); expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); @@ -205,9 +206,9 @@ void main() { expect(contents.trim().isNotEmpty, isTrue); } expect( - adapter.root.children, + modules, isNotEmpty, - reason: 'FirFilter hierarchy should have children', + reason: 'FirFilter netlist should have module definitions', ); }); @@ -221,7 +222,7 @@ void main() { await la.build(); const outPath = 'build/LogicArrayExample.rohd.json'; - final adapter = await convertTestWriteNetlist(la, outPath); + final modules = await convertTestWriteNetlist(la, outPath); if (!isJS) { final f = File(outPath); expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); @@ -229,9 +230,9 @@ void main() { expect(contents.trim().isNotEmpty, isTrue); } expect( - adapter.root.children, + modules, isNotEmpty, - reason: 'LogicArrayExample hierarchy should have children', + reason: 'LogicArrayExample netlist should have module definitions', ); }); @@ -244,7 +245,7 @@ void main() { await oven.build(); const outPath = 'build/OvenModule.rohd.json'; - final adapter = await convertTestWriteNetlist(oven, outPath); + final modules = await convertTestWriteNetlist(oven, outPath); if (!isJS) { final f = File(outPath); expect(f.existsSync(), isTrue, reason: 'ROHD JSON should be created'); @@ -252,9 +253,9 @@ void main() { expect(contents.trim().isNotEmpty, isTrue); } expect( - adapter.root.children, + modules, isNotEmpty, - reason: 'OvenModule hierarchy should have children', + reason: 'OvenModule netlist should have module definitions', ); }); @@ -281,180 +282,4 @@ void main() { expect(file.existsSync(), isTrue, reason: 'ROHD JSON should be created'); } }); - - // ── Design API tests ───────────────────────────────────────────────── - // - // These exercise the production path: build(netlistOptions: ...) → - // ModuleTree.instance.toJson() which produces the unified JSON format - // {"hierarchy": {...}, "netlist": {"modules": {...}}} consumed by DevTools. - - group('Design API unified netlist generation', () { - tearDown(() async { - await Simulator.reset(); - ModuleTree.clearCustomNetlistJson(); - }); - - /// Build [module] with synthesis, write the unified JSON, and validate - /// that both hierarchy and netlist sections are present and parseable. - Future validateDesignApi( - Module module, - String outPath, { - NetlistOptions? options, - }) async { - await module.build(netlistOptions: options ?? const NetlistOptions()); - - // ── Full netlist (with connections) ──────────────────────────── - final fullJson = ModuleTree.instance.toFullNetlistJson(); - expect(fullJson, isNotNull, - reason: 'toFullNetlistJson should not be null'); - final full = jsonDecode(fullJson!) as Map; - - expect( - full.containsKey('netlist'), - isTrue, - reason: 'Full JSON should have netlist section', - ); - - final netlistSection = full['netlist'] as Map; - expect( - netlistSection.containsKey('modules'), - isTrue, - reason: 'Netlist section should have modules', - ); - final modules = netlistSection['modules'] as Map; - expect( - modules, - isNotEmpty, - reason: 'Netlist should contain at least one module', - ); - - // Verify cells have connections (the key difference from slim) - final topModule = modules.values.first as Map; - final cells = topModule['cells'] as Map? ?? {}; - if (cells.isNotEmpty) { - 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, - reason: 'Full netlist cells should have connections', - ); - } - - // Verify the netlist section parses through hierarchy adapter - final adapter = NetlistHierarchyAdapter.fromMap(netlistSection); - expect( - adapter.root.children, - isNotEmpty, - reason: '${module.definitionName} should have hierarchy children', - ); - - // ── Slim netlist (no connections, for incremental loading) ───── - final slimJson = ModuleTree.instance.toModuleSignalJson(); - expect(slimJson, isNotNull, - reason: 'toModuleSignalJson should not be null'); - final slim = jsonDecode(slimJson!) as Map; - expect( - slim.containsKey('netlist'), - isTrue, - reason: 'Slim JSON should have netlist section', - ); - - // ── Module count parity between full and slim ───────────────── - final slimNetlist = slim['netlist'] as Map; - final slimModules = slimNetlist['modules'] as Map; - expect( - slimModules.length, - equals(modules.length), - reason: 'Slim and full should have same number of modules', - ); - - // Write the full unified JSON for inspection - if (!isJS) { - final file = File(outPath); - await file.create(recursive: true); - await file.writeAsString(fullJson); - } - } - - test('Counter via design API', () async { - final en = Logic(name: 'en'); - final reset = Logic(name: 'reset'); - final clk = SimpleClockGenerator(10).clk; - final counter = Counter(en, reset, clk); - - await validateDesignApi(counter, 'build/Counter.design.rohd.json'); - }); - - test('FIR filter via design API', () 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 validateDesignApi(fir, 'build/FirFilter.design.rohd.json'); - }); - - test('LogicArray via design API', () 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 validateDesignApi(la, 'build/LogicArrayExample.design.rohd.json'); - }); - - test('OvenModule via design API', () 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 validateDesignApi(oven, 'build/OvenModule.design.rohd.json'); - }); - - test('TreeOfTwoInputModules via design API', () async { - final seq = List.generate(4, (_) => Logic(width: 8)); - final tree = TreeOfTwoInputModules(seq, (a, b) => mux(a > b, a, b)); - - await tree.build(netlistOptions: const NetlistOptions()); - - final fullJson = ModuleTree.instance.toFullNetlistJson(); - expect(fullJson, isNotNull); - final unified = jsonDecode(fullJson!) as Map; - - expect(unified.containsKey('netlist'), isTrue); - - final netlistSection = unified['netlist'] as Map; - final modules = netlistSection['modules'] as Map; - expect(modules, isNotEmpty, reason: 'Tree netlist should have modules'); - - if (!isJS) { - final file = File('build/TreeOfTwoInputModules.design.rohd.json'); - await file.create(recursive: true); - await file.writeAsString(fullJson); - } - }); - }); } From bb6010206199b2c150cbfe2e6a6c2fc0664d0145 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 22 Apr 2026 17:20:13 -0700 Subject: [PATCH 14/15] crisp representation of bit connections, option to compact spaces --- .../synthesizers/netlist/netlist_options.dart | 16 ++ .../netlist/netlist_synthesizer.dart | 11 +- .../synthesizers/netlist/netlist_utils.dart | 137 +++++++++++++ test/netlist_test.dart | 183 ++++++++++++++++++ 4 files changed, 346 insertions(+), 1 deletion(-) diff --git a/lib/src/synthesizers/netlist/netlist_options.dart b/lib/src/synthesizers/netlist/netlist_options.dart index 7dcbbe029..edc9147e8 100644 --- a/lib/src/synthesizers/netlist/netlist_options.dart +++ b/lib/src/synthesizers/netlist/netlist_options.dart @@ -103,6 +103,20 @@ class NetlistOptions { /// 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 @@ -118,5 +132,7 @@ class NetlistOptions { this.collapseUnpackToPack = false, this.enableDCE = true, this.slimMode = false, + this.compressBitRanges = false, + this.compactJson = false, }); } diff --git a/lib/src/synthesizers/netlist/netlist_synthesizer.dart b/lib/src/synthesizers/netlist/netlist_synthesizer.dart index 2815d6111..d858c9b71 100644 --- a/lib/src/synthesizers/netlist/netlist_synthesizer.dart +++ b/lib/src/synthesizers/netlist/netlist_synthesizer.dart @@ -1616,11 +1616,20 @@ class NetlistSynthesizer extends Synthesizer { /// Generate the combined netlist JSON from a [SynthBuilder]'s results. Future generateCombinedJson(SynthBuilder synth, Module top) async { final modules = await buildModulesMap(synth, top); + + if (options.compressBitRanges) { + compressModulesMap(modules); + } + final combined = { 'creator': 'NetlistSynthesizer (rohd)', 'modules': modules, }; - return const JsonEncoder.withIndent(' ').convert(combined); + + final encoder = options.compactJson + ? const JsonEncoder() + : const JsonEncoder.withIndent(' '); + return encoder.convert(combined); } /// Convenience: synthesize [top] into a combined netlist JSON string. diff --git a/lib/src/synthesizers/netlist/netlist_utils.dart b/lib/src/synthesizers/netlist/netlist_utils.dart index 0bf1d49fe..0e5fd1196 100644 --- a/lib/src/synthesizers/netlist/netlist_utils.dart +++ b/lib/src/synthesizers/netlist/netlist_utils.dart @@ -517,3 +517,140 @@ String constValuePart(Const c) { } return '${c.width}_h${value.toRadixString(16)}'; } + +// -- Bit-range compression / expansion ------------------------------------ + +/// Compresses a list of bit IDs by replacing contiguous ascending runs of +/// 3 or more integers with `"start:end"` range strings. +/// +/// Elements that are already strings (Yosys constant bits `"0"` and `"1"`) +/// are passed through unchanged. Single integers and pairs that don't form +/// a run of ≥3 are kept as-is. +/// +/// Example: +/// ```dart +/// compressBits([52, 53, 54, 55]) // => ["52:55"] +/// compressBits([3, 5, 6, 7, 8]) // => [3, "5:8"] +/// compressBits(["0", 2, 3]) // => ["0", 2, 3] +/// ``` +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; +} + +/// Expands a compressed bit-ID list back to individual elements. +/// +/// Range strings `"start:end"` are expanded to `[start, start+1, ..., end]`. +/// Yosys constant strings `"0"` and `"1"` are passed through unchanged. +/// Integer elements are passed through unchanged. +/// +/// This is the inverse of [compressBits]: +/// `expandBits(compressBits(bits))` returns the original list. +List expandBits(List bits) { + final result = []; + for (final element in bits) { + if (element is int) { + result.add(element); + } else if (element is String) { + final colonIdx = element.indexOf(':'); + if (colonIdx > 0) { + final start = int.tryParse(element.substring(0, colonIdx)); + final end = int.tryParse(element.substring(colonIdx + 1)); + if (start != null && end != null && end >= start) { + for (var i = start; i <= end; i++) { + result.add(i); + } + continue; + } + } + // Not a range — pass through (e.g. "0", "1"). + result.add(element); + } else { + result.add(element); + } + } + return result; +} + +/// Applies [compressBits] to all `bits` arrays and cell `connections` +/// arrays in a modules map (the top-level `modules` value from a +/// Yosys-compatible JSON netlist). +/// +/// Modifies the map in place and returns it for convenience. +Map> compressModulesMap( + Map> modules, +) { + for (final moduleDef in modules.values) { + // Compress port bits. + 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()); + } + } + } + + // Compress cell connection arrays. + 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]!); + } + } + } + } + + // Compress netname bits. + 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()); + } + } + } + } + } + return modules; +} diff --git a/test/netlist_test.dart b/test/netlist_test.dart index ffbfbe6b7..cbafa171e 100644 --- a/test/netlist_test.dart +++ b/test/netlist_test.dart @@ -533,4 +533,187 @@ void main() { } }); }); + + // ----------------------------------------------------------------------- + // Bit-range compression & compact JSON + // ----------------------------------------------------------------------- + group('Bit-range compression', () { + test('compressBits replaces contiguous runs of >=3', () { + expect(compressBits([52, 53, 54, 55]), equals(['52:55'])); + expect(compressBits([3, 5, 6, 7, 8]), equals([3, '5:8'])); + expect(compressBits([1, 2]), equals([1, 2])); + expect(compressBits([10]), equals([10])); + expect(compressBits([]), equals([])); + }); + + test('compressBits preserves constant strings', () { + expect(compressBits(['0', 2, 3, 4, 5, '1']), equals(['0', '2:5', '1'])); + }); + + test('compressBits handles mixed non-contiguous', () { + expect( + compressBits([1, 3, 4, 5, 10, 11, 12]), equals([1, '3:5', '10:12'])); + }); + + test('expandBits is inverse of compressBits', () { + final original = [52, 53, 54, 55, 56, 57]; + final compressed = compressBits(original); + expect(expandBits(compressed), equals(original)); + }); + + test('expandBits preserves constant strings', () { + expect(expandBits(['0', '5:8', '1']), equals(['0', 5, 6, 7, 8, '1'])); + }); + + test('expandBits passes through plain ints', () { + expect(expandBits([1, 2, 3]), equals([1, 2, 3])); + }); + + test('compressBitRanges option compresses netlist 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. + final decodedCompressed = jsonDecode(jsonCompressed) as Map; + final decodedNormal = jsonDecode(jsonNormal) as Map; + + // Same module keys. + expect( + (decodedCompressed['modules'] as Map).keys.toSet(), + equals((decodedNormal['modules'] as Map).keys.toSet()), + ); + + // Compressed JSON should contain range strings. + expect(jsonCompressed, contains(RegExp(r'"\d+:\d+"'))); + }); + + 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('compressModulesMap round-trips through expand', () async { + final mod = _buildFilterBank(); + await mod.build(); + + final synth = NetlistSynthesizer(); + final sb = SynthBuilder(mod, synth); + final modules = await synth.buildModulesMap(sb, mod); + + // Capture original bits from every port, cell connection, netname. + final originalBits = >{}; + for (final entry in modules.entries) { + final ports = + entry.value['ports'] as Map>?; + if (ports != null) { + for (final p in ports.entries) { + final bits = p.value['bits'] as List?; + if (bits != null) { + originalBits['${entry.key}.port.${p.key}'] = + List.from(bits); + } + } + } + } + + // Compress in place. + compressModulesMap(modules); + + // Verify at least some ranges were created. + var rangeCount = 0; + for (final moduleDef in modules.values) { + final ports = moduleDef['ports'] as Map>?; + if (ports != null) { + for (final p in ports.values) { + final bits = p['bits'] as List?; + if (bits != null) { + for (final b in bits) { + if (b is String && b.contains(':')) rangeCount++; + } + } + } + } + } + expect(rangeCount, greaterThan(0), + reason: 'FilterBank should have compressible bit ranges'); + + // Expand and verify round-trip. + for (final entry in modules.entries) { + final ports = + entry.value['ports'] as Map>?; + if (ports != null) { + for (final p in ports.entries) { + final bits = p.value['bits'] as List?; + if (bits != null) { + final key = '${entry.key}.port.${p.key}'; + if (originalBits.containsKey(key)) { + expect(expandBits(bits.cast()), originalBits[key], + reason: 'round-trip failed for $key'); + } + } + } + } + } + }); + }); } From 905daa288bb4057895606b4486c96358d146ca09 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 22 Apr 2026 22:28:50 -0700 Subject: [PATCH 15/15] compress bit representation for better density --- .../synthesizers/netlist/netlist_passes.dart | 4269 +++++++++-------- .../netlist/netlist_synthesizer.dart | 133 +- .../synthesizers/netlist/netlist_utils.dart | 992 ++-- test/netlist_test.dart | 167 +- 4 files changed, 2768 insertions(+), 2793 deletions(-) diff --git a/lib/src/synthesizers/netlist/netlist_passes.dart b/lib/src/synthesizers/netlist/netlist_passes.dart index 33a41dcd2..33c1c65c2 100644 --- a/lib/src/synthesizers/netlist/netlist_passes.dart +++ b/lib/src/synthesizers/netlist/netlist_passes.dart @@ -14,2476 +14,2507 @@ import 'package:rohd/rohd.dart'; -/// Collects a combined modules map from [SynthesisResult]s suitable for -/// JSON emission. -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; +/// 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, + }; } - allModules[typeName] = { - 'attributes': attrs, - 'ports': result.ports, - 'cells': result.cells, - 'netnames': result.netnames, - }; } + return allModules; } - 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. -void applyMaximalSubsetGrouping( - Map> allModules, -) { - for (final moduleDef in allModules.values) { - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; - } + // -- 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) = - buildWireMaps(cells, moduleDef); + // 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; + 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; - } + // 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? ?? {}; + 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); + // 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); + 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; + } 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, + ]; + } } - final m = 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]!); } } - 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) = 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 (!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'] = - makeBufCell(aWidth, sourceBusParentBits, outputBits.cast()); - } else { - cellsToAdd['maxsub_slice_$replIdx'] = 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). -void applyCollapseConcats( - 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) = - 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? ?? {}; - - // 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') { + if (inputBits.isEmpty) { continue; } - final m = 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) = traceBackward(bit, wireDriverCell, cells); - intermediates.addAll(chain); + final outputBits = [ + for (final b in conns['Y'] as List) + if (b is int) b, + ]; - // Identify source net. + // 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) { - valid = false; - break; - } - if (busName == null) { - busName = info.$1; - busBits = info.$2; - } else if (busName != info.$1) { - valid = false; + allFromOneBus = false; break; } - final idx = busBits!.indexOf(traced); - if (idx < 0) { - valid = false; + if (sourceBusNet == null) { + sourceBusNet = info.$1; + sourceBusBits = info.$2; + } else if (sourceBusNet != info.$1) { + allFromOneBus = 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; + if (!allFromOneBus || sourceBusNet == null || sourceBusBits == null) { + continue; } - 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++; + // Verify the traced source bits form a contiguous sub-range + // of the source bus. + if (sourceBits.length != inputBits.length) { continue; } - var runEnd = runStart; - while (runEnd + 1 < inputPorts.length) { - final nextT = portTraces[runEnd + 1]; - if (!nextT.valid) { - break; - } - if (nextT.busName != t.busName) { + + // 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; } - // Check contiguity across port boundary. - final curLast = portTraces[runEnd].sourceIndices.last; - final nextFirst = nextT.sourceIndices.first; - if (nextFirst != curLast + 1) { + 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; } - runEnd++; } - if (runEnd > runStart) { - runs.add((runStart, runEnd)); + if (!contiguous) { + continue; } - 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 (isExclusiveChain( - intermediates: allIntermediates, + // 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, )) { - validRuns.add((startIdx, endIdx, allIntermediates)); + continue; } - } - - 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; + // 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(); - // Remove exclusive intermediate cells for all valid runs. - for (final (_, _, intermediates) in validRuns) { - cellsToRemove.addAll(intermediates); - } + final offset = indices.first; + final yWidth = outputBits.length; + final aWidth = sourceBusBits.length; - 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, - ]; + // Mark intermediate cells and the concat for removal. + cellsToRemove + ..addAll(intermediateCells) + ..add(concatName); - cellsToRemove.add(concatName); if (yWidth == aWidth) { - cellsToAdd['collapse_buf_$replIdx'] = - makeBufCell(aWidth, sourceBusParentBits, outputBits); + cellsToAdd['maxsub_buf_$replIdx'] = NetlistUtils.makeBufCell( + aWidth, sourceBusParentBits, outputBits.cast()); } else { - cellsToAdd['collapse_slice_$replIdx'] = makeSliceCell( - srcOffset, aWidth, yWidth, sourceBusParentBits, outputBits); + cellsToAdd['maxsub_slice_$replIdx'] = NetlistUtils.makeSliceCell( + offset, + aWidth, + yWidth, + sourceBusParentBits, + outputBits.cast()); } 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'] = 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'; - - 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); } - - // Apply removals and additions. - cellsToRemove.forEach(cells.remove); - cells.addAll(cellsToAdd); } -} - -// -- 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. -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); + // -- 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 moduleDef in allModules.values) { + final cells = moduleDef['cells'] as Map>?; + if (cells == null || cells.isEmpty) { + continue; } - } - if (sliceConcat.length < 2) { - continue; - } - // Build wire-ID → driver cell and wire-ID → consumer cells maps. - final ( - :wireDriverCell, - wireConsumerCells: wireConsumerSets, - :bitToNetInfo, - ) = 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 wire-driver, wire-consumer, and bit-to-net maps ------- + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); - // 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); - } - } - } - } - } + final cellsToRemove = {}; + final cellsToAdd = >{}; + var replIdx = 0; - // 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)) { + // --- 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; } - visited.add(node); - for (final nb in adj[node]!) { - if (!comp.contains(nb)) { - queue.add(nb); - } + if (cellsToRemove.contains(concatName)) { + continue; } - } - 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; - } + final conns = concatCell['connections'] as Map? ?? {}; - // 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); - } + // 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, + ], + )); } } - } - - // 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; + 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)); } } - // 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; - } - } + inputPorts.sort((a, b) => a.$1.compareTo(b.$1)); + + if (inputPorts.length < 2) { + continue; } - } - if (extInputBits.isEmpty || extOutputBits.isEmpty) { - continue; // degenerate component, skip - } + // --- Trace each port's bits back to a source bus ---------------- + final portTraces = <({ + String? busName, + List? busBits, + List sourceIndices, + Set intermediates, + bool valid, + })>[]; - // 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? ?? {}; + for (final (_, _, bits) in inputPorts) { + final sourceIndices = []; + final intermediates = {}; + String? busName; + List? busBits; + var valid = 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 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); } - } - } - // 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(); - } + // 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; + } - // 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(); - } + portTraces.add(( + busName: busName, + busBits: busBits, + sourceIndices: sourceIndices, + intermediates: intermediates, + valid: valid, + )); + } - // 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') { + // --- 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; } - - final subComp = {cn}; - final bfsQueue = [cn]; - while (bfsQueue.isNotEmpty) { - final cur = bfsQueue.removeLast(); - final curCell = cells[cur]; - if (curCell == null) { - continue; + var runEnd = runStart; + while (runEnd + 1 < inputPorts.length) { + final nextT = portTraces[runEnd + 1]; + if (!nextT.valid) { + break; } - 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 (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 (subComp.length >= 2 && subComp.length < comp.length) { - groupQueue.add(subComp); + if (runEnd > runStart) { + runs.add((runStart, runEnd)); } + runStart = runEnd + 1; } - continue; - } - // Build the synthetic module's internal wire-ID space. - final usedIds = {}; - for (final cn in comp) { - final cell = cells[cn]; - if (cell == null) { + if (runs.isEmpty) { 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); - } + + // --- 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)); } } - } - - 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'; - } + if (validRuns.isEmpty) { + continue; + } - 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'; - } + // --- Check whether ALL ports form a single valid run ------------ + final allCollapsed = validRuns.length == 1 && + validRuns.first.$1 == 0 && + validRuns.first.$2 == inputPorts.length - 1; - // 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; - } + // Remove exclusive intermediate cells for all valid runs. + for (final (_, _, intermediates) in validRuns) { + cellsToRemove.addAll(intermediates); + } - // Build netnames for the child module. - final childNetnames = {}; - for (final pe in childPorts.entries) { - childNetnames[pe.key] = { - 'bits': pe.value['bits'], - 'attributes': {}, - }; - } + 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 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); + 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; } - } - 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); + + // --- 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 (missingBits.isNotEmpty) { - childNetnames['${cellName}_$portName'] = { - 'bits': missingBits, - 'hide_name': 1, - 'attributes': {}, - }; + + 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++; } } - } - - // 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, - }; - } - } + // Preserve Y. + newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; + newDirs['Y'] = 'output'; - // Add all new synthetic module definitions. - allModules.addAll(newModuleDefs); -} + cellsToAdd['${concatName}_collapsed'] = { + 'hide_name': concatCell['hide_name'], + 'type': r'$concat', + 'parameters': {}, + 'attributes': concatCell['attributes'] ?? {}, + 'port_directions': newDirs, + 'connections': newConns, + }; + } -/// 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. -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; + // Apply removals and additions. + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); } + } - // 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') { + // -- 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; } - final conns = cell['connections'] as Map?; - if (conns == null) { - 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); + } } - final aBits = conns['A'] as List?; - if (aBits == null) { + if (sliceConcat.length < 2) { continue; } - final key = aBits.join(','); - (slicesByInput[key] ??= []).add(entry.key); - } - var bufIdx = 0; - for (final sliceGroup in slicesByInput.values) { - if (sliceGroup.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>?; - // 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); + // 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); + } + } } } } - // 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)) { + // Find connected components via BFS. + final visited = {}; + final components = >[]; + for (final start in sliceConcat) { + if (visited.contains(start)) { 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; + 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 (!allSameDest) { - break; + if (comp.length >= 2) { + components.add(comp); } } - // 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; + // 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); + } + } } - 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; + } + + // 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 (!allSameDest || destId == null) { - continue; - } + if (extInputBits.isEmpty || extOutputBits.isEmpty) { + continue; // degenerate component, skip + } - // 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; - } + // 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? ?? {}; - // 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'] = 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. -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; + // 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; + } } } - } - 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]; - } + // 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; } - } - } 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') { + final subComp = {cn}; + final bfsQueue = [cn]; + while (bfsQueue.isNotEmpty) { + final cur = bfsQueue.removeLast(); + final curCell = cells[cur]; + if (curCell == null) { continue; } - final m = 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 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); + } + } } } - 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]; + if (subComp.length >= 2 && subComp.length < comp.length) { + groupQueue.add(subComp); } } + continue; } - } - // 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; + // Build the synthetic module's internal wire-ID space. + final usedIds = {}; + for (final cn in comp) { + final cell = cells[cn]; + if (cell == null) { + continue; } - visited.add(current); - final next = driver[current]; - if (next == null) { - break; + 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); + } + } } - 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); - } + var nextLocalId = 2; + final idRemap = {}; + for (final id in usedIds) { + idRemap[id] = nextLocalId++; } - if (portInputBits.contains('x')) { - anyUnresolved = true; - break; + 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'; } - if (portOutputBits.isEmpty) { - continue; + 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'; } - // 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]!); + // 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; } - final cellKey = '${cellName}_${pEntry.key}'; + // Build netnames for the child module. + final childNetnames = {}; + for (final pe in childPorts.entries) { + childNetnames[pe.key] = { + 'bits': pe.value['bits'], + 'attributes': {}, + }; + } - if (sourcePortNames.length == 1) { - final srcPort = sourcePortNames.first; - final srcWidth = inputPortWidths[srcPort] ?? 0; - if (portOutputBits.length == srcWidth) { - // Same width → $buf - perPortCells['${cellKey}_buf'] = 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'] = makeSliceCell( - offset, - srcWidth, - portOutputBits.length, - (conns[srcPort] as List?)?.cast() ?? [], - portOutputBits); + 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': {}, + }; + } } - } else { - // Multiple source ports – should be rare after the grouping - // guard excludes multi-source concats. Fall back to $buf. - perPortCells['${cellKey}_buf'] = - 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); - } + // Choose a name for the synthetic module type. + final syntheticTypeName = 'struct_assign_${moduleName}_$groupIdx'; + final syntheticInstanceName = 'struct_assign_$groupIdx'; + groupIdx++; - removals.forEach(cells.remove); - cells.addAll(replacements); - } + // Register the synthetic module definition. + newModuleDefs[syntheticTypeName] = { + 'attributes': {'src': 'generated'}, + 'ports': childPorts, + 'cells': childCells, + 'netnames': childNetnames, + }; - // 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); -} + // Remove the grouped cells from the parent. + claimedCells.addAll(comp); + comp.forEach(cells.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]. -void applyConcatToBufferReplacement( - Map> allModules, -) { - for (final moduleDef in allModules.values) { - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; + // Add a hierarchical cell referencing the synthetic module. + cells[syntheticInstanceName] = { + 'hide_name': 0, + 'type': syntheticTypeName, + 'parameters': {}, + 'attributes': {}, + 'port_directions': instancePortDirs, + 'connections': instanceConns, + }; + } } - final modPorts = moduleDef['ports'] as Map>?; - if (modPorts == null) { - continue; - } + // Add all new synthetic module definitions. + allModules.addAll(newModuleDefs); + } - // 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') { + /// 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; } - final bits = portEntry.value['bits'] as List? ?? []; - for (final b in bits) { - if (b is int) { - bitToPort[b] = portEntry.key; + + // 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); } - } - final removals = []; - final additions = >{}; - var bufIdx = 0; + var bufIdx = 0; + for (final sliceGroup in slicesByInput.values) { + if (sliceGroup.length < 2) { + continue; + } - // 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; + // 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); + } + } } - } - } - for (final entry in cells.entries) { - final cell = entry.value; - if ((cell['type'] as String?) != r'$concat') { - continue; - } + // 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; - final conns = cell['connections'] as Map? ?? {}; - final pdirs = cell['port_directions'] as Map? ?? {}; + // 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; + } + } - // Collect input ranges and the Y output. - // Port names follow the pattern "[upper:lower]" or "[bit]". - final rangedInputs = >{}; // lower → bits - List? yBits; + // 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; + } + } + } + } - 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; + if (!allSameDest || destId == null) { continue; } - if (dir != 'input') { + + // 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; } - // Parse "[upper:lower]" or "[bit]". - final match = 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; - } + + // 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; } - 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; + // 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; } - final port = bitToPort[b]; - if (port == null) { - allFromSamePort = false; - break; + if (!valid || expectedOffset != aWidth) { + continue; } - sourcePort ??= port; - if (port != sourcePort) { - allFromSamePort = false; - break; + + // 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]!); } - } - if (!allFromSamePort || sourcePort == null) { - continue; - } + // Remove slice cells. + sliceGroup.forEach(cells.remove); - // Verify full-width coverage of the source port. - final portBits = modPorts[sourcePort]!['bits']! as List; - if (allInputBits.length != portBits.length) { - continue; + // Insert $buf cell. + cells['struct_buf_$bufIdx'] = + NetlistUtils.makeBufCell(aWidth, aBus, yBus); + bufIdx++; } - - // Replace $concat with $buf. - removals.add(entry.key); - additions['concat_buf_$bufIdx'] = - makeBufCell(allInputBits.length, allInputBits, yBits); - bufIdx++; } - - removals.forEach(cells.remove); - cells.addAll(additions); } -} -// -- Collapse selects into struct_pack --------------------------------- + /// 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, + }; -/// 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. -void applyCollapseSelectsIntoPack( - Map> allModules, -) { - for (final moduleDef in allModules.values) { - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; + if (structAssignTypes.isEmpty) { + return; } - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - 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') { + // 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 bits = [ - for (final b in conns[portName] as List) - if (b is int) b, - ]; - if (bits.isEmpty) { + + 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; } - // All bits must be driven by the same $slice cell. - final firstDriver = wireDriverCell[bits.first]; - if (firstDriver == 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 driverCell = cells[firstDriver]; - if (driverCell == null) { - continue; + + 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]; + } + } + } } - if ((driverCell['type'] as String?) != r'$slice') { - continue; + + // 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; } - if (cellsToRemove.contains(firstDriver)) { - continue; + + // 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); + } } - final allFromSameSlice = bits.every( - (b) => wireDriverCell[b] == firstDriver, - ); - if (!allFromSameSlice) { + if (perPortCells.isEmpty) { 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) { + // 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; } - // 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); + collapsedTypes.add(type); + removals.add(cellName); + replacements.addAll(perPortCells); } + + removals.forEach(cells.remove); + cells.addAll(replacements); } - cellsToRemove.forEach(cells.remove); + // 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); + } - // 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') { + /// 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 conns = packCell['connections'] as Map? ?? {}; - final dirs = packCell['port_directions'] as Map? ?? {}; + final modPorts = moduleDef['ports'] as Map>?; + if (modPorts == null) { + continue; + } - // Collect all input bits in field declaration order. - final allInputBits = []; - for (final portName in conns.keys) { - if (dirs[portName] != 'input') { + // 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; } - for (final b in conns[portName] as List) { + final bits = portEntry.value['bits'] as List? ?? []; + for (final b in bits) { if (b is int) { - allInputBits.add(b); + bitToPort[b] = portEntry.key; } } } - 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; + 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; + } } } - if (!contiguous) { - continue; - } - final yBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; - if (yBits.length != allInputBits.length) { - continue; - } + for (final entry in cells.entries) { + final cell = entry.value; + if ((cell['type'] as String?) != r'$concat') { + 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. -void applyCollapseUnpackToConcat( - Map> allModules, -) { - for (final moduleDef in allModules.values) { - 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; - while (anyChanged) { - anyChanged = false; - - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - buildWireMaps(cells, moduleDef); + final conns = cell['connections'] as Map? ?? {}; + final pdirs = cell['port_directions'] as Map? ?? {}; - final cellsToRemove = {}; - final cellsToAdd = >{}; - var replIdx = globalReplIdx; + // Collect input ranges and the Y output. + // Port names follow the pattern "[upper:lower]" or "[bit]". + final rangedInputs = >{}; // lower → bits + List? yBits; - for (final concatEntry in cells.entries.toList()) { - final concatName = concatEntry.key; - final concatCell = concatEntry.value; - if ((concatCell['type'] as String?) != r'$concat') { - continue; + 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 (cellsToRemove.contains(concatName)) { + + if (yBits == null || rangedInputs.isEmpty) { continue; } - final conns = concatCell['connections'] as Map? ?? {}; + // 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]!); + } - // 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; + // 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 m = 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 - ], - )); + final port = bitToPort[b]; + if (port == null) { + allFromSamePort = false; + break; } - } - 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)); + sourcePort ??= port; + if (port != sourcePort) { + allFromSamePort = false; + break; } } - inputPorts.sort((a, b) => a.$1.compareTo(b.$1)); - if (inputPorts.length < 2) { + + if (!allFromSamePort || sourcePort == null) { continue; } - // --- Extended trace: through $buf/$slice AND $struct_unpack ------ - final portTraces = <({ - String? unpackName, - List? unpackABits, - List sourceIndices, - Set intermediates, - bool valid, - })>[]; + // Verify full-width coverage of the source port. + final portBits = modPorts[sourcePort]!['bits']! as List; + if (allInputBits.length != portBits.length) { + continue; + } - for (final (_, _, bits) in inputPorts) { - final sourceIndices = []; - final intermediates = {}; - String? unpackName; - List? unpackABits; - var valid = true; + // Replace $concat with $buf. + removals.add(entry.key); + additions['concat_buf_$bufIdx'] = + NetlistUtils.makeBufCell(allInputBits.length, allInputBits, yBits); + bufIdx++; + } - for (final bit in bits) { - final (traced, chain) = traceBackward(bit, wireDriverCell, cells); - intermediates.addAll(chain); + removals.forEach(cells.remove); + cells.addAll(additions); + } + } - // 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; - } + // -- 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 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, - ]; + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); - // 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; - } - } + final cellsToRemove = {}; - if (outPort == null || bitIdx == null) { - valid = false; - break; - } + for (final packEntry in cells.entries.toList()) { + final packName = packEntry.key; + final packCell = packEntry.value; + if ((packCell['type'] as String?) != r'$struct_pack') { + continue; + } - // 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; - } - } + final conns = packCell['connections'] as Map? ?? {}; + final dirs = packCell['port_directions'] as Map? ?? {}; - if (fieldOffset == null) { - valid = false; - break; - } + 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; + } - final aIdx = fieldOffset + bitIdx; - if (aIdx >= aBits.length) { - valid = false; - break; - } + // 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; + } - intermediates.add(driverName); + final allFromSameSlice = bits.every( + (b) => wireDriverCell[b] == firstDriver, + ); + if (!allFromSameSlice) { + continue; + } - if (unpackName == null) { - unpackName = driverName; - unpackABits = aBits; - } else if (unpackName != driverName) { - valid = false; - break; + // 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; } - sourceIndices.add(aIdx); + return consumers.every((c) => c == packName || c == '__port__'); + }); + if (!exclusive) { + continue; } - portTraces.add(( - unpackName: unpackName, - unpackABits: unpackABits, - sourceIndices: sourceIndices, - intermediates: intermediates, - valid: valid, - )); + // 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); } + } - // --- 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++; + 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; } - 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; + for (final b in conns[portName] as List) { + if (b is int) { + allInputBits.add(b); } - runEnd++; } - if (runEnd > runStart) { - runs.add((runStart, runEnd)); + } + 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; } - runStart = runEnd + 1; + } + if (!contiguous) { + continue; } - 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) { + 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 moduleDef in allModules.values) { + 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; + while (anyChanged) { + anyChanged = false; + + 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; } - final unpackNames = portTraces.map((t) => t.unpackName).toSet(); - if (unpackNames.length != 1 || unpackNames.first == null) { + if (cellsToRemove.contains(concatName)) { 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 && - !isExclusiveChain( - intermediates: removable, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - continue; - } + final conns = + concatCell['connections'] as Map? ?? {}; - // 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); + // 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 + ], + )); } } - final outputBits = [ - for (final b in conns['Y'] as List) - if (b is int) b, - ]; - if (reorderedA.length != outputBits.length) { + 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; } - cellsToRemove - ..addAll(removable) - ..add(uName) - ..add(concatName); - cellsToAdd['unpack_concat_buf_$replIdx'] = - makeBufCell(reorderedA.length, reorderedA, outputBits); - replIdx++; - 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; + } - // --- 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); + 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, + )); } - // 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 || - isExclusiveChain( - intermediates: removable, - ownerCell: concatName, - cells: cells, - wireConsumerCells: wireConsumerCells, - )) { - validRuns.add((startIdx, endIdx, removable)); + + // --- 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 (validRuns.isEmpty) { - continue; - } + 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!; - final allCollapsed = validRuns.length == 1 && - validRuns.first.$1 == 0 && - validRuns.first.$2 == inputPorts.length - 1; + // 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; + } - for (final (_, _, intermediates) in validRuns) { - cellsToRemove.addAll(intermediates); - } + // 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; + } - 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 + ..addAll(removable) + ..add(uName) + ..add(concatName); + cellsToAdd['unpack_concat_buf_$replIdx'] = NetlistUtils.makeBufCell( + reorderedA.length, reorderedA, outputBits); + replIdx++; + continue; + } - 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'] = - makeBufCell(aWidth, sourceBits, outputBits); - } else { - cellsToAdd['unpack_concat_buf_$replIdx'] = makeSliceCell( - srcOffset, aWidth, yWidth, sourceBits, outputBits); + // --- 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)); + } } - replIdx++; - continue; - } - // --- Partial collapse — rebuild concat with fewer ports --------- - cellsToRemove.add(concatName); + if (validRuns.isEmpty) { + continue; + } - final newConns = >{}; - final newDirs = {}; - var outBitOffset = 0; + 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, + ]; - var portIdx = 0; - while (portIdx < inputPorts.length) { - (int, int, Set)? activeRun; - for (final run in validRuns) { - if (run.$1 == portIdx) { - activeRun = run; - break; + 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; } - 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; + // --- 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; } - for (final b in pe.value as List) { - if (b is int) { - unpackOutBitList.add(b); + } + + 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]; + // 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++; } - 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); + 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 width = tracedBits.length; - cellsToAdd['unpack_concat_buf_$replIdx'] = - makeBufCell(width, tracedBits, tracedBits); - replIdx++; + cellsToAdd['unpack_concat_buf_$replIdx'] = + NetlistUtils.makeBufCell(width, tracedBits, tracedBits); + replIdx++; - final hi = outBitOffset + width - 1; - final portName = - hi == outBitOffset ? '[$hi]' : '[$hi:$outBitOffset]'; - newConns[portName] = tracedBits; - newDirs[portName] = 'input'; - outBitOffset += width; + 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++; + 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, - }; - } + newConns['Y'] = [for (final b in conns['Y'] as List) b as Object]; + newDirs['Y'] = 'output'; - 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 = 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; + cellsToAdd['${concatName}_collapsed'] = { + 'hide_name': concatCell['hide_name'], + 'type': r'$concat', + 'parameters': {}, + 'attributes': concatCell['attributes'] ?? {}, + 'port_directions': newDirs, + 'connections': newConns, + }; } - if (unpacksToRemove.contains(unpackName)) { - continue; + + cellsToRemove.forEach(cells.remove); + cells.addAll(cellsToAdd); + if (cellsToRemove.isNotEmpty || cellsToAdd.isNotEmpty) { + anyChanged = true; } + globalReplIdx = replIdx; - final uConns = unpackCell['connections'] as Map? ?? {}; - final uDirs = - unpackCell['port_directions'] as Map? ?? {}; + // 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; - // Collect all output bits in field declaration order. - final allOutputBits = []; - for (final pname in uConns.keys) { - if (uDirs[pname] != 'output') { + 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; } - for (final b in uConns[pname] as List) { - if (b is int) { - allOutputBits.add(b); - } + if (unpacksToRemove.contains(unpackName)) { + continue; } - } - 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 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); + } + } } - final consumer = consumers.first; - if (consumer == '__port__') { - allToOneBuf = false; - break; + if (allOutputBits.isEmpty) { + continue; } - final consumerCell = cells[consumer]; - if (consumerCell == null || - (consumerCell['type'] as String?) != r'$buf') { - allToOneBuf = false; - break; + + // 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 (targetBufName == null) { - targetBufName = consumer; - } else if (consumer != targetBufName) { - allToOneBuf = false; - break; + if (!allToOneBuf || targetBufName == null) { + continue; } - } - 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 (bufsToRemove.contains(targetBufName)) { + continue; } - } - 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, - ]; + 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, + ]; - if (unpackABits.length != bufYBits.length) { - continue; - } + // 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; + } - 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); - } + // 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, + ]; - unpacksToRemove.forEach(cells.remove); - bufsToRemove.forEach(cells.remove); - cells.addAll(bufsToAdd); - if (unpacksToRemove.isNotEmpty || bufsToRemove.isNotEmpty) { - anyChanged = true; - } - } // end while (anyChanged) - } -} + if (unpackABits.length != bufYBits.length) { + continue; + } -// -- Collapse struct_unpack to struct_pack ----------------------------- + 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); + } -/// 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. -void applyCollapseUnpackToPack( - Map> allModules, -) { - for (final moduleDef in allModules.values) { - final cells = moduleDef['cells'] as Map>?; - if (cells == null || cells.isEmpty) { - continue; + unpacksToRemove.forEach(cells.remove); + bufsToRemove.forEach(cells.remove); + cells.addAll(bufsToAdd); + if (unpacksToRemove.isNotEmpty || bufsToRemove.isNotEmpty) { + anyChanged = true; + } + } // end while (anyChanged) } + } - final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = - 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') { + // -- 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 conns = packCell['connections'] as Map? ?? {}; - final dirs = packCell['port_directions'] as Map? ?? {}; + final (:wireDriverCell, :wireConsumerCells, :bitToNetInfo) = + NetlistUtils.buildWireMaps(cells, moduleDef); - 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) { + 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; } - // Trace each bit backward through $buf/$slice chains. - final tracedBits = []; - final intermediates = {}; - var allTraceToUnpack = true; - String? unpackName; + final conns = packCell['connections'] as Map? ?? {}; + final dirs = packCell['port_directions'] as Map? ?? {}; - for (final bit in bits) { - final (traced, chain) = 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; + for (final portName in conns.keys.toList()) { + if (dirs[portName] != 'input') { + continue; } - final driverCell = cells[driverName]; - if (driverCell == null || - (driverCell['type'] as String?) != r'$struct_unpack') { - allTraceToUnpack = false; - break; + final bits = [ + for (final b in conns[portName] as List) + if (b is int) b, + ]; + if (bits.isEmpty) { + continue; } - if (unpackName == null) { - unpackName = driverName; - } else if (unpackName != driverName) { - allTraceToUnpack = false; - break; + // 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); } - tracedBits.add(traced); - } + if (!allTraceToUnpack || intermediates.isEmpty) { + continue; + } - 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(); - // 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; + } - if (removable.isEmpty) { - continue; - } + // Verify exclusivity. + if (!NetlistUtils.isExclusiveChain( + intermediates: removable, + ownerCell: packName, + cells: cells, + wireConsumerCells: wireConsumerCells, + )) { + continue; + } - // Verify exclusivity. - if (!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); } - - // Rewire: replace the pack's input port with the traced bits. - conns[portName] = tracedBits.cast().toList(); - cellsToRemove.addAll(removable); } - } - cellsToRemove.forEach(cells.remove); + cellsToRemove.forEach(cells.remove); + } } } diff --git a/lib/src/synthesizers/netlist/netlist_synthesizer.dart b/lib/src/synthesizers/netlist/netlist_synthesizer.dart index d858c9b71..7c1b3e3be 100644 --- a/lib/src/synthesizers/netlist/netlist_synthesizer.dart +++ b/lib/src/synthesizers/netlist/netlist_synthesizer.dart @@ -201,7 +201,7 @@ class NetlistSynthesizer extends Synthesizer { // For non-constants, follow replacement chain to resolve merged logics. // For constants, keep them separate to create distinct const drivers. if (!sl.isConstant) { - resolved = resolveReplacement(resolved); + resolved = NetlistUtils.resolveReplacement(resolved); } final ids = synthLogicIds.putIfAbsent( resolved, () => List.generate(resolved.width, (_) => nextId++)); @@ -220,7 +220,7 @@ class NetlistSynthesizer extends Synthesizer { if (synthLogics != null) { for (final sl in synthLogics) { final ids = getIds(sl); - final portName = portNameForSynthLogic(sl, modulePorts); + final portName = NetlistUtils.portNameForSynthLogic(sl, modulePorts); if (portName != null) { ports[portName] = {'direction': direction, 'bits': ids}; } @@ -306,7 +306,7 @@ class NetlistSynthesizer extends Synthesizer { // -- Collapse bit-slice ports on Combinational / Sequential ---- if (sub is Combinational || sub is Sequential) { - collapseAlwaysBlockPorts( + NetlistUtils.collapseAlwaysBlockPorts( synthDef, instance, cellPortDirs, @@ -322,7 +322,8 @@ class NetlistSynthesizer extends Synthesizer { final portName = pe.key; final synthLogic = instance.inputMapping[portName] ?? instance.inOutMapping[portName]; - if (synthLogic != null && isConstantSynthLogic(synthLogic)) { + if (synthLogic != null && + NetlistUtils.isConstantSynthLogic(synthLogic)) { portsToRemove.add(portName); blockedConstSynthLogics.add(synthLogic.replacement ?? synthLogic); } @@ -346,8 +347,8 @@ class NetlistSynthesizer extends Synthesizer { if (sl == null) { continue; // aggregated port, already renamed } - final resolved = resolveReplacement(sl); - final namerName = tryGetSynthLogicName(resolved); + final resolved = NetlistUtils.resolveReplacement(sl); + final namerName = NetlistUtils.tryGetSynthLogicName(resolved); if (namerName != null && namerName != portName) { renames[portName] = namerName; } @@ -579,7 +580,7 @@ class NetlistSynthesizer extends Synthesizer { arraysWithExplicitCells.add(logic); } // Also check the resolved replacement chain. - final resolved = resolveReplacement(inputSL); + final resolved = NetlistUtils.resolveReplacement(inputSL); final logic2 = synthDef.logicToSynthMap.entries .where((e) => e.value == resolved) .map((e) => e.key) @@ -1118,7 +1119,8 @@ class NetlistSynthesizer extends Synthesizer { } return bestNamed.name; } - return bestAny?.name ?? resolveReplacement(srcSynthLogic).name; + return bestAny?.name ?? + NetlistUtils.resolveReplacement(srcSynthLogic).name; } // Build port_directions and connections. @@ -1194,7 +1196,7 @@ class NetlistSynthesizer extends Synthesizer { // Insert a $buf cell: input = original (shared) IDs, // output = fresh IDs. cells['passthrough_buf_$bufIdx'] = - makeBufCell(outBits.length, outBits, freshBits); + NetlistUtils.makeBufCell(outBits.length, outBits, freshBits); // Update the output port to use the fresh IDs. p.value['bits'] = freshBits; @@ -1319,7 +1321,7 @@ class NetlistSynthesizer extends Synthesizer { .where((e) => !blockedConstSynthLogics.contains(e.key)) .where((e) => e.value.isNotEmpty)) { final sl = entry.key; - final constValue = constValueFromSynthLogic(sl); + final constValue = NetlistUtils.constValueFromSynthLogic(sl); if (constValue == null) { continue; } @@ -1338,7 +1340,7 @@ class NetlistSynthesizer extends Synthesizer { emittedConstWires.addAll(resolvedIds.whereType()); } - final valuePart = constValuePart(constValue); + final valuePart = NetlistUtils.constValuePart(constValue); final cellName = 'const_${constIdx}_$valuePart'; final valueLiteral = valuePart.replaceFirst('_', "'"); @@ -1485,7 +1487,7 @@ class NetlistSynthesizer extends Synthesizer { for (final entry in synthLogicIds.entries .where((e) => !e.key.isConstant && !e.key.declarationCleared)) { final sl = entry.key; - final name = tryGetSynthLogicName(sl); + final name = NetlistUtils.tryGetSynthLogicName(sl); if (name != null) { var bits = applyAlias(entry.value.cast()); // For element signals whose IDs were remapped by the @@ -1574,26 +1576,26 @@ class NetlistSynthesizer extends Synthesizer { ) { if (options.groupStructConversions) { if (options.groupMaximalSubsets) { - applyMaximalSubsetGrouping(modules); + NetlistPasses.applyMaximalSubsetGrouping(modules); } if (options.collapseConcats) { - applyCollapseConcats(modules); + NetlistPasses.applyCollapseConcats(modules); } if (options.collapseSelectsIntoPack) { - applyCollapseSelectsIntoPack(modules); + NetlistPasses.applyCollapseSelectsIntoPack(modules); } if (options.collapseUnpackToConcat) { - applyCollapseUnpackToConcat(modules); + NetlistPasses.applyCollapseUnpackToConcat(modules); } if (options.collapseUnpackToPack) { - applyCollapseUnpackToPack(modules); + NetlistPasses.applyCollapseUnpackToPack(modules); } - applyStructConversionGrouping(modules); + NetlistPasses.applyStructConversionGrouping(modules); if (options.collapseStructGroups) { - collapseStructGroupModules(modules); + NetlistPasses.collapseStructGroupModules(modules); } - applyStructBufferInsertion(modules); - applyConcatToBufferReplacement(modules); + NetlistPasses.applyStructBufferInsertion(modules); + NetlistPasses.applyConcatToBufferReplacement(modules); } } @@ -1605,8 +1607,8 @@ class NetlistSynthesizer extends Synthesizer { /// avoiding redundant re-synthesis. Future>> buildModulesMap( SynthBuilder synth, Module top) async { - final modules = - collectModuleEntries(synth.synthesisResults, topModule: top); + final modules = NetlistPasses.collectModuleEntries(synth.synthesisResults, + topModule: top); applyPostProcessingPasses(modules); @@ -1618,7 +1620,7 @@ class NetlistSynthesizer extends Synthesizer { final modules = await buildModulesMap(synth, top); if (options.compressBitRanges) { - compressModulesMap(modules); + _compressModulesMap(modules); } final combined = { @@ -1632,6 +1634,89 @@ class NetlistSynthesizer extends Synthesizer { return encoder.convert(combined); } + /// 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. diff --git a/lib/src/synthesizers/netlist/netlist_utils.dart b/lib/src/synthesizers/netlist/netlist_utils.dart index 0e5fd1196..3e82e93b5 100644 --- a/lib/src/synthesizers/netlist/netlist_utils.dart +++ b/lib/src/synthesizers/netlist/netlist_utils.dart @@ -10,647 +10,519 @@ import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; -/// Find the port name in [portMap] that corresponds to [sl]. -String? portNameForSynthLogic(SynthLogic sl, Map portMap) { - for (final e in portMap.entries) { - if (sl.logics.contains(e.value)) { - return e.key; +/// 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; } - 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). -String? tryGetSynthLogicName(SynthLogic sl) { - try { - return sl.name; - // ignore: avoid_catches_without_on_clauses - } catch (_) { - 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. -SynthLogic resolveReplacement(SynthLogic sl) { - var r = sl; - while (r.replacement != null) { - r = r.replacement!; + /// 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; } - return r; -} -/// Anchored regex for range-named concat port labels like `[7:0]` or `[3]`. -final rangePortRe = RegExp(r'^\[(\d+)(?::(\d+))?\]$'); - -/// Create a `$buf` cell map. -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. -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. -({ - 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); + /// 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 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); + 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, - ); -} + 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. -(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; - } - 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) { + /// 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; } - 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) { + final driverCell = cells[driverName]; + if (driverCell == null) { break; } - final srcIdx = offset + idx; - if (srcIdx < 0 || srcIdx >= aBits.length || aBits[srcIdx] is! int) { + final dt = driverCell['type'] as String?; + if (dt != r'$buf' && dt != r'$slice') { break; } - current = aBits[srcIdx] as int; + 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); } - 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). -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; + /// 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; } - for (final b in pe.value as List) { - if (b is! int) { - continue; - } - final consumers = wireConsumerCells[b]; - if (consumers == null) { + 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 cn in consumers) { - if (cn != ownerCell && !intermediates.contains(cn)) { - if (allowPortConsumers && cn == '__port__') { - 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 false; } } } } + return true; } - 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. -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); - } + /// 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; - // 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 - } + // 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 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)); - } - } + final resolvedOutput = resolveReplacement(outputSL); + final resolvedInput = resolveReplacement(inputSL); - // Collapse each group with > 1 contiguous member. - for (final entry in inputGroups.entries) { - if (entry.value.length <= 1) { - continue; + busSubsetLookup[resolvedOutput] = (bsMod, resolvedInput, bsInst); } - final rootSL = entry.key; - final ports = entry.value..sort((a, b) => a.$2.compareTo(b.$2)); + // Group input ports by root signal, also tracking the BusSubset + // instantiations that produced each port. + final inputGroups = >{}; - // 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; + for (final e in instance.inputMapping.entries) { + final portName = e.key; + if (!connections.containsKey(portName)) { + continue; // already filtered } - 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 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)); + } } - final aggBits = rootIds.sublist(minBit, maxBit + 1).cast(); - // Choose a name for the aggregate port. - final rootName = tryGetSynthLogicName(rootSL) ?? 'agg_${minBit}_$maxBit'; + // Collapse each group with > 1 contiguous member. + for (final entry in inputGroups.entries) { + if (entry.value.length <= 1) { + continue; + } - // 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'; - } + final rootSL = entry.key; + final ports = entry.value..sort((a, b) => a.$2.compareTo(b.$2)); - // ── Output-side collapsing (Combinational → Swizzle) ─────────────── + // 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; + } - // 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; - } - } + final minBit = ports.first.$2; + final maxBit = ports.last.$2 + ports.last.$3 - 1; - // 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; - } + // 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(); - 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)); - } - } + // Choose a name for the aggregate port. + final rootName = tryGetSynthLogicName(rootSL) ?? 'agg_${minBit}_$maxBit'; - // Collapse each group with > 1 contiguous member. - for (final entry in outputGroups.entries) { - if (entry.value.length <= 1) { - continue; + // 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; + } } - final swizOutSL = entry.key; - final ports = entry.value..sort((a, b) => a.$2.compareTo(b.$2)); + // Group output ports by Swizzle output signal. + final outputGroups = >{}; - // Verify contiguous. - var expectedBit = ports.first.$2; - var contiguous = true; - for (final (_, offset, width, _) in ports) { - if (offset != expectedBit) { - contiguous = false; - break; + for (final e in instance.outputMapping.entries) { + final portName = e.key; + if (!connections.containsKey(portName)) { + continue; } - 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 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)); + } } - final aggBits = outIds.sublist(minBit, maxBit + 1).cast(); - final outName = - tryGetSynthLogicName(swizOutSL) ?? 'agg_out_${minBit}_$maxBit'; + // Collapse each group with > 1 contiguous member. + for (final entry in outputGroups.entries) { + if (entry.value.length <= 1) { + continue; + } - // 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'; - } -} + final swizOutSL = entry.key; + final ports = entry.value..sort((a, b) => a.$2.compareTo(b.$2)); -/// Check if a SynthLogic is a constant (following replacement chain). -bool isConstantSynthLogic(SynthLogic sl) => resolveReplacement(sl).isConstant; + // 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; + } -/// Extract the Const value from a constant SynthLogic. -Const? constValueFromSynthLogic(SynthLogic sl) { - final resolved = resolveReplacement(sl); - for (final logic in resolved.logics) { - if (logic is Const) { - return logic; - } - } - return null; -} + final minBit = ports.first.$2; + final maxBit = ports.last.$2 + ports.last.$3 - 1; -/// Value portion of a constant name: `_h` or `_b`. -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)}'; -} + final outIds = getIds(swizOutSL); + if (maxBit >= outIds.length) { + continue; + } + final aggBits = outIds.sublist(minBit, maxBit + 1).cast(); -// -- Bit-range compression / expansion ------------------------------------ + final outName = + tryGetSynthLogicName(swizOutSL) ?? 'agg_out_${minBit}_$maxBit'; -/// Compresses a list of bit IDs by replacing contiguous ascending runs of -/// 3 or more integers with `"start:end"` range strings. -/// -/// Elements that are already strings (Yosys constant bits `"0"` and `"1"`) -/// are passed through unchanged. Single integers and pairs that don't form -/// a run of ≥3 are kept as-is. -/// -/// Example: -/// ```dart -/// compressBits([52, 53, 54, 55]) // => ["52:55"] -/// compressBits([3, 5, 6, 7, 8]) // => [3, "5:8"] -/// compressBits(["0", 2, 3]) // => ["0", 2, 3] -/// ``` -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]); - } + // 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); } - i = j + 1; + connections[outName] = aggBits; + portDirs[outName] = 'output'; } - pending.clear(); } - for (final element in bits) { - if (element is int) { - pending.add(element); - } else { - flushPending(); - result.add(element); - } - } - flushPending(); - return result; -} + /// Check if a SynthLogic is a constant (following replacement chain). + static bool isConstantSynthLogic(SynthLogic sl) => + resolveReplacement(sl).isConstant; -/// Expands a compressed bit-ID list back to individual elements. -/// -/// Range strings `"start:end"` are expanded to `[start, start+1, ..., end]`. -/// Yosys constant strings `"0"` and `"1"` are passed through unchanged. -/// Integer elements are passed through unchanged. -/// -/// This is the inverse of [compressBits]: -/// `expandBits(compressBits(bits))` returns the original list. -List expandBits(List bits) { - final result = []; - for (final element in bits) { - if (element is int) { - result.add(element); - } else if (element is String) { - final colonIdx = element.indexOf(':'); - if (colonIdx > 0) { - final start = int.tryParse(element.substring(0, colonIdx)); - final end = int.tryParse(element.substring(colonIdx + 1)); - if (start != null && end != null && end >= start) { - for (var i = start; i <= end; i++) { - result.add(i); - } - continue; - } + /// 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; } - // Not a range — pass through (e.g. "0", "1"). - result.add(element); - } else { - result.add(element); } + return null; } - return result; -} -/// Applies [compressBits] to all `bits` arrays and cell `connections` -/// arrays in a modules map (the top-level `modules` value from a -/// Yosys-compatible JSON netlist). -/// -/// Modifies the map in place and returns it for convenience. -Map> compressModulesMap( - Map> modules, -) { - for (final moduleDef in modules.values) { - // Compress port bits. - 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()); - } + /// 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; } } - - // Compress cell connection arrays. - 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]!); - } - } - } + if (hasXZ) { + return '${c.width}_b${bitChars.join()}'; } - - // Compress netname bits. - 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()); - } - } + 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)}'; } - return modules; } diff --git a/test/netlist_test.dart b/test/netlist_test.dart index cbafa171e..43eb94cc4 100644 --- a/test/netlist_test.dart +++ b/test/netlist_test.dart @@ -395,8 +395,9 @@ void main() { await mod.build(); final synth = SynthBuilder(mod, NetlistSynthesizer()); - final modulesMap = - collectModuleEntries(synth.synthesisResults, topModule: mod); + final modulesMap = NetlistPasses.collectModuleEntries( + synth.synthesisResults, + topModule: mod); expect(modulesMap, contains(mod.definitionName)); expect(modulesMap.length, greaterThan(1)); @@ -538,38 +539,7 @@ void main() { // Bit-range compression & compact JSON // ----------------------------------------------------------------------- group('Bit-range compression', () { - test('compressBits replaces contiguous runs of >=3', () { - expect(compressBits([52, 53, 54, 55]), equals(['52:55'])); - expect(compressBits([3, 5, 6, 7, 8]), equals([3, '5:8'])); - expect(compressBits([1, 2]), equals([1, 2])); - expect(compressBits([10]), equals([10])); - expect(compressBits([]), equals([])); - }); - - test('compressBits preserves constant strings', () { - expect(compressBits(['0', 2, 3, 4, 5, '1']), equals(['0', '2:5', '1'])); - }); - - test('compressBits handles mixed non-contiguous', () { - expect( - compressBits([1, 3, 4, 5, 10, 11, 12]), equals([1, '3:5', '10:12'])); - }); - - test('expandBits is inverse of compressBits', () { - final original = [52, 53, 54, 55, 56, 57]; - final compressed = compressBits(original); - expect(expandBits(compressed), equals(original)); - }); - - test('expandBits preserves constant strings', () { - expect(expandBits(['0', '5:8', '1']), equals(['0', 5, 6, 7, 8, '1'])); - }); - - test('expandBits passes through plain ints', () { - expect(expandBits([1, 2, 3]), equals([1, 2, 3])); - }); - - test('compressBitRanges option compresses netlist JSON', () async { + 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(); @@ -585,18 +555,34 @@ void main() { // Compressed should be shorter. expect(jsonCompressed.length, lessThan(jsonNormal.length)); - // Both should parse as valid JSON. + // Both should parse as valid JSON with the same module keys. final decodedCompressed = jsonDecode(jsonCompressed) as Map; final decodedNormal = jsonDecode(jsonNormal) as Map; - - // Same module keys. expect( (decodedCompressed['modules'] as Map).keys.toSet(), equals((decodedNormal['modules'] as Map).keys.toSet()), ); - // Compressed JSON should contain range strings. + // 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 { @@ -652,66 +638,67 @@ void main() { expect(jsonBoth.length, lessThan(jsonCompactOnly.length)); }); - test('compressModulesMap round-trips through expand', () async { + test( + 'compressed FilterBank round-trips: range strings expand to ' + 'same bit IDs as uncompressed', () async { final mod = _buildFilterBank(); await mod.build(); - final synth = NetlistSynthesizer(); - final sb = SynthBuilder(mod, synth); - final modules = await synth.buildModulesMap(sb, mod); - - // Capture original bits from every port, cell connection, netname. - final originalBits = >{}; - for (final entry in modules.entries) { - final ports = - entry.value['ports'] as Map>?; - if (ports != null) { - for (final p in ports.entries) { - final bits = p.value['bits'] as List?; - if (bits != null) { - originalBits['${entry.key}.port.${p.key}'] = - List.from(bits); - } - } - } - } + // Generate both compressed and uncompressed. + final synthNormal = NetlistSynthesizer(); + final jsonNormal = await synthNormal.synthesizeToJson(mod); + final normalModules = (jsonDecode(jsonNormal) + as Map)['modules'] as Map; - // Compress in place. - compressModulesMap(modules); - - // Verify at least some ranges were created. - var rangeCount = 0; - for (final moduleDef in modules.values) { - final ports = moduleDef['ports'] as Map>?; - if (ports != null) { - for (final p in ports.values) { - final bits = p['bits'] as List?; - if (bits != null) { - for (final b in bits) { - if (b is String && b.contains(':')) rangeCount++; - } - } - } + 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; } - } - expect(rangeCount, greaterThan(0), - reason: 'FilterBank should have compressible bit ranges'); - - // Expand and verify round-trip. - for (final entry in modules.entries) { - final ports = - entry.value['ports'] as Map>?; - if (ports != null) { - for (final p in ports.entries) { - final bits = p.value['bits'] as List?; - if (bits != null) { - final key = '${entry.key}.port.${p.key}'; - if (originalBits.containsKey(key)) { - expect(expandBits(bits.cast()), originalBits[key], - reason: 'round-trip failed for $key'); + + 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'); } } });