Repository: amaranth-lang/amaranth
Pull request #1453 (2024-07-19 23:08:13)

Implement RFC 69 [0.5 backport]
See https://github.com/amaranth-lang/amaranth/pull/1448.


Patch:
diff --git a/amaranth/lib/io.py b/amaranth/lib/io.py
index e4ea8b2..6eed2bf 100644
--- a/amaranth/lib/io.py
+++ b/amaranth/lib/io.py
@@ -1,5 +1,6 @@
 import enum
 import operator
+import warnings
 from abc import ABCMeta, abstractmethod
 from collections.abc import Iterable
 
@@ -11,7 +12,7 @@ from .. import tracer
 
 
 __all__ = [
-    "Direction", "PortLike", "SingleEndedPort", "DifferentialPort",
+    "Direction", "PortLike", "SingleEndedPort", "DifferentialPort", "SimulationPort",
     "Buffer", "FFBuffer", "DDRBuffer",
     "Pin",
 ]
@@ -57,6 +58,12 @@ class PortLike(metaclass=ABCMeta):
         :class:`amaranth.hdl.IOPort` is not an instance of :class:`amaranth.lib.io.PortLike`.
     """
 
+    # TODO(amaranth-0.6): remove
+    def __init_subclass__(cls):
+        if cls.__add__ is PortLike.__add__:
+            warnings.warn(f"{cls.__module__}.{cls.__qualname__} must override the `__add__` method",
+                          DeprecationWarning, stacklevel=2)
+
     @property
     @abstractmethod
     def direction(self):
@@ -108,6 +115,32 @@ class PortLike(metaclass=ABCMeta):
         """
         raise NotImplementedError # :nocov:
 
