Repository: amaranth-lang/amaranth
Pull request #1093 (2024-02-08 16:53:34)

Implement RFC 27 amendment: deprecate `add_sync_process`, not `add_process`.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/1093?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`ace7aea`)](https://app.codecov.io/gh/amaranth-lang/amaranth/commit/ace7aea375f0abea88a16c01ce86139c03dbdbef?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) 86.99% compared to head [(`09c7637`)](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/1093?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) 87.01%.


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1093      +/-   ##
==========================================
+ Coverage   86.99%   87.01%   +0.01%     
==========================================
  Files          41       41              
  Lines        7161     7154       -7     
  Branches     1705     1703       -2     
==========================================
- Hits         6230     6225       -5     
+ Misses        768      766       -2     
  Partials      163      163              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/1093?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang).


Patch:
diff --git a/amaranth/back/rtlil.py b/amaranth/back/rtlil.py
index 0879453..a442238 100644
--- a/amaranth/back/rtlil.py
+++ b/amaranth/back/rtlil.py
@@ -1,13 +1,9 @@
+from typing import Iterable
 import io
-from collections import OrderedDict
-from contextlib import contextmanager
-import warnings
-import re
 
-from .._utils import flatten
 from ..utils import bits_for
-from ..hdl import _ast, _ir, _mem, _xfrm, _repr
 from ..lib import wiring
+from ..hdl import _repr, _ast, _ir, _nir
 
 
 __all__ = ["convert", "convert_fragment"]
@@ -89,11 +85,6 @@ class _BufferedBuilder:
         self._buffer.write(fmt.format(*args, **kwargs))
 
 