+    # TODO(amaranth-0.6): make abstract
+    # @abstractmethod
+    def __add__(self, other):
+        """Concatenates two library I/O ports of the same type.
+
+        The direction of the resulting port is:
+
+        * The same as the direction of both, if the two ports have the same direction.
+        * :attr:`Direction.Input` if a bidirectional port is concatenated with an input port.
+        * :attr:`Direction.Output` if a bidirectional port is concatenated with an output port.
+
+        Returns
+        -------
+        :py:`type(self)`
+            A new :py:`type(self)` which contains wires from :py:`self` followed by wires
+            from :py:`other`, preserving their polarity inversion.
+
+        Raises
+        ------
+        :exc:`ValueError`
+            If an input port is concatenated with an output port.
+        :exc:`TypeError`
+            If :py:`self` and :py:`other` have different types.
+        """
+        raise NotImplementedError # :nocov:
+
 
 class SingleEndedPort(PortLike):
     """Represents a single-ended library I/O port.
@@ -124,9 +157,9 @@ class SingleEndedPort(PortLike):
         same length as the width of :py:`io`, and the inversion is specified for individual wires.
     direction : :class:`Direction` or :class:`str`
         Set of allowed buffer directions. A string is converted to a :class:`Direction` first.
-        If equal to :attr:`Direction.Input` or :attr:`Direction.Output`, this port can only be used
-        with buffers of matching direction. If equal to :attr:`Direction.Bidir`, this port can be
-        used with buffers of any direction.
+        If equal to :attr:`~Direction.Input` or :attr:`~Direction.Output`, this port can only be
+        used with buffers of matching direction. If equal to :attr:`~Direction.Bidir`, this port
+        can be used with buffers of any direction.
 
     Attributes
     ----------
@@ -176,27 +209,6 @@ class SingleEndedPort(PortLike):
                                direction=self._direction)
 
     def __add__(self, other):
-        """Concatenates two single-ended library I/O ports.
-
-        The direction of the resulting port is:
-
-        * The same as the direction of both, if the two ports have the same direction.
-        * :attr:`Direction.Input` if a bidirectional port is concatenated with an input port.
-        * :attr:`Direction.Output` if a bidirectional port is concatenated with an output port.
-
-        Returns
-        -------
-        :class:`SingleEndedPort`
-            A new :class:`SingleEndedPort` which contains wires from :py:`self` followed by wires
-            from :py:`other`, preserving their polarity inversion.
-
-        Raises
-        ------
-        :exc:`ValueError`
-            If an input port is concatenated with an output port.
-        :exc:`TypeError`
-            If :py:`self` and :py:`other` have incompatible types.
-        """
         if not isinstance(other, SingleEndedPort):
             return NotImplemented
         return SingleEndedPort(Cat(self._io, other._io), invert=self._invert + other._invert,
@@ -231,9 +243,9 @@ class DifferentialPort(PortLike):
         individual wires.
     direction : :class:`Direction` or :class:`str`
         Set of allowed buffer directions. A string is converted to a :class:`Direction` first.
-        If equal to :attr:`Direction.Input` or :attr:`Direction.Output`, this port can only be used
-        with buffers of matching direction. If equal to :attr:`Direction.Bidir`, this port can be
-        used with buffers of any direction.
+        If equal to :attr:`~Direction.Input` or :attr:`~Direction.Output`, this port can only be
+        used with buffers of matching direction. If equal to :attr:`~Direction.Bidir`, this port
+        can be used with buffers of any direction.
 
     Attributes
     ----------
@@ -293,27 +305,6 @@ class DifferentialPort(PortLike):
                                 direction=self._direction)
 
     def __add__(self, other):
-        """Concatenates two differential library I/O ports.
-
-        The direction of the resulting port is:
-
-        * The same as the direction of both, if the two ports have the same direction.
-        * :attr:`Direction.Input` if a bidirectional port is concatenated with an input port.
-        * :attr:`Direction.Output` if a bidirectional port is concatenated with an output port.
-
-        Returns
-        -------
-        :class:`DifferentialPort`
-            A new :class:`DifferentialPort` which contains pairs of wires from :py:`self` followed
-            by pairs of wires from :py:`other`, preserving their polarity inversion.
-
-        Raises
-        ------
-        :exc:`ValueError`
-            If an input port is concatenated with an output port.
-        :exc:`TypeError`
-            If :py:`self` and :py:`other` have incompatible types.
-        """
         if not isinstance(other, DifferentialPort):
             return NotImplemented
         return DifferentialPort(Cat(self._p, other._p), Cat(self._n, other._n),
@@ -331,6 +322,167 @@ class DifferentialPort(PortLike):
                 f"direction={self._direction})")
 
 
+class SimulationPort(PortLike):
+    """Represents a simulation library I/O port.
+
+    Implements the :class:`PortLike` interface.
+
+    Parameters
+    ----------
+    direction : :class:`Direction` or :class:`str`
+        Set of allowed buffer directions. A string is converted to a :class:`Direction` first.
+        If equal to :attr:`~Direction.Input` or :attr:`~Direction.Output`, this port can only be
+        used with buffers of matching direction. If equal to :attr:`~Direction.Bidir`, this port
+        can be used with buffers of any direction.
+    width : :class:`int`
+        Width of the port. The width of each of the attributes :py:`i`, :py:`o`, :py:`oe` (whenever
+        present) equals :py:`width`.
+    invert : :class:`bool` or iterable of :class:`bool`
+        Polarity inversion. If the value is a simple :class:`bool`, it specifies inversion for
+        the entire port. If the value is an iterable of :class:`bool`, the iterable must have the
+        same length as the width of :py:`p` and :py:`n`, and the inversion is specified for
+        individual wires.
+    name : :class:`str` or :py:`None`
+        Name of the port. This name is only used to derive the names of the input, output, and
+        output enable signals.
+    src_loc_at : :class:`int`
+        :ref:`Source location <lang-srcloc>`. Used to infer :py:`name` if not specified.
+
+    Attributes
+    ----------
+    i : :class:`Signal`
+        Input signal. Present if :py:`direction in (Input, Bidir)`.
+    o : :class:`Signal`
+        Ouptut signal. Present if :py:`direction in (Output, Bidir)`.
+    oe : :class:`Signal`
+        Output enable signal. Present if :py:`direction in (Output, Bidir)`.
+    invert : :class:`tuple` of :class:`bool`
+        The :py:`invert` parameter, normalized to specify polarity inversion per-wire.
+    direction : :class:`Direction`
+        The :py:`direction` parameter, normalized to the :class:`Direction` enumeration.
+    """
+    def __init__(self, direction, width, *, invert=False, name=None, src_loc_at=0):
+        if name is not None and not isinstance(name, str):
+            raise TypeError(f"Name must be a string, not {name!r}")
+        if name is None:
+            name = tracer.get_var_name(depth=2 + src_loc_at, default="$port")
+
+        if not (isinstance(width, int) and width >= 0):
+            raise TypeError(f"Width must be a non-negative integer, not {width!r}")
+
+        self._direction = Direction(direction)
+
+        self._i = self._o = self._oe = None
+        if self._direction in (Direction.Input, Direction.Bidir):
+            self._i  = Signal(width, name=f"{name}__i")
+        if self._direction in (Direction.Output, Direction.Bidir):
+            self._o  = Signal(width, name=f"{name}__o")
+            self._oe = Signal(width, name=f"{name}__oe",
+                              init=~0 if self._direction is Direction.Output else 0)
+
+        if isinstance(invert, bool):
+            self._invert = (invert,) * width
+        elif isinstance(invert, Iterable):
+            self._invert = tuple(invert)
+            if len(self._invert) != width:
+                raise ValueError(f"Length of 'invert' ({len(self._invert)}) doesn't match "
+                                 f"port width ({width})")
+            if not all(isinstance(item, bool) for item in self._invert):
+                raise TypeError(f"'invert' must be a bool or iterable of bool, not {invert!r}")
+        else:
+            raise TypeError(f"'invert' must be a bool or iterable of bool, not {invert!r}")
+
+    @property
+    def i(self):
+        if self._i is None:
+            raise AttributeError(
+                "Simulation port with output direction does not have an input signal")
+        return self._i
+
+    @property
+    def o(self):
+        if self._o is None:
+            raise AttributeError(
+                "Simulation port with input direction does not have an output signal")
+        return self._o
+
+    @property
+    def oe(self):
+        if self._oe is None:
+            raise AttributeError(
+                "Simulation port with input direction does not have an output enable signal")
+        return self._oe
+
+    @property
+    def invert(self):
+        return self._invert
+
+    @property
+    def direction(self):
+        return self._direction
+
+    def __len__(self):
+        if self._direction is Direction.Input:
+            return len(self._i)
+        if self._direction is Direction.Output:
+            assert len(self._o) == len(self._oe)
+            return len(self._o)
+        if self._direction is Direction.Bidir:
+            assert len(self._i) == len(self._o) == len(self._oe)
+            return len(self._i)
+        assert False # :nocov:
+
+    def __getitem__(self, key):
+        result = object.__new__(type(self))
+        result._i  = None if self._i  is None else self._i [key]
+        result._o  = None if self._o  is None else self._o [key]
+        result._oe = None if self._oe is None else self._oe[key]
+        if isinstance(key, slice):
+            result._invert = self._invert[key]
+        else:
+            result._invert = (self._invert[key],)
+        result._direction = self._direction
+        return result
+
+    def __invert__(self):
+        result = object.__new__(type(self))
+        result._i = self._i
+        result._o = self._o
+        result._oe = self._oe
+        result._invert = tuple(not invert for invert in self._invert)
+        result._direction = self._direction
+        return result
+
+    def __add__(self, other):
+        if not isinstance(other, SimulationPort):
+            return NotImplemented
+        direction = self._direction & other._direction
+        result = object.__new__(type(self))
+        result._i  = None if direction is Direction.Output else Cat(self._i,  other._i)
+        result._o  = None if direction is Direction.Input  else Cat(self._o,  other._o)
+        result._oe = None if direction is Direction.Input  else Cat(self._oe, other._oe)
+        result._invert = self._invert + other._invert
+        result._direction = direction
+        return result
+
+    def __repr__(self):
+        parts = []
+        if self._i is not None:
+            parts.append(f"i={self._i!r}")
+        if self._o is not None:
+            parts.append(f"o={self._o!r}")
+        if self._oe is not None:
+            parts.append(f"oe={self._oe!r}")
+        if not any(self._invert):
+            invert = False
+        elif all(self._invert):
+            invert = True
+        else:
+            invert = self._invert
+        return (f"SimulationPort({', '.join(parts)}, invert={invert!r}, "
+                f"direction={self._direction})")
+
+
 class Buffer(wiring.Component):
     """A combinational I/O buffer component.
 
@@ -476,6 +628,18 @@ class Buffer(wiring.Component):
             else:
                 m.submodules += IOBufferInstance(self._port.p, o=o_inv, oe=self.oe, i=i_inv)
                 m.submodules += IOBufferInstance(self._port.n, o=~o_inv, oe=self.oe)
+        elif isinstance(self._port, SimulationPort):
+            if self.direction is Direction.Bidir:
+                # Loop back `o` if `oe` is asserted. This frees the test harness from having to
+                # provide this functionality itself.
+                for i_inv_bit, oe_bit, o_bit, i_bit in \
+                        zip(i_inv, self._port.oe, self._port.o, self._port.i):
+                    m.d.comb += i_inv_bit.eq(Cat(Mux(oe_bit, o_bit, i_bit)))
+            if self.direction is Direction.Input:
+                m.d.comb += i_inv.eq(self._port.i)
+            if self.direction in (Direction.Output, Direction.Bidir):
+                m.d.comb += self._port.o.eq(o_inv)
+                m.d.comb += self._port.oe.eq(self.oe.replicate(len(self._port)))
         else:
             raise TypeError("Cannot elaborate generic 'Buffer' with port {self._port!r}") # :nocov:
 
@@ -719,6 +883,12 @@ class DDRBuffer(wiring.Component):
 
         This limitation may be lifted in the future.
 
+    .. warning::
+
+        Double data rate I/O buffers are not compatible with :class:`SimulationPort`.
+
+        This limitation may be lifted in the future.
+
     Parameters
     ----------
     direction : :class:`Direction`
@@ -826,6 +996,9 @@ class DDRBuffer(wiring.Component):
         if hasattr(platform, "get_io_buffer"):
             return platform.get_io_buffer(self)
 
+        if isinstance(self._port, SimulationPort):
+            raise NotImplementedError(f"DDR buffers are not supported in simulation") # :nocov:
+
         raise NotImplementedError(f"DDR buffers are not supported on {platform!r}") # :nocov:
 
 
diff --git a/docs/changes.rst b/docs/changes.rst
index 93d44fb..cc99a0b 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -1,7 +1,7 @@
 Changelog
 #########
 
-This document describes changes to the public interfaces in the Amaranth language and standard library. It does not include most bug fixes or implementation changes.
+This document describes changes to the public interfaces in the Amaranth language and standard library. It does not include most bug fixes or implementation changes; versions which do not include notable changes are not listed here.
 
 
 Documentation for past releases
@@ -9,6 +9,7 @@ Documentation for past releases
 
 Documentation for past releases of the Amaranth language and toolchain is available online:
 
+* `Amaranth 0.5.0 <https://amaranth-lang.org/docs/amaranth/v0.5.0/>`_
 * `Amaranth 0.4.5 <https://amaranth-lang.org/docs/amaranth/v0.4.5/>`_
 * `Amaranth 0.4.4 <https://amaranth-lang.org/docs/amaranth/v0.4.4/>`_
 * `Amaranth 0.4.3 <https://amaranth-lang.org/docs/amaranth/v0.4.3/>`_
@@ -22,6 +23,14 @@ Version 0.5.1
 =============
 
 
+Implemented RFCs
+----------------
+
+.. _RFC 69: https://amaranth-lang.org/rfcs/0069-simulation-port.html
+
+* `RFC 69`_: Add a ``lib.io.PortLike`` object usable in simulation
+
+
 Standard library changes
 ------------------------
 
@@ -30,10 +39,11 @@ Standard library changes
 * Added: views of :class:`amaranth.lib.data.ArrayLayout` can be indexed with negative integers or slices.
 * Added: :py:`len()` works on views of :class:`amaranth.lib.data.ArrayLayout`.
 * Added: views of :class:`amaranth.lib.data.ArrayLayout` are iterable.
+* Added: :class:`io.SimulationPort`. (`RFC 69`_)
 
 
-Version 0.5
-===========
+Version 0.5.0
+=============
 
 The Migen compatibility layer has been removed.
 
@@ -132,9 +142,9 @@ Language changes
 * Deprecated: :class:`amaranth.hdl.Memory`. (`RFC 45`_)
 * Deprecated: upwards propagation of clock domains. (`RFC 59`_)
 * Deprecated: :meth:`Value.implies`.