-class _ProxiedBuilder:
-    def _append(self, *args, **kwargs):
-        self.rtlil._append(*args, **kwargs)
-
-
 class _AttrBuilder:
     def __init__(self, emit_src, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -183,10 +174,7 @@ class _ModuleBuilder(_AttrBuilder, _BufferedBuilder, _Namer):
                 self._append("    parameter \\{} {}\n",
                              param, _const(value))
         for port, wire in ports.items():
-            # By convention, Yosys ports named $\d+ are positional. Amaranth does not support
-            # connecting cell ports by position. See amaranth-lang/amaranth#733.
-            assert not re.match(r"^\$\d+$", port)
-            self._append("    connect {} {}\n", port, wire)
+            self._append("    connect \\{} {}\n", port, wire)
         self._append("  end\n")
         return name
 
@@ -216,11 +204,14 @@ class _ProcessBuilder(_AttrBuilder, _BufferedBuilder):
         return _CaseBuilder(self, indent=2)
 
 
-class _CaseBuilder(_ProxiedBuilder):
+class _CaseBuilder:
     def __init__(self, rtlil, indent):
         self.rtlil  = rtlil
         self.indent = indent
 
+    def _append(self, *args, **kwargs):
+        self.rtlil._append(*args, **kwargs)
+
     def __enter__(self):
         return self
 
@@ -234,7 +225,7 @@ class _CaseBuilder(_ProxiedBuilder):
         return _SwitchBuilder(self.rtlil, cond, attrs, src, self.indent)
 
 
-class _SwitchBuilder(_AttrBuilder, _ProxiedBuilder):
+class _SwitchBuilder(_AttrBuilder):
     def __init__(self, rtlil, cond, attrs, src, indent):
         super().__init__(emit_src=rtlil.emit_src)
         self.rtlil  = rtlil
@@ -243,6 +234,9 @@ class _SwitchBuilder(_AttrBuilder, _ProxiedBuilder):
         self.src    = src
         self.indent = indent
 
+    def _append(self, *args, **kwargs):
+        self.rtlil._append(*args, **kwargs)
+
     def __enter__(self):
         self._attributes(self.attrs, src=self.src, indent=self.indent)
         self._append("{}switch {}\n", "  " * self.indent, self.cond)
@@ -268,777 +262,738 @@ def _src(src_loc):
     return f"{file}:{line}"
 
 
-class _LegalizeValue(Exception):
-    def __init__(self, value, branches, src_loc):
-        self.value    = value
-        self.branches = list(branches)
-        self.src_loc  = src_loc
-
-
-class _ValueCompilerState:
-    def __init__(self, rtlil):
-        self.rtlil  = rtlil
-        self.wires  = _ast.SignalDict()
-        self.driven = _ast.SignalDict()
-        self.ports  = _ast.SignalDict()
-        self.anys   = _ast.ValueDict()
-
-        self.expansions = _ast.ValueDict()
-
-    def add_driven(self, signal, sync):
-        self.driven[signal] = sync
-
-    def add_port(self, signal, kind):
-        assert kind in ("i", "o", "io")
-        if kind == "i":
-            kind = "input"
-        elif kind == "o":
-            kind = "output"
-        elif kind == "io":
-            kind = "inout"
-        self.ports[signal] = (len(self.ports), kind)
-
-    def resolve(self, signal, prefix=None):
-        if len(signal) == 0:
-            return "{ }", "{ }"
-
-        if signal in self.wires:
-            return self.wires[signal]
-
-        if signal in self.ports:
-            port_id, port_kind = self.ports[signal]
-        else:
-            port_id = port_kind = None
-        if prefix is not None:
-            wire_name = f"{prefix}_{signal.name}"
-        else:
-            wire_name = signal.name
-
-        is_sync_driven = signal in self.driven and self.driven[signal]
-
-        attrs = dict(signal.attrs)
-        for repr in signal._value_repr:
-            if repr.path == () and isinstance(repr.format, _repr.FormatEnum):
-                enum = repr.format.enum
-                attrs["enum_base_type"] = enum.__name__
-                for value in enum:
-                    attrs["enum_value_{:0{}b}".format(value.value, signal.width)] = value.name
-
-        # For every signal in the sync domain, assign \sig's initial value (using the \init reg
-        # attribute) to the reset value.
-        if is_sync_driven:
-            attrs["init"] = _ast.Const(signal.reset, signal.width)
-
-        wire_curr = self.rtlil.wire(width=signal.width, name=wire_name,
-                                    port_id=port_id, port_kind=port_kind,
-                                    attrs=attrs, src=_src(signal.src_loc))
-        if is_sync_driven:
-            wire_next = self.rtlil.wire(width=signal.width, name=wire_curr + "$next",
-                                        src=_src(signal.src_loc))
-        else:
-            wire_next = None
-        self.wires[signal] = (wire_curr, wire_next)
-
-        return wire_curr, wire_next
-
-    def resolve_curr(self, signal, prefix=None):
-        wire_curr, wire_next = self.resolve(signal, prefix)
-        return wire_curr
-
-    def expand(self, value):
-        if not self.expansions:
-            return value
-        return self.expansions.get(value, value)
-
-    @contextmanager
-    def expand_to(self, value, expansion):
-        try:
-            assert value not in self.expansions
-            self.expansions[value] = expansion
-            yield
-        finally:
-            del self.expansions[value]
-
-
-class _ValueCompiler(_xfrm.ValueVisitor):
-    def __init__(self, state):
-        self.s = state
-
-    def on_unknown(self, value):
-        if value is None:
-            return None
-        else:
-            super().on_unknown(value)
-
-    def on_ClockSignal(self, value):
-        raise NotImplementedError # :nocov:
-
-    def on_ResetSignal(self, value):
-        raise NotImplementedError # :nocov:
-
-    def on_Cat(self, value):
-        return "{{ {} }}".format(" ".join(reversed([self(o) for o in value.parts])))
-
-    def _prepare_value_for_Slice(self, value):
-        raise NotImplementedError # :nocov:
-
-    def on_Slice(self, value):
-        if value.start == 0 and value.stop == len(value.value):
-            return self(value.value)
-
-        sigspec = self._prepare_value_for_Slice(value.value)
-        if value.start == value.stop:
-            return "{}"
-        elif value.start + 1 == value.stop:
-            return f"{sigspec} [{value.start}]"
+class MemoryInfo:
+    def __init__(self, memid):
+        self.memid = memid
+        self.num_write_ports = 0
+        self.write_port_ids = {}
+
+
+class ModuleEmitter:
+    def __init__(self, builder, netlist, module, name_map, empty_checker):
+        self.builder = builder
+        self.netlist = netlist
+        self.module = module
+        self.name_map = name_map
+        self.empty_checker = empty_checker
+
+        # Internal state of the emitter. This conceptually consists of three parts:
+        # (1) memory information;
+        # (2) name and attribute preferences for wires corresponding to signals;
+        # (3) mapping of Amaranth netlist entities to RTLIL netlist entities.
+        # Value names are preferences: they are candidate names for values that may or may not get
+        # used for cell outputs. Attributes are mandatory: they are always emitted, but can be
+        # squashed if several signals end up aliasing the same driven wire.
+        self.memories = {} # cell idx -> MemoryInfo
+        self.value_names = {} # value -> signal or port name
+        self.value_attrs = {} # value -> dict
+        self.sigport_wires = {} # signal or port name -> (wire, value)
+        self.driven_sigports = set() # set of signal or port name
+        self.nets = {} # net -> (wire name, bit idx)
+        self.cell_wires = {} # cell idx -> wire name
+        self.instance_wires = {} # (cell idx, output name) -> wire name
+
+    def emit(self):
+        self.collect_memory_info()
+        self.assign_value_names()
+        self.collect_init_attrs()
+        self.emit_signal_wires()
+        self.emit_port_wires()
+        self.emit_cell_wires()
+        self.emit_submodule_wires()
+        self.emit_connects()
+        self.emit_submodules()
+        self.emit_cells()
+
+    def collect_memory_info(self):
+        for cell_idx in self.module.cells:
+            cell = self.netlist.cells[cell_idx]
+            if isinstance(cell, _nir.Memory):
+                self.memories[cell_idx] = MemoryInfo(
+                    self.builder.memory(cell.width, cell.depth, name=cell.name,
+                                       attrs=cell.attributes, src=_src(cell.src_loc)))
+
+        for cell_idx in self.module.cells:
+            cell = self.netlist.cells[cell_idx]
+            if isinstance(cell, _nir.SyncWritePort):
+                memory_info = self.memories[cell.memory]
+                memory_info.write_port_ids[cell_idx] = memory_info.num_write_ports
+                memory_info.num_write_ports += 1
+
+    def assign_value_names(self):
+        for signal, name in self.module.signal_names.items():
+            value = self.netlist.signals[signal]
+            if value not in self.value_names:
+                self.value_names[value] = name
+
+    def collect_init_attrs(self):
+        # Flip-flops are special in Yosys; the initial value is stored not as a cell parameter but
+        # as an attribute of a wire connected to the output of the flip-flop. The claimed benefit
+        # of this arrangement is that fine cells, which cannot have parameters (so that certain
+        # backends, like BLIF, which cannot represent parameters--or attributes--can be used to
+        # emit these cells), then do not need to have 3x more variants (one for initialized to 0,
+        # one for 1, one for X).
+        #
+        # At the time of writing, 2024-02-11, Yosys has 125 (one hundred twenty five) fine FF cells,
+        # which are generated by a Python script because they have gotten completely out of hand
+        # long ago and no one could keep track of them manually. This list features such beauties
+        # as $_DFFSRE_PPPN_ and its other 7 cousins.
+        #
+        # These are real cells, used by real Yosys developers! Look at what they have done for us,
+        # with all the subtly unsynthesizable Verilog we sent them and all of the incompatibilities
+        # with vendor toolchains we reported!
+        #
+        # Nothing is fine about these cells. The decision to have `init` as a wire attribute is
+        # quite possibly the single worst design decision in Yosys, and not having to dealing with
+        # that bullshit again is enough of a reason to implement an FPGA toolchain from scratch.
+        #
+        # Just have 375 fine cells, bro. Trust me bro. You will certainly not regret having 375
+        # fine cells in your toolchain. Or at least you will be able to process netlists without
+        # having to special-case this one godforsaken attribute every time you look at a wire.
+        #
+        # -- @whitequark
+        for cell_idx in self.module.cells:
+            cell = self.netlist.cells[cell_idx]
+            if isinstance(cell, _nir.FlipFlop):
+                width = len(cell.data)
+                attrs = {"init": _ast.Const(cell.init, width), **cell.attributes}
+                value = _nir.Value(_nir.Net.from_cell(cell_idx, bit) for bit in range(width))
+                self.value_attrs[value] = attrs
+
+    def emit_signal_wires(self):
+        for signal, name in self.module.signal_names.items():
+            value = self.netlist.signals[signal]
+
+            # One of: (1) empty and created here, (2) `init` filled in by `collect_init_attrs`,
+            # (3) populated by some other signal aliasing the same nets. In the last case, we will
+            # glue attributes for these signals together, but synthesizers (including Yosys, when
+            # the design is flattened) will do that anyway, so it doesn't matter.
+            attrs = self.value_attrs.setdefault(value, {})
+            attrs.update(signal.attrs)
+
+            for repr in signal._value_repr:
+                if repr.path == () and isinstance(repr.format, _repr.FormatEnum):
+                    enum = repr.format.enum
+                    attrs["enum_base_type"] = enum.__name__
+                    for enum_value in enum:
+                        attrs["enum_value_{:0{}b}".format(enum_value.value, signal.width)] = enum_value.name
+
+            if name in self.module.ports:
+                port_value, _flow = self.module.ports[name]
+                assert value == port_value
+                self.name_map[signal] = (*self.module.name, f"\\{name}")
+            else:
+                wire = self.builder.wire(width=signal.width, name=name, attrs=attrs,
+                                         src=_src(signal.src_loc))
+                self.sigport_wires[name] = (wire, value)
+                self.name_map[signal] = (*self.module.name, wire)
+
+    def emit_port_wires(self):
+        for port_id, (name, (value, flow)) in enumerate(self.module.ports.items()):
+            wire = self.builder.wire(width=len(value), port_id=port_id, port_kind=flow.value,
+                                     name=name, attrs=self.value_attrs.get(value, {}))
+            self.sigport_wires[name] = (wire, value)
+            if flow == _nir.ModuleNetFlow.OUTPUT:
+                continue
+            # If we just emitted an input or inout port, it is driving the value.
+            self.driven_sigports.add(name)
+            for bit, net in enumerate(value):
+                self.nets[net] = (wire, bit)
+
+    def emit_driven_wire(self, value):
+        # Emits a wire for a value, in preparation for driving it.
+        if value in self.value_names:
+            # If there is a signal or port matching this value, reuse its wire as the canonical
+            # wire of the nets involved.
+            name = self.value_names[value]
+            wire, named_value = self.sigport_wires[name]
+            assert value == named_value, \
+                f"Inconsistent values {value!r}, {named_value!r} for wire {name!r}"
+            self.driven_sigports.add(name)
         else:
-            return f"{sigspec} [{value.stop - 1}:{value.start}]"
+            # Otherwise, make an anonymous wire.
+            wire = self.builder.wire(len(value), attrs=self.value_attrs.get(value, {}))
+        for bit, net in enumerate(value):
+            self.nets[net] = (wire, bit)
+        return wire
+
+    def emit_cell_wires(self):
+        for cell_idx in self.module.cells:
+            cell = self.netlist.cells[cell_idx]
+            if isinstance(cell, _nir.Top):
+                continue
+            elif isinstance(cell, _nir.Instance):
+                for name, (start, width) in cell.ports_o.items():
+                    nets = [_nir.Net.from_cell(cell_idx, start + bit) for bit in range(width)]
+                    wire = self.emit_driven_wire(_nir.Value(nets))
+                    self.instance_wires[cell_idx, name] = wire
+                continue # Instances use one wire per output, not per cell.
+            elif isinstance(cell, (_nir.PriorityMatch, _nir.Matches)):
+                continue # Inlined into assignment lists.
+            elif isinstance(cell, (_nir.SyncProperty, _nir.AsyncProperty, _nir.Memory,
+                                   _nir.SyncWritePort)):
+                continue # No outputs.
+            elif isinstance(cell, _nir.AssignmentList):
+                width = len(cell.default)
+            elif isinstance(cell, (_nir.Operator, _nir.Part, _nir.ArrayMux, _nir.AnyValue,
+                                   _nir.SyncReadPort, _nir.AsyncReadPort)):
+                width = cell.width
+            elif isinstance(cell, _nir.FlipFlop):
+                width = len(cell.data)
+            elif isinstance(cell, _nir.Initial):
+                width = 1
+            elif isinstance(cell, _nir.IOBuffer):
+                width = len(cell.pad)
+            else:
+                assert False # :nocov:
+            # Single output cell connected to a wire.
+            nets = [_nir.Net.from_cell(cell_idx, bit) for bit in range(width)]
+            wire = self.emit_driven_wire(_nir.Value(nets))
+            self.cell_wires[cell_idx] = wire
+
+    def emit_submodule_wires(self):
+        for submodule_idx in self.module.submodules:
+            submodule = self.netlist.modules[submodule_idx]
+            for _name, (value, flow) in submodule.ports.items():
+                if flow == _nir.ModuleNetFlow.OUTPUT:
+                    self.emit_driven_wire(value)
+
+    def sigspec(self, *parts: '_nir.Net | Iterable[_nir.Net]'):
+        value = _nir.Value()
+        for part in parts:
+            value += _nir.Value(part)
+
+        chunks = []
+        begin_pos = 0
+        while begin_pos < len(value):
+            end_pos = begin_pos
+            if value[begin_pos].is_const:
+                while end_pos < len(value) and value[end_pos].is_const:
+                    end_pos += 1
+                width = end_pos - begin_pos
+                bits = "".join(str(net.const) for net in value[begin_pos:end_pos])
+                chunks.append(f"{width}'{bits[::-1]}")
+            else:
+                wire, start_bit = self.nets[value[begin_pos]]
+                bit = start_bit
+                while (end_pos < len(value) and
+                        not value[end_pos].is_const and
+                        self.nets[value[end_pos]] == (wire, bit)):
+                    end_pos += 1
+                    bit += 1
+                width = end_pos - begin_pos
+                if width == 1:
+                    chunks.append(f"{wire} [{start_bit}]")
+                else:
+                    chunks.append(f"{wire} [{start_bit + width - 1}:{start_bit}]")
+            begin_pos = end_pos
+
+        if len(chunks) == 1:
+            return chunks[0]
+        return "{ " + " ".join(reversed(chunks)) + " }"
+
+    def emit_connects(self):
+        for name, (wire, value) in self.sigport_wires.items():
+            if name not in self.driven_sigports:
+                self.builder.connect(wire, self.sigspec(value))
+
+    def emit_submodules(self):
+        for submodule_idx in self.module.submodules:
+            submodule = self.netlist.modules[submodule_idx]
+            if not self.empty_checker.is_empty(submodule_idx):
+                dotted_name = ".".join(submodule.name)
+                self.builder.cell(f"\\{dotted_name}", submodule.name[-1], ports={
+                    name: self.sigspec(value)
+                    for name, (value, _flow) in submodule.ports.items()
+                })
 
-    def on_ArrayProxy(self, value):
-        index = self.s.expand(value.index)
-        if isinstance(index, _ast.Const):
-            if index.value < len(value.elems):
-                elem = value.elems[index.value]
+    def emit_assignment_list(self, cell_idx, cell):
+        def emit_assignments(case, cond):
+            # Emits assignments from the assignment list into the given case.
+            # ``cond`` is the net which is the condition for ``case`` being active.
+            # Returns once it hits an assignment whose condition is not nested within ``cond``,
+            # letting parent invocation take care of the remaining assignments.
+            nonlocal pos
+
+            emitted_switch = False
+            while pos < len(cell.assignments):
+                assign = cell.assignments[pos]
+                if assign.cond == cond and not emitted_switch:
+                    # Not nested, and we didn't emit a switch yet, so emit the assignment.
+                    case.assign(self.sigspec(lhs[assign.start:assign.start + len(assign.value)]),
+                                self.sigspec(assign.value))
+                    pos += 1
+                elif assign.cond == cond:
+                    # Not nested, but we emitted a subswitch. Wrap the assignments in a dummy
+                    # switch. This is necessary because Yosys executes all assignments before all
+                    # subswitches (but allows you to mix asssignments and switches in RTLIL, for
+                    # maximum confusion).
+                    with case.switch("{ }") as switch:
+                        with switch.case("") as subcase:
+                            while pos < len(cell.assignments):
+                                assign = cell.assignments[pos]
+                                if assign.cond == cond:
+                                    subcase.assign(self.sigspec(lhs[assign.start:assign.start +
+                                                                    len(assign.value)]),
+                                                   self.sigspec(assign.value))
+                                    pos += 1
+                                else:
+                                    break
+                else:
+                    # Condition doesn't match this case's condition — either we encountered
+                    # a nested condition, or we should break out.  Try to find out exactly
+                    # how we are nested.
+                    search_cond = assign.cond
+                    while True:
+                        if search_cond == cond:
+                            # We have found the PriorityMatch cell that we should enter.
+                            break
+                        if search_cond == _nir.Net.from_const(1):
+                            # If this isn't nested condition, go back to parent invocation.
+                            return
+                        # Grab the PriorityMatch cell that is on the next level of nesting.
+                        priority_cell_idx = search_cond.cell
+                        priority_cell = self.netlist.cells[priority_cell_idx]
+                        assert isinstance(priority_cell, _nir.PriorityMatch)
+                        search_cond = priority_cell.en
+                    # We assume that:
+                    # 1. PriorityMatch inputs can only be Match cell outputs, or constant 1.
+                    # 2. All Match cells driving a given PriorityMatch cell test the same value.
+                    # Grab the tested value from a random Match cell.
+                    test = _nir.Value()
+                    for net in priority_cell.inputs:
+                        if net != _nir.Net.from_const(1):
+                            matches_cell = self.netlist.cells[net.cell]
+                            assert isinstance(matches_cell, _nir.Matches)
+                            test = matches_cell.value
+                            break
+                    # Now emit cases for all PriorityMatch inputs, in sequence. Consume as many
+                    # assignments as possible along the way.
+                    with case.switch(self.sigspec(test)) as switch:
+                        for bit, net in enumerate(priority_cell.inputs):
+                            subcond = _nir.Net.from_cell(priority_cell_idx, bit)
+                            if net == _nir.Net.from_const(1):
+                                patterns = ()
+                            else:
+                                # Validate the above assumptions.
+                                matches_cell = self.netlist.cells[net.cell]
+                                assert isinstance(matches_cell, _nir.Matches)
+                                assert test == matches_cell.value
+                                patterns = matches_cell.patterns
+                            with switch.case(*patterns) as subcase:
+                                emit_assignments(subcase, subcond)
+                    emitted_switch = True
+
+        lhs = _nir.Value(_nir.Net.from_cell(cell_idx, bit) for bit in range(len(cell.default)))
+        with self.builder.process(src=_src(cell.src_loc)) as proc:
+            with proc.case() as root_case:
+                root_case.assign(self.sigspec(lhs), self.sigspec(cell.default))
+
+                pos = 0 # nonlocally used in `emit_assignments`
+                emit_assignments(root_case, _nir.Net.from_const(1))
+                assert pos == len(cell.assignments)
+
+    def emit_operator(self, cell_idx, cell):
+        UNARY_OPERATORS = {
+            "-":    "$neg",
+            "~":    "$not",
+            "b":    "$reduce_bool",
+            "r|":   "$reduce_or",
+            "r&":   "$reduce_and",
+            "r^":   "$reduce_xor",
+        }
+        BINARY_OPERATORS = {
+            #                    A_SIGNED, B_SIGNED
+            "+":   ("$add",      False,    False),
+            "-":   ("$sub",      False,    False),
+            "*":   ("$mul",      False,    False),
+            "u//": ("$divfloor", False,    False),
+            "s//": ("$divfloor", True,     True),
+            "u%":  ("$modfloor", False,    False),
+            "s%":  ("$modfloor", True,     True),
+            "<<":  ("$shl",      False,    False),
+            "u>>": ("$shr",      False,    False),
+            "s>>": ("$sshr",     True,     False),
+            "&":   ("$and",      False,    False),
+            "|":   ("$or",       False,    False),
+            "^":   ("$xor",      False,    False),
+            "==":  ("$eq",       False,    False),
+            "!=":  ("$ne",       False,    False),
+            "u<":  ("$lt",       False,    False),
+            "u>":  ("$gt",       False,    False),
+            "u<=": ("$le",       False,    False),
+            "u>=": ("$ge",       False,    False),
+            "s<":  ("$lt",       True,     True),
+            "s>":  ("$gt",       True,     True),
+            "s<=": ("$le",       True,     True),
+            "s>=": ("$ge",       True,     True),
+        }
+        if len(cell.inputs) == 1:
+            cell_type = UNARY_OPERATORS[cell.operator]
+            operand, = cell.inputs
+            self.builder.cell(cell_type, ports={
+                "A": self.sigspec(operand),
+                "Y": self.cell_wires[cell_idx]
+            }, params={
+                "A_SIGNED": False,
+                "A_WIDTH": len(operand),
+                "Y_WIDTH": cell.width,
+            }, src=_src(cell.src_loc))
+        elif len(cell.inputs) == 2:
+            cell_type, a_signed, b_signed = BINARY_OPERATORS[cell.operator]
+            operand_a, operand_b = cell.inputs
+            if cell.operator in ("u//", "s//", "u%", "s%"):
+                result = self.builder.wire(cell.width)
+                self.builder.cell(cell_type, ports={
+                    "A": self.sigspec(operand_a),
+                    "B": self.sigspec(operand_b),
+                    "Y": result,
+                }, params={
+                    "A_SIGNED": a_signed,
+                    "B_SIGNED": b_signed,
+                    "A_WIDTH": len(operand_a),
+                    "B_WIDTH": len(operand_b),
+                    "Y_WIDTH": cell.width,
+                }, src=_src(cell.src_loc))
+                nonzero = self.builder.wire(1)
+                self.builder.cell("$reduce_bool", ports={
+                    "A": self.sigspec(operand_b),
+                    "Y": nonzero,
+                }, params={
+                    "A_SIGNED": False,
+                    "A_WIDTH": len(operand_b),
+                    "Y_WIDTH": 1,
+                }, src=_src(cell.src_loc))
+                self.builder.cell("$mux", ports={
+                    "S": nonzero,
+                    "A": self.sigspec(_nir.Value.zeros(cell.width)),
+                    "B": result,
+                    "Y": self.cell_wires[cell_idx]
+                }, params={
+                    "WIDTH": cell.width,
+                }, src=_src(cell.src_loc))
             else:
-                elem = value.elems[-1]
-            return self.match_shape(elem, value.shape())
-        else:
-            max_index = 1 << len(value.index)
-            max_elem  = len(value.elems)
-            raise _LegalizeValue(value.index, range(min(max_index, max_elem)), value.src_loc)
-
-
-class _RHSValueCompiler(_ValueCompiler):
-    operator_map = {
-        (1, "~"):    "$not",
-        (1, "-"):    "$neg",
-        (1, "b"):    "$reduce_bool",
-        (1, "r|"):   "$reduce_or",
-        (1, "r&"):   "$reduce_and",
-        (1, "r^"):   "$reduce_xor",
-        (2, "+"):    "$add",
-        (2, "-"):    "$sub",
-        (2, "*"):    "$mul",
-        (2, "//"):   "$divfloor",
-        (2, "%"):    "$modfloor",
-        (2, "**"):   "$pow",
-        (2, "<<"):   "$sshl",
-        (2, ">>"):   "$sshr",
-        (2, "&"):    "$and",
-        (2, "^"):    "$xor",
-        (2, "|"):    "$or",
-        (2, "=="):   "$eq",
-        (2, "!="):   "$ne",
-        (2, "<"):    "$lt",
-        (2, "<="):   "$le",
-        (2, ">"):    "$gt",
-        (2, ">="):   "$ge",
-        (3, "m"):    "$mux",
-    }
-
-    def on_value(self, value):
-        return super().on_value(self.s.expand(value))
-
-    def on_Const(self, value):
-        return _const(value)
-
-    def on_AnyValue(self, value):
-        if value in self.s.anys:
-            return self.s.anys[value]
-
-        res_shape = value.shape()
-        res = self.s.rtlil.wire(width=res_shape.width, src=_src(value.src_loc))
-        self.s.rtlil.cell("$" + value.kind.value, ports={
-            "\\Y": res,
-        }, params={
-            "WIDTH": res_shape.width,
-        }, src=_src(value.src_loc))
-        self.s.anys[value] = res
-        return res
-
-    def on_Initial(self, value):
-        res = self.s.rtlil.wire(width=1, src=_src(value.src_loc))
-        self.s.rtlil.cell("$initstate", ports={
-            "\\Y": res,
-        }, src=_src(value.src_loc))
-        return res
-
-    def on_Signal(self, value):
-        wire_curr, wire_next = self.s.resolve(value)
-        return wire_curr
-
-    def on_Operator_unary(self, value):
-        arg, = value.operands
-        if value.operator in ("u", "s"):
-            # These operators don't change the bit pattern, only its interpretation.
-            return self(arg)
-
-        arg_shape, res_shape = arg.shape(), value.shape()
-        res = self.s.rtlil.wire(width=res_shape.width, src=_src(value.src_loc))
-        self.s.rtlil.cell(self.operator_map[(1, value.operator)], ports={
-            "\\A": self(arg),
-            "\\Y": res,
-        }, params={
-            "A_SIGNED": arg_shape.signed,
-            "A_WIDTH":  arg_shape.width,
-            "Y_WIDTH":  res_shape.width,
-        }, src=_src(value.src_loc))
-        return res
-
-    def match_shape(self, value, new_shape):
-        if isinstance(value, _ast.Const):
-            return self(_ast.Const(value.value, new_shape))
-
-        value_shape = value.shape()
-        if new_shape.width <= value_shape.width:
-            return self(_ast.Slice(value, 0, new_shape.width))
-
-        res = self.s.rtlil.wire(width=new_shape.width, src=_src(value.src_loc))
-        self.s.rtlil.cell("$pos", ports={
-            "\\A": self(value),
-            "\\Y": res,
-        }, params={
-            "A_SIGNED": value_shape.signed,
-            "A_WIDTH":  value_shape.width,
-            "Y_WIDTH":  new_shape.width,
-        }, src=_src(value.src_loc))
-        return res
-
-    def on_Operator_binary(self, value):
-        lhs, rhs = value.operands
-        lhs_shape, rhs_shape, res_shape = lhs.shape(), rhs.shape(), value.shape()
-        if lhs_shape.signed == rhs_shape.signed or value.operator in ("<<", ">>", "**"):
-            lhs_wire = self(lhs)
-            rhs_wire = self(rhs)
+                self.builder.cell(cell_type, ports={
+                    "A": self.sigspec(operand_a),
+                    "B": self.sigspec(operand_b),
+                    "Y": self.cell_wires[cell_idx],
+                }, params={
+                    "A_SIGNED": a_signed,
+                    "B_SIGNED": b_signed,
+                    "A_WIDTH": len(operand_a),
+                    "B_WIDTH": len(operand_b),
+                    "Y_WIDTH": cell.width,
+                }, src=_src(cell.src_loc))
         else:
-            lhs_shape = rhs_shape = _ast.signed(max(lhs_shape.width + rhs_shape.signed,
-                                                   rhs_shape.width + lhs_shape.signed))
-            lhs_wire = self.match_shape(lhs, lhs_shape)
-            rhs_wire = self.match_shape(rhs, rhs_shape)
-        res = self.s.rtlil.wire(width=res_shape.width, src=_src(value.src_loc))
-        self.s.rtlil.cell(self.operator_map[(2, value.operator)], ports={
-            "\\A": lhs_wire,
-            "\\B": rhs_wire,
-            "\\Y": res,
-        }, params={
-            "A_SIGNED": lhs_shape.signed,
-            "A_WIDTH":  lhs_shape.width,
-            "B_SIGNED": rhs_shape.signed,
-            "B_WIDTH":  rhs_shape.width,
-            "Y_WIDTH":  res_shape.width,
-        }, src=_src(value.src_loc))
-        if value.operator in ("//", "%"):
-            # RTLIL leaves division by zero undefined, but we require it to return zero.
-            divmod_res = res
-            res = self.s.rtlil.wire(width=res_shape.width, src=_src(value.src_loc))
-            self.s.rtlil.cell("$mux", ports={
-                "\\A": divmod_res,
-                "\\B": self(_ast.Const(0, res_shape)),
-                "\\S": self(rhs == 0),
-                "\\Y": res,
+            assert cell.operator == "m"
+            condition, if_true, if_false = cell.inputs
+            self.builder.cell("$mux", ports={
+                "S": self.sigspec(condition),
+                "A": self.sigspec(if_false),
+                "B": self.sigspec(if_true),
+                "Y": self.cell_wires[cell_idx]
             }, params={
-                "WIDTH": res_shape.width
-            }, src=_src(value.src_loc))
-        return res
-
-    def on_Operator_mux(self, value):
-        sel, val1, val0 = value.operands
-        if len(sel) != 1:
-            sel = sel.bool()
-        res_shape = value.shape()
-        val1_wire = self.match_shape(val1, res_shape)
-        val0_wire = self.match_shape(val0, res_shape)
-        res = self.s.rtlil.wire(width=res_shape.width, src=_src(value.src_loc))
-        self.s.rtlil.cell("$mux", ports={
-            "\\A": val0_wire,
-            "\\B": val1_wire,
-            "\\S": self(sel),
-            "\\Y": res,
-        }, params={
-            "WIDTH": res_shape.width
-        }, src=_src(value.src_loc))
-        return res
-
-    def on_Operator(self, value):
-        if len(value.operands) == 1:
-            return self.on_Operator_unary(value)
-        elif len(value.operands) == 2:
-            return self.on_Operator_binary(value)
-        elif len(value.operands) == 3:
-            assert value.operator == "m"
-            return self.on_Operator_mux(value)
-        else:
-            raise TypeError # :nocov:
+                "WIDTH": cell.width,
+            }, src=_src(cell.src_loc))
 
-    def _prepare_value_for_Slice(self, value):
-        if isinstance(value, (_ast.Signal, _ast.Slice, _ast.Cat)):
-            sigspec = self(value)
+    def emit_part(self, cell_idx, cell):
+        if cell.stride == 1:
+            offset = self.sigspec(cell.offset)
+            offset_width = len(cell.offset)
         else:
-            sigspec = self.s.rtlil.wire(len(value), src=_src(value.src_loc))
-            self.s.rtlil.connect(sigspec, self(value))
-        return sigspec
-
-    def on_Part(self, value):
-        lhs, rhs = value.value, value.offset
-        if value.stride != 1:
-            rhs *= value.stride
-        lhs_shape, rhs_shape, res_shape = lhs.shape(), rhs.shape(), value.shape()
-        res = self.s.rtlil.wire(width=res_shape.width, src=_src(value.src_loc))
-        # Note: Verilog's x[o+:w] construct produces a $shiftx cell, not a $shift cell.
-        # However, Amaranth's semantics defines the out-of-range bits to be zero, so it is correct
-        # to use a $shift cell here instead, even though it produces less idiomatic Verilog.
-        self.s.rtlil.cell("$shift", ports={
-            "\\A": self(lhs),
-            "\\B": self(rhs),
-            "\\Y": res,
+            stride = _ast.Const(cell.stride)
+            offset_width = len(cell.offset) + stride.width
+            offset = self.builder.wire(offset_width)
+            self.builder.cell("$mul", ports={
+                "A": self.sigspec(cell.offset),
+                "B": _const(stride),
+                "Y": offset,
+            }, params={
+                "A_SIGNED": False,
+                "B_SIGNED": False,
+                "A_WIDTH": len(cell.offset),
+                "B_WIDTH": stride.width,
+                "Y_WIDTH": offset_width,
+            }, src=_src(cell.src_loc))
+        self.builder.cell("$shift", ports={
+            "A": self.sigspec(cell.value),
+            "B": offset,
+            "Y": self.cell_wires[cell_idx],
         }, params={
-            "A_SIGNED": lhs_shape.signed,
-            "A_WIDTH":  lhs_shape.width,
-            "B_SIGNED": rhs_shape.signed,
-            "B_WIDTH":  rhs_shape.width,
-            "Y_WIDTH":  res_shape.width,
-        }, src=_src(value.src_loc))
-        return res
-
-
-class _LHSValueCompiler(_ValueCompiler):
-    def on_Const(self, value):
-        raise TypeError # :nocov:
-
-    def on_AnyValue(self, value):
-        raise TypeError # :nocov:
-
-    def on_Initial(self, value):
-        raise TypeError # :nocov:
-
-    def on_Operator(self, value):
-        if value.operator in ("u", "s"):
-            # These operators are transparent on the LHS.
-            arg, = value.operands
-            return self(arg)
-
-        raise TypeError # :nocov:
-
-    def match_shape(self, value, new_shape):
-        value_shape = value.shape()
-        if new_shape.width == value_shape.width:
-            return self(value)
-        elif new_shape.width < value_shape.width:
-            return self(_ast.Slice(value, 0, new_shape.width))
-        else: # new_shape.width > value_shape.width
-            dummy_bits = new_shape.width - value_shape.width
-            dummy_wire = self.s.rtlil.wire(dummy_bits)
-            return f"{{ {dummy_wire} {self(value)} }}"
-
-    def on_Signal(self, value):
-        if value not in self.s.driven:
-            raise ValueError(f"No LHS wire for non-driven signal {value!r}")
-        wire_curr, wire_next = self.s.resolve(value)
-        return wire_next or wire_curr
-
-    def _prepare_value_for_Slice(self, value):
-        assert isinstance(value, (_ast.Signal, _ast.Slice, _ast.Cat, _ast.Part))
-        return self(value)
-
-    def on_Part(self, value):
-        offset = self.s.expand(value.offset)
-        if isinstance(offset, _ast.Const):
-            start = offset.value * value.stride
-            stop  = start + value.width
-            slice = self(_ast.Slice(value.value, start, min(len(value.value), stop)))
-            if len(value.value) >= stop:
-                return slice
-            else:
-                dummy_wire = self.s.rtlil.wire(stop - len(value.value))
-                return f"{{ {dummy_wire} {slice} }}"
-        else:
-            # Only so many possible parts. The amount of branches is exponential; if value.offset
-            # is large (e.g. 32-bit wide), trying to naively legalize it is likely to exhaust
-            # system resources.
-            max_branches = len(value.value) // value.stride + 1
-            raise _LegalizeValue(value.offset,
-                                 range(1 << len(value.offset))[:max_branches],
-                                 value.src_loc)
-
-
-class _StatementCompiler(_xfrm.StatementVisitor):
-    def __init__(self, state, rhs_compiler, lhs_compiler):
-        self.state        = state
-        self.rhs_compiler = rhs_compiler
-        self.lhs_compiler = lhs_compiler
-
-        self._domain      = None
-        self._case        = None
-        self._test_cache  = {}
-        self._has_rhs     = False
-        self._wrap_assign = False
-
-    @contextmanager
-    def case(self, switch, values, attrs={}, src=""):
-        try:
-            old_case = self._case
-            with switch.case(*values, attrs=attrs, src=src) as self._case:
-                yield
-        finally:
-            self._case = old_case
-
-    def _check_rhs(self, value):
-        if self._has_rhs or next(iter(value._rhs_signals()), None) is not None:
-            self._has_rhs = True
-
-    def on_Assign(self, stmt):
-        self._check_rhs(stmt.rhs)
-
-        lhs_shape, rhs_shape = stmt.lhs.shape(), stmt.rhs.shape()
-        if lhs_shape.width == rhs_shape.width:
-            rhs_sigspec = self.rhs_compiler(stmt.rhs)
-        else:
-            # In RTLIL, LHS and RHS of assignment must have exactly same width.
-            rhs_sigspec = self.rhs_compiler.match_shape(stmt.rhs, lhs_shape)
-        if self._wrap_assign:
-            # In RTLIL, all assigns are logically sequenced before all switches, even if they are
-            # interleaved in the source. In Amaranth, the source ordering is used. To handle this
-            # mismatch, we wrap all assigns following a switch in a dummy switch.
-            with self._case.switch("{ }") as wrap_switch:
-                with wrap_switch.case() as wrap_case:
-                    wrap_case.assign(self.lhs_compiler(stmt.lhs), rhs_sigspec)
-        else:
-            self._case.assign(self.lhs_compiler(stmt.lhs), rhs_sigspec)
-
-    def on_Property(self, stmt):
-        self(stmt._check.eq(stmt.test))
-        self(stmt._en.eq(1))
-
-        en_wire = self.rhs_compiler(stmt._en)
-        check_wire = self.rhs_compiler(stmt._check)
-        self.state.rtlil.cell("$" + stmt.kind.value, ports={
-            "\\A": check_wire,
-            "\\EN": en_wire,
-        }, src=_src(stmt.src_loc), name=stmt.name)
-
-    def on_Switch(self, stmt):
-        self._check_rhs(stmt.test)
-
-        if not self.state.expansions:
-            # We repeatedly translate the same switches over and over (see the LHSGroupAnalyzer
-            # related code below), and translating the switch test only once helps readability.
-            if stmt not in self._test_cache:
-                self._test_cache[stmt] = self.rhs_compiler(stmt.test)
-            test_sigspec = self._test_cache[stmt]
-        else:
-            # However, if the switch test contains an illegal value, then it may not be cached
-            # (since the illegal value will be repeatedly replaced with different constants), so
-            # don't cache anything in that case.
-            test_sigspec = self.rhs_compiler(stmt.test)
-
-        with self._case.switch(test_sigspec, src=_src(stmt.src_loc)) as switch:
-            for values, stmts in stmt.cases.items():
-                case_attrs = {}
-                case_src = None
-                if values in stmt.case_src_locs:
-                    case_src = _src(stmt.case_src_locs[values])
-                if isinstance(stmt.test, _ast.Signal) and stmt.test.decoder:
-                    decoded_values = []
-                    for value in values:
-                        if "-" in value:
-                            decoded_values.append("<multiple>")
+            "A_SIGNED": cell.value_signed,
+            "B_SIGNED": False,
+            "A_WIDTH": len(cell.value),
+            "B_WIDTH": offset_width,
+            "Y_WIDTH": cell.width,
+        }, src=_src(cell.src_loc))
+
+    def emit_array_mux(self, cell_idx, cell):
+        wire = self.cell_wires[cell_idx]
+        with self.builder.process(src=_src(cell.src_loc)) as proc:
+            with proc.case() as root_case:
+                with root_case.switch(self.sigspec(cell.index)) as switch:
+                    for index, elem in enumerate(cell.elems):
+                        if len(cell.index) > 0:
+                            pattern = "{:0{}b}".format(index, len(cell.index))
                         else:
-                            decoded_values.append(stmt.test.decoder(int(value, 2)))
-                    case_attrs["amaranth.decoding"] = "|".join(decoded_values)
-                with self.case(switch, values, attrs=case_attrs, src=case_src):
-                    self._wrap_assign = False
-                    self.on_statements(stmts)
-        self._wrap_assign = True
-
-    def on_statement(self, stmt):
-        try:
-            super().on_statement(stmt)
-        except _LegalizeValue as legalize:
-            with self._case.switch(self.rhs_compiler(legalize.value),
-                                   src=_src(legalize.src_loc)) as switch:
-                shape = legalize.value.shape()
-                tests = ["{:0{}b}".format(v, shape.width) for v in legalize.branches]
-                if tests:
-                    tests[-1] = "-" * shape.width
-                for branch, test in zip(legalize.branches, tests):
-                    with self.case(switch, (test,)):
-                        self._wrap_assign = False
-                        branch_value = _ast.Const(branch, shape)
-                        with self.state.expand_to(legalize.value, branch_value):
-                            self.on_statement(stmt)
-            self._wrap_assign = True
-
-    def on_statements(self, stmts):
-        for stmt in stmts:
-            self.on_statement(stmt)
-
-
-def _convert_fragment(builder, fragment, name_map, hierarchy):
-    if isinstance(fragment, _ir.Instance):
-        port_map = OrderedDict()
-        for port_name, (value, dir) in fragment.named_ports.items():
-            port_map[f"\\{port_name}"] = value
-
-        params = OrderedDict(fragment.parameters)
-
-        if fragment.type[0] == "$":
-            return fragment.type, port_map, params
+                            pattern = ""
+                        with switch.case(pattern) as case:
+                            case.assign(wire, self.sigspec(elem))
+                    with switch.case() as case:
+                        case.assign(wire, self.sigspec(cell.elems[0]))
+
+    def emit_flip_flop(self, cell_idx, cell):
+        ports = {
+            "D": self.sigspec(cell.data),
+            "CLK": self.sigspec(cell.clk),
+            "Q": self.cell_wires[cell_idx]
+        }
+        params = {
+            "WIDTH": len(cell.data),
+            "CLK_POLARITY": {
+                "pos": True,
+                "neg": False,
+            }[cell.clk_edge]
+        }
+        if cell.arst == _nir.Net.from_const(0):
+            cell_type = "$dff"
         else:
-            return f"\\{fragment.type}", port_map, params
-
-    if isinstance(fragment, _mem.MemoryInstance):
-        init = "".join(format(_ast.Const(elem, _ast.unsigned(fragment._width)).value, f"0{fragment._width}b") for elem in reversed(fragment._init))
-        init = _ast.Const(int(init or "0", 2), fragment._depth * fragment._width)
-        rd_clk = []
-        rd_clk_enable = 0
-        rd_clk_polarity = 0
-        rd_transparency_mask = 0
-        for index, port in enumerate(fragment._read_ports):
-            if port._domain != "comb":
-                cd = fragment.domains[port._domain]
-                rd_clk.append(cd.clk)
-                if cd.clk_edge == "pos":
-                    rd_clk_polarity |= 1 << index
-                rd_clk_enable |= 1 << index
-                for write_index in port._transparency:
-                    rd_transparency_mask |= 1 << (index * len(fragment._write_ports) + write_index)
-            else:
-                rd_clk.append(_ast.Const(0, 1))
-        wr_clk = []
-        wr_clk_enable = 0
-        wr_clk_polarity = 0
-        for index, port in enumerate(fragment._write_ports):
-            cd = fragment.domains[port._domain]
-            wr_clk.append(cd.clk)
-            wr_clk_enable |= 1 << index
-            if cd.clk_edge == "pos":
-                wr_clk_polarity |= 1 << index
+            cell_type = "$adff"
+            ports["ARST"] = self.sigspec(cell.arst)
+            params["ARST_POLARITY"] = True
+            params["ARST_VALUE"] = _ast.Const(cell.init, len(cell.data))
+        self.builder.cell(cell_type, ports=ports, params=params, src=_src(cell.src_loc))
+
+    def emit_io_buffer(self, cell_idx, cell):
+        self.builder.cell("$tribuf", ports={
+            "Y": self.sigspec(cell.pad),
+            "A": self.sigspec(cell.o),
+            "EN": self.sigspec(cell.oe),
+        }, params={
+            "WIDTH": len(cell.pad),
+        }, src=_src(cell.src_loc))
+        self.builder.connect(self.cell_wires[cell_idx], self.sigspec(cell.pad))
+
+    def emit_memory(self, cell_idx, cell):
+        memory_info = self.memories[cell_idx]
+        self.builder.cell("$meminit_v2", ports={
+            "ADDR": self.sigspec(),
+            "DATA": self.sigspec(
+                _nir.Net.from_const((row >> bit) & 1)
+                for row in cell.init
+                for bit in range(cell.width)
+            ),
+            "EN": self.sigspec(_nir.Value.ones(cell.width)),
+        }, params={
+            "MEMID": memory_info.memid,
+            "ABITS": 0,
+            "WIDTH": cell.width,
+            "WORDS": cell.depth,
+            "PRIORITY": 0,
+        }, src=_src(cell.src_loc))
+
+    def emit_write_port(self, cell_idx, cell):
+        memory_info = self.memories[cell.memory]
+        ports = {
+            "ADDR": self.sigspec(cell.addr),
+            "DATA": self.sigspec(cell.data),
+            "EN": self.sigspec(cell.en),
+            "CLK": self.sigspec(cell.clk),
+        }
         params = {
-            "MEMID": builder._make_name(hierarchy[-1], local=False),
-            "SIZE": fragment._depth,
-            "OFFSET": 0,
-            "ABITS": _ast.Shape.cast(range(fragment._depth)).width,
-            "WIDTH": fragment._width,
-            "INIT": init,
-            "RD_PORTS": len(fragment._read_ports),
-            "RD_CLK_ENABLE": _ast.Const(rd_clk_enable, max(1, len(fragment._read_ports))),
-            "RD_CLK_POLARITY": _ast.Const(rd_clk_polarity, max(1, len(fragment._read_ports))),
-            "RD_TRANSPARENCY_MASK": _ast.Const(rd_transparency_mask, max(1, len(fragment._read_ports) * len(fragment._write_ports))),
-            "RD_COLLISION_X_MASK": _ast.Const(0, max(1, len(fragment._read_ports) * len(fragment._write_ports))),
-            "RD_WIDE_CONTINUATION": _ast.Const(0, max(1, len(fragment._read_ports))),
-            "RD_CE_OVER_SRST": _ast.Const(0, max(1, len(fragment._read_ports))),
-            "RD_ARST_VALUE": _ast.Const(0, len(fragment._read_ports) * fragment._width),
-            "RD_SRST_VALUE": _ast.Const(0, len(fragment._read_ports) * fragment._width),
-            "RD_INIT_VALUE": _ast.Const(0, len(fragment._read_ports) * fragment._width),
-            "WR_PORTS": len(fragment._write_ports),
-            "WR_CLK_ENABLE": _ast.Const(wr_clk_enable, max(1, len(fragment._write_ports))),
-            "WR_CLK_POLARITY": _ast.Const(wr_clk_polarity, max(1, len(fragment._write_ports))),
-            "WR_PRIORITY_MASK": _ast.Const(0, max(1, len(fragment._write_ports) * len(fragment._write_ports))),
-            "WR_WIDE_CONTINUATION": _ast.Const(0, max(1, len(fragment._write_ports))),
+            "MEMID": memory_info.memid,
+            "ABITS": len(cell.addr),
+            "WIDTH": len(cell.data),
+            "CLK_ENABLE": True,
+            "CLK_POLARITY": {
+                "pos": True,
+                "neg": False,
+            }[cell.clk_edge],
+            "PORTID": memory_info.write_port_ids[cell_idx],
+            "PRIORITY_MASK": 0,
         }
-        def make_en(port):
-            if len(port._data) == 0:
-                return _ast.Const(0, 0)
-            granularity = len(port._data) // len(port._en)
-            return _ast.Cat(en_bit.replicate(granularity) for en_bit in port._en)
-        port_map = {
-            "\\RD_CLK": _ast.Cat(rd_clk),
-            "\\RD_EN": _ast.Cat(port._en for port in fragment._read_ports),
-            "\\RD_ARST": _ast.Const(0, len(fragment._read_ports)),
-            "\\RD_SRST": _ast.Const(0, len(fragment._read_ports)),
-            "\\RD_ADDR": _ast.Cat(port._addr for port in fragment._read_ports),
-            "\\RD_DATA": _ast.Cat(port._data for port in fragment._read_ports),
-            "\\WR_CLK": _ast.Cat(wr_clk),
-            "\\WR_EN": _ast.Cat(make_en(port) for port in fragment._write_ports),
-            "\\WR_ADDR": _ast.Cat(port._addr for port in fragment._write_ports),
-            "\\WR_DATA": _ast.Cat(port._data for port in fragment._write_ports),
+        self.builder.cell(f"$memwr_v2", ports=ports, params=params, src=_src(cell.src_loc))
+
+    def emit_read_port(self, cell_idx, cell):
+        memory_info = self.memories[cell.memory]
+        ports = {
+            "ADDR": self.sigspec(cell.addr),
+            "DATA": self.cell_wires[cell_idx],
+            "ARST": self.sigspec(_nir.Net.from_const(0)),
+            "SRST": self.sigspec(_nir.Net.from_const(0)),
         }
-        return "$mem_v2", port_map, params
-
-    module_name  = ".".join(name or "anonymous" for name in hierarchy)
-    module_attrs = OrderedDict()
-    if len(hierarchy) == 1:
-        module_attrs["top"] = 1
-
-    with builder.module(module_name, attrs=module_attrs) as module:
-        compiler_state = _ValueCompilerState(module)
-        rhs_compiler   = _RHSValueCompiler(compiler_state)
-        lhs_compiler   = _LHSValueCompiler(compiler_state)
-        stmt_compiler  = _StatementCompiler(compiler_state, rhs_compiler, lhs_compiler)
-
-        # Register all signals driven in the current fragment. This must be done first, as it
-        # affects further codegen; e.g. whether \sig$next signals will be generated and used.
-        for domain, statements in fragment.statements.items():
-            for signal in statements._lhs_signals():
-                compiler_state.add_driven(signal, sync=domain != "comb")
-
-        # Transform all signals used as ports in the current fragment eagerly and outside of
-        # any hierarchy, to make sure they get sensible (non-prefixed) names.
-        for signal in fragment.ports:
-            compiler_state.add_port(signal, fragment.ports[signal])
-            compiler_state.resolve_curr(signal)
-
-        # Transform all clocks clocks and resets eagerly and outside of any hierarchy, to make
-        # sure they get sensible (non-prefixed) names. This does not affect semantics.
-        for domain, _ in fragment.iter_sync():
-            cd = fragment.domains[domain]
-            compiler_state.resolve_curr(cd.clk)
-            if cd.rst is not None:
-                compiler_state.resolve_curr(cd.rst)
-
-        # Transform all subfragments to their respective cells. Transforming signals connected
-        # to their ports into wires eagerly makes sure they get sensible (prefixed with submodule
-        # name) names.
-        for subfragment, sub_name in fragment.subfragments:
-            if not (subfragment.ports or subfragment.statements or subfragment.subfragments or
-                    isinstance(subfragment, (_ir.Instance, _mem.MemoryInstance))):
-                # If the fragment is completely empty, skip translating it, otherwise synthesis
-                # tools (including Yosys and Vivado) will treat it as a black box when it is
-                # loaded after conversion to Verilog.
-                continue
-
-            if sub_name is None:
-                sub_name = module.anonymous()
-
-            sub_type, sub_port_map, sub_params = \
-                _convert_fragment(builder, subfragment, name_map,
-                                  hierarchy=hierarchy + (sub_name,))
-
-            sub_ports = OrderedDict()
-            for port, value in sub_port_map.items():
-                if not isinstance(subfragment, (_ir.Instance, _mem.MemoryInstance)):
-                    for signal in value._rhs_signals():
-                        compiler_state.resolve_curr(signal, prefix=sub_name)
-                if len(value) > 0 or sub_type == "$mem_v2":
-                    sub_ports[port] = rhs_compiler(value)
-
-            if isinstance(subfragment, _ir.Instance):
-                src = _src(subfragment.src_loc)
-            elif isinstance(subfragment, _mem.MemoryInstance):
-                src = _src(subfragment._src_loc)
-            else:
-                src = ""
-
-            module.cell(sub_type, name=sub_name, ports=sub_ports, params=sub_params,
-                        attrs=subfragment.attrs, src=src)
-
-        # If we emit all of our combinatorial logic into a single RTLIL process, Verilog
-        # simulators will break horribly, because Yosys write_verilog transforms RTLIL processes
-        # into always @* blocks with blocking assignment, and that does not create delta cycles.
-        #
-        # Therefore, we translate the fragment as many times as there are independent groups
-        # of signals (a group is a transitive closure of signals that appear together on LHS),
-        # splitting them into many RTLIL (and thus Verilog) processes.
-        for domain, statements in fragment.statements.items():
-            lhs_grouper = _xfrm.LHSGroupAnalyzer()
-            lhs_grouper.on_statements(statements)
-
-            for group, group_signals in lhs_grouper.groups().items():
-                lhs_group_filter = _xfrm.LHSGroupFilter(group_signals)
-                group_stmts = lhs_group_filter(statements)
-
-                with module.process(name=f"$group_{group}") as process:
-                    with process.case() as case:
-                        # For every signal in comb domain, assign \sig$next to the reset value.
-                        # For every signal in sync domains, assign \sig$next to the current
-                        # value (\sig).
-                        for signal in group_signals:
-                            if domain == "comb":
-                                prev_value = _ast.Const(signal.reset, signal.width)
-                            else:
-                                prev_value = signal
-                            case.assign(lhs_compiler(signal), rhs_compiler(prev_value))
-
-                        # Convert statements into decision trees.
-                        stmt_compiler._domain = domain
-                        stmt_compiler._case = case
-                        stmt_compiler._has_rhs = False
-                        stmt_compiler._wrap_assign = False
-                        stmt_compiler(group_stmts)
-
-        # For every driven signal in the sync domain, create a flop of appropriate type. Which type
-        # is appropriate depends on the domain: for domains with sync reset, it is a $dff, for
-        # domains with async reset it is an $adff. The latter is directly provided with the reset
-        # value as a parameter to the cell, which is directly assigned during reset.
-        for domain, signal in fragment.iter_sync():
-            cd = fragment.domains[domain]
-
-            wire_clk = compiler_state.resolve_curr(cd.clk)
-            wire_rst = compiler_state.resolve_curr(cd.rst) if cd.rst is not None else None
-            wire_curr, wire_next = compiler_state.resolve(signal)
-
-            if not cd.async_reset:
-                # For sync reset flops, the reset value comes from logic inserted by
-                # `hdl.xfrm.DomainLowerer`.
-                module.cell("$dff", ports={
-                    "\\CLK": wire_clk,
-                    "\\D": wire_next,
-                    "\\Q": wire_curr
-                }, params={
-                    "CLK_POLARITY": int(cd.clk_edge == "pos"),
-                    "WIDTH": signal.width
-                })
+        if isinstance(cell, _nir.AsyncReadPort):
+            transparency_mask = 0
+        if isinstance(cell, _nir.SyncReadPort):
+            transparency_mask = sum(
+                1 << memory_info.write_port_ids[write_port_cell_index]
+                for write_port_cell_index in cell.transparent_for
+            )
+        params = {
+            "MEMID": memory_info.memid,
+            "ABITS": len(cell.addr),
+            "WIDTH": cell.width,
+            "TRANSPARENCY_MASK": _ast.Const(transparency_mask, memory_info.num_write_ports),
+            "COLLISION_X_MASK": _ast.Const(0, memory_info.num_write_ports),
+            "ARST_VALUE": _ast.Const(0, cell.width),
+            "SRST_VALUE": _ast.Const(0, cell.width),
+            "INIT_VALUE": _ast.Const(0, cell.width),
+            "CE_OVER_SRST": False,
+        }
+        if isinstance(cell, _nir.AsyncReadPort):
+            ports.update({
+                "EN": self.sigspec(_nir.Net.from_const(1)),
+                "CLK": self.sigspec(_nir.Net.from_const(0)),
+            })
+            params.update({
+                "CLK_ENABLE": False,
+                "CLK_POLARITY": True,
+            })
+        if isinstance(cell, _nir.SyncReadPort):
+            ports.update({
+                "EN": self.sigspec(cell.en),
+                "CLK": self.sigspec(cell.clk),
+            })
+            params.update({
+                "CLK_ENABLE": True,
+                "CLK_POLARITY": {
+                    "pos": True,
+                    "neg": False,
+                }[cell.clk_edge],
+            })
+        self.builder.cell(f"$memrd_v2", ports=ports, params=params, src=_src(cell.src_loc))
+
+    def emit_property(self, cell_idx, cell):
+        if isinstance(cell, _nir.AsyncProperty):
+            ports = {
+                "A": self.sigspec(cell.test),
+                "EN": self.sigspec(cell.en),
+            }
+        if isinstance(cell, _nir.SyncProperty):
+            test = self.builder.wire(1, attrs={"init": _ast.Const(0, 1)})
+            en = self.builder.wire(1, attrs={"init": _ast.Const(0, 1)})
+            for (d, q) in [
+                (cell.test, test),
+                (cell.en, en),
+            ]:
+                ports = {
+                    "D": self.sigspec(d),
+                    "Q": q,
+                    "CLK": self.sigspec(cell.clk),
+                }
+                params = {
+                    "WIDTH": 1,
+                    "CLK_POLARITY": {
+                        "pos": True,
+                        "neg": False,
+                    }[cell.clk_edge],
+                }
+                self.builder.cell(f"$dff", ports=ports, params=params, src=_src(cell.src_loc))
+            ports = {
+                "A": test,
+                "EN": en,
+            }
+        self.builder.cell(f"${cell.kind}", name=cell.name, ports=ports, src=_src(cell.src_loc))
+
+    def emit_any_value(self, cell_idx, cell):
+        self.builder.cell(f"${cell.kind}", ports={
+            "Y": self.cell_wires[cell_idx],
+        }, params={
+            "WIDTH": cell.width,
+        }, src=_src(cell.src_loc))
+
+    def emit_initial(self, cell_idx, cell):
+        self.builder.cell("$initstate", ports={
+            "Y": self.cell_wires[cell_idx],
+        }, src=_src(cell.src_loc))
+
+    def emit_instance(self, cell_idx, cell):
+        ports = {}
+        for name, nets in cell.ports_i.items():
+            ports[name] = self.sigspec(nets)
+        for name in cell.ports_o:
+            ports[name] = self.instance_wires[cell_idx, name]
+        for name, nets in cell.ports_io.items():
+            ports[name] = self.sigspec(nets)
+        if cell.type.startswith("$"):
+            type = cell.type
+        else:
+            type = "\\" + cell.type
+        self.builder.cell(type, cell.name, ports=ports, params=cell.parameters,
+                         attrs=cell.attributes, src=_src(cell.src_loc))
+
+    def emit_cells(self):
+        for cell_idx in self.module.cells:
+            cell = self.netlist.cells[cell_idx]
+            if isinstance(cell, _nir.Top):
+                pass
+            elif isinstance(cell, _nir.Matches):
+                pass # Matches is only referenced from PriorityMatch cells and inlined there
+            elif isinstance(cell, _nir.PriorityMatch):
+                pass # PriorityMatch is only referenced from AssignmentList cells and inlined there
+            elif isinstance(cell, _nir.AssignmentList):
+                self.emit_assignment_list(cell_idx, cell)
+            elif isinstance(cell, _nir.Operator):
+                self.emit_operator(cell_idx, cell)
+            elif isinstance(cell, _nir.Part):
+                self.emit_part(cell_idx, cell)
+            elif isinstance(cell, _nir.ArrayMux):
+                self.emit_array_mux(cell_idx, cell)
+            elif isinstance(cell, _nir.FlipFlop):
+                self.emit_flip_flop(cell_idx, cell)
+            elif isinstance(cell, _nir.IOBuffer):
+                self.emit_io_buffer(cell_idx, cell)
+            elif isinstance(cell, _nir.Memory):
+                self.emit_memory(cell_idx, cell)
+            elif isinstance(cell, _nir.SyncWritePort):
+                self.emit_write_port(cell_idx, cell)
+            elif isinstance(cell, (_nir.AsyncReadPort, _nir.SyncReadPort)):
+                self.emit_read_port(cell_idx, cell)
+            elif isinstance(cell, (_nir.AsyncProperty, _nir.SyncProperty)):
+                self.emit_property(cell_idx, cell)
+            elif isinstance(cell, _nir.AnyValue):
+                self.emit_any_value(cell_idx, cell)
+            elif isinstance(cell, _nir.Initial):
+                self.emit_initial(cell_idx, cell)
+            elif isinstance(cell, _nir.Instance):
+                self.emit_instance(cell_idx, cell)
             else:
-                # For async reset flops, the reset value is provided directly to the cell.
-                module.cell("$adff", ports={
-                    "\\ARST": wire_rst,
-                    "\\CLK": wire_clk,
-                    "\\D": wire_next,
-                    "\\Q": wire_curr
-                }, params={
-                    "ARST_POLARITY": _ast.Const(1),
-                    "ARST_VALUE": _ast.Const(signal.reset, signal.width),
-                    "CLK_POLARITY": int(cd.clk_edge == "pos"),
-                    "WIDTH": signal.width
-                })
+                assert False # :nocov:
 
-        # Any signals that are used but neither driven nor connected to an input port always
-        # assume their reset values. We need to assign the reset value explicitly, since only
-        # driven sync signals are handled by the logic above.
-        #
-        # Because this assignment is done at a late stage, a single Signal object can get assigned
-        # many times, once in each module it is used. This is a deliberate decision; the possible
-        # alternatives are to add ports for undriven signals (which requires choosing one module
-        # to drive it to reset value arbitrarily) or to replace them with their reset value (which
-        # removes valuable source location information).
-        driven = _ast.SignalSet()
-        for domain, statements in fragment.statements.items():
-            driven.update(statements._lhs_signals())
-        driven.update(fragment.iter_ports(dir="i"))
-        driven.update(fragment.iter_ports(dir="io"))
-        for subfragment, sub_name in fragment.subfragments:
-            driven.update(subfragment.iter_ports(dir="o"))
-            driven.update(subfragment.iter_ports(dir="io"))
-
-        for wire in compiler_state.wires:
-            if wire in driven:
-                continue
-            wire_curr, _ = compiler_state.wires[wire]
-            module.connect(wire_curr, rhs_compiler(_ast.Const(wire.reset, wire.width)))
 
-    # Collect the names we've given to our ports in RTLIL, and correlate these with the signals
-    # represented by these ports. If we are a submodule, this will be necessary to create a cell
-    # for us in the parent module.
-    port_map = OrderedDict()
-    for signal in fragment.ports:
-        port_map[compiler_state.resolve_curr(signal)] = signal
+# Empty modules are interpreted by some toolchains (Yosys, Xilinx, ...) as black boxes, and
+# must not be emitted.
+class EmptyModuleChecker:
+    def __init__(self, netlist):
+        self.netlist = netlist
+        self.empty = set()
+        self.check(0)
 
-    # Finally, collect the names we've given to each wire in RTLIL, and provide these to
-    # the caller, to allow manipulating them in the toolchain.
-    for signal in compiler_state.wires:
-        wire_name = compiler_state.resolve_curr(signal)
-        if wire_name.startswith("\\"):
-            wire_name = wire_name[1:]
-        name_map[signal] = hierarchy + (wire_name,)
+    def check(self, module_idx):
+        is_empty = not self.netlist.modules[module_idx].cells
+        for submodule in self.netlist.modules[module_idx].submodules:
+            is_empty &= self.check(submodule)
+        if is_empty:
+            self.empty.add(module_idx)
+        return is_empty
 
-    return module.name, port_map, {}
+    def is_empty(self, module_idx):
+        return module_idx in self.empty
 
 
 def convert_fragment(fragment, name="top", *, emit_src=True):
     assert isinstance(fragment, _ir.Fragment)
-    builder = _Builder(emit_src=emit_src)
     name_map = _ast.SignalDict()
-    _convert_fragment(builder, fragment, name_map, hierarchy=(name,))
+    netlist = _ir.build_netlist(fragment, name=name)
+    empty_checker = EmptyModuleChecker(netlist)
+    builder = _Builder(emit_src=emit_src)
+    for module_idx, module in enumerate(netlist.modules):
+        if empty_checker.is_empty(module_idx):
+            continue
+        attrs = {"top": 1} if module_idx == 0 else {}
+        with builder.module(".".join(module.name), attrs=attrs) as module_builder:
+            ModuleEmitter(module_builder, netlist, module, name_map,
+                          empty_checker=empty_checker).emit()
     return str(builder), name_map
 
 
@@ -1047,7 +1002,7 @@ def convert(elaboratable, name="top", platform=None, *, ports=None, emit_src=Tru
             hasattr(elaboratable, "signature") and
             isinstance(elaboratable.signature, wiring.Signature)):
         ports = []
-        for path, member, value in elaboratable.signature.flatten(elaboratable):
+        for _path, _member, value in elaboratable.signature.flatten(elaboratable):
             if isinstance(value, _ast.ValueCastable):
                 value = value.as_value()
             if isinstance(value, _ast.Value):
@@ -1055,5 +1010,5 @@ def convert(elaboratable, name="top", platform=None, *, ports=None, emit_src=Tru
     elif ports is None:
         raise TypeError("The `convert()` function requires a `ports=` argument")
     fragment = _ir.Fragment.get(elaboratable, platform).prepare(ports=ports, **kwargs)
-    il_text, name_map = convert_fragment(fragment, name, emit_src=emit_src)
+    il_text, _name_map = convert_fragment(fragment, name, emit_src=emit_src)
     return il_text
diff --git a/amaranth/build/plat.py b/amaranth/build/plat.py
index 293a6f1..03a6360 100644
--- a/amaranth/build/plat.py
+++ b/amaranth/build/plat.py
@@ -89,7 +89,7 @@ class Platform(ResourceManager, metaclass=ABCMeta):
         #     may fail.
         # This is OK because even if `require_tool` succeeds, the toolchain might be broken anyway.
         # The check only serves to catch common errors earlier.
-        if do_build and not self._toolchain_env_var not in os.environ:
+        if do_build and self._toolchain_env_var not in os.environ:
             for tool in self.required_tools:
                 require_tool(tool)
 
diff --git a/amaranth/hdl/_ast.py b/amaranth/hdl/_ast.py
index 25c557b..4340df9 100644
--- a/amaranth/hdl/_ast.py
+++ b/amaranth/hdl/_ast.py
@@ -23,7 +23,7 @@ __all__ = [
     "Initial",
     "Statement", "Switch",
     "Property", "Assign", "Assert", "Assume", "Cover",
-    "ValueKey", "ValueDict", "ValueSet", "SignalKey", "SignalDict", "SignalSet",
+    "SignalKey", "SignalDict", "SignalSet",
 ]
 
 
@@ -1770,25 +1770,17 @@ class Property(Statement, MustUse):
         Assume = "assume"
         Cover  = "cover"
 
-    def __init__(self, kind, test, *, _check=None, _en=None, name=None, src_loc_at=0):
+    def __init__(self, kind, test, *, name=None, src_loc_at=0):
         super().__init__(src_loc_at=src_loc_at)
         self.kind   = self.Kind(kind)
         self.test   = Value.cast(test)
-        self._check = _check
-        self._en    = _en
         self.name   = name
         if not isinstance(self.name, str) and self.name is not None:
             raise TypeError("Property name must be a string or None, not {!r}"
                             .format(self.name))
-        if self._check is None:
-            self._check = Signal(reset_less=True, name=f"${self.kind.value}$check")
-            self._check.src_loc = self.src_loc
-        if _en is None:
-            self._en = Signal(reset_less=True, name=f"${self.kind.value}$en")
-            self._en.src_loc = self.src_loc
 
     def _lhs_signals(self):
-        return SignalSet((self._en, self._check))
+        return set()
 
     def _rhs_signals(self):
         return self.test._rhs_signals()
@@ -1962,110 +1954,6 @@ class _MappedKeySet(MutableSet, _MappedKeyCollection):
                                   ", ".join(repr(x) for x in self))
 
 
-class ValueKey:
-    def __init__(self, value):
-        self.value = Value.cast(value)
-        if isinstance(self.value, Const):
-            self._hash = hash(self.value.value)
-        elif isinstance(self.value, (Signal, AnyValue)):
-            self._hash = hash(self.value.duid)
-        elif isinstance(self.value, (ClockSignal, ResetSignal)):
-            self._hash = hash(self.value.domain)
-        elif isinstance(self.value, Operator):
-            self._hash = hash((self.value.operator,
-                               tuple(ValueKey(o) for o in self.value.operands)))
-        elif isinstance(self.value, Slice):
-            self._hash = hash((ValueKey(self.value.value), self.value.start, self.value.stop))
-        elif isinstance(self.value, Part):
-            self._hash = hash((ValueKey(self.value.value), ValueKey(self.value.offset),
-                              self.value.width, self.value.stride))
-        elif isinstance(self.value, Cat):
-            self._hash = hash(tuple(ValueKey(o) for o in self.value.parts))
-        elif isinstance(self.value, ArrayProxy):
-            self._hash = hash((ValueKey(self.value.index),
-                              tuple(ValueKey(e) for e in self.value._iter_as_values())))
-        elif isinstance(self.value, Initial):
-            self._hash = 0
-        else: # :nocov:
-            raise TypeError("Object {!r} cannot be used as a key in value collections"
-                            .format(self.value))
-
-    def __hash__(self):
-        return self._hash
-
-    def __eq__(self, other):
-        if type(other) is not ValueKey:
-            return False
-        if type(self.value) is not type(other.value):
-            return False
-
-        if isinstance(self.value, Const):
-            return self.value.value == other.value.value and self.value.width == other.value.width
-        elif isinstance(self.value, (Signal, AnyValue)):
-            return self.value is other.value
-        elif isinstance(self.value, (ClockSignal, ResetSignal)):
-            return self.value.domain == other.value.domain
-        elif isinstance(self.value, Operator):
-            return (self.value.operator == other.value.operator and
-                    len(self.value.operands) == len(other.value.operands) and
-                    all(ValueKey(a) == ValueKey(b)
-                        for a, b in zip(self.value.operands, other.value.operands)))
-        elif isinstance(self.value, Slice):
-            return (ValueKey(self.value.value) == ValueKey(other.value.value) and
-                    self.value.start == other.value.start and
-                    self.value.stop == other.value.stop)
-        elif isinstance(self.value, Part):
-            return (ValueKey(self.value.value) == ValueKey(other.value.value) and
-                    ValueKey(self.value.offset) == ValueKey(other.value.offset) and
-                    self.value.width == other.value.width and
-                    self.value.stride == other.value.stride)
-        elif isinstance(self.value, Cat):
-            return (len(self.value.parts) == len(other.value.parts) and
-                    all(ValueKey(a) == ValueKey(b)
-                        for a, b in zip(self.value.parts, other.value.parts)))
-        elif isinstance(self.value, ArrayProxy):
-            return (ValueKey(self.value.index) == ValueKey(other.value.index) and
-                    len(self.value.elems) == len(other.value.elems) and
-                    all(ValueKey(a) == ValueKey(b)
-                        for a, b in zip(self.value._iter_as_values(),
-                                        other.value._iter_as_values())))
-        elif isinstance(self.value, Initial):
-            return True
-        else: # :nocov:
-            raise TypeError("Object {!r} cannot be used as a key in value collections"
-                            .format(self.value))
-
-    def __lt__(self, other):
-        if not isinstance(other, ValueKey):
-            return False
-        if type(self.value) != type(other.value):
-            return False
-
-        if isinstance(self.value, Const):
-            return self.value < other.value
-        elif isinstance(self.value, (Signal, AnyValue)):
-            return self.value.duid < other.value.duid
-        elif isinstance(self.value, Slice):
-            return (ValueKey(self.value.value) < ValueKey(other.value.value) and
-                    self.value.start < other.value.start and
-                    self.value.end < other.value.end)
-        else: # :nocov:
-            raise TypeError("Object {!r} cannot be used as a key in value collections")
-
-    def __repr__(self):
-        return f"<{__name__}.ValueKey {self.value!r}>"
-
-
-class ValueDict(_MappedKeyDict):
-    _map_key   = ValueKey
-    _unmap_key = lambda self, key: key.value
-
-
-class ValueSet(_MappedKeySet):
-    _map_key   = ValueKey
-    _unmap_key = lambda self, key: key.value
-
-
 class SignalKey:
     def __init__(self, signal):
         self.signal = signal
diff --git a/amaranth/hdl/_ir.py b/amaranth/hdl/_ir.py
index 8fdc919..5138de0 100644
--- a/amaranth/hdl/_ir.py
+++ b/amaranth/hdl/_ir.py
@@ -1,20 +1,17 @@
-from abc import ABCMeta
+from typing import Tuple
 from collections import defaultdict, OrderedDict
 from functools import reduce
 import warnings
 
-from .. import tracer
-from .._utils import *
-from .._unused import *
-from ._ast import *
-from ._ast import _StatementList
-from ._cd import *
+from .._utils import flatten, memoize
+from .. import tracer, _unused
+from . import _ast, _cd, _ir, _nir
 
 
 __all__ = ["UnusedElaboratable", "Elaboratable", "DriverConflict", "Fragment", "Instance"]
 
 
-class UnusedElaboratable(UnusedMustUse):
+class UnusedElaboratable(_unused.UnusedMustUse):
     # The warning is initially silenced. If everything that has been constructed remains unused,
     # it means the application likely crashed (with an exception, or in another way that does not
     # call `sys.excepthook`), and it's not necessary to show any warnings.
@@ -22,7 +19,7 @@ class UnusedElaboratable(UnusedMustUse):
     _MustUse__silence = True
 
 
-class Elaboratable(MustUse):
+class Elaboratable(_unused.MustUse):
     _MustUse__warning = UnusedElaboratable
 
 
@@ -64,7 +61,7 @@ class Fragment:
             obj = new_obj
 
     def __init__(self):
-        self.ports = SignalDict()
+        self.ports = _ast.SignalDict()
         self.drivers = OrderedDict()
         self.statements = {}
         self.domains = OrderedDict()
@@ -89,7 +86,7 @@ class Fragment:
     def add_driver(self, signal, domain="comb"):
         assert isinstance(domain, str)
         if domain not in self.drivers:
-            self.drivers[domain] = SignalSet()
+            self.drivers[domain] = _ast.SignalSet()
         self.drivers[domain].add(signal)
 
     def iter_drivers(self):
@@ -109,7 +106,7 @@ class Fragment:
                 yield domain, signal
 
     def iter_signals(self):
-        signals = SignalSet()
+        signals = _ast.SignalSet()
         signals |= self.ports.keys()
         for domain, domain_signals in self.drivers.items():
             if domain != "comb":
@@ -122,7 +119,7 @@ class Fragment:
 
     def add_domains(self, *domains):
         for domain in flatten(domains):
-            assert isinstance(domain, ClockDomain)
+            assert isinstance(domain, _cd.ClockDomain)
             assert domain.name not in self.domains
             self.domains[domain.name] = domain
 
@@ -131,9 +128,9 @@ class Fragment:
 
     def add_statements(self, domain, *stmts):
         assert isinstance(domain, str)
-        for stmt in Statement.cast(stmts):
+        for stmt in _ast.Statement.cast(stmts):
             stmt._MustUse__used = True
-            self.statements.setdefault(domain, _StatementList()).append(stmt)
+            self.statements.setdefault(domain, _ast._StatementList()).append(stmt)
 
     def add_subfragment(self, subfragment, name=None):
         assert isinstance(subfragment, Fragment)
@@ -186,15 +183,15 @@ class Fragment:
         assert mode in ("silent", "warn", "error")
         from ._mem import MemoryInstance
 
-        driver_subfrags = SignalDict()
+        driver_subfrags = _ast.SignalDict()
         def add_subfrag(registry, entity, entry):
             # Because of missing domain insertion, at the point when this code runs, we have
             # a mixture of bound and unbound {Clock,Reset}Signals. Map the bound ones to
             # the actual signals (because the signal itself can be driven as well); but leave
             # the unbound ones as it is, because there's no concrete signal for it yet anyway.
-            if isinstance(entity, ClockSignal) and entity.domain in self.domains:
+            if isinstance(entity, _ast.ClockSignal) and entity.domain in self.domains:
                 entity = self.domains[entity.domain].clk
-            elif isinstance(entity, ResetSignal) and entity.domain in self.domains:
+            elif isinstance(entity, _ast.ResetSignal) and entity.domain in self.domains:
                 entity = self.domains[entity.domain].rst
 
             if entity not in registry:
@@ -265,7 +262,7 @@ class Fragment:
             return self._resolve_hierarchy_conflicts(hierarchy, mode)
 
         # Nothing was flattened, we're done!
-        return SignalSet(driver_subfrags.keys())
+        return _ast.SignalSet(driver_subfrags.keys())
 
     def _propagate_domains_up(self, hierarchy=("top",)):
         from ._xfrm import DomainRenamer
@@ -296,18 +293,18 @@ class Fragment:
             if not all(names):
                 names = sorted(f"<unnamed #{i}>" if n is None else f"'{n}'"
                                for f, n, i in subfrags)
-                raise DomainError("Domain '{}' is defined by subfragments {} of fragment '{}'; "
-                                  "it is necessary to either rename subfragment domains "
-                                  "explicitly, or give names to subfragments"
-                                  .format(domain_name, ", ".join(names), ".".join(hierarchy)))
+                raise _cd.DomainError(
+                    "Domain '{}' is defined by subfragments {} of fragment '{}'; it is necessary "
+                    "to either rename subfragment domains explicitly, or give names to subfragments"
+                    .format(domain_name, ", ".join(names), ".".join(hierarchy)))
 
             if len(names) != len(set(names)):
                 names = sorted(f"#{i}" for f, n, i in subfrags)
-                raise DomainError("Domain '{}' is defined by subfragments {} of fragment '{}', "
-                                  "some of which have identical names; it is necessary to either "
-                                  "rename subfragment domains explicitly, or give distinct names "
-                                  "to subfragments"
-                                  .format(domain_name, ", ".join(names), ".".join(hierarchy)))
+                raise _cd.DomainError(
+                    "Domain '{}' is defined by subfragments {} of fragment '{}', some of which "
+                    "have identical names; it is necessary to either rename subfragment domains "
+                    "explicitly, or give distinct names to subfragments"
+                    .format(domain_name, ", ".join(names), ".".join(hierarchy)))
 
             for subfrag, name, i in subfrags:
                 domain_name_map = {domain_name: f"{name}_{domain_name}"}
@@ -343,8 +340,8 @@ class Fragment:
                 continue
             value = missing_domain(domain_name)
             if value is None:
-                raise DomainError(f"Domain '{domain_name}' is used but not defined")
-            if type(value) is ClockDomain:
+                raise _cd.DomainError(f"Domain '{domain_name}' is used but not defined")
+            if type(value) is _cd.ClockDomain:
                 self.add_domains(value)
                 # And expose ports on the newly added clock domain, since it is added directly
                 # and there was no chance to add any logic driving it.
@@ -353,7 +350,7 @@ class Fragment:
                 new_fragment = Fragment.get(value, platform=platform)
                 if domain_name not in new_fragment.domains:
                     defined = new_fragment.domains.keys()
-                    raise DomainError(
+                    raise _cd.DomainError(
                         "Fragment returned by missing domain callback does not define "
                         "requested domain '{}' (defines {})."
                         .format(domain_name, ", ".join(f"'{n}'" for n in defined)))
@@ -463,12 +460,12 @@ class Fragment:
 
         parent = {self: None}
         level  = {self: 0}
-        uses   = SignalDict()
-        defs   = SignalDict()
-        ios    = SignalDict()
+        uses   = _ast.SignalDict()
+        defs   = _ast.SignalDict()
+        ios    = _ast.SignalDict()
         self._prepare_use_def_graph(parent, level, uses, defs, ios, self)
 
-        ports = SignalSet(ports)
+        ports = _ast.SignalSet(ports)
         if all_undef_as_ports:
             for sig in uses:
                 if sig in defs:
@@ -530,7 +527,7 @@ class Fragment:
             else:
                 self.add_ports(sig, dir="i")
 
-    def prepare(self, ports=None, missing_domain=lambda name: ClockDomain(name)):
+    def prepare(self, ports=None, missing_domain=lambda name: _cd.ClockDomain(name)):
         from ._xfrm import DomainLowerer
 
         new_domains = self._propagate_domains(missing_domain)
@@ -541,14 +538,14 @@ class Fragment:
             if not isinstance(ports, tuple) and not isinstance(ports, list):
                 msg = "`ports` must be either a list or a tuple, not {!r}"\
                         .format(ports)
-                if isinstance(ports, Value):
+                if isinstance(ports, _ast.Value):
                     msg += " (did you mean `ports=(<signal>,)`, rather than `ports=<signal>`?)"
                 raise TypeError(msg)
             mapped_ports = []
             # Lower late bound signals like ClockSignal() to ports.
             port_lowerer = DomainLowerer(fragment.domains)
             for port in ports:
-                if not isinstance(port, (Signal, ClockSignal, ResetSignal)):
+                if not isinstance(port, (_ast.Signal, _ast.ClockSignal, _ast.ResetSignal)):
                     raise TypeError("Only signals may be added as ports, not {!r}"
                                     .format(port))
                 mapped_ports.append(port_lowerer.on_value(port))
@@ -573,7 +570,7 @@ class Fragment:
             may get a different name.
         """
 
-        signal_names   = SignalDict()
+        signal_names   = _ast.SignalDict()
         assigned_names = set()
 
         def add_signal_name(signal):
@@ -599,7 +596,7 @@ class Fragment:
         for statements in self.statements.values():
             for statement in statements:
                 for signal in statement._lhs_signals() | statement._rhs_signals():
-                    if not isinstance(signal, (ClockSignal, ResetSignal)):
+                    if not isinstance(signal, (_ast.ClockSignal, _ast.ResetSignal)):
                         add_signal_name(signal)
 
         return signal_names
@@ -657,7 +654,7 @@ class Instance(Fragment):
             elif kind == "p":
                 self.parameters[name] = value
             elif kind in ("i", "o", "io"):
-                self.named_ports[name] = (Value.cast(value), kind)
+                self.named_ports[name] = (_ast.Value.cast(value), kind)
             else:
                 raise NameError("Instance argument {!r} should be a tuple (kind, name, value) "
                                 "where kind is one of \"a\", \"p\", \"i\", \"o\", or \"io\""
@@ -669,12 +666,763 @@ class Instance(Fragment):
             elif kw.startswith("p_"):
                 self.parameters[kw[2:]] = arg
             elif kw.startswith("i_"):
-                self.named_ports[kw[2:]] = (Value.cast(arg), "i")
+                self.named_ports[kw[2:]] = (_ast.Value.cast(arg), "i")
             elif kw.startswith("o_"):
-                self.named_ports[kw[2:]] = (Value.cast(arg), "o")
+                self.named_ports[kw[2:]] = (_ast.Value.cast(arg), "o")
             elif kw.startswith("io_"):
-                self.named_ports[kw[3:]] = (Value.cast(arg), "io")
+                self.named_ports[kw[3:]] = (_ast.Value.cast(arg), "io")
             else:
                 raise NameError("Instance keyword argument {}={!r} does not start with one of "
                                 "\"a_\", \"p_\", \"i_\", \"o_\", or \"io_\""
                                 .format(kw, arg))
+
+
+############################################################################################### >:3
+
+
+class NetlistDriver:
+    def __init__(self, module_idx: int, signal: _ast.Signal,
+                 domain: '_cd.ClockDomain | None', *, src_loc):
+        self.module_idx = module_idx
+        self.signal = signal
+        self.domain = domain
+        self.src_loc = src_loc
+        self.assignments = []
+
+    def emit_value(self, builder):
+        if self.domain is None:
+            reset = _ast.Const(self.signal.reset, self.signal.width)
+            default, _signed = builder.emit_rhs(self.module_idx, reset)
+        else:
+            default = builder.emit_signal(self.signal)
+        if len(self.assignments) == 1:
+            assign, = self.assignments
+            if assign.cond == 1 and assign.start == 0 and len(assign.value) == len(default):
+                return assign.value
+        cell = _nir.AssignmentList(self.module_idx, default=default, assignments=self.assignments,
+                                   src_loc=self.signal.src_loc)
+        return builder.netlist.add_value_cell(len(default), cell)
+
+
+class NetlistEmitter:
+    def __init__(self, netlist: _nir.Netlist, fragment_names: 'dict[_ir.Fragment, str]'):
+        self.netlist = netlist
+        self.fragment_names = fragment_names
+        self.drivers = _ast.SignalDict()
+        self.rhs_cache: dict[int, Tuple[_nir.Value, bool, _ast.Value]] = {}
+
+        # Collected for driver conflict diagnostics only.
+        self.late_net_to_signal = {}
+        self.connect_src_loc = {}
+
+    def emit_signal(self, signal) -> _nir.Value:
+        if signal in self.netlist.signals:
+            return self.netlist.signals[signal]
+        value = self.netlist.alloc_late_value(len(signal))
+        self.netlist.signals[signal] = value
+        for bit, net in enumerate(value):
+            self.late_net_to_signal[net] = (signal, bit)
+        return value
+
+    # Used for instance outputs and read port data, not used for actual assignments.
+    def emit_lhs(self, value: _ast.Value):
+        if isinstance(value, _ast.Signal):
+            return self.emit_signal(value)
+        elif isinstance(value, _ast.Cat):
+            result = []
+            for part in value.parts:
+                result += self.emit_lhs(part)
+            return _nir.Value(result)
+        elif isinstance(value, _ast.Slice):
+            return self.emit_lhs(value.value)[value.start:value.stop]
+        elif isinstance(value, _ast.Operator):
+            assert value.operator in ('u', 's')
+            return self.emit_lhs(value.operands[0])
+        else:
+            raise TypeError # :nocov:
+
+    def extend(self, value: _nir.Value, signed: bool, width: int):
+        nets = list(value)
+        while len(nets) < width:
+            if signed:
+                nets.append(nets[-1])
+            else:
+                nets.append(_nir.Net.from_const(0))
+        return _nir.Value(nets)
+
+    def emit_operator(self, module_idx: int, operator: str, *inputs: _nir.Value, src_loc):
+        op = _nir.Operator(module_idx, operator=operator, inputs=inputs, src_loc=src_loc)
+        return self.netlist.add_value_cell(op.width, op)
+
+    def unify_shapes_bitwise(self,
+            operand_a: _nir.Value, signed_a: bool, operand_b: _nir.Value, signed_b: bool):
+        if signed_a == signed_b:
+            width = max(len(operand_a), len(operand_b))
+        elif signed_a:
+            width = max(len(operand_a), len(operand_b) + 1)
+        else: # signed_b
+            width = max(len(operand_a) + 1, len(operand_b))
+        operand_a = self.extend(operand_a, signed_a, width)
+        operand_b = self.extend(operand_b, signed_b, width)
+        signed = signed_a or signed_b
+        return (operand_a, operand_b, signed)
+
+    def emit_rhs(self, module_idx: int, value: _ast.Value) -> Tuple[_nir.Value, bool]:
+        """Emits a RHS value, returns a tuple of (value, is_signed)"""
+        try:
+            result, signed, value = self.rhs_cache[id(value)]
+            return result, signed
+        except KeyError:
+            pass
+        if isinstance(value, _ast.Const):
+            result = _nir.Value(
+                _nir.Net.from_const((value.value >> bit) & 1)
+                for bit in range(value.width)
+            )
+            signed = value.signed
+        elif isinstance(value, _ast.Signal):
+            result = self.emit_signal(value)
+            signed = value.signed
+        elif isinstance(value, _ast.Operator):
+            if len(value.operands) == 1:
+                operand_a, signed_a = self.emit_rhs(module_idx, value.operands[0])
+                if value.operator == 's':
+                    result = operand_a
+                    signed = True
+                elif value.operator == 'u':
+                    result = operand_a
+                    signed = False
+                elif value.operator == '+':
+                    result = operand_a
+                    signed = signed_a
+                elif value.operator == '-':
+                    operand_a = self.extend(operand_a, signed_a, len(operand_a) + 1)
+                    result = self.emit_operator(module_idx, '-', operand_a,
+                                                src_loc=value.src_loc)
+                    signed = True
+                elif value.operator == '~':
+                    result = self.emit_operator(module_idx, '~', operand_a,
+                                                src_loc=value.src_loc)
+                    signed = signed_a
+                elif value.operator in ('b', 'r|', 'r&', 'r^'):
+                    result = self.emit_operator(module_idx, value.operator, operand_a,
+                                                src_loc=value.src_loc)
+                    signed = False
+                else:
+                    assert False # :nocov:
+            elif len(value.operands) == 2:
+                operand_a, signed_a = self.emit_rhs(module_idx, value.operands[0])
+                operand_b, signed_b = self.emit_rhs(module_idx, value.operands[1])
+                if value.operator in ('|', '&', '^'):
+                    operand_a, operand_b, signed = \
+                        self.unify_shapes_bitwise(operand_a, signed_a, operand_b, signed_b)
+                    result = self.emit_operator(module_idx, value.operator, operand_a, operand_b,
+                                                src_loc=value.src_loc)
+                elif value.operator in ('+', '-'):
+                    operand_a, operand_b, signed = \
+                        self.unify_shapes_bitwise(operand_a, signed_a, operand_b, signed_b)
+                    width = len(operand_a) + 1
+                    operand_a = self.extend(operand_a, signed, width)
+                    operand_b = self.extend(operand_b, signed, width)
+                    result = self.emit_operator(module_idx, value.operator, operand_a, operand_b,
+                                                src_loc=value.src_loc)
+                    if value.operator == '-':
+                        signed = True
+                elif value.operator == '*':
+                    width = len(operand_a) + len(operand_b)
+                    operand_a = self.extend(operand_a, signed_a, width)
+                    operand_b = self.extend(operand_b, signed_b, width)
+                    result = self.emit_operator(module_idx, '*', operand_a, operand_b,
+                                                src_loc=value.src_loc)
+                    signed = signed_a or signed_b
+                elif value.operator == '//':
+                    width = len(operand_a) + signed_b
+                    operand_a, operand_b, signed = \
+                        self.unify_shapes_bitwise(operand_a, signed_a, operand_b, signed_b)
+                    if len(operand_a) < width:
+                        operand_a = self.extend(operand_a, signed, width)
+                        operand_b = self.extend(operand_b, signed, width)
+                    operator = 's//' if signed else 'u//'
+                    result = _nir.Value(
+                        self.emit_operator(module_idx, operator, operand_a, operand_b,
+                                           src_loc=value.src_loc)[:width]
+                    )
+                elif value.operator == '%':
+                    width = len(operand_b)
+                    operand_a, operand_b, signed = \
+                        self.unify_shapes_bitwise(operand_a, signed_a, operand_b, signed_b)
+                    operator = 's%' if signed else 'u%'
+                    result = _nir.Value(
+                        self.emit_operator(module_idx, operator, operand_a, operand_b,
+                                           src_loc=value.src_loc)[:width]
+                    )
+                    signed = signed_b
+                elif value.operator == '<<':
+                    operand_a = self.extend(operand_a, signed_a,
+                                            len(operand_a) + 2 ** len(operand_b) - 1)
+                    result = self.emit_operator(module_idx, '<<', operand_a, operand_b,
+                                                src_loc=value.src_loc)
+                    signed = signed_a
+                elif value.operator == '>>':
+                    operator = 's>>' if signed_a else 'u>>'
+                    result = self.emit_operator(module_idx, operator, operand_a, operand_b,
+                                                src_loc=value.src_loc)
+                    signed = signed_a
+                elif value.operator in ('==', '!='):
+                    operand_a, operand_b, signed = \
+                        self.unify_shapes_bitwise(operand_a, signed_a, operand_b, signed_b)
+                    result = self.emit_operator(module_idx, value.operator, operand_a, operand_b,
+                                                src_loc=value.src_loc)
+                    signed = False
+                elif value.operator in ('<', '>', '<=', '>='):
+                    operand_a, operand_b, signed = \
+                        self.unify_shapes_bitwise(operand_a, signed_a, operand_b, signed_b)
+                    operator = ('s' if signed else 'u') + value.operator
+                    result = self.emit_operator(module_idx, operator, operand_a, operand_b,
+                                                src_loc=value.src_loc)
+                    signed = False
+                else:
+                    assert False # :nocov:
+            elif len(value.operands) == 3:
+                assert value.operator == 'm'
+                operand_s, signed_s = self.emit_rhs(module_idx, value.operands[0])
+                operand_a, signed_a = self.emit_rhs(module_idx, value.operands[1])
+                operand_b, signed_b = self.emit_rhs(module_idx, value.operands[2])
+                if len(operand_s) != 1:
+                    operand_s = self.emit_operator(module_idx, 'b', operand_s,
+                                                   src_loc=value.src_loc)
+                operand_a, operand_b, signed = \
+                    self.unify_shapes_bitwise(operand_a, signed_a, operand_b, signed_b)
+                result = self.emit_operator(module_idx, 'm', operand_s, operand_a, operand_b,
+                                            src_loc=value.src_loc)
+            else:
+                assert False # :nocov:
+        elif isinstance(value, _ast.Slice):
+            inner, _signed = self.emit_rhs(module_idx, value.value)
+            result = _nir.Value(inner[value.start:value.stop])
+            signed = False
+        elif isinstance(value, _ast.Part):
+            inner, signed = self.emit_rhs(module_idx, value.value)
+            offset, _signed = self.emit_rhs(module_idx, value.offset)
+            cell = _nir.Part(module_idx, value=inner, value_signed=signed, width=value.width,
+                             stride=value.stride, offset=offset, src_loc=value.src_loc)
+            result = self.netlist.add_value_cell(value.width, cell)
+            signed = False
+        elif isinstance(value, _ast.ArrayProxy):
+            elems = [self.emit_rhs(module_idx, elem) for elem in value.elems]
+            width = 0
+            signed = False
+            for elem, elem_signed in elems:
+                if elem_signed:
+                    if not signed:
+                        width += 1
+                        signed = True
+                    width = max(width, len(elem))
+                elif signed:
+                    width = max(width, len(elem) + 1)
+                else:
+                    width = max(width, len(elem))
+            elems = tuple(self.extend(elem, elem_signed, width) for elem, elem_signed in elems)
+            index, _signed = self.emit_rhs(module_idx, value.index)
+            cell = _nir.ArrayMux(module_idx, width=width, elems=elems, index=index,
+                                 src_loc=value.src_loc)
+            result = self.netlist.add_value_cell(width, cell)
+        elif isinstance(value, _ast.Cat):
+            nets = []
+            for val in value.parts:
+                inner, _signed = self.emit_rhs(module_idx, val)
+                for net in inner:
+                    nets.append(net)
+            result = _nir.Value(nets)
+            signed = False
+        elif isinstance(value, _ast.AnyValue):
+            result = self.netlist.add_value_cell(value.width,
+                _nir.AnyValue(module_idx, kind=value.kind.value, width=value.width,
+                              src_loc=value.src_loc))
+            signed = value.signed
+        elif isinstance(value, _ast.Initial):
+            result = self.netlist.add_value_cell(1, _nir.Initial(module_idx, src_loc=value.src_loc))
+            signed = False
+        else:
+            assert False # :nocov:
+        assert value.shape().width == len(result), \
+            f"Value {value!r} with shape {value.shape()!r} does not match " \
+            f"result with width {len(result)}"
+        # Add the value itself to the cache to make sure `id(value)` remains allocated and pointing
+        # at `value`. This would be a weakref.WeakKeyDictionary if `value` was hashable.
+        self.rhs_cache[id(value)] = result, signed, value
+        return (result, signed)
+
+    def connect(self, lhs: _nir.Value, rhs: _nir.Value, *, src_loc):
+        assert len(lhs) == len(rhs)
+        for left, right in zip(lhs, rhs):
+            if left in self.netlist.connections:
+                signal, bit = self.late_net_to_signal[left]
+                other_src_loc = self.connect_src_loc[left]
+                raise _ir.DriverConflict(f"Bit {bit} of signal {signal!r} has multiple drivers: "
+                                         f"{other_src_loc} and {src_loc}")
+            self.netlist.connections[left] = right
+            self.connect_src_loc[left] = src_loc
+
+    def emit_stmt(self, module_idx: int, fragment: _ir.Fragment, domain: str,
+                  stmt: _ast.Statement, cond: _nir.Net):
+        if domain == "comb":
+            cd: _cd.ClockDomain | None = None
+        else:
+            cd = fragment.domains[domain]
+        if isinstance(stmt, _ast.Assign):
+            if isinstance(stmt.lhs, _ast.Signal):
+                signal = stmt.lhs
+                start = 0
+                width = signal.width
+            elif isinstance(stmt.lhs, _ast.Slice):
+                signal = stmt.lhs.value
+                start = stmt.lhs.start
+                width = stmt.lhs.stop - stmt.lhs.start
+            else:
+                assert False # :nocov:
+            assert isinstance(signal, _ast.Signal)
+            if signal in self.drivers:
+                driver = self.drivers[signal]
+                if driver.domain is not cd:
+                    raise _ir.DriverConflict(
+                        f"Signal {signal} driven from domain {cd} at {stmt.src_loc} and domain "
+                        f"{driver.domain} at {driver.src_loc}")
+                if driver.module_idx != module_idx:
+                    mod_name = ".".join(self.netlist.modules[module_idx].name or ("<toplevel>",))
+                    other_mod_name = \
+                        ".".join(self.netlist.modules[driver.module_idx].name or ("<toplevel>",))
+                    raise _ir.DriverConflict(
+                        f"Signal {signal} driven from module {mod_name} at {stmt.src_loc} and "
+                        f"module {other_mod_name} at {driver.src_loc}")
+            else:
+                driver = NetlistDriver(module_idx, signal, domain=cd, src_loc=stmt.src_loc)
+                self.drivers[signal] = driver
+            rhs, signed = self.emit_rhs(module_idx, stmt.rhs)
+            if len(rhs) > width:
+                rhs = _nir.Value(rhs[:width])
+            if len(rhs) < width:
+                rhs = self.extend(rhs, signed, width)
+            driver.assignments.append(_nir.Assignment(cond=cond, start=start, value=rhs,
+                                                      src_loc=stmt.src_loc))
+        elif isinstance(stmt, _ast.Property):
+            test, _signed = self.emit_rhs(module_idx, stmt.test)
+            if len(test) != 1:
+                test = self.emit_operator(module_idx, 'b', test, src_loc=stmt.src_loc)
+            test, = test
+            en_cell = _nir.AssignmentList(module_idx,
+                default=_nir.Value.zeros(),
+                assignments=[
+                    _nir.Assignment(cond=cond, start=0, value=_nir.Value.ones(),
+                                    src_loc=stmt.src_loc)
+                ],
+                src_loc=stmt.src_loc)
+            cond, = self.netlist.add_value_cell(1, en_cell)
+            if cd is None:
+                cell = _nir.AsyncProperty(module_idx, kind=stmt.kind.value, test=test, en=cond,
+                                          name=stmt.name, src_loc=stmt.src_loc)
+            else:
+                clk, = self.emit_signal(cd.clk)
+                cell = _nir.SyncProperty(module_idx, kind=stmt.kind.value, test=test, en=cond,
+                                         clk=clk, clk_edge=cd.clk_edge, name=stmt.name,
+                                         src_loc=stmt.src_loc)
+            self.netlist.add_cell(cell)
+        elif isinstance(stmt, _ast.Switch):
+            test, _signed = self.emit_rhs(module_idx, stmt.test)
+            conds = []
+            for patterns in stmt.cases:
+                if patterns:
+                    for pattern in patterns:
+                        assert len(pattern) == len(test)
+                    cell = _nir.Matches(module_idx, value=test, patterns=patterns,
+                                        src_loc=stmt.case_src_locs.get(patterns))
+                    net, = self.netlist.add_value_cell(1, cell)
+                    conds.append(net)
+                else:
+                    conds.append(_nir.Net.from_const(1))
+            cell = _nir.PriorityMatch(module_idx, en=cond, inputs=_nir.Value(conds),
+                                      src_loc=stmt.src_loc)
+            conds = self.netlist.add_value_cell(len(conds), cell)
+            for subcond, substmts in zip(conds, stmt.cases.values()):
+                for substmt in substmts:
+                    self.emit_stmt(module_idx, fragment, domain, substmt, subcond)
+        else:
+            assert False # :nocov:
+
+    def emit_tribuf(self, module_idx: int, instance: _ir.Instance):
+        pad = self.emit_lhs(instance.named_ports["Y"][0])
+        o, _signed = self.emit_rhs(module_idx, instance.named_ports["A"][0])
+        (oe,), _signed = self.emit_rhs(module_idx, instance.named_ports["EN"][0])
+        assert len(pad) == len(o)
+        cell = _nir.IOBuffer(module_idx, pad=pad, o=o, oe=oe, src_loc=instance.src_loc)
+        self.netlist.add_cell(cell)
+
+    def emit_memory(self, module_idx: int, fragment: '_mem.MemoryInstance', name: str):
+        cell = _nir.Memory(module_idx,
+            width=fragment._width,
+            depth=fragment._depth,
+            init=fragment._init,
+            name=name,
+            attributes=fragment._attrs,
+            src_loc=fragment._src_loc,
+        )
+        return self.netlist.add_cell(cell)
+
+    def emit_write_port(self, module_idx: int, fragment: '_mem.MemoryInstance',
+                        port: '_mem.MemoryInstance._WritePort', memory: int):
+        data, _signed = self.emit_rhs(module_idx, port._data)
+        addr, _signed = self.emit_rhs(module_idx, port._addr)
+        en, _signed = self.emit_rhs(module_idx, port._en)
+        en = _nir.Value([en[bit // port._granularity] for bit in range(len(port._data))])
+        cd = fragment.domains[port._domain]
+        clk, = self.emit_signal(cd.clk)
+        cell = _nir.SyncWritePort(module_idx,
+            memory=memory,
+            data=data,
+            addr=addr,
+            en=en,
+            clk=clk,
+            clk_edge=cd.clk_edge,
+            src_loc=port._data.src_loc,
+        )
+        return self.netlist.add_cell(cell)
+
+    def emit_read_port(self, module_idx: int, fragment: '_mem.MemoryInstance',
+                       port: '_mem.MemoryInstance._ReadPort', memory: int,
+                       write_ports: 'list[int]'):
+        addr, _signed = self.emit_rhs(module_idx, port._addr)
+        if port._domain == "comb":
+            cell = _nir.AsyncReadPort(module_idx,
+                memory=memory,
+                width=len(port._data),
+                addr=addr,
+                src_loc=port._data.src_loc,
+            )
+        else:
+            (en,), _signed = self.emit_rhs(module_idx, port._en)
+            cd = fragment.domains[port._domain]
+            clk, = self.emit_signal(cd.clk)
+            cell = _nir.SyncReadPort(module_idx,
+                memory=memory,
+                width=len(port._data),
+                addr=addr,
+                en=en,
+                clk=clk,
+                clk_edge=cd.clk_edge,
+                transparent_for=tuple(write_ports[idx] for idx in port._transparency),
+                src_loc=port._data.src_loc,
+            )
+        data = self.netlist.add_value_cell(len(port._data), cell)
+        self.connect(self.emit_lhs(port._data), data, src_loc=port._data.src_loc)
+
+    def emit_instance(self, module_idx: int, instance: _ir.Instance, name: str):
+        ports_i = {}
+        ports_o = {}
+        ports_io = {}
+        outputs = []
+        next_output_bit = 0
+        for port_name, (port_conn, dir) in instance.named_ports.items():
+            if dir == 'i':
+                ports_i[port_name], _signed = self.emit_rhs(module_idx, port_conn)
+            elif dir == 'o':
+                port_conn = self.emit_lhs(port_conn)
+                ports_o[port_name] = (next_output_bit, len(port_conn))
+                outputs.append((next_output_bit, port_conn))
+                next_output_bit += len(port_conn)
+            elif dir == 'io':
+                ports_io[port_name] = self.emit_lhs(port_conn)
+            else:
+                assert False # :nocov:
+        cell = _nir.Instance(module_idx,
+            type=instance.type,
+            name=name,
+            parameters=instance.parameters,
+            attributes=instance.attrs,
+            ports_i=ports_i,
+            ports_o=ports_o,
+            ports_io=ports_io,
+            src_loc=instance.src_loc,
+        )
+        output_nets = self.netlist.add_value_cell(width=next_output_bit, cell=cell)
+        for start_bit, port_conn in outputs:
+            self.connect(port_conn, _nir.Value(output_nets[start_bit:start_bit + len(port_conn)]),
+                         src_loc=instance.src_loc)
+
+    def emit_top_ports(self, fragment: _ir.Fragment, signal_names: _ast.SignalDict):
+        next_input_bit = 2 # 0 and 1 are reserved for constants
+        top = self.netlist.top
+        for signal, dir in fragment.ports.items():
+            assert signal not in self.netlist.signals
+            name = signal_names[signal]
+            if dir == 'i':
+                top.ports_i[name] = (next_input_bit, signal.width)
+                nets = _nir.Value(
+                    _nir.Net.from_cell(0, bit)
+                    for bit in range(next_input_bit, next_input_bit + signal.width)
+                )
+                next_input_bit += signal.width
+                self.netlist.signals[signal] = nets
+            elif dir == 'o':
+                top.ports_o[name] = self.emit_signal(signal)
+            elif dir == 'io':
+                top.ports_io[name] = (next_input_bit, signal.width)
+                nets = _nir.Value(
+                    _nir.Net.from_cell(0, bit)
+                    for bit in range(next_input_bit, next_input_bit + signal.width)
+                )
+                next_input_bit += signal.width
+                self.netlist.signals[signal] = nets
+
+    def emit_drivers(self):
+        for driver in self.drivers.values():
+            value = driver.emit_value(self)
+            if driver.domain is not None:
+                clk, = self.emit_signal(driver.domain.clk)
+                if driver.domain.rst is not None and driver.domain.async_reset:
+                    arst, = self.emit_signal(driver.domain.rst)
+                else:
+                    arst = _nir.Net.from_const(0)
+                cell = _nir.FlipFlop(driver.module_idx,
+                    data=value,
+                    init=driver.signal.reset,
+                    clk=clk,
+                    clk_edge=driver.domain.clk_edge,
+                    arst=arst,
+                    attributes=driver.signal.attrs,
+                    src_loc=driver.signal.src_loc,
+                )
+                value = self.netlist.add_value_cell(len(value), cell)
+            if driver.assignments:
+                src_loc = driver.assignments[0].src_loc
+            else:
+                src_loc = driver.signal.src_loc
+            self.connect(self.emit_signal(driver.signal), value, src_loc=src_loc)
+
+        # Connect all undriven signal bits to their reset values. This can only happen for entirely
+        # undriven signals, or signals that are partially driven by instances.
+        for signal, value in self.netlist.signals.items():
+            for bit, net in enumerate(value):
+                if net.is_late and net not in self.netlist.connections:
+                    self.netlist.connections[net] = _nir.Net.from_const((signal.reset >> bit) & 1)
+
+    def emit_fragment(self, fragment: _ir.Fragment, parent_module_idx: 'int | None'):
+        from . import _mem
+
+        fragment_name = self.fragment_names[fragment]
+        if isinstance(fragment, _ir.Instance):
+            assert parent_module_idx is not None
+            if fragment.type == "$tribuf":
+                self.emit_tribuf(parent_module_idx, fragment)
+            else:
+                self.emit_instance(parent_module_idx, fragment, name=fragment_name[-1])
+        elif isinstance(fragment, _mem.MemoryInstance):
+            assert parent_module_idx is not None
+            memory = self.emit_memory(parent_module_idx, fragment, name=fragment_name[-1])
+            write_ports = []
+            for port in fragment._write_ports:
+                write_ports.append(self.emit_write_port(parent_module_idx, fragment, port, memory))
+            for port in fragment._read_ports:
+                self.emit_read_port(parent_module_idx, fragment, port, memory, write_ports)
+        elif type(fragment) is _ir.Fragment:
+            module_idx = self.netlist.add_module(parent_module_idx, fragment_name)
+            signal_names = fragment._assign_names_to_signals()
+            self.netlist.modules[module_idx].signal_names = signal_names
+            if parent_module_idx is None:
+                self.emit_top_ports(fragment, signal_names)
+            for signal in signal_names:
+                self.emit_signal(signal)
+            for domain, stmts in fragment.statements.items():
+                for stmt in stmts:
+                    self.emit_stmt(module_idx, fragment, domain, stmt, _nir.Net.from_const(1))
+            for subfragment, _name in fragment.subfragments:
+                self.emit_fragment(subfragment, module_idx)
+            if parent_module_idx is None:
+                self.emit_drivers()
+        else:
+            assert False # :nocov:
+
+
+def _emit_netlist(netlist: _nir.Netlist, fragment, hierarchy):
+    fragment_names = fragment._assign_names_to_fragments(hierarchy)
+    NetlistEmitter(netlist, fragment_names).emit_fragment(fragment, None)
+
+
+def _compute_net_flows(netlist: _nir.Netlist):
+    # Computes the net flows for all modules of the netlist.
+    #
+    # The rules for net flows are as follows:
+    #
+    # - the modules that have a given net in their net_flow form a subtree of the hierarchy
+    # - INTERNAL is used in the root of the subtree and nowhere else
+    # - OUTPUT is used for modules that contain the definition of the net, or are on the
+    #   path from the definition to the root
+    # - remaining modules have a flow of INPUT (unless the net is a top-level inout port,
+    #   in which case it is INOUT)
+    #
+    # In other words, the tree looks something like this:
+    #
+    # - [no flow] <<< top
+    #   - [no flow]
+    #   - INTERNAL
+    #     - INPUT << use
+    #       - [no flow]
+    #     - INPUT
+    #       - INPUT << use
+    #     - OUTPUT
+    #       - INPUT << use
+    #       - [no flow]
+    #       - OUTPUT << def
+    #         - INPUT
+    #           - INPUT
+    #         - [no flow]
+    #   - [no flow]
+    #   - [no flow]
+    #
+    # This function doesn't assign the INOUT flow — that is corrected later, in compute_ports.
+    lca = {}
+
+    # Initialize by marking the definition point of every net.
+    for cell_idx, cell in enumerate(netlist.cells):
+        for net in cell.output_nets(cell_idx):
+            lca[net] = cell.module_idx
+            netlist.modules[cell.module_idx].net_flow[net] = _nir.ModuleNetFlow.INTERNAL
+
+    # Marks a use of a net within a given module, and adjusts its netflows in all modules
+    # as required.
+    def use_net(net, use_module):
+        if net.is_const:
+            return
+        # If the net is already present in the current module, we're done.
+        if net in netlist.modules[use_module].net_flow:
+            return
+        modules = netlist.modules
+        # Otherwise, we need to route the net through the hierarchy from def_module
+        # to use_module. We do that by treating use_module and def_module as pointers
+        # and moving them up the hierarchy until they meet at the new LCA.
+        def_module = lca[net]
+        # While def_module deeper than use_module, go up with def_module.
+        while len(modules[def_module].name) > len(modules[use_module].name):
+            modules[def_module].net_flow[net] = _nir.ModuleNetFlow.OUTPUT
+            def_module = modules[def_module].parent
+        # While use_module deeper than def_module, go up with use_module.
+        # If use_module is below def_module in the hierarchy, we may hit
+        # another module which already uses this net before hitting def_module,
+        # so check for this case.
+        while len(modules[def_module].name) < len(modules[use_module].name):
+            if net in modules[use_module].net_flow:
+                return
+            modules[use_module].net_flow[net] = _nir.ModuleNetFlow.INPUT
+            use_module = modules[use_module].parent
+        # Now both pointers should be at the same depth within the hierarchy.
+        assert len(modules[def_module].name) == len(modules[use_module].name)
+        # Move both pointers up until they meet.
+        while def_module != use_module:
+            modules[def_module].net_flow[net] = _nir.ModuleNetFlow.OUTPUT
+            def_module = modules[def_module].parent
+            modules[use_module].net_flow[net] = _nir.ModuleNetFlow.INPUT
+            use_module = modules[use_module].parent
+            assert len(modules[def_module].name) == len(modules[use_module].name)
+        # And mark the new LCA.
+        modules[def_module].net_flow[net] = _nir.ModuleNetFlow.INTERNAL
+        lca[net] = def_module
+
+    # Now mark all uses and flesh out the structure.
+    for cell in netlist.cells:
+        for net in cell.input_nets():
+            use_net(net, cell.module_idx)
+    # TODO: ?
+    for module_idx, module in enumerate(netlist.modules):
+        for signal in module.signal_names:
+            for net in netlist.signals[signal]:
+                use_net(net, module_idx)
+
+
+def _compute_ports(netlist: _nir.Netlist):
+    # Compute the indexes at which the outputs of a cell should be split to create a distinct port.
+    # These indexes are stored here as nets.
+    port_starts = set()
+    for start, _ in netlist.top.ports_i.values():
+        port_starts.add(_nir.Net.from_cell(0, start))
+    for start, width in netlist.top.ports_io.values():
+        port_starts.add(_nir.Net.from_cell(0, start))
+    for cell_idx, cell in enumerate(netlist.cells):
+        if isinstance(cell, _nir.Instance):
+            for start, _ in cell.ports_o.values():
+                port_starts.add(_nir.Net.from_cell(cell_idx, start))
+
+    # Compute the set of all inout nets. Currently, a net has inout flow iff it is connected to
+    # a toplevel inout port.
+    inouts = set()
+    for start, width in netlist.top.ports_io.values():
+        for idx in range(start, start + width):
+            inouts.add(_nir.Net.from_cell(0, idx))
+
+    for module in netlist.modules:
+        # Collect preferred names for ports. If a port exactly matches a signal, we reuse
+        # the signal name for the port. Otherwise, we synthesize a private name.
+        name_table = {}
+        for signal, name in module.signal_names.items():
+            value = netlist.signals[signal]
+            if value not in name_table and not name.startswith('$'):
+                name_table[value] = name
+
+        # Gather together "adjacent" nets with the same flow into ports.
+        visited = set()
+        for net in sorted(module.net_flow):
+            flow = module.net_flow[net]
+            if flow == _nir.ModuleNetFlow.INTERNAL:
+                continue
+            if flow == _nir.ModuleNetFlow.INPUT and net in inouts:
+                flow = module.net_flow[net] = _nir.ModuleNetFlow.INOUT
+            if net in visited:
+                continue
+            # We found a net that needs a port. Keep joining the next nets output by the same
+            # cell into the same port, if applicable, but stop at instance/top port boundaries.
+            nets = [net]
+            while True:
+                succ = _nir.Net.from_cell(net.cell, net.bit + 1)
+                if succ in port_starts:
+                    break
+                if succ not in module.net_flow:
+                    break
+                if module.net_flow[succ] != module.net_flow[net]:
+                    break
+                net = succ
+                nets.append(net)
+            value = _nir.Value(nets)
+            # Joined as many nets as we could, now name and add the port.
+            if value in name_table:
+                name = name_table[value]
+            else:
+                name = f"port${value[0].cell}${value[0].bit}"
+            module.ports[name] = (value, flow)
+            visited.update(value)
+
+    # The 0th cell and the 0th module correspond to the toplevel. Transfer the net flows from
+    # the toplevel cell (used for data flow) to the toplevel module (used to split netlist into
+    # modules in the backends).
+    top_module = netlist.modules[0]
+    for name, (start, width) in netlist.top.ports_i.items():
+        top_module.ports[name] = (
+            _nir.Value(_nir.Net.from_cell(0, start + bit) for bit in range(width)),
+            _nir.ModuleNetFlow.INPUT
+        )
+    for name, (start, width) in netlist.top.ports_io.items():
+        top_module.ports[name] = (
+            _nir.Value(_nir.Net.from_cell(0, start + bit) for bit in range(width)),
+            _nir.ModuleNetFlow.INOUT
+        )
+    for name, value in netlist.top.ports_o.items():
+        top_module.ports[name] = (value, _nir.ModuleNetFlow.OUTPUT)
+
+
+def build_netlist(fragment, *, name="top"):
+    from ._xfrm import AssignmentLegalizer
+
+    fragment = AssignmentLegalizer()(fragment)
+    netlist = _nir.Netlist()
+    _emit_netlist(netlist, fragment, hierarchy=(name,))
+    netlist.resolve_all_nets()
+    _compute_net_flows(netlist)
+    _compute_ports(netlist)
+    return netlist
diff --git a/amaranth/hdl/_nir.py b/amaranth/hdl/_nir.py
new file mode 100644
index 0000000..0cb48c8
--- /dev/null
+++ b/amaranth/hdl/_nir.py
@@ -0,0 +1,1003 @@
+from typing import Iterable
+import enum
+
+from ._ast import SignalDict
+
+
+__all__ = [
+    # Netlist core
+    "Net", "Value", "Netlist", "ModuleNetFlow", "Module", "Cell", "Top",
+    # Computation cells
+    "Operator", "Part", "ArrayMux",
+    # Decision tree cells
+    "Matches", "PriorityMatch", "Assignment", "AssignmentList",
+    # Storage cells
+    "FlipFlop", "Memory", "SyncWritePort", "AsyncReadPort", "SyncReadPort",
+    # Formal verification cells
+    "Initial", "AnyValue", "AsyncProperty", "SyncProperty",
+    # Foreign interface cells
+    "Instance", "IOBuffer",
+]
+
+
+class Net(int):
+    @classmethod
+    def from_cell(cls, cell: int, bit: int):
+        assert bit in range(1 << 16)
+        assert cell >= 0
+        if cell == 0:
+            assert bit >= 2
+        return cls((cell << 16) | bit)
+
+    @classmethod
+    def from_const(cls, val: int):
+        assert val in (0, 1)
+        return cls(val)
+
+    @classmethod
+    def from_late(cls, val: int):
+        assert val < 0
+        return cls(val)
+
+    @property
+    def is_const(self):
+        return self in (0, 1)
+
+    @property
+    def const(self):
+        assert self in (0, 1)
+        return int(self)
+
+    @property
+    def is_late(self):
+        return self < 0
+
+    @property
+    def is_cell(self):
+        return self >= 2
+
+    @property
+    def cell(self):
+        assert self >= 2
+        return self >> 16
+
+    @property
+    def bit(self):
+        assert self >= 2
+        return self & 0xffff
+
+    @classmethod
+    def ensure(cls, value: 'Net'):
+        assert isinstance(value, cls)
+        return value
+
+
+class Value(tuple):
+    def __new__(cls, nets: 'Net | Iterable[Net]' = ()):
+        if isinstance(nets, Net):
+            return super().__new__(cls, (nets,))
+        return super().__new__(cls, (Net.ensure(net) for net in nets))
+
+    @classmethod
+    def zeros(cls, digits=1):
+        return cls(Net.from_const(0) for _ in range(digits))
+
+    @classmethod
+    def ones(cls, digits=1):
+        return cls(Net.from_const(1) for _ in range(digits))
+
+
+class Netlist:
+    """A fine netlist. Consists of:
+
+    - a flat array of cells
+    - a dictionary of connections for late-bound nets
+    - a map of hierarchical names to nets
+    - a map of signals to nets
+
+    The nets are virtual: a list of nets is not materialized anywhere in the netlist.
+    A net is a single bit wide and represented as a single int. The int is encoded as follows:
+
+    - A negative number means a late-bound net. The net should be looked up in the ``connections``
+      dictionary to find its driver.
+    - Non-negative numbers are cell outputs, and are split into bitfields as follows:
+
+      - bits 0-15: output bit index within a cell (exact meaning is cell-dependent)
+      - bits 16-...: index of cell in ``netlist.cells``
+
+    Cell 0 is always ``Top``.  The first two output bits of ``Top`` are considered to be constants
+    ``0`` and ``1``, which effectively means that net encoded as ``0`` is always a constant ``0`` and
+    net encoded as ``1`` is always a constant ``1``.
+
+    Multi-bit values are represented as tuples of int.
+
+    Attributes
+    ----------
+
+    cells : list of ``Cell``
+    connections : dict of (negative) int to int
+    signals : dict of Signal to ``Value``
+    """
+    def __init__(self):
+        self.modules: list[Module] = []
+        self.cells: list[Cell] = [Top()]
+        self.connections: dict[Net, Net] = {}
+        self.signals = SignalDict()
+        self.last_late_net = 0
+
+    def resolve_net(self, net: Net):
+        assert isinstance(net, Net)
+        while net.is_late:
+            net = self.connections[net]
+        return net
+
+    def resolve_value(self, value: Value):
+        return Value(self.resolve_net(net) for net in value)
+
+    def resolve_all_nets(self):
+        for cell in self.cells:
+            cell.resolve_nets(self)
+        for sig in self.signals:
+            self.signals[sig] = self.resolve_value(self.signals[sig])
+
+    def __str__(self):
+        def net_to_str(net):
+            net = self.resolve_net(net)
+            if net.is_const:
+                return f"{net.const}"
+            return f"{net.cell}.{net.bit}"
+        def val_to_str(val):
+            return "{" + " ".join(net_to_str(x) for x in val) + "}"
+        result = []
+        for module_idx, module in enumerate(self.modules):
+            result.append(f"module {module_idx} [parent {module.parent}]: {' '.join(module.name)}")
+            if module.submodules:
+                result.append(f"    submodules {' '.join(str(x) for x in module.submodules)} ")
+            result.append(f"    cells {' '.join(str(x) for x in module.cells)} ")
+            for name, (val, flow) in module.ports.items():
+                result.append(f"    port {name} {val_to_str(val)}: {flow}")
+        for cell_idx, cell in enumerate(self.cells):
+            result.append(f"cell {cell_idx} [module {cell.module_idx}]: ")
+            if isinstance(cell, Top):
+                result.append("top")
+                for name, val in cell.ports_o.items():
+                    result.append(f"    output {name}: {val_to_str(val)}")
+                for name, (start, num) in cell.ports_i.items():
+                    result.append(f"    input {name}: 0.{start}..0.{start+num-1}")
+                for name, (start, num) in cell.ports_io.items():
+                    result.append(f"    inout {name}: 0.{start}..0.{start+num-1}")
+            elif isinstance(cell, Matches):
+                result.append(f"matches {val_to_str(cell.value)}, {' | '.join(cell.patterns)}")
+            elif isinstance(cell, PriorityMatch):
+                result.append(f"priority_match {net_to_str(cell.en)}, {val_to_str(cell.inputs)}")
+            elif isinstance(cell, AssignmentList):
+                result.append(f"list {val_to_str(cell.default)}")
+                for assign in cell.assignments:
+                    result.append(f"    if {net_to_str(assign.cond)} start {assign.start} <- {val_to_str(assign.value)}")
+            elif isinstance(cell, Operator):
+                inputs = ", ".join(val_to_str(input) for input in cell.inputs)
+                result.append(f"{cell.operator} {inputs}")
+            elif isinstance(cell, Part):
+                result.append(f"part {val_to_str(cell.value)}, {val_to_str(cell.offset)}, {cell.width}, {cell.stride}, {cell.value_signed}")
+            elif isinstance(cell, ArrayMux):
+                result.append(f"array {cell.width}, {val_to_str(cell.index)}, {', '.join(val_to_str(elem) for elem in cell.elems)}")
+            elif isinstance(cell, FlipFlop):
+                result.append(f"ff {val_to_str(cell.data)} {cell.init} @{cell.clk_edge}edge {net_to_str(cell.clk)} {net_to_str(cell.arst)}")
+                for attr_name, attr_value in cell.attributes.items():
+                    result.append(f"    attribute {attr_name} {attr_value}")
+            elif isinstance(cell, Memory):
+                result.append(f"memory {cell.name} {cell.width} {cell.depth} {cell.init}")
+                for attr_name, attr_value in cell.attributes.items():
+                    result.append(f"    attribute {attr_name} {attr_value}")
+            elif isinstance(cell, SyncWritePort):
+                result.append(f"wrport {cell.memory} {val_to_str(cell.data)} {val_to_str(cell.addr)} {val_to_str(cell.en)} @{cell.clk_edge}edge {net_to_str(cell.clk)}")
+            elif isinstance(cell, AsyncReadPort):
+                result.append(f"rdport {cell.memory} {cell.width} {val_to_str(cell.addr)}")
+            elif isinstance(cell, SyncReadPort):
+                result.append(f"rdport {cell.memory} {cell.width} {val_to_str(cell.addr)} {net_to_str(cell.en)} @{cell.clk_edge}edge {net_to_str(cell.clk)}")
+                for port in cell.transparent_for:
+                    result.append(f"    transparent {port}")
+            elif isinstance(cell, Initial):
+                result.append("initial")
+            elif isinstance(cell, AnyValue):
+                result.append("{cell.kind} {cell.width}")
+            elif isinstance(cell, AsyncProperty):
+                result.append(f"{cell.kind} {cell.name!r} {net_to_str(cell.test)} {net_to_str(cell.en)}")
+            elif isinstance(cell, SyncProperty):
+                result.append(f"{cell.kind} {cell.name!r} {net_to_str(cell.test)} {net_to_str(cell.en)} @{cell.clk_edge}edge {net_to_str(cell.clk)}")
+            elif isinstance(cell, Instance):
+                result.append("instance {cell.type} {cell.name}")
+                for attr_name, attr_value in cell.attributes.items():
+                    result.append(f"    attribute {attr_name} {attr_value}")
+                for attr_name, attr_value in cell.parameters.items():
+                    result.append(f"    parameter {attr_name} {attr_value}")
+                for attr_name, (start, num) in cell.ports_o.items():
+                    result.append(f"    output {attr_name} {cell_idx}.{start}..{cell_idx}.{start+num-1}")
+                for attr_name, attr_value in cell.ports_i.items():
+                    result.append(f"    input {attr_name} {val_to_str(attr_value)}")
+                for attr_name, attr_value in cell.ports_io.items():
+                    result.append(f"    inout {attr_name} {val_to_str(attr_value)}")
+            elif isinstance(cell, IOBuffer):
+                result.append(f"iob {val_to_str(cell.pad)} {val_to_str(cell.o)} {net_to_str(cell.oe)}")
+            else:
+                assert False # :nocov:
+        return "\n".join(result)
+
+    def add_module(self, parent, name: str):
+        module_idx = len(self.modules)
+        self.modules.append(Module(parent, name))
+        if module_idx == 0:
+            self.modules[0].cells.append(0)
+        if parent is not None:
+            self.modules[parent].submodules.append(module_idx)
+        return module_idx
+
+    def add_cell(self, cell):
+        idx = len(self.cells)
+        self.cells.append(cell)
+        self.modules[cell.module_idx].cells.append(idx)
+        return idx
+
+    def add_value_cell(self, width: int, cell):
+        cell_idx = self.add_cell(cell)
+        return Value(Net.from_cell(cell_idx, bit) for bit in range(width))
+
+    def alloc_late_value(self, width: int):
+        self.last_late_net -= width
+        return Value(Net.from_late(self.last_late_net + bit) for bit in range(width))
+
+    @property
+    def top(self):
+        top = self.cells[0]
+        assert isinstance(top, Top)
+        return top
+
+
+class ModuleNetFlow(enum.Enum):
+    """Describes how a given Net flows into or out of a Module.
+
+    The net can also be none of these (not present in the dictionary at all),
+    when it is not present in the module at all.
+    """
+
+    #: The net is present in the module (used in the module or needs
+    #: to be routed through it between its submodules), but is not
+    #: present outside its subtree and thus is not a port of this module.
+    INTERNAL = "internal"
+
+    #: The net is present in the module, and is not driven from
+    #: the module or any of its submodules.  It is thus an input
+    #: port of this module.
+    INPUT    = "input"
+
+    #: The net is present in the module, is driven from the module or
+    #: one of its submodules, and is also used outside of its subtree.
+    #: It is thus an output port of this module.
+    OUTPUT   = "output"
+
+    #: The net is a special top-level inout net that is used within
+    #: this module or its submodules.  It is an inout port of this module.
+    INOUT    = "inout"
+
+
+class Module:
+    """A module within the netlist.
+
+    Attributes
+    ----------
+
+    parent: index of parent module, or ``None`` for top module
+    name: a tuple of str, hierarchical name of this module (top has empty tuple)
+    submodules: a list of nested module indices
+    signal_names: a SignalDict from Signal to str, signal names visible in this module
+    net_flow: a dict from Net to NetFlow, describes how a net is used within this module
+    ports: a dict from port name to (Value, NetFlow) pair
+    cells: a list of cell indices that belong to this module
+    """
+    def __init__(self, parent, name):
+        self.parent = parent
+        self.name = name
+        self.submodules = []
+        self.signal_names = SignalDict()
+        self.net_flow = {}
+        self.ports = {}
+        self.cells = []
+
+
+class Cell:
+    """A base class for all cell types.
+
+    Attributes
+    ----------
+
+    src_loc: str
+    module: int, index of the module this cell belongs to (within Netlist.modules)
+    """
+
+    def __init__(self, module_idx: int, *, src_loc):
+        self.module_idx = module_idx
+        self.src_loc = src_loc
+
+    def input_nets(self):
+        raise NotImplementedError
+
+    def output_nets(self, self_idx: int):
+        raise NotImplementedError
+
+    def resolve_nets(self, netlist: Netlist):
+        raise NotImplementedError
+
+
+class Top(Cell):
+    """A special cell type representing top-level ports. Must be present in the netlist exactly
+    once, at index 0.
+
+    Top-level outputs are stored as a dict of names to their assigned values.
+
+    Top-level inputs and inouts are effectively the output of this cell. They are both stored
+    as a dict of names to a (start bit index, width) tuple. Output bit indices 0 and 1 are reserved
+    for constant nets, so the lowest bit index that can be assigned to a port is 2.
+
+    Top-level inouts are special and can only be used by inout ports of instances, or in the pad
+    value of an ``IoBuf`` cell.
+
+    Attributes
+    ----------
+
+    ports_o: dict of str to Value
+    ports_i: dict of str to (int, int)
+    ports_io: dict of str to (int, int)
+    """
+    def __init__(self):
+        super().__init__(module_idx=0, src_loc=None)
+
+        self.ports_o = {}
+        self.ports_i = {}
+        self.ports_io = {}
+
+    def input_nets(self):
+        nets = set()
+        for value in self.ports_o.values():
+            nets |= set(value)
+        return nets
+
+    def output_nets(self, self_idx: int):
+        nets = set()
+        for start, width in self.ports_i.values():
+            for bit in range(start, start + width):
+                nets.add(Net.from_cell(self_idx, bit))
+        for start, width in self.ports_io.values():
+            for bit in range(start, start + width):
+                nets.add(Net.from_cell(self_idx, bit))
+        return nets
+
+    def resolve_nets(self, netlist: Netlist):
+        for port in self.ports_o:
+            self.ports_o[port] = netlist.resolve_value(self.ports_o[port])
+
+
+class Operator(Cell):
+    """Roughly corresponds to ``hdl.ast.Operator``.
+
+    The available operators are roughly the same as in AST, with some changes:
+
+    - '<', '>', '<=', '>=', '//', '%', '>>' have signed and unsigned variants that are selected
+      by prepending 'u' or 's' to operator name
+    - 's', 'u', and unary '+' are redundant and do not exist
+    - many operators restrict input widths to be the same as output width,
+      and/or to be the same as each other
+
+    The unary operators are:
+
+    - '-', '~': like AST, input same width as output
+    - 'b', 'r|', 'r&', 'r^': like AST, 1-bit output
+
+    The binary operators are:
+
+    - '+', '-', '*', '&', '^', '|', 'u//', 's//', 'u%', 's%': like AST, both inputs same width as output
+    - '<<', 'u>>', 's>>': like AST, first input same width as output
+    - '==', '!=', 'u<', 's<', 'u>', 's>', 'u<=', 's<=', 'u>=', 's>=': like AST, both inputs need to have
+      the same width, 1-bit output
+
+    The ternary operators are:
+
+    - 'm': like AST, first input needs to have width of 1, second and third operand need to have the same
+      width as output
+
+    Attributes
+    ----------
+
+    operator: str, symbol of the operator (from the above list)
+    inputs: tuple of Value
+    """
+
+    def __init__(self, module_idx, *, operator: str, inputs, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        self.operator = operator
+        self.inputs = tuple(Value(input) for input in inputs)
+
+    @property
+    def width(self):
+        if self.operator in ('~', '-', '+', '*', '&', '^', '|', 'u//', 's//', 'u%', 's%', '<<', 'u>>', 's>>'):
+            return len(self.inputs[0])
+        elif self.operator in ('b', 'r&', 'r^', 'r|', '==', '!=', 'u<', 's<', 'u>', 's>', 'u<=', 's<=', 'u>=', 's>='):
+            return 1
+        elif self.operator == 'm':
+            return len(self.inputs[1])
+        else:
+            assert False # :nocov:
+
+    def input_nets(self):
+        nets = set()
+        for value in self.inputs:
+            nets |= set(value)
+        return nets
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, bit) for bit in range(self.width)}
+
+    def resolve_nets(self, netlist: Netlist):
+        self.inputs = tuple(netlist.resolve_value(val) for val in self.inputs)
+
+
+class Part(Cell):
+    """Corresponds to ``hdl.ast.Part``.
+
+    Attributes
+    ----------
+
+    value: Value, the data input
+    value_signed: bool
+    offset: Value, the offset input
+    width: int
+    stride: int
+    """
+    def __init__(self, module_idx, *, value, value_signed, offset, width, stride, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        assert type(width) is int
+        assert type(stride) is int
+
+        self.value = Value(value)
+        self.value_signed = value_signed
+        self.offset = Value(offset)
+        self.width = width
+        self.stride = stride
+
+    def input_nets(self):
+        return set(self.value) | set(self.offset)
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, bit) for bit in range(self.width)}
+
+    def resolve_nets(self, netlist: Netlist):
+        self.value = netlist.resolve_value(self.value)
+        self.offset = netlist.resolve_value(self.offset)
+
+
+class ArrayMux(Cell):
+    """Corresponds to ``hdl.ast.ArrayProxy``. All values in the ``elems`` array need to have
+    the same width as the output.
+
+    Attributes
+    ----------
+
+    width: int (width of output and all inputs)
+    elems: tuple of Value
+    index: Value
+    """
+    def __init__(self, module_idx, *, width, elems, index, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        self.width = width
+        self.elems = tuple(Value(val) for val in elems)
+        self.index = Value(index)
+
+    def input_nets(self):
+        nets = set(self.index)
+        for value in self.elems:
+            nets |= set(value)
+        return nets
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, bit) for bit in range(self.width)}
+
+    def resolve_nets(self, netlist: Netlist):
+        self.elems = tuple(netlist.resolve_value(val) for val in self.elems)
+        self.index = netlist.resolve_value(self.index)
+
+
+class Matches(Cell):
+    """A combinatorial cell performing a comparison like ``Value.matches``
+    (or, equivalently, a case condition).
+
+    Attributes
+    ----------
+
+    value: Value
+    patterns: tuple of str, each str contains '0', '1', '-'
+    """
+    def __init__(self, module_idx, *, value, patterns, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        self.value = Value(value)
+        self.patterns = tuple(patterns)
+
+    def input_nets(self):
+        return set(self.value)
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, 0)}
+
+    def resolve_nets(self, netlist: Netlist):
+        self.value = netlist.resolve_value(self.value)
+
+
+class PriorityMatch(Cell):
+    """Used to represent a single switch on the control plane of processes.
+
+    The output is the same length as ``inputs``. If ``en`` is ``0``, the output
+    is all-0. Otherwise, output keeps the lowest-numbered ``1`` bit in the input
+    (if any) and masks all other bits to ``0``.
+
+    Note: the RTLIL backend requires all bits of ``inputs`` to be driven
+    by a ``Match`` cell within the same module.
+
+    Attributes
+    ----------
+    en: Net
+    inputs: Value
+    """
+    def __init__(self, module_idx, *, en, inputs, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        self.en = Net.ensure(en)
+        self.inputs = Value(inputs)
+
+    def input_nets(self):
+        return set(self.inputs) | {self.en}
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, bit) for bit in range(len(self.inputs))}
+
+    def resolve_nets(self, netlist: Netlist):
+        self.en = netlist.resolve_net(self.en)
+        self.inputs = netlist.resolve_value(self.inputs)
+
+
+class Assignment:
+    """A single assignment in an ``AssignmentList``.
+
+    The assignment is executed iff ``cond`` is true. When the assignment
+    is executed, ``len(value)`` bits starting at position `offset` are set
+    to the value ``value``, and the remaining bits are unchanged.
+    Assignments to out-of-bounds bit indices are ignored.
+
+    Attributes
+    ----------
+
+    cond: Net
+    start: int
+    value: Value
+    src_loc: str
+    """
+    def __init__(self, *, cond, start, value, src_loc):
+        assert isinstance(start, int)
+        self.cond = Net.ensure(cond)
+        self.start = start
+        self.value = Value(value)
+        self.src_loc = src_loc
+
+    def resolve_nets(self, netlist: Netlist):
+        self.cond = netlist.resolve_net(self.cond)
+        self.value = netlist.resolve_value(self.value)
+
+
+class AssignmentList(Cell):
+    """Used to represent a single assigned signal on the data plane of processes.
+
+    The output of this cell is determined by starting with the ``default`` value,
+    then executing each assignment in sequence.
+
+    Note: the RTLIL backend requires all ``cond`` inputs of assignments to be driven
+    by a ``PriorityMatch`` cell within the same module.
+
+    Attributes
+    ----------
+    default: Value
+    assignments: tuple of ``Assignment``
+    """
+    def __init__(self, module_idx, *, default, assignments, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        assignments = tuple(assignments)
+        for assign in assignments:
+            assert isinstance(assign, Assignment)
+
+        self.default = Value(default)
+        self.assignments: tuple[Assignment, ...] = assignments
+
+    def input_nets(self):
+        nets = set(self.default)
+        for assign in self.assignments:
+            nets.add(assign.cond)
+            nets |= set(assign.value)
+        return nets
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, bit) for bit in range(len(self.default))}
+
+    def resolve_nets(self, netlist: Netlist):
+        for assign in self.assignments:
+            assign.resolve_nets(netlist)
+        self.default = netlist.resolve_value(self.default)
+
+
+class FlipFlop(Cell):
+    """A flip-flop. ``data`` is the data input. ``init`` is the initial and async reset value.
+    ``clk`` and ``clk_edge`` work as in a ``ClockDomain``. ``arst`` is the async reset signal,
+    or ``0`` if async reset is not used.
+
+    Attributes
+    ----------
+
+    data: Value
+    init: int
+    clk: Net
+    clk_edge: str, either 'pos' or 'neg'
+    arst: Net
+    attributes: dict from str to int, Const, or str
+    """
+    def __init__(self, module_idx, *, data, init, clk, clk_edge, arst, attributes, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        assert clk_edge in ('pos', 'neg')
+        assert type(init) is int
+
+        self.data = Value(data)
+        self.init = init
+        self.clk = Net.ensure(clk)
+        self.clk_edge = clk_edge
+        self.arst = Net.ensure(arst)
+        self.attributes = attributes
+
+    def input_nets(self):
+        return set(self.data) | {self.clk, self.arst}
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, bit) for bit in range(len(self.data))}
+
+    def resolve_nets(self, netlist: Netlist):
+        self.data = netlist.resolve_value(self.data)
+        self.clk = netlist.resolve_net(self.clk)
+        self.arst = netlist.resolve_net(self.arst)
+
+
+class Memory(Cell):
+    """Corresponds to ``Memory``.  ``init`` must have length equal to ``depth``.
+    Read and write ports are separate cells.
+
+    Attributes
+    ----------
+
+    width: int
+    depth: int
+    init: tuple of int
+    name: str
+    attributes: dict from str to int, Const, or str
+    """
+    def __init__(self, module_idx, *, width, depth, init, name, attributes, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        self.width = width
+        self.depth = depth
+        self.init = tuple(init)
+        self.name = name
+        self.attributes = attributes
+
+    def input_nets(self):
+        return set()
+
+    def output_nets(self, self_idx: int):
+        return set()
+
+    def resolve_nets(self, netlist: Netlist):
+        pass
+
+
+class SyncWritePort(Cell):
+    """A single write port of a memory.  This cell has no output.
+
+    Attributes
+    ----------
+
+    memory: cell index of ``Memory``
+    data: Value
+    addr: Value
+    en: Value
+    clk: Net
+    clk_edge: str, either 'pos' or 'neg'
+    """
+    def __init__(self, module_idx, memory, *, data, addr, en, clk, clk_edge, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        assert clk_edge in ('pos', 'neg')
+        self.memory = memory
+        self.data = Value(data)
+        self.addr = Value(addr)
+        self.en = Value(en)
+        self.clk = Net.ensure(clk)
+        self.clk_edge = clk_edge
+
+    def input_nets(self):
+        return set(self.data) | set(self.addr) | set(self.en) | {self.clk}
+
+    def output_nets(self, self_idx: int):
+        return set()
+
+    def resolve_nets(self, netlist: Netlist):
+        self.data = netlist.resolve_value(self.data)
+        self.addr = netlist.resolve_value(self.addr)
+        self.en = netlist.resolve_value(self.en)
+        self.clk = netlist.resolve_net(self.clk)
+
+
+class AsyncReadPort(Cell):
+    """A single asynchronous read port of a memory.
+
+    Attributes
+    ----------
+
+    memory: cell index of ``Memory``
+    width: int
+    addr: Value
+    """
+    def __init__(self, module_idx, memory, *, width, addr, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        self.memory = memory
+        self.width = width
+        self.addr = Value(addr)
+
+    def input_nets(self):
+        return set(self.addr)
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, bit) for bit in range(self.width)}
+
+    def resolve_nets(self, netlist: Netlist):
+        self.addr = netlist.resolve_value(self.addr)
+
+class SyncReadPort(Cell):
+    """A single synchronous read port of a memory.  The cell output is the data port.
+    ``transparent_for`` is the set of write ports (identified by cell index) that this
+    read port is transparent with.
+
+    Attributes
+    ----------
+
+    memory: cell index of ``Memory``
+    width: int
+    addr: Value
+    en: Net
+    clk: Net
+    clk_edge: str, either 'pos' or 'neg'
+    transparent_for: tuple of int
+    """
+    def __init__(self, module_idx, memory, *, width, addr, en, clk, clk_edge, transparent_for, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        assert clk_edge in ('pos', 'neg')
+        self.memory = memory
+        self.width = width
+        self.addr = Value(addr)
+        self.en = Net.ensure(en)
+        self.clk = Net.ensure(clk)
+        self.clk_edge = clk_edge
+        self.transparent_for = tuple(transparent_for)
+
+    def input_nets(self):
+        return set(self.addr) | {self.en, self.clk}
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, bit) for bit in range(self.width)}
+
+    def resolve_nets(self, netlist: Netlist):
+        self.addr = netlist.resolve_value(self.addr)
+        self.en = netlist.resolve_net(self.en)
+        self.clk = netlist.resolve_net(self.clk)
+
+class Initial(Cell):
+    """Corresponds to ``Initial`` value."""
+
+    def input_nets(self):
+        return set()
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, 0)}
+
+    def resolve_nets(self, netlist: Netlist):
+        pass
+
+
+class AnyValue(Cell):
+    """Corresponds to ``AnyConst`` or ``AnySeq``. ``kind`` must be either ``'anyconst'``
+    or ``'anyseq'``.
+
+    Attributes
+    ----------
+
+    kind: str, 'anyconst' or 'anyseq'
+    width: int
+    """
+    def __init__(self, module_idx, *, kind, width, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        assert kind in ('anyconst', 'anyseq')
+        self.kind = kind
+        self.width = width
+
+    def input_nets(self):
+        return set()
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, bit) for bit in range(self.width)}
+
+    def resolve_nets(self, netlist: Netlist):
+        pass
+
+
+class AsyncProperty(Cell):
+    """Corresponds to ``Assert``, ``Assume``, or ``Cover`` in the "comb" domain.
+
+    Attributes
+    ----------
+
+    kind: str, either 'assert', 'assume', or 'cover'
+    test: Net
+    en: Net
+    name: str
+    """
+    def __init__(self, module_idx, *, kind, test, en, name, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        assert kind in ('assert', 'assume', 'cover')
+        self.kind = kind
+        self.test = Net.ensure(test)
+        self.en = Net.ensure(en)
+        self.name = name
+
+    def input_nets(self):
+        return {self.test, self.en}
+
+    def output_nets(self, self_idx: int):
+        return set()
+
+    def resolve_nets(self, netlist: Netlist):
+        self.test = netlist.resolve_net(self.test)
+        self.en = netlist.resolve_net(self.en)
+
+
+class SyncProperty(Cell):
+    """Corresponds to ``Assert``, ``Assume``, or ``Cover`` in domains other than "comb".
+
+    Attributes
+    ----------
+
+    kind: str, either 'assert', 'assume', or 'cover'
+    test: Net
+    en: Net
+    clk: Net
+    clk_edge: str, either 'pos' or 'neg'
+    name: str
+    """
+
+    def __init__(self, module_idx, *, kind, test, en, clk, clk_edge, name, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        assert kind in ('assert', 'assume', 'cover')
+        assert clk_edge in ('pos', 'neg')
+        self.kind = kind
+        self.test = Net.ensure(test)
+        self.en = Net.ensure(en)
+        self.clk = Net.ensure(clk)
+        self.clk_edge = clk_edge
+        self.name = name
+
+    def input_nets(self):
+        return {self.test, self.en, self.clk}
+
+    def output_nets(self, self_idx: int):
+        return set()
+
+    def resolve_nets(self, netlist: Netlist):
+        self.test = netlist.resolve_net(self.test)
+        self.en = netlist.resolve_net(self.en)
+        self.clk = netlist.resolve_net(self.clk)
+
+
+class Instance(Cell):
+    """Corresponds to ``Instance``. ``type``, ``parameters`` and ``attributes`` work the same as in
+    ``Instance``. Input and inout ports are represented as a dict of port names to values.
+    Inout ports must be connected to nets corresponding to an IO port of the ``Top`` cell.
+
+    Output ports are represented as a dict of port names to (start bit index, width) describing
+    their position in the virtual "output" of this cell.
+
+    Attributes
+    ----------
+
+    type: str
+    name: str
+    parameters: dict of str to Const, int, or str
+    attributes: dict of str to Const, int, or str
+    ports_i: dict of str to Value
+    ports_o: dict of str to pair of int (index start, width)
+    ports_io: dict of str to Value
+    """
+
+    def __init__(self, module_idx, *, type, name, parameters, attributes, ports_i, ports_o, ports_io, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        self.type = type
+        self.name = name
+        self.parameters = parameters
+        self.attributes = attributes
+        self.ports_i = {name: Value(val) for name, val in ports_i.items()}
+        self.ports_o = ports_o
+        self.ports_io = {name: Value(val) for name, val in ports_io.items()}
+
+    def input_nets(self):
+        nets = set()
+        for val in self.ports_i.values():
+            nets |= set(val)
+        for val in self.ports_io.values():
+            nets |= set(val)
+        return nets
+
+    def output_nets(self, self_idx: int):
+        nets = set()
+        for start, width in self.ports_o.values():
+            for bit in range(start, start + width):
+                nets.add(Net.from_cell(self_idx, bit))
+        return nets
+
+    def resolve_nets(self, netlist: Netlist):
+        for port in self.ports_i:
+            self.ports_i[port] = netlist.resolve_value(self.ports_i[port])
+        for port in self.ports_io:
+            self.ports_io[port] = netlist.resolve_value(self.ports_io[port])
+
+
+class IOBuffer(Cell):
+    """An IO buffer cell. ``pad`` must be connected to nets corresponding to an IO port
+    of the ``Top`` cell. This cell does two things:
+
+    - a tristate buffer is inserted driving ``pad`` based on ``o`` and ``oe`` nets (output buffer)
+    - the value of ``pad`` is sampled and made available as output of this cell (input buffer)
+
+    Attributes
+    ----------
+
+    pad: Value
+    o: Value
+    oe: Net
+    """
+    def __init__(self, module_idx, *, pad, o, oe, src_loc):
+        super().__init__(module_idx, src_loc=src_loc)
+
+        self.pad = Value(pad)
+        self.o = Value(o)
+        self.oe = Net.ensure(oe)
+
+    def input_nets(self):
+        return set(self.pad) | set(self.o) | {self.oe}
+
+    def output_nets(self, self_idx: int):
+        return {Net.from_cell(self_idx, bit) for bit in range(len(self.pad))}
+
+    def resolve_nets(self, netlist: Netlist):
+        self.pad = netlist.resolve_value(self.pad)
+        self.o = netlist.resolve_value(self.o)
+        self.oe = netlist.resolve_net(self.oe)
diff --git a/amaranth/hdl/_xfrm.py b/amaranth/hdl/_xfrm.py
index 2ba1fa3..ec3c057 100644
--- a/amaranth/hdl/_xfrm.py
+++ b/amaranth/hdl/_xfrm.py
@@ -16,8 +16,7 @@ __all__ = ["ValueVisitor", "ValueTransformer",
            "FragmentTransformer",
            "TransformedElaboratable",
            "DomainCollector", "DomainRenamer", "DomainLowerer",
-           "SwitchCleaner", "LHSGroupAnalyzer", "LHSGroupFilter",
-           "ResetInserter", "EnableInserter"]
+           "ResetInserter", "EnableInserter", "AssignmentLegalizer"]
 
 
 class ValueVisitor(metaclass=ABCMeta):
@@ -195,7 +194,7 @@ class StatementTransformer(StatementVisitor):
         return Assign(self.on_value(stmt.lhs), self.on_value(stmt.rhs))
 
     def on_Property(self, stmt):
-        return Property(stmt.kind, self.on_value(stmt.test), _check=stmt._check, _en=stmt._en, name=stmt.name)
+        return Property(stmt.kind, self.on_value(stmt.test), name=stmt.name)
 
     def on_Switch(self, stmt):
         cases = OrderedDict((k, self.on_statement(s)) for k, s in stmt.cases.items())
@@ -533,97 +532,6 @@ class DomainLowerer(FragmentTransformer, ValueTransformer, StatementTransformer)
         return new_fragment
 
 
-class SwitchCleaner(StatementVisitor):
-    def on_ignore(self, stmt):
-        return stmt
-
-    on_Assign   = on_ignore
-    on_Property = on_ignore
-
-    def on_Switch(self, stmt):
-        cases = OrderedDict((k, self.on_statement(s)) for k, s in stmt.cases.items())
-        if any(len(s) for s in cases.values()):
-            return Switch(stmt.test, cases)
-
-    def on_statements(self, stmts):
-        stmts = flatten(self.on_statement(stmt) for stmt in stmts)
-        return _StatementList(stmt for stmt in stmts if stmt is not None)
-
-
-class LHSGroupAnalyzer(StatementVisitor):
-    def __init__(self):
-        self.signals = SignalDict()
-        self.unions  = OrderedDict()
-
-    def find(self, signal):
-        if signal not in self.signals:
-            self.signals[signal] = len(self.signals)
-        group = self.signals[signal]
-        while group in self.unions:
-            group = self.unions[group]
-        self.signals[signal] = group
-        return group
-
-    def unify(self, root, *leaves):
-        root_group = self.find(root)
-        for leaf in leaves:
-            leaf_group = self.find(leaf)
-            if root_group == leaf_group:
-                continue
-            self.unions[leaf_group] = root_group
-
-    def groups(self):
-        groups = OrderedDict()
-        for signal in self.signals:
-            group = self.find(signal)
-            if group not in groups:
-                groups[group] = SignalSet()
-            groups[group].add(signal)
-        return groups
-
-    def on_Assign(self, stmt):
-        lhs_signals = stmt._lhs_signals()
-        if lhs_signals:
-            self.unify(*stmt._lhs_signals())
-
-    def on_Property(self, stmt):
-        lhs_signals = stmt._lhs_signals()
-        if lhs_signals:
-            self.unify(*stmt._lhs_signals())
-
-    def on_Switch(self, stmt):
-        for case_stmts in stmt.cases.values():
-            self.on_statements(case_stmts)
-
-    def on_statements(self, stmts):
-        assert not isinstance(stmts, str)
-        for stmt in stmts:
-            self.on_statement(stmt)
-
-    def __call__(self, stmts):
-        self.on_statements(stmts)
-        return self.groups()
-
-
-class LHSGroupFilter(SwitchCleaner):
-    def __init__(self, signals):
-        self.signals = signals
-
-    def on_Assign(self, stmt):
-        # The invariant provided by LHSGroupAnalyzer is that all signals that ever appear together
-        # on LHS are a part of the same group, so it is sufficient to check any of them.
-        lhs_signals = stmt.lhs._lhs_signals()
-        if lhs_signals:
-            any_lhs_signal = next(iter(lhs_signals))
-            if any_lhs_signal in self.signals:
-                return stmt
-
-    def on_Property(self, stmt):
-        any_lhs_signal = next(iter(stmt._lhs_signals()))
-        if any_lhs_signal in self.signals:
-            return stmt
-
-
 class _ControlInserter(FragmentTransformer):
     def __init__(self, controls):
         self.src_loc = None
@@ -655,10 +563,23 @@ class ResetInserter(_ControlInserter):
         fragment.add_statements(domain, Switch(self.controls[domain], {1: stmts}, src_loc=self.src_loc))
 
 
+class _PropertyEnableInserter(StatementTransformer):
+    def __init__(self, en):
+        self.en = en
+
+    def on_Property(self, stmt):
+        return Switch(
+            self.en,
+            {1: [stmt]},
+            src_loc=stmt.src_loc,
+        )
+
+
 class EnableInserter(_ControlInserter):
     def _insert_control(self, fragment, domain, signals):
         stmts = [s.eq(s) for s in signals]
         fragment.add_statements(domain, Switch(self.controls[domain], {0: stmts}, src_loc=self.src_loc))
+        fragment.statements[domain] = _PropertyEnableInserter(self.controls[domain])(fragment.statements[domain])
 
     def on_fragment(self, fragment):
         new_fragment = super().on_fragment(fragment)
@@ -670,3 +591,85 @@ class EnableInserter(_ControlInserter):
                 if port._domain in self.controls:
                     port._en = Mux(self.controls[port._domain], port._en, Const(0, len(port._en)))
         return new_fragment
+
+
+class AssignmentLegalizer(FragmentTransformer, StatementTransformer):
+    """Ensures all assignments in switches have one of the following on the LHS:
+
+    - a `Signal`
+    - a `Slice` with `value` that is a `Signal`
+    """
+    def emit_assign(self, lhs, rhs, lhs_start=0, lhs_stop=None):
+        if isinstance(lhs, ArrayProxy):
+            # Lower into a switch.
+            cases = {}
+            for idx, val in enumerate(lhs.elems):
+                cases[idx] = self.emit_assign(val, rhs, lhs_start, lhs_stop)
+            return [Switch(lhs.index, cases)]
+        elif isinstance(lhs, Part):
+            offset = lhs.offset
+            width = lhs.width
+            if lhs_start != 0:
+                width -= lhs_start
+            if lhs_stop is not None:
+                width = lhs_stop - lhs_start
+            cases = {}
+            lhs_width = len(lhs.value)
+            for idx in range(lhs_width):
+                start = lhs_start + idx * lhs.stride
+                if start >= lhs_width:
+                    break
+                stop = min(start + width, lhs_width)
+                cases[idx] = self.emit_assign(lhs.value, rhs, start, stop)
+            return [Switch(offset, cases)]
+        elif isinstance(lhs, Slice):
+            part_start = lhs_start + lhs.start
+            if lhs_stop is not None:
+                part_stop = lhs_stop + lhs.start
+            else:
+                part_stop = lhs_start + lhs.stop
+            return self.emit_assign(lhs.value, rhs, part_start, part_stop)
+        elif isinstance(lhs, Cat):
+            # Split into several assignments.
+            part_stop = 0
+            res = []
+            if lhs_stop is None:
+                lhs_len = len(lhs) - lhs_start
+            else:
+                lhs_len = lhs_stop - lhs_start
+            if len(rhs) < lhs_len:
+                rhs |= Const(0, Shape(lhs_len, signed=rhs.shape().signed))
+            for val in lhs.parts:
+                part_start = part_stop
+                part_len = len(val)
+                part_stop = part_start + part_len
+                if lhs_start >= part_stop:
+                    continue
+                if lhs_start < part_start:
+                    part_lhs_start = 0
+                    part_rhs_start = part_start - lhs_start
+                else:
+                    part_lhs_start = lhs_start - part_start
+                    part_rhs_start = 0
+                if lhs_stop is not None and lhs_stop <= part_start:
+                    continue
+                elif lhs_stop is None or lhs_stop >= part_stop:
+                    part_lhs_stop = None
+                else:
+                    part_lhs_stop = lhs_stop - part_start
+                res += self.emit_assign(val, rhs[part_rhs_start:], part_lhs_start, part_lhs_stop)
+            return res
+        elif isinstance(lhs, Signal):
+            # Already ok.
+            if lhs_start != 0 or lhs_stop is not None:
+                return [Assign(lhs[lhs_start:lhs_stop], rhs)]
+            else:
+                return [Assign(lhs, rhs)]
+        elif isinstance(lhs, Operator):
+            assert lhs.operator in ('u', 's')
+            return self.emit_assign(lhs.operands[0], rhs, lhs_start, lhs_stop)
+        else:
+            raise TypeError
+
+    def on_Assign(self, stmt):
+        return self.emit_assign(stmt.lhs, stmt.rhs)
diff --git a/amaranth/hdl/ast.py b/amaranth/hdl/ast.py
index c69f85d..6f575f1 100644
--- a/amaranth/hdl/ast.py
+++ b/amaranth/hdl/ast.py
@@ -13,7 +13,7 @@ __all__ = [
     "Initial",
     "Statement", "Switch",
     "Property", "Assign", "Assert", "Assume", "Cover",
-    "ValueKey", "ValueDict", "ValueSet", "SignalKey", "SignalDict", "SignalSet",
+    "SignalKey", "SignalDict", "SignalSet",
 ]
 
 
diff --git a/amaranth/hdl/xfrm.py b/amaranth/hdl/xfrm.py
index 2b96db6..b490f00 100644
--- a/amaranth/hdl/xfrm.py
+++ b/amaranth/hdl/xfrm.py
@@ -9,7 +9,6 @@ __all__ = ["ValueVisitor", "ValueTransformer",
            "FragmentTransformer",
            "TransformedElaboratable",
            "DomainCollector", "DomainRenamer", "DomainLowerer",
-           "SwitchCleaner", "LHSGroupAnalyzer", "LHSGroupFilter",
            "ResetInserter", "EnableInserter"]
 
 
diff --git a/amaranth/sim/core.py b/amaranth/sim/core.py
index a50e468..0c7ecaa 100644
--- a/amaranth/sim/core.py
+++ b/amaranth/sim/core.py
@@ -80,7 +80,6 @@ class Simulator:
                             .format(process))
         return process
 
-    @deprecated("The `add_process` method is deprecated per RFC 27. Use `add_testbench` instead.")
     def add_process(self, process):
         process = self._check_process(process)
         def wrapper():
@@ -89,6 +88,7 @@ class Simulator:
             yield from process()
         self._engine.add_coroutine_process(wrapper, default_cmd=None)
 
+    @deprecated("The `add_sync_process` method is deprecated per RFC 47. Use `add_process` or `add_testbench` instead.")
     def add_sync_process(self, process, *, domain="sync"):
         process = self._check_process(process)
         def wrapper():
@@ -107,25 +107,6 @@ class Simulator:
                 except StopIteration:
                     break
                 try:
-                    if isinstance(command, (Settle, Delay, Tick)):
-                        frame = generator.gi_frame
-                        module_globals = frame.f_globals
-                        if '__name__' in module_globals:
-                            module = module_globals['__name__']
-                        else:
-                            module = "<string>"
-                        # If the warning action is "error", this call will throw the warning, and
-                        # the try block will redirect it into the generator.
-                        warnings.warn_explicit(
-                            f"Using `{command.__class__.__name__}` is deprecated within "
-                            f"`add_sync_process` per RFC 27; use `add_testbench` instead.",
-                            DeprecationWarning,
-                            filename=frame.f_code.co_filename,
-                            lineno=frame.f_lineno,
-                            module=module,
-                            registry=module_globals.setdefault("__warningregistry__", {}),
-                            module_globals=module_globals,
-                        )
                     result = yield command
                     exception = None
                 except Exception as e:
diff --git a/docs/changes.rst b/docs/changes.rst
index 08792f8..8d7908a 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -29,9 +29,8 @@ Apply the following changes to code written against Amaranth 0.4 to migrate it t
 * Replace uses of ``Value.matches()`` with no patterns with ``Const(1)``
 * Update uses of ``amaranth.utils.log2_int(need_pow2=False)`` to :func:`amaranth.utils.ceil_log2`
 * Update uses of ``amaranth.utils.log2_int(need_pow2=True)`` to :func:`amaranth.utils.exact_log2`
-* Update uses of ``Simulator.add_process`` to ``Simulator.add_testbench``
 * Convert uses of ``Simulator.add_sync_process`` used as testbenches to ``Simulator.add_testbench``
-* Convert uses of ``yield Tick()`` within remaining ``Simulator.add_sync_process`` to plain ``yield``
+* Convert other uses of ``Simulator.add_sync_process`` to ``Simulator.add_process``
 
 
 Implemented RFCs
@@ -81,7 +80,7 @@ Toolchain changes
 
 * Added: ``Simulator.add_testbench``. (`RFC 27`_)
 * Deprecated: ``Settle`` simulation command. (`RFC 27`_)
-* Deprecated: ``Simulator.add_process``. (`RFC 27`_)
+* Deprecated: ``Simulator.add_sync_process``. (`RFC 27`_)
 * Removed: (deprecated in 0.4) use of mixed-case toolchain environment variable names, such as ``NMIGEN_ENV_Diamond`` or ``AMARANTH_ENV_Diamond``; use upper-case environment variable names, such as ``AMARANTH_ENV_DIAMOND``.
 
 
diff --git a/examples/basic/ctr_en.py b/examples/basic/ctr_en.py
index 833b8bd..ae67c52 100644
--- a/examples/basic/ctr_en.py
+++ b/examples/basic/ctr_en.py
@@ -23,12 +23,12 @@ print(verilog.convert(ctr, ports=[ctr.o, ctr.en]))
 sim = Simulator(ctr)
 sim.add_clock(1e-6)
 def ce_proc():
-    yield; yield; yield
+    yield Tick(); yield Tick(); yield Tick()
     yield ctr.en.eq(1)
-    yield; yield; yield
+    yield Tick(); yield Tick(); yield Tick()
     yield ctr.en.eq(0)
-    yield; yield; yield
+    yield Tick(); yield Tick(); yield Tick()
     yield ctr.en.eq(1)
-sim.add_sync_process(ce_proc)
+sim.add_testbench(ce_proc)
 with sim.write_vcd("ctrl.vcd", "ctrl.gtkw", traces=[ctr.en, ctr.v, ctr.o]):
     sim.run_until(100e-6, run_passive=True)


Test patch:
diff --git a/tests/test_hdl_xfrm.py b/tests/test_hdl_xfrm.py
index 7709ec3..56cc2ec 100644
--- a/tests/test_hdl_xfrm.py
+++ b/tests/test_hdl_xfrm.py
@@ -244,136 +244,6 @@ class DomainLowererTestCase(FHDLTestCase):
             DomainLowerer()(f)
 
 
-class SwitchCleanerTestCase(FHDLTestCase):
-    def test_clean(self):
-        a = Signal()
-        b = Signal()
-        c = Signal()
-        stmts = [
-            Switch(a, {
-                1: a.eq(0),
-                0: [
-                    b.eq(1),
-                    Switch(b, {1: [
-                        Switch(a|b, {})
-                    ]})
-                ]
-            })
-        ]
-
-        self.assertRepr(SwitchCleaner()(stmts), """
-        (
-            (switch (sig a)
-                (case 1
-                    (eq (sig a) (const 1'd0)))
-                (case 0
-                    (eq (sig b) (const 1'd1)))
-            )
-        )
-        """)
-
-
-class LHSGroupAnalyzerTestCase(FHDLTestCase):
-    def test_no_group_unrelated(self):
-        a = Signal()
-        b = Signal()
-        stmts = [
-            a.eq(0),
-            b.eq(0),
-        ]
-
-        groups = LHSGroupAnalyzer()(stmts)
-        self.assertEqual(list(groups.values()), [
-            SignalSet((a,)),
-            SignalSet((b,)),
-        ])
-
-    def test_group_related(self):
-        a = Signal()
-        b = Signal()
-        stmts = [
-            a.eq(0),
-            Cat(a, b).eq(0),
-        ]
-
-        groups = LHSGroupAnalyzer()(stmts)
-        self.assertEqual(list(groups.values()), [
-            SignalSet((a, b)),
-        ])
-
-    def test_no_loops(self):
-        a = Signal()
-        b = Signal()
-        stmts = [
-            a.eq(0),
-            Cat(a, b).eq(0),
-            Cat(a, b).eq(0),
-        ]
-
-        groups = LHSGroupAnalyzer()(stmts)
-        self.assertEqual(list(groups.values()), [
-            SignalSet((a, b)),
-        ])
-
-    def test_switch(self):
-        a = Signal()
-        b = Signal()
-        stmts = [
-            a.eq(0),
-            Switch(a, {
-                1: b.eq(0),
-            })
-        ]
-
-        groups = LHSGroupAnalyzer()(stmts)
-        self.assertEqual(list(groups.values()), [
-            SignalSet((a,)),
-            SignalSet((b,)),
-        ])
-
-    def test_lhs_empty(self):
-        stmts = [
-            Cat().eq(0)
-        ]
-
-        groups = LHSGroupAnalyzer()(stmts)
-        self.assertEqual(list(groups.values()), [
-        ])
-
-
-class LHSGroupFilterTestCase(FHDLTestCase):
-    def test_filter(self):
-        a = Signal()
-        b = Signal()
-        c = Signal()
-        stmts = [
-            Switch(a, {
-                1: a.eq(0),
-                0: [
-                    b.eq(1),
-                    Switch(b, {1: []})
-                ]
-            })
-        ]
-
-        self.assertRepr(LHSGroupFilter(SignalSet((a,)))(stmts), """
-        (
-            (switch (sig a)
-                (case 1
-                    (eq (sig a) (const 1'd0)))
-                (case 0 )
-            )
-        )
-        """)
-
-    def test_lhs_empty(self):
-        stmts = [
-            Cat().eq(0)
-        ]
-
-        self.assertRepr(LHSGroupFilter(SignalSet())(stmts), "()")
-
-
 class ResetInserterTestCase(FHDLTestCase):
     def setUp(self):
         self.s1 = Signal()
@@ -547,6 +417,329 @@ class EnableInserterTestCase(FHDLTestCase):
         """)
 
 
+class AssignmentLegalizerTestCase(FHDLTestCase):
+    def test_simple(self):
+        s1 = Signal(8)
+        s2 = Signal(8)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            s1.eq(s2)
+        )
+        f.add_driver(s1, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((eq (sig s1) (sig s2)))
+        """)
+
+    def test_simple_slice(self):
+        s1 = Signal(8)
+        s2 = Signal(4)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            s1[2:6].eq(s2)
+        )
+        f.add_driver(s1, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((eq (slice (sig s1) 2:6) (sig s2)))
+        """)
+
+    def test_simple_part(self):
+        s1 = Signal(8)
+        s2 = Signal(4)
+        s3 = Signal(4)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            s1.bit_select(s3, 4).eq(s2)
+        )
+        f.add_driver(s1, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((switch (sig s3)
+                (case 0000 (eq (slice (sig s1) 0:4) (sig s2)))
+                (case 0001 (eq (slice (sig s1) 1:5) (sig s2)))
+                (case 0010 (eq (slice (sig s1) 2:6) (sig s2)))
+                (case 0011 (eq (slice (sig s1) 3:7) (sig s2)))
+                (case 0100 (eq (slice (sig s1) 4:8) (sig s2)))
+                (case 0101 (eq (slice (sig s1) 5:8) (sig s2)))
+                (case 0110 (eq (slice (sig s1) 6:8) (sig s2)))
+                (case 0111 (eq (slice (sig s1) 7:8) (sig s2)))
+            ))
+        """)
+
+    def test_simple_part_word(self):
+        s1 = Signal(8)
+        s2 = Signal(4)
+        s3 = Signal(4)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            s1.word_select(s3, 4).eq(s2)
+        )
+        f.add_driver(s1, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((switch (sig s3)
+                (case 0000 (eq (slice (sig s1) 0:4) (sig s2)))
+                (case 0001 (eq (slice (sig s1) 4:8) (sig s2)))
+            ))
+        """)
+
+    def test_simple_concat(self):
+        s1 = Signal(4)
+        s2 = Signal(4)
+        s3 = Signal(4)
+        s4 = Signal(12)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            Cat(s1, s2, s3).eq(s4)
+        )
+        f.add_driver(s1, "sync")
+        f.add_driver(s2, "sync")
+        f.add_driver(s3, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            (
+                (eq (sig s1) (slice (sig s4) 0:12))
+                (eq (sig s2) (slice (sig s4) 4:12))
+                (eq (sig s3) (slice (sig s4) 8:12))
+            )
+        """)
+
+    def test_simple_concat_narrow(self):
+        s1 = Signal(4)
+        s2 = Signal(4)
+        s3 = Signal(4)
+        s4 = Signal(signed(6))
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            Cat(s1, s2, s3).eq(s4)
+        )
+        f.add_driver(s1, "sync")
+        f.add_driver(s2, "sync")
+        f.add_driver(s3, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            (
+                (eq (sig s1) (slice (| (sig s4) (const 12'sd0)) 0:12))
+                (eq (sig s2) (slice (| (sig s4) (const 12'sd0)) 4:12))
+                (eq (sig s3) (slice (| (sig s4) (const 12'sd0)) 8:12))
+            )
+        """)
+
+    def test_simple_operator(self):
+        s1 = Signal(8)
+        s2 = Signal(8)
+        s3 = Signal(8)
+        f = Fragment()
+        f.add_statements("sync", [
+            s1.as_signed().eq(s3),
+            s2.as_unsigned().eq(s3),
+        ])
+        f.add_driver(s1, "sync")
+        f.add_driver(s2, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            (
+                (eq (sig s1) (sig s3))
+                (eq (sig s2) (sig s3))
+            )
+        """)
+
+    def test_simple_array(self):
+        s1 = Signal(8)
+        s2 = Signal(8)
+        s3 = Signal(8)
+        s4 = Signal(8)
+        s5 = Signal(8)
+        f = Fragment()
+        f.add_statements("sync", [
+            Array([s1, s2, s3])[s4].eq(s5),
+        ])
+        f.add_driver(s1, "sync")
+        f.add_driver(s2, "sync")
+        f.add_driver(s3, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((switch (sig s4)
+                (case 00000000 (eq (sig s1) (sig s5)))
+                (case 00000001 (eq (sig s2) (sig s5)))
+                (case 00000010 (eq (sig s3) (sig s5)))
+            ))
+        """)
+
+    def test_sliced_slice(self):
+        s1 = Signal(12)
+        s2 = Signal(4)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            s1[1:11][2:6].eq(s2)
+        )
+        f.add_driver(s1, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((eq (slice (sig s1) 3:7) (sig s2)))
+        """)
+
+    def test_sliced_concat(self):
+        s1 = Signal(4)
+        s2 = Signal(4)
+        s3 = Signal(4)
+        s4 = Signal(4)
+        s5 = Signal(4)
+        s6 = Signal(8)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            Cat(s1, s2, s3, s4, s5)[5:14].eq(s6)
+        )
+        f.add_driver(s1, "sync")
+        f.add_driver(s2, "sync")
+        f.add_driver(s3, "sync")
+        f.add_driver(s4, "sync")
+        f.add_driver(s5, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            (
+                (eq (slice (sig s2) 1:4) (slice (| (sig s6) (const 9'd0)) 0:9))
+                (eq (sig s3)             (slice (| (sig s6) (const 9'd0)) 3:9))
+                (eq (slice (sig s4) 0:2) (slice (| (sig s6) (const 9'd0)) 7:9))
+            )
+        """)
+
+    def test_sliced_part(self):
+        s1 = Signal(8)
+        s2 = Signal(4)
+        s3 = Signal(4)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            s1.bit_select(s3, 4)[1:3].eq(s2)
+        )
+        f.add_driver(s1, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((switch (sig s3)
+                (case 0000 (eq (slice (sig s1) 1:3) (sig s2)))
+                (case 0001 (eq (slice (sig s1) 2:4) (sig s2)))
+                (case 0010 (eq (slice (sig s1) 3:5) (sig s2)))
+                (case 0011 (eq (slice (sig s1) 4:6) (sig s2)))
+                (case 0100 (eq (slice (sig s1) 5:7) (sig s2)))
+                (case 0101 (eq (slice (sig s1) 6:8) (sig s2)))
+                (case 0110 (eq (slice (sig s1) 7:8) (sig s2)))
+            ))
+        """)
+
+    def test_sliced_part_word(self):
+        s1 = Signal(8)
+        s2 = Signal(4)
+        s3 = Signal(4)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            s1.word_select(s3, 4)[1:3].eq(s2)
+        )
+        f.add_driver(s1, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((switch (sig s3)
+                (case 0000 (eq (slice (sig s1) 1:3) (sig s2)))
+                (case 0001 (eq (slice (sig s1) 5:7) (sig s2)))
+            ))
+        """)
+
+    def test_sliced_array(self):
+        s1 = Signal(8)
+        s2 = Signal(8)
+        s3 = Signal(8)
+        s4 = Signal(8)
+        s5 = Signal(8)
+        f = Fragment()
+        f.add_statements("sync", [
+            Array([s1, s2, s3])[s4][2:7].eq(s5),
+        ])
+        f.add_driver(s1, "sync")
+        f.add_driver(s2, "sync")
+        f.add_driver(s3, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((switch (sig s4)
+                (case 00000000 (eq (slice (sig s1) 2:7) (sig s5)))
+                (case 00000001 (eq (slice (sig s2) 2:7) (sig s5)))
+                (case 00000010 (eq (slice (sig s3) 2:7) (sig s5)))
+            ))
+        """)
+
+    def test_part_slice(self):
+        s1 = Signal(8)
+        s2 = Signal(4)
+        s3 = Signal(4)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            s1[1:7].bit_select(s3, 4).eq(s2)
+        )
+        f.add_driver(s1, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((switch (sig s3)
+                (case 0000 (eq (slice (sig s1) 1:5) (sig s2)))
+                (case 0001 (eq (slice (sig s1) 2:6) (sig s2)))
+                (case 0010 (eq (slice (sig s1) 3:7) (sig s2)))
+                (case 0011 (eq (slice (sig s1) 4:7) (sig s2)))
+                (case 0100 (eq (slice (sig s1) 5:7) (sig s2)))
+                (case 0101 (eq (slice (sig s1) 6:7) (sig s2)))
+            ))
+        """)
+
+    def test_sliced_part_slice(self):
+        s1 = Signal(12)
+        s2 = Signal(4)
+        s3 = Signal(4)
+        f = Fragment()
+        f.add_statements(
+            "sync",
+            s1[3:9].bit_select(s3, 4)[1:3].eq(s2)
+        )
+        f.add_driver(s1, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            ((switch (sig s3)
+                (case 0000 (eq (slice (sig s1) 4:6) (sig s2)))
+                (case 0001 (eq (slice (sig s1) 5:7) (sig s2)))
+                (case 0010 (eq (slice (sig s1) 6:8) (sig s2)))
+                (case 0011 (eq (slice (sig s1) 7:9) (sig s2)))
+                (case 0100 (eq (slice (sig s1) 8:9) (sig s2)))
+            ))
+        """)
+
+
+    def test_sliced_operator(self):
+        s1 = Signal(8)
+        s2 = Signal(8)
+        s3 = Signal(8)
+        f = Fragment()
+        f.add_statements("sync", [
+            s1.as_signed()[2:7].eq(s3),
+            s2.as_unsigned()[2:7].eq(s3),
+        ])
+        f.add_driver(s1, "sync")
+        f.add_driver(s2, "sync")
+        f = AssignmentLegalizer()(f)
+        self.assertRepr(f.statements["sync"], """
+            (
+                (eq (slice (sig s1) 2:7) (sig s3))
+                (eq (slice (sig s2) 2:7) (sig s3))
+            )
+        """)
+
+
 class _MockElaboratable(Elaboratable):
     def __init__(self):
         self.s1 = Signal()
diff --git a/tests/test_lib_cdc.py b/tests/test_lib_cdc.py
index a271032..b5439c4 100644
--- a/tests/test_lib_cdc.py
+++ b/tests/test_lib_cdc.py
@@ -26,13 +26,13 @@ class FFSynchronizerTestCase(FHDLTestCase):
         def process():
             self.assertEqual((yield o), 0)
             yield i.eq(1)
-            yield
+            yield Tick()
             self.assertEqual((yield o), 0)
-            yield
+            yield Tick()
             self.assertEqual((yield o), 0)
-            yield
+            yield Tick()
             self.assertEqual((yield o), 1)
-        sim.add_sync_process(process)
+        sim.add_process(process)
         sim.run()
 
     def test_reset_value(self):
@@ -45,13 +45,13 @@ class FFSynchronizerTestCase(FHDLTestCase):
         def process():
             self.assertEqual((yield o), 1)
             yield i.eq(0)
-            yield
+            yield Tick()
             self.assertEqual((yield o), 1)
-            yield
+            yield Tick()
             self.assertEqual((yield o), 1)
-            yield
+            yield Tick()
             self.assertEqual((yield o), 0)
-        sim.add_sync_process(process)
+        sim.add_process(process)
         sim.run()
 
 
@@ -221,17 +221,17 @@ class PulseSynchronizerTestCase(FHDLTestCase):
             yield ps.i.eq(0)
             # TODO: think about reset
             for n in range(5):
-                yield
+                yield Tick()
             # Make sure no pulses are generated in quiescent state
             for n in range(3):
-                yield
+                yield Tick()
                 self.assertEqual((yield ps.o), 0)
             # Check conservation of pulses
             accum = 0
             for n in range(10):
                 yield ps.i.eq(1 if n < 4 else 0)
-                yield
+                yield Tick()
                 accum += yield ps.o
             self.assertEqual(accum, 4)
-        sim.add_sync_process(process)
+        sim.add_process(process)
         sim.run()
diff --git a/tests/test_lib_coding.py b/tests/test_lib_coding.py
index 41f77fe..29ffcee 100644
--- a/tests/test_lib_coding.py
+++ b/tests/test_lib_coding.py
@@ -75,16 +75,18 @@ class DecoderTestCase(FHDLTestCase):
 
 
 class ReversibleSpec(Elaboratable):
-    def __init__(self, encoder_cls, decoder_cls, args):
+    def __init__(self, encoder_cls, decoder_cls, i_width, args):
         self.encoder_cls = encoder_cls
         self.decoder_cls = decoder_cls
         self.coder_args  = args
+        self.i           = Signal(i_width)
 
     def elaborate(self, platform):
         m = Module()
         enc, dec = self.encoder_cls(*self.coder_args), self.decoder_cls(*self.coder_args)
         m.submodules += enc, dec
         m.d.comb += [
+            enc.i.eq(self.i),
             dec.i.eq(enc.o),
             Assert(enc.i == dec.o)
         ]
@@ -92,16 +94,20 @@ class ReversibleSpec(Elaboratable):
 
 
 class HammingDistanceSpec(Elaboratable):
-    def __init__(self, distance, encoder_cls, args):
+    def __init__(self, distance, encoder_cls, i_width, args):
         self.distance    = distance
         self.encoder_cls = encoder_cls
         self.coder_args  = args
+        self.i1          = Signal(i_width)
+        self.i2          = Signal(i_width)
 
     def elaborate(self, platform):
         m = Module()
         enc1, enc2 = self.encoder_cls(*self.coder_args), self.encoder_cls(*self.coder_args)
         m.submodules += enc1, enc2
         m.d.comb += [
+            enc1.i.eq(self.i1),
+            enc2.i.eq(self.i2),
             Assume(enc1.i + 1 == enc2.i),
             Assert(sum(enc1.o ^ enc2.o) == self.distance)
         ]
@@ -110,9 +116,10 @@ class HammingDistanceSpec(Elaboratable):
 
 class GrayCoderTestCase(FHDLTestCase):
     def test_reversible(self):
-        spec = ReversibleSpec(encoder_cls=GrayEncoder, decoder_cls=GrayDecoder, args=(16,))
-        self.assertFormal(spec, mode="prove")
+        spec = ReversibleSpec(encoder_cls=GrayEncoder, decoder_cls=GrayDecoder, i_width=16,
+                              args=(16,))
+        self.assertFormal(spec, [spec.i], mode="prove")
 
     def test_distance(self):
-        spec = HammingDistanceSpec(distance=1, encoder_cls=GrayEncoder, args=(16,))
-        self.assertFormal(spec, mode="prove")
+        spec = HammingDistanceSpec(distance=1, encoder_cls=GrayEncoder, i_width=16, args=(16,))
+        self.assertFormal(spec, [spec.i1, spec.i2], mode="prove")
diff --git a/tests/test_lib_crc.py b/tests/test_lib_crc.py
index db06442..96b57fb 100644
--- a/tests/test_lib_crc.py
+++ b/tests/test_lib_crc.py
@@ -243,13 +243,13 @@ class CRCTestCase(unittest.TestCase):
                     yield crc.start.eq(word == b"1")
                     yield crc.data.eq(word)
                     yield crc.valid.eq(1)
-                    yield
+                    yield Tick()
                 yield crc.valid.eq(0)
-                yield
+                yield Tick()
                 self.assertEqual((yield crc.crc), check)
 
             sim = Simulator(crc)
-            sim.add_sync_process(process)
+            sim.add_testbench(process)
             sim.add_clock(1e-6)
             sim.run()
 
@@ -283,18 +283,18 @@ class CRCTestCase(unittest.TestCase):
 
                 def process():
                     yield crc.start.eq(1)
-                    yield
+                    yield Tick()
                     yield crc.start.eq(0)
                     for word in words:
                         yield crc.data.eq(word)
                         yield crc.valid.eq(1)
-                        yield
+                        yield Tick()
                     yield crc.valid.eq(0)
-                    yield
+                    yield Tick()
                     self.assertEqual((yield crc.crc), check)
 
                 sim = Simulator(crc)
-                sim.add_sync_process(process)
+                sim.add_testbench(process)
                 sim.add_clock(1e-6)
                 sim.run()
 
@@ -334,17 +334,17 @@ class CRCTestCase(unittest.TestCase):
 
             def process():
                 yield crc.start.eq(1)
-                yield
+                yield Tick()
                 yield crc.start.eq(0)
                 for word in words:
                     yield crc.data.eq(word)
                     yield crc.valid.eq(1)
-                    yield
+                    yield Tick()
                 yield crc.valid.eq(0)
-                yield
+                yield Tick()
                 self.assertTrue((yield crc.match_detected))
 
             sim = Simulator(crc)
-            sim.add_sync_process(process)
+            sim.add_testbench(process)
             sim.add_clock(1e-6)
             sim.run()
diff --git a/tests/test_lib_fifo.py b/tests/test_lib_fifo.py
index e35c978..5ea9e48 100644
--- a/tests/test_lib_fifo.py
+++ b/tests/test_lib_fifo.py
@@ -123,18 +123,31 @@ class FIFOModelEquivalenceSpec(Elaboratable):
     signals, the behavior of the implementation under test exactly matches the ideal model,
     except for behavior not defined by the model.
     """
-    def __init__(self, fifo, r_domain, w_domain):
+    def __init__(self, fifo, *, is_async=False):
         self.fifo = fifo
+        self.is_async = is_async
 
-        self.r_domain = r_domain
-        self.w_domain = w_domain
+        if is_async:
+            self.cd_read = ClockDomain()
+            self.cd_write = ClockDomain()
+        else:
+            self.cd_sync = ClockDomain()
+            self.cd_read = self.cd_write = self.cd_sync
 
     @_ignore_deprecated
     def elaborate(self, platform):
         m = Module()
+
+        if self.is_async:
+            m.domains += self.cd_read
+            m.domains += self.cd_write
+        else:
+            m.domains += self.cd_sync
+
         m.submodules.dut  = dut  = self.fifo
         m.submodules.gold = gold = FIFOModel(width=dut.width, depth=dut.depth,
-                                             r_domain=self.r_domain, w_domain=self.w_domain)
+                                             r_domain=self.cd_read.name,
+                                             w_domain=self.cd_write.name)
 
         m.d.comb += [
             gold.r_en.eq(dut.r_rdy & dut.r_en),
@@ -158,30 +171,35 @@ class FIFOContractSpec(Elaboratable):
     consecutively, they must be read out consecutively at some later point, no matter all other
     circumstances, with the exception of reset.
     """
-    def __init__(self, fifo, *, r_domain, w_domain, bound):
+    def __init__(self, fifo, *, is_async=False, bound):
         self.fifo     = fifo
-        self.r_domain = r_domain
-        self.w_domain = w_domain
+        self.is_async = is_async
         self.bound    = bound
 
+        self.cd_sync = ClockDomain()
+        if is_async:
+            self.cd_read = ClockDomain()
+            self.cd_write = ClockDomain()
+        else:
+            self.cd_read = self.cd_write = self.cd_sync
+
     @_ignore_deprecated
     def elaborate(self, platform):
         m = Module()
         m.submodules.dut = fifo = self.fifo
 
-        m.domains += ClockDomain("sync")
-        m.d.comb += ResetSignal().eq(0)
-        if self.w_domain != "sync":
-            m.domains += ClockDomain(self.w_domain)
-            m.d.comb += ResetSignal(self.w_domain).eq(0)
-        if self.r_domain != "sync":
-            m.domains += ClockDomain(self.r_domain)
-            m.d.comb += ResetSignal(self.r_domain).eq(0)
+        m.domains += self.cd_sync
+        m.d.comb += self.cd_sync.rst.eq(0)
+        if self.is_async:
+            m.domains += self.cd_read
+            m.domains += self.cd_write
+            m.d.comb += self.cd_write.rst.eq(0)
+            m.d.comb += self.cd_read.rst.eq(0)
 
         entry_1 = AnyConst(fifo.width)
         entry_2 = AnyConst(fifo.width)
 
-        with m.FSM(domain=self.w_domain) as write_fsm:
+        with m.FSM(domain=self.cd_write.name) as write_fsm:
             with m.State("WRITE-1"):
                 with m.If(fifo.w_rdy):
                     m.d.comb += [
@@ -199,7 +217,7 @@ class FIFOContractSpec(Elaboratable):
             with m.State("DONE"):
                 pass
 
-        with m.FSM(domain=self.r_domain) as read_fsm:
+        with m.FSM(domain=self.cd_read.name) as read_fsm:
             read_1 = Signal(fifo.width)
             read_2 = Signal(fifo.width)
             with m.State("READ"):
@@ -225,18 +243,18 @@ class FIFOContractSpec(Elaboratable):
         with m.If(cycle == self.bound):
             m.d.comb += Assert(read_fsm.ongoing("DONE"))
 
-        with m.If(ResetSignal(domain=self.w_domain)):
+        with m.If(self.cd_write.rst):
             m.d.comb += Assert(~fifo.r_rdy)
 
-        if self.w_domain != "sync" or self.r_domain != "sync":
-            # rose_w_domain_clk = Rose(ClockSignal(self.w_domain))
+        if self.is_async:
+            # rose_w_domain_clk = Rose(self.cd_write.clk)
             past_w_domain_clk = Signal()
-            m.d.sync += past_w_domain_clk.eq(ClockSignal(self.w_domain))
-            rose_w_domain_clk = (past_w_domain_clk == 0) & (ClockSignal(self.w_domain) == 1)
-            # rose_r_domain_clk = Rose(ClockSignal(self.r_domain))
+            m.d.sync += past_w_domain_clk.eq(self.cd_write.clk)
+            rose_w_domain_clk = (past_w_domain_clk == 0) & (self.cd_write.clk == 1)
+            # rose_r_domain_clk = Rose(self.cd_read.clk)
             past_r_domain_clk = Signal()
-            m.d.sync += past_r_domain_clk.eq(ClockSignal(self.r_domain))
-            rose_r_domain_clk = (past_r_domain_clk == 0) & (ClockSignal(self.r_domain) == 1)
+            m.d.sync += past_r_domain_clk.eq(self.cd_read.clk)
+            rose_r_domain_clk = (past_r_domain_clk == 0) & (self.cd_read.clk == 1)
 
             m.d.comb += Assume(rose_w_domain_clk | rose_r_domain_clk)
 
@@ -245,10 +263,13 @@ class FIFOContractSpec(Elaboratable):
 
 class FIFOFormalCase(FHDLTestCase):
     def check_sync_fifo(self, fifo):
-        self.assertFormal(FIFOModelEquivalenceSpec(fifo, r_domain="sync", w_domain="sync"),
-                          mode="bmc", depth=fifo.depth + 1)
-        self.assertFormal(FIFOContractSpec(fifo, r_domain="sync", w_domain="sync",
-                                           bound=fifo.depth * 2 + 1),
+        spec_equiv = FIFOModelEquivalenceSpec(fifo, is_async=False)
+        self.assertFormal(spec_equiv, [
+                              spec_equiv.cd_sync.clk, spec_equiv.cd_sync.rst,
+                              fifo.w_en, fifo.w_data, fifo.r_en,
+                          ], mode="bmc", depth=fifo.depth + 1)
+        spec_contract = FIFOContractSpec(fifo, is_async=False, bound=fifo.depth * 2 + 1)
+        self.assertFormal(spec_contract, [spec_contract.cd_sync.clk],
                           mode="hybrid", depth=fifo.depth * 2 + 1)
 
     def test_sync_pot(self):
@@ -272,11 +293,19 @@ class FIFOFormalCase(FHDLTestCase):
     def check_async_fifo(self, fifo):
         # TODO: properly doing model equivalence checking on this likely requires multiclock,
         # which is not really documented nor is it clear how to use it.
-        # self.assertFormal(FIFOModelEquivalenceSpec(fifo, r_domain="read", w_domain="write"),
+        # spec_equiv = FIFOModelEquivalenceSpec(fifo, is_async=True)
+        # self.assertFormal(spec_equiv, [
+        #                       spec_equiv.cd_write.clk, spec_equiv.cd_write.rst,
+        #                       spec_equiv.cd_read.clk, spec_equiv.cd_read.rst,
+        #                       fifo.w_en, fifo.w_data, fifo.r_en,
+        #                   ],
         #                   mode="bmc", depth=fifo.depth * 3 + 1)
-        self.assertFormal(FIFOContractSpec(fifo, r_domain="read", w_domain="write",
-                                           bound=fifo.depth * 4 + 1),
-                          mode="hybrid", depth=fifo.depth * 4 + 1)
+        spec_contract = FIFOContractSpec(fifo, is_async=True, bound=fifo.depth * 4 + 1)
+        self.assertFormal(spec_contract, [
+                              spec_contract.cd_sync.clk,
+                              spec_contract.cd_write.clk,
+                              spec_contract.cd_read.clk,
+                          ], mode="hybrid", depth=fifo.depth * 4 + 1)
 
     def test_async(self):
         self.check_async_fifo(AsyncFIFO(width=8, depth=4))
@@ -296,7 +325,7 @@ class AsyncFIFOSimCase(FHDLTestCase):
             for i in range(10):
                 yield fifo.w_data.eq(i)
                 yield fifo.w_en.eq(1)
-                yield
+                yield Tick()
 
                 if (i - ff_syncronizer_latency) > 0:
                     self.assertEqual((yield fifo.r_level), i - ff_syncronizer_latency)
@@ -305,7 +334,7 @@ class AsyncFIFOSimCase(FHDLTestCase):
 
         simulator = Simulator(fifo)
         simulator.add_clock(100e-6)
-        simulator.add_sync_process(testbench)
+        simulator.add_process(testbench)
         simulator.run()
 
     def check_async_fifo_level(self, fifo, fill_in, expected_level, read=False):
@@ -315,10 +344,9 @@ class AsyncFIFOSimCase(FHDLTestCase):
             for i in range(fill_in):
                 yield fifo.w_data.eq(i)
                 yield fifo.w_en.eq(1)
-                yield
+                yield Tick("write")
             yield fifo.w_en.eq(0)
-            yield
-            yield
+            yield Tick ("write")
             self.assertEqual((yield fifo.w_level), expected_level)
             yield write_done.eq(1)
 
@@ -326,14 +354,14 @@ class AsyncFIFOSimCase(FHDLTestCase):
             if read:
                 yield fifo.r_en.eq(1)
             while not (yield write_done):
-                yield
+                yield Tick("read")
             self.assertEqual((yield fifo.r_level), expected_level)
 
         simulator = Simulator(fifo)
         simulator.add_clock(100e-6, domain="write")
-        simulator.add_sync_process(write_process, domain="write")
+        simulator.add_testbench(write_process)
         simulator.add_clock(50e-6, domain="read")
-        simulator.add_sync_process(read_process, domain="read")
+        simulator.add_testbench(read_process)
         with simulator.write_vcd("test.vcd"):
             simulator.run()
 
diff --git a/tests/test_sim.py b/tests/test_sim.py
index 61e3dbb..fd90e8d 100644
--- a/tests/test_sim.py
+++ b/tests/test_sim.py
@@ -435,29 +435,30 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
 
     def test_counter_process(self):
         self.setUp_counter()
-        with _ignore_deprecated():
-            with self.assertSimulation(self.m) as sim:
-                def process():
-                    self.assertEqual((yield self.count), 4)
+        with self.assertSimulation(self.m) as sim:
+            def process():
+                self.assertEqual((yield self.count), 4)
+                yield Delay(1e-6)
+                self.assertEqual((yield self.count), 4)
+                yield self.sync.clk.eq(1)
+                self.assertEqual((yield self.count), 4)
+                with _ignore_deprecated():
+                    yield Settle()
+                self.assertEqual((yield self.count), 5)
+                yield Delay(1e-6)
+                self.assertEqual((yield self.count), 5)
+                yield self.sync.clk.eq(0)
+                self.assertEqual((yield self.count), 5)
+                with _ignore_deprecated():
+                    yield Settle()
+                self.assertEqual((yield self.count), 5)
+                for _ in range(3):
                     yield Delay(1e-6)
-                    self.assertEqual((yield self.count), 4)
                     yield self.sync.clk.eq(1)
-                    self.assertEqual((yield self.count), 4)
-                    yield Settle()
-                    self.assertEqual((yield self.count), 5)
                     yield Delay(1e-6)
-                    self.assertEqual((yield self.count), 5)
                     yield self.sync.clk.eq(0)
-                    self.assertEqual((yield self.count), 5)
-                    yield Settle()
-                    self.assertEqual((yield self.count), 5)
-                    for _ in range(3):
-                        yield Delay(1e-6)
-                        yield self.sync.clk.eq(1)
-                        yield Delay(1e-6)
-                        yield self.sync.clk.eq(0)
-                    self.assertEqual((yield self.count), 0)
-                sim.add_process(process)
+                self.assertEqual((yield self.count), 0)
+            sim.add_process(process)
 
     def test_counter_clock_and_sync_process(self):
         self.setUp_counter()
@@ -472,7 +473,8 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
                 for _ in range(3):
                     yield
                 self.assertEqual((yield self.count), 0)
-            sim.add_sync_process(process)
+            with _ignore_deprecated():
+                sim.add_sync_process(process)
 
     def test_reset(self):
         self.setUp_counter()
@@ -481,14 +483,15 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
         times = 0
         def process():
             nonlocal times
+            yield Tick()
             self.assertEqual((yield self.count), 4)
-            yield
+            yield Tick()
             self.assertEqual((yield self.count), 5)
-            yield
+            yield Tick()
             self.assertEqual((yield self.count), 6)
-            yield
+            yield Tick()
             times += 1
-        sim.add_sync_process(process)
+        sim.add_process(process)
         sim.run()
         sim.reset()
         sim.run()
@@ -520,19 +523,37 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
             def process():
                 yield self.a.eq(5)
                 yield self.b.eq(1)
-                yield
+                yield Tick()
                 self.assertEqual((yield self.x), 4)
-                yield
+                yield Tick()
                 self.assertEqual((yield self.o), 6)
                 yield self.s.eq(1)
-                yield
-                yield
+                yield Tick()
+                yield Tick()
                 self.assertEqual((yield self.o), 4)
                 yield self.s.eq(2)
-                yield
-                yield
+                yield Tick()
+                yield Tick()
                 self.assertEqual((yield self.o), 0)
-            sim.add_sync_process(process)
+            sim.add_process(process)
+
+    def test_alu_bench(self):
+        self.setUp_alu()
+        with self.assertSimulation(self.m) as sim:
+            sim.add_clock(1e-6)
+            def process():
+                yield self.a.eq(5)
+                yield self.b.eq(1)
+                self.assertEqual((yield self.x), 4)
+                yield Tick()
+                self.assertEqual((yield self.o), 6)
+                yield self.s.eq(1)
+                yield Tick()
+                self.assertEqual((yield self.o), 4)
+                yield self.s.eq(2)
+                yield Tick()
+                self.assertEqual((yield self.o), 0)
+            sim.add_testbench(process)
 
     def setUp_clock_phase(self):
         self.m = Module()
@@ -543,10 +564,10 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
         self.check = self.m.domains.check = ClockDomain()
 
         self.expected = [
-            [0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0],
-            [0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1],
-            [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1],
-            [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0],
+            [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0],
+            [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1],
+            [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1],
+            [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0],
         ]
 
     def test_clock_phase(self):
@@ -567,11 +588,11 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
                     self.phase270.clk
                 ]
                 for i in range(16):
-                    yield
+                    yield Tick("check")
                     for j, c in enumerate(clocks):
                         self.assertEqual((yield c), self.expected[j][i])
 
-            sim.add_sync_process(proc, domain="check")
+            sim.add_process(proc)
 
     def setUp_multiclock(self):
         self.sys = ClockDomain()
@@ -588,15 +609,15 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
 
             def sys_process():
                 yield Passive()
-                yield
-                yield
+                yield Tick("sys")
+                yield Tick("sys")
                 self.fail()
             def pix_process():
-                yield
-                yield
-                yield
-            sim.add_sync_process(sys_process, domain="sys")
-            sim.add_sync_process(pix_process, domain="pix")
+                yield Tick("pix")
+                yield Tick("pix")
+                yield Tick("pix")
+            sim.add_testbench(sys_process)
+            sim.add_testbench(pix_process)
 
     def setUp_lhs_rhs(self):
         self.i = Signal(8)
@@ -644,7 +665,7 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
         with self.assertSimulation(Module()) as sim:
             with self.assertRaisesRegex(TypeError,
                     r"^Cannot add a process 1 because it is not a generator function$"):
-                sim.add_sync_process(1)
+                sim.add_process(1)
 
     def test_add_process_wrong_generator(self):
         with self.assertSimulation(Module()) as sim:
@@ -652,7 +673,7 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
                     r"^Cannot add a process <.+?> because it is not a generator function$"):
                 def process():
                     yield Delay()
-                sim.add_sync_process(process())
+                sim.add_process(process())
 
     def test_add_clock_wrong_twice(self):
         m = Module()
@@ -678,31 +699,14 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
 
     def test_command_wrong(self):
         survived = False
-        with _ignore_deprecated():
-            with self.assertSimulation(Module()) as sim:
-                def process():
-                    nonlocal survived
-                    with self.assertRaisesRegex(TypeError,
-                            r"Received unsupported command 1 from process .+?"):
-                        yield 1
-                    survived = True
-                sim.add_process(process)
-        self.assertTrue(survived)
-
-    def test_sync_command_deprecated(self):
-        survived = False
-        m = Module()
-        dummy = Signal()
-        m.d.sync += dummy.eq(1)
-        with self.assertSimulation(m) as sim:
+        with self.assertSimulation(Module()) as sim:
             def process():
                 nonlocal survived
-                with self.assertWarnsRegex(DeprecationWarning,
-                        r"Using `Delay` is deprecated within `add_sync_process`"):
-                    yield Delay(1e-8)
+                with self.assertRaisesRegex(TypeError,
+                        r"Received unsupported command 1 from process .+?"):
+                    yield 1
                 survived = True
-            sim.add_sync_process(process)
-            sim.add_clock(1e-6)
+            sim.add_process(process)
         self.assertTrue(survived)
 
     def test_sync_command_wrong(self):
@@ -717,7 +721,8 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
                         r"Received unsupported command 1 from process .+?"):
                     yield 1
                 survived = True
-            sim.add_sync_process(process)
+            with _ignore_deprecated():
+                sim.add_sync_process(process)
             sim.add_clock(1e-6)
         self.assertTrue(survived)
 
@@ -762,15 +767,13 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
         with self.assertSimulation(self.m) as sim:
             def process():
                 yield self.rdport.addr.eq(1)
-                yield
-                yield
+                yield Tick()
                 self.assertEqual((yield self.rdport.data), 0x55)
                 yield self.rdport.addr.eq(2)
-                yield
-                yield
+                yield Tick()
                 self.assertEqual((yield self.rdport.data), 0x00)
             sim.add_clock(1e-6)
-            sim.add_sync_process(process)
+            sim.add_testbench(process)
 
     def test_memory_write(self):
         self.setUp_memory()
@@ -779,13 +782,13 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
                 yield self.wrport.addr.eq(4)
                 yield self.wrport.data.eq(0x33)
                 yield self.wrport.en.eq(1)
-                yield
+                yield Tick()
                 yield self.wrport.en.eq(0)
                 yield self.rdport.addr.eq(4)
-                yield
+                yield Tick()
                 self.assertEqual((yield self.rdport.data), 0x33)
             sim.add_clock(1e-6)
-            sim.add_sync_process(process)
+            sim.add_testbench(process)
 
     def test_memory_write_granularity(self):
         self.setUp_memory(wr_granularity=4)
@@ -864,14 +867,13 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
         self.m.submodules.rdport = self.rdport = self.memory.read_port()
         with self.assertSimulation(self.m) as sim:
             def process():
-                yield
+                yield Tick()
                 self.assertEqual((yield self.rdport.data), 0xaa)
                 yield self.rdport.addr.eq(1)
-                yield
-                yield
+                yield Tick()
                 self.assertEqual((yield self.rdport.data), 0x55)
             sim.add_clock(1e-6)
-            sim.add_sync_process(process)
+            sim.add_testbench(process)
 
     def test_comb_bench_process(self):
         m = Module()
@@ -1022,11 +1024,11 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
                 self.assertEqual((yield self.memory[Const(2)]), 0x00)
                 yield self.memory[Const(1)].eq(Const(0x33))
                 self.assertEqual((yield self.memory[Const(1)]), 0x55)
-                yield
+                yield Tick()
                 self.assertEqual((yield self.memory[Const(1)]), 0x33)
 
             sim.add_clock(1e-6)
-            sim.add_sync_process(process)
+            sim.add_process(process)
 
     def test_vcd_wrong_nonzero_time(self):
         s = Signal()
diff --git a/tests/utils.py b/tests/utils.py
index a186990..a069f18 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -26,7 +26,7 @@ class FHDLTestCase(unittest.TestCase):
             return repr_str.strip()
         self.assertEqual(prepare_repr(repr(obj)), prepare_repr(repr_str))
 
-    def assertFormal(self, spec, mode="bmc", depth=1):
+    def assertFormal(self, spec, ports=None, mode="bmc", depth=1):
         stack = traceback.extract_stack()
         for frame in reversed(stack):
             if os.path.dirname(__file__) not in frame.filename:
@@ -72,7 +72,7 @@ class FHDLTestCase(unittest.TestCase):
             mode=mode,
             depth=depth,
             script=script,
-            rtlil=rtlil.convert_fragment(Fragment.get(spec, platform="formal").prepare())[0]
+            rtlil=rtlil.convert(spec, ports=ports, platform="formal"),
         )
         with subprocess.Popen(
                 [require_tool("sby"), "-f", "-d", spec_name],