-* Removed: (deprecated in 0.4) :meth:`Const.normalize`. (`RFC 5`_)
-* Removed: (deprecated in 0.4) :class:`Repl`. (`RFC 10`_)
-* Removed: (deprecated in 0.4) :class:`ast.Sample`, :class:`ast.Past`, :class:`ast.Stable`, :class:`ast.Rose`, :class:`ast.Fell`.
+* Removed: (deprecated in 0.4.0) :meth:`Const.normalize`. (`RFC 5`_)
+* Removed: (deprecated in 0.4.0) :class:`Repl`. (`RFC 10`_)
+* Removed: (deprecated in 0.4.0) :class:`ast.Sample`, :class:`ast.Past`, :class:`ast.Stable`, :class:`ast.Rose`, :class:`ast.Fell`.
 * Removed: assertion names in :class:`Assert`, :class:`Assume` and :class:`Cover`. (`RFC 50`_)
 * Removed: accepting non-subclasses of :class:`Elaboratable` as elaboratables.
 
@@ -153,9 +163,9 @@ Standard library changes
 * Added: :mod:`amaranth.lib.meta`, :class:`amaranth.lib.wiring.ComponentMetadata`. (`RFC 30`_)
 * Added: :mod:`amaranth.lib.stream`. (`RFC 61`_)
 * Deprecated: :mod:`amaranth.lib.coding`. (`RFC 63`_)
-* Removed: (deprecated in 0.4) :mod:`amaranth.lib.scheduler`. (`RFC 19`_)
-* Removed: (deprecated in 0.4) :class:`amaranth.lib.fifo.FIFOInterface` with :py:`fwft=False`. (`RFC 20`_)
-* Removed: (deprecated in 0.4) :class:`amaranth.lib.fifo.SyncFIFO` with :py:`fwft=False`. (`RFC 20`_)
+* Removed: (deprecated in 0.4.0) :mod:`amaranth.lib.scheduler`. (`RFC 19`_)
+* Removed: (deprecated in 0.4.0) :class:`amaranth.lib.fifo.FIFOInterface` with :py:`fwft=False`. (`RFC 20`_)
+* Removed: (deprecated in 0.4.0) :class:`amaranth.lib.fifo.SyncFIFO` with :py:`fwft=False`. (`RFC 20`_)
 
 
 Toolchain changes
@@ -170,7 +180,7 @@ Toolchain changes
 * Deprecated: :py:`Simulator.add_sync_process`. (`RFC 27`_)
 * Deprecated: generator-based simulation processes and testbenches. (`RFC 36`_)
 * Deprecated: the :py:`run_passive` argument to :meth:`Simulator.run_until <amaranth.sim.Simulator.run_until>` has been deprecated, and does nothing.
-* 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``.
+* Removed: (deprecated in 0.4.0) 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``.
 
 
 Platform integration changes
@@ -183,11 +193,11 @@ Platform integration changes
 * Added: ``build.sh``  begins with ``#!/bin/sh``.
 * Changed: ``IntelPlatform`` renamed to ``AlteraPlatform``.
 * Deprecated: argument :py:`run_script=` in :meth:`BuildPlan.execute_local`.
-* Removed: (deprecated in 0.4) :mod:`vendor.intel`, :mod:`vendor.lattice_ecp5`, :mod:`vendor.lattice_ice40`, :mod:`vendor.lattice_machxo2_3l`, :mod:`vendor.quicklogic`, :mod:`vendor.xilinx`. (`RFC 18`_)
+* Removed: (deprecated in 0.4.0) :mod:`vendor.intel`, :mod:`vendor.lattice_ecp5`, :mod:`vendor.lattice_ice40`, :mod:`vendor.lattice_machxo2_3l`, :mod:`vendor.quicklogic`, :mod:`vendor.xilinx`. (`RFC 18`_)
 
 
-Version 0.4
-===========
+Version 0.4.0
+=============
 
 Support has been added for a new and improved way of defining data structures in :mod:`amaranth.lib.data` and component interfaces in :mod:`amaranth.lib.wiring`, as defined in `RFC 1`_ and `RFC 2`_. :class:`Record` has been deprecated. In a departure from the usual policy, to give designers additional time to migrate, :class:`Record` will be removed in Amaranth 0.6 (one release later than normal).
 
diff --git a/docs/stdlib/io.rst b/docs/stdlib/io.rst
index 3740ccf..295fbc8 100644
--- a/docs/stdlib/io.rst
+++ b/docs/stdlib/io.rst
@@ -60,7 +60,8 @@ All of the following examples assume that one of the built-in FPGA platforms is
 
 .. testcode::
 
-    from amaranth.lib import io, wiring
+    from amaranth.sim import Simulator
+    from amaranth.lib import io, wiring, stream
     from amaranth.lib.wiring import In, Out
 
 
@@ -191,6 +192,74 @@ In this example of a `source-synchronous interface <https://en.wikipedia.org/wik
 This component transmits :py:`dout` on each cycle as two halves: the low 8 bits on the rising edge of the data clock, and the high 8 bits on the falling edge of the data clock. The transmission is *edge-aligned*, meaning that the data edges exactly coincide with the clock edges.
 
 
+Simulation
+----------
+
+The Amaranth simulator, :mod:`amaranth.sim`, cannot simulate :ref:`core I/O values <lang-iovalues>` or :ref:`I/O buffer instances <lang-iobufferinstance>` as it only operates on unidirectionally driven two-state wires. This module provides a simulation-only library I/O port, :class:`SimulationPort`, so that components that use library I/O buffers can be tested.
+
+A component that is designed for testing should accept the library I/O ports it will drive as constructor parameters rather than requesting them from the platform directly. Synthesizable designs will instantiate the component with a :class:`SingleEndedPort`, :class:`DifferentialPort`, or a platform-specific library I/O port, while tests will instantiate the component with a :class:`SimulationPort`. Tests are able to inject inputs into the component using :py:`sim_port.i`, capture the outputs of the component via :py:`sim_port.o`, and ensure that the component is driving the outputs at the appropriate times using :py:`sim_port.oe`.
+
+For example, consider a simple serializer that accepts a stream of multi-bit data words and outputs them bit by bit. It can be tested as follows:
+
+.. testcode::
+
+    class OutputSerializer(wiring.Component):
+        data: In(stream.Signature(8))
+
+        def __init__(self, dclk_port, dout_port):
+            self.dclk_port = dclk_port
+            self.dout_port = dout_port
+
+            super().__init__()
+
+        def elaborate(self, platform):
+            m = Module()
+
+            m.submodules.dclk = dclk = io.Buffer("o", self.dclk_port)
+            m.submodules.dout = dout = io.Buffer("o", self.dout_port)
+
+            index = Signal(range(8))
+            m.d.comb += dout.o.eq(self.data.payload.bit_select(index, 1))
+
+            with m.If(self.data.valid):
+                m.d.sync += dclk.o.eq(~dclk.o)
+                with m.If(dclk.o):
+                    m.d.sync += index.eq(index + 1)
+                    with m.If(index == 7):
+                        m.d.comb += self.data.ready.eq(1)
+
+            return m
+
+    def test_output_serializer():
+        dclk_port = io.SimulationPort("o", 1)
+        dout_port = io.SimulationPort("o", 1)
+
+        dut = OutputSerializer(dclk_port, dout_port)
+
+        async def testbench_write_data(ctx):
+            ctx.set(dut.data.payload, 0xA1)
+            ctx.set(dut.data.valid, 1)
+            await ctx.tick().until(dut.data.ready)
+            ctx.set(dut.data.valid, 0)
+
+        async def testbench_sample_output(ctx):
+            for bit in [1,0,0,0,0,1,0,1]:
+                _, dout_value = await ctx.posedge(dut.dclk_port.o).sample(dut.dout_port.o)
+                assert ctx.get(dut.dout_port.oe) == 1, "DUT is not driving the data output"
+                assert dout_value == bit, "DUT drives the wrong value on data output"
+
+        sim = Simulator(dut)
+        sim.add_clock(1e-6)
+        sim.add_testbench(testbench_write_data)
+        sim.add_testbench(testbench_sample_output)
+        sim.run()
+
+.. testcode::
+    :hide:
+
+    test_output_serializer()
+
+
 Ports
 -----
 
@@ -199,6 +268,7 @@ Ports
 .. autoclass:: PortLike
 .. autoclass:: SingleEndedPort
 .. autoclass:: DifferentialPort
+.. autoclass:: SimulationPort
 
 
 Buffers


Test patch:
diff --git a/tests/test_lib_io.py b/tests/test_lib_io.py
index 9dc208e..e904e7f 100644
--- a/tests/test_lib_io.py
+++ b/tests/test_lib_io.py
@@ -30,6 +30,14 @@ class DirectionTestCase(FHDLTestCase):
             Direction.Bidir & 3
 
 
+class PortLikeTestCase(FHDLTestCase):
+    def test_warn___add__(self):
+        with self.assertWarnsRegex(DeprecationWarning,
+                r"WrongPortLike must override the `__add__` method$"):
+            class WrongPortLike(PortLike):
+                pass
+
+
 class SingleEndedPortTestCase(FHDLTestCase):
     def test_construct(self):
         io = IOPort(4)
@@ -161,6 +169,123 @@ class DifferentialPortTestCase(FHDLTestCase):
         self.assertRepr(iport, "DifferentialPort((io-port iop), (io-port ion), invert=(False, True, False, True), direction=Direction.Output)")
 
 
+class SimulationPortTestCase(FHDLTestCase):
+    def test_construct(self):
+        port_io = SimulationPort("io", 2)
+        self.assertEqual(port_io.direction, Direction.Bidir)
+        self.assertEqual(len(port_io), 2)
+        self.assertEqual(port_io.invert, (False, False))
+        self.assertIsInstance(port_io.i, Signal)
+        self.assertEqual(port_io.i.shape(), unsigned(2))
+        self.assertIsInstance(port_io.o, Signal)
+        self.assertEqual(port_io.o.shape(), unsigned(2))
+        self.assertEqual(port_io.o.init, 0)
+        self.assertIsInstance(port_io.oe, Signal)
+        self.assertEqual(port_io.oe.shape(), unsigned(2))
+        self.assertEqual(port_io.oe.init, 0)
+        self.assertRepr(port_io, "SimulationPort(i=(sig port_io__i), o=(sig port_io__o), oe=(sig port_io__oe), invert=False, direction=Direction.Bidir)")
+
+        port_i = SimulationPort("i", 3, invert=True)
+        self.assertEqual(port_i.direction, Direction.Input)
+        self.assertEqual(len(port_i), 3)
+        self.assertEqual(port_i.invert, (True, True, True))
+        self.assertIsInstance(port_i.i, Signal)
+        self.assertEqual(port_i.i.shape(), unsigned(3))
+        with self.assertRaisesRegex(AttributeError,
+                r"^Simulation port with input direction does not have an output signal$"):
+            port_i.o
+        with self.assertRaisesRegex(AttributeError,
+                r"^Simulation port with input direction does not have an output enable signal$"):
+            port_i.oe
+        self.assertRepr(port_i, "SimulationPort(i=(sig port_i__i), invert=True, direction=Direction.Input)")
+
+        port_o = SimulationPort("o", 2, invert=(True, False))
+        self.assertEqual(port_o.direction, Direction.Output)
+        self.assertEqual(len(port_o), 2)
+        self.assertEqual(port_o.invert, (True, False))
+        with self.assertRaisesRegex(AttributeError,
+                r"^Simulation port with output direction does not have an input signal$"):
+            port_o.i
+        self.assertIsInstance(port_o.o, Signal)
+        self.assertEqual(port_o.o.shape(), unsigned(2))
+        self.assertEqual(port_o.o.init, 0)
+        self.assertIsInstance(port_o.oe, Signal)
+        self.assertEqual(port_o.oe.shape(), unsigned(2))
+        self.assertEqual(port_o.oe.init, 0b11)
+        self.assertRepr(port_o, "SimulationPort(o=(sig port_o__o), oe=(sig port_o__oe), invert=(True, False), direction=Direction.Output)")
+
+    def test_construct_empty(self):
+        port_i = SimulationPort("i", 0, invert=True)
+        self.assertEqual(port_i.direction, Direction.Input)
+        self.assertEqual(len(port_i), 0)
+        self.assertEqual(port_i.invert, ())
+        self.assertIsInstance(port_i.i, Signal)
+        self.assertEqual(port_i.i.shape(), unsigned(0))
+        self.assertRepr(port_i, "SimulationPort(i=(sig port_i__i), invert=False, direction=Direction.Input)")
+
+    def test_name(self):
+        port = SimulationPort("io", 2, name="nyaa")
+        self.assertRepr(port, "SimulationPort(i=(sig nyaa__i), o=(sig nyaa__o), oe=(sig nyaa__oe), invert=False, direction=Direction.Bidir)")
+
+    def test_name_wrong(self):
+        with self.assertRaisesRegex(TypeError,
+                r"^Name must be a string, not 1$"):
+            SimulationPort("io", 1, name=1)
+
+    def test_construct_wrong(self):
+        with self.assertRaisesRegex(TypeError,
+                r"^Width must be a non-negative integer, not 'a'$"):
+            SimulationPort("io", "a")
+        with self.assertRaisesRegex(TypeError,
+                r"^Width must be a non-negative integer, not -1$"):
+            SimulationPort("io", -1)
+        with self.assertRaisesRegex(TypeError,
+                r"^'invert' must be a bool or iterable of bool, not 3$"):
+            SimulationPort("io", 1, invert=3)
+        with self.assertRaisesRegex(TypeError,
+                r"^'invert' must be a bool or iterable of bool, not \[1, 2\]$"):
+            SimulationPort("io", 2, invert=[1, 2])
+        with self.assertRaisesRegex(ValueError,
+                r"^Length of 'invert' \(2\) doesn't match port width \(1\)$"):
+            SimulationPort("io", 1, invert=(False, True))
+
+    def test_slice(self):
+        port_io = SimulationPort("io", 2)
+        self.assertRepr(port_io[0], "SimulationPort(i=(slice (sig port_io__i) 0:1), o=(slice (sig port_io__o) 0:1), oe=(slice (sig port_io__oe) 0:1), invert=False, direction=Direction.Bidir)")
+
+        port_i = SimulationPort("i", 3, invert=True)
+        self.assertRepr(port_i[1:3], "SimulationPort(i=(slice (sig port_i__i) 1:3), invert=True, direction=Direction.Input)")
+
+        port_o = SimulationPort("o", 2, invert=(True, False))
+        self.assertRepr(port_o[1], "SimulationPort(o=(slice (sig port_o__o) 1:2), oe=(slice (sig port_o__oe) 1:2), invert=False, direction=Direction.Output)")
+
+    def test_invert(self):
+        port_io = SimulationPort("io", 2)
+        self.assertRepr(~port_io, "SimulationPort(i=(sig port_io__i), o=(sig port_io__o), oe=(sig port_io__oe), invert=True, direction=Direction.Bidir)")
+
+        port_i = SimulationPort("i", 3, invert=True)
+        self.assertRepr(~port_i, "SimulationPort(i=(sig port_i__i), invert=False, direction=Direction.Input)")
+
+        port_o = SimulationPort("o", 2, invert=(True, False))
+        self.assertRepr(~port_o, "SimulationPort(o=(sig port_o__o), oe=(sig port_o__oe), invert=(False, True), direction=Direction.Output)")
+
+    def test_add(self):
+        port_io = SimulationPort("io", 2)
+        port_io2 = SimulationPort("io", 2)
+        port_i = SimulationPort("i", 3, invert=True)
+        port_o = SimulationPort("o", 2, invert=(True, False))
+
+        self.assertRepr(port_io + port_io2, "SimulationPort(i=(cat (sig port_io__i) (sig port_io2__i)), o=(cat (sig port_io__o) (sig port_io2__o)), oe=(cat (sig port_io__oe) (sig port_io2__oe)), invert=False, direction=Direction.Bidir)")
+        self.assertRepr(port_io + port_i, "SimulationPort(i=(cat (sig port_io__i) (sig port_i__i)), invert=(False, False, True, True, True), direction=Direction.Input)")
+        self.assertRepr(port_io + port_o, "SimulationPort(o=(cat (sig port_io__o) (sig port_o__o)), oe=(cat (sig port_io__oe) (sig port_o__oe)), invert=(False, False, True, False), direction=Direction.Output)")
+
+    def test_add_wrong(self):
+        io = IOPort(1)
+        with self.assertRaisesRegex(TypeError,
+                r"^unsupported operand type\(s\) for \+: 'SimulationPort' and 'SingleEndedPort'$"):
+            SimulationPort("io", 2) + SingleEndedPort(io)
+
+
 class BufferTestCase(FHDLTestCase):
     def test_signature(self):
         sig_i = Buffer.Signature("i", 4)
@@ -378,6 +503,121 @@ class BufferTestCase(FHDLTestCase):
         )
         """)
 
+    def test_elaborate_sim(self):
+        port = SimulationPort("io", 4)
+        buf = Buffer("io", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, buf.o, buf.oe, port.i, port.o, port.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (input 'port__i' 0.7:11)
+                (output 'i' 5.0:4)
+                (output 'port__o' 0.2:6)
+                (output 'port__oe' (cat 0.6 0.6 0.6 0.6))
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (input 'port__i' 7:11)
+                (output 'i' 5.0:4)
+                (output 'port__o' 0.2:6)
+                (output 'port__oe' (cat 0.6 0.6 0.6 0.6))
+            ))
+            (cell 1 0 (m 0.6 0.2 0.7))
+            (cell 2 0 (m 0.6 0.3 0.8))
+            (cell 3 0 (m 0.6 0.4 0.9))
+            (cell 4 0 (m 0.6 0.5 0.10))
+            (cell 5 0 (assignment_list 4'd0 (1 0:1 1.0) (1 1:2 2.0) (1 2:3 3.0) (1 3:4 4.0)))
+        )
+        """)
+
+        port = SimulationPort("io", 4, invert=[False, True, False, True])
+        buf = Buffer("io", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, buf.o, buf.oe, port.i, port.o, port.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (input 'port__i' 0.7:11)
+                (output 'i' 2.0:4)
+                (output 'port__o' 1.0:4)
+                (output 'port__oe' (cat 0.6 0.6 0.6 0.6))
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (input 'port__i' 7:11)
+                (output 'i' 2.0:4)
+                (output 'port__o' 1.0:4)
+                (output 'port__oe' (cat 0.6 0.6 0.6 0.6))
+            ))
+            (cell 1 0 (^ 0.2:6 4'd10))
+            (cell 2 0 (^ 7.0:4 4'd10))
+            (cell 3 0 (m 0.6 1.0 0.7))
+            (cell 4 0 (m 0.6 1.1 0.8))
+            (cell 5 0 (m 0.6 1.2 0.9))
+            (cell 6 0 (m 0.6 1.3 0.10))
+            (cell 7 0 (assignment_list 4'd0 (1 0:1 3.0) (1 1:2 4.0) (1 2:3 5.0) (1 3:4 6.0)))
+        )
+        """)
+
+        buf = Buffer("i", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, port.i])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'port__i' 0.2:6)
+                (output 'i' 1.0:4)
+            )
+            (cell 0 0 (top
+                (input 'port__i' 2:6)
+                (output 'i' 1.0:4)
+            ))
+            (cell 1 0 (^ 0.2:6 4'd10))
+        )
+        """)
+
+        buf = Buffer("o", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.o, buf.oe, port.o, port.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (output 'port__o' 1.0:4)
+                (output 'port__oe' (cat 0.6 0.6 0.6 0.6))
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (output 'port__o' 1.0:4)
+                (output 'port__oe' (cat 0.6 0.6 0.6 0.6))
+            ))
+            (cell 1 0 (^ 0.2:6 4'd10))
+        )
+        """)
+
+        # check that a port without `port.o`/`port.oe` works
+        port = SimulationPort("i", 4, invert=[False, True, False, True])
+        buf = Buffer("i", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, port.i])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'port__i' 0.2:6)
+                (output 'i' 1.0:4)
+            )
+            (cell 0 0 (top
+                (input 'port__i' 2:6)
+                (output 'i' 1.0:4)
+            ))
+            (cell 1 0 (^ 0.2:6 4'd10))
+        )
+        """)
+
 
 class FFBufferTestCase(FHDLTestCase):
     def test_signature(self):
@@ -616,6 +856,150 @@ class FFBufferTestCase(FHDLTestCase):
         )
         """)
 
+    def test_elaborate_sim(self):
+        port = SimulationPort("io", 4)
+        buf = FFBuffer("io", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, buf.o, buf.oe, port.i, port.o, port.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (input 'port__i' 0.7:11)
+                (input 'clk' 0.11)
+                (input 'rst' 0.12)
+                (output 'i' 5.0:4)
+                (output 'port__o' 6.0:4)
+                (output 'port__oe' (cat 7.0 7.0 7.0 7.0))
+            )
+            (module 1 0 ('top' 'io_buffer')
+                (input 'port__i' 0.7:11)
+                (input 'port__o' 6.0:4)
+                (input 'oe' 7.0)
+                (output 'i' 8.0:4)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (input 'port__i' 7:11)
+                (input 'clk' 11:12)
+                (input 'rst' 12:13)
+                (output 'i' 5.0:4)
+                (output 'port__o' 6.0:4)
+                (output 'port__oe' (cat 7.0 7.0 7.0 7.0))
+            ))
+            (cell 1 1 (m 7.0 6.0 0.7))
+            (cell 2 1 (m 7.0 6.1 0.8))
+            (cell 3 1 (m 7.0 6.2 0.9))
+            (cell 4 1 (m 7.0 6.3 0.10))
+            (cell 5 0 (flipflop 8.0:4 0 pos 0.11 0))
+            (cell 6 0 (flipflop 0.2:6 0 pos 0.11 0))
+            (cell 7 0 (flipflop 0.6 0 pos 0.11 0))
+            (cell 8 1 (assignment_list 4'd0 (1 0:1 1.0) (1 1:2 2.0) (1 2:3 3.0) (1 3:4 4.0)))
+        )
+        """)
+
+        port = SimulationPort("io", 4, invert=[False, True, False, True])
+        buf = FFBuffer("io", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, buf.o, buf.oe, port.i, port.o, port.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (input 'port__i' 0.7:11)
+                (input 'clk' 0.11)
+                (input 'rst' 0.12)
+                (output 'i' 7.0:4)
+                (output 'port__o' 1.0:4)
+                (output 'port__oe' (cat 9.0 9.0 9.0 9.0))
+            )
+            (module 1 0 ('top' 'io_buffer')
+                (input 'port__i' 0.7:11)
+                (output 'o_inv' 1.0:4)
+                (output 'i' 2.0:4)
+                (input 'o' 8.0:4)
+                (input 'oe' 9.0)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (input 'port__i' 7:11)
+                (input 'clk' 11:12)
+                (input 'rst' 12:13)
+                (output 'i' 7.0:4)
+                (output 'port__o' 1.0:4)
+                (output 'port__oe' (cat 9.0 9.0 9.0 9.0))
+            ))
+            (cell 1 1 (^ 8.0:4 4'd10))
+            (cell 2 1 (^ 10.0:4 4'd10))
+            (cell 3 1 (m 9.0 1.0 0.7))
+            (cell 4 1 (m 9.0 1.1 0.8))
+            (cell 5 1 (m 9.0 1.2 0.9))
+            (cell 6 1 (m 9.0 1.3 0.10))
+            (cell 7 0 (flipflop 2.0:4 0 pos 0.11 0))
+            (cell 8 0 (flipflop 0.2:6 0 pos 0.11 0))
+            (cell 9 0 (flipflop 0.6 0 pos 0.11 0))
+            (cell 10 1 (assignment_list 4'd0 (1 0:1 3.0) (1 1:2 4.0) (1 2:3 5.0) (1 3:4 6.0)))
+        )
+        """)
+
+        buf = FFBuffer("i", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, port.i])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'port__i' 0.2:6)
+                (input 'clk' 0.6)
+                (input 'rst' 0.7)
+                (output 'i' 2.0:4)
+            )
+            (module 1 0 ('top' 'io_buffer')
+                (input 'i_inv' 0.2:6)
+                (output 'i' 1.0:4)
+            )
+            (cell 0 0 (top
+                (input 'port__i' 2:6)
+                (input 'clk' 6:7)
+                (input 'rst' 7:8)
+                (output 'i' 2.0:4)
+            ))
+            (cell 1 1 (^ 0.2:6 4'd10))
+            (cell 2 0 (flipflop 1.0:4 0 pos 0.6 0))
+        )
+        """)
+
+        buf = FFBuffer("o", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.o, buf.oe, port.o, port.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (input 'clk' 0.7)
+                (input 'rst' 0.8)
+                (output 'port__o' 1.0:4)
+                (output 'port__oe' (cat 3.0 3.0 3.0 3.0))
+            )
+            (module 1 0 ('top' 'io_buffer')
+                (output 'o_inv' 1.0:4)
+                (input 'o' 2.0:4)
+                (input 'oe' 3.0)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (input 'clk' 7:8)
+                (input 'rst' 8:9)
+                (output 'port__o' 1.0:4)
+                (output 'port__oe' (cat 3.0 3.0 3.0 3.0))
+            ))
+            (cell 1 1 (^ 2.0:4 4'd10))
+            (cell 2 0 (flipflop 0.2:6 0 pos 0.7 0))
+            (cell 3 0 (flipflop 0.6 0 pos 0.7 0))
+        )
+        """)
+
 
 class DDRBufferTestCase(FHDLTestCase):
     def test_signature(self):
diff --git a/tests/utils.py b/tests/utils.py
index a069f18..5509b37 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -16,15 +16,57 @@ __all__ = ["FHDLTestCase"]
 
 
 class FHDLTestCase(unittest.TestCase):
+    maxDiff = None
+
     def assertRepr(self, obj, repr_str):
         if isinstance(obj, list):
             obj = Statement.cast(obj)
-        def prepare_repr(repr_str):
+        def squish_repr(repr_str):
             repr_str = re.sub(r"\s+",   " ",  repr_str)
             repr_str = re.sub(r"\( (?=\()", "(", repr_str)
             repr_str = re.sub(r"\) (?=\))", ")", repr_str)
             return repr_str.strip()
-        self.assertEqual(prepare_repr(repr(obj)), prepare_repr(repr_str))
+        def format_repr(input_repr, *, indent="    "):
+            output_repr = []
+            prefix = "\n"
+            name = None
+            index = 0
+            stack = []
+            current = ""
+            for char in input_repr:
+                if char == "(":
+                    stack.append((prefix, name, index))
+                    name, index = None, 0
+                    output_repr.append(char)
+                    if len(stack) == 1:
+                        prefix += indent
+                        output_repr.append(prefix)
+                elif char == ")":
+                    indented = (len(stack) == 1 or name in ("module", "top"))
+                    prefix, name, index = stack.pop()
+                    if indented:
+                        output_repr.append(prefix)
+                    output_repr.append(char)
+                elif char == " ":
+                    if name is None:
+                        name = current
+                        if name in ("module", "top"):
+                            prefix += indent
+                    else:
+                        index += 1
+                    current = ""
+                    if len(stack) == 1 or name == "module" and index >= 3 or name == "top":
+                        output_repr.append(prefix)
+                    else:
+                        output_repr.append(char)
+                elif name is None:
+                    current += char
+                    output_repr.append(char)
+                else:
+                    output_repr.append(char)
+            return "".join(output_repr)
+        # print("\n" + format_repr(squish_repr(repr(obj))))
+        self.assertEqual(format_repr(squish_repr(repr(obj))), format_repr(squish_repr(repr_str)))
 
     def assertFormal(self, spec, ports=None, mode="bmc", depth=1):
         stack = traceback.extract_stack()