Repository: amaranth-lang/amaranth
Pull request #1355 (2024-05-08 22:50:07)

hdl._ir, lib, vendor: add `RequirePosedge`, use it whenever required.


Patch:
diff --git a/.editorconfig b/.editorconfig
index 2d0ad6a..6d3184c 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -7,3 +7,6 @@ indent_size = 4
 max_line_length = 100
 trim_trailing_whitespace = true
 insert_final_newline = true
+
+[*.yaml]
+indent_size = 2
diff --git a/amaranth/build/res.py b/amaranth/build/res.py
index d800a67..fe7d0f6 100644
--- a/amaranth/build/res.py
+++ b/amaranth/build/res.py
@@ -1,8 +1,10 @@
+import warnings
 from collections import OrderedDict
 
 from ..hdl import *
 from ..hdl._ast import SignalDict
 from ..lib import wiring, io
+from .._utils import _ignore_deprecated
 
 from .dsl import *
 
@@ -247,7 +249,11 @@ class ResourceManager:
                 if dir == "-":
                     return port
                 else:
-                    pin = wiring.flipped(io.Pin(len(phys), dir, xdr=xdr, path=path))
+                    warnings.warn(f"Using platform.request without `dir=\"-\"` is deprecated; "
+                                  f"use `amaranth.lib.io.*Buffer` components instead",
+                                  DeprecationWarning, stacklevel=2)
+                    with _ignore_deprecated():
+                        pin = wiring.flipped(io.Pin(len(phys), dir, xdr=xdr, path=path))
                     buffer = PinBuffer(pin, port)
                     self._pins.append((pin, port, buffer))
 
diff --git a/amaranth/hdl/_ir.py b/amaranth/hdl/_ir.py
index 66750c1..f24cd2e 100644
--- a/amaranth/hdl/_ir.py
+++ b/amaranth/hdl/_ir.py
@@ -10,8 +10,9 @@ from . import _ast, _cd, _ir, _nir
 
 __all__ = [
     "AlreadyElaborated", "UnusedElaboratable", "Elaboratable", "DuplicateElaboratable",
-    "DriverConflict", "Fragment", "Instance", "IOBufferInstance", "PortDirection", "Design",
-    "build_netlist",
+    "DomainRequirementFailed", "DriverConflict",
+    "Fragment", "Instance", "IOBufferInstance", "RequirePosedge", "PortDirection",
+    "Design", "build_netlist",
 ]
 
 
@@ -40,6 +41,11 @@ class DuplicateElaboratable(Exception):
     pass
 
 
+class DomainRequirementFailed(Exception):
+    """Raised when a module has unsatisfied requirements about a clock domain, such as getting
+    a negedge domain when only posedge domains are supported."""
+
+
 class Fragment:
     @staticmethod
     def get(obj, platform):
@@ -385,6 +391,19 @@ class IOBufferInstance(Fragment):
         self.src_loc = src_loc or tracer.get_src_loc(src_loc_at)
 
 
+class RequirePosedge(Fragment):
+    """A special fragment that requires a given domain to have :py:`clk_edge="pos"`, failing
+    elaboration otherwise.
+
+    This is a private interface, without a stability guarantee.
+    """
+
+    def __init__(self, domain, *, src_loc_at=0, src_loc=None):
+        super().__init__()
+        self._domain = domain
+        self.src_loc = src_loc or tracer.get_src_loc(src_loc_at)
+
+
 def _add_name(assigned_names, name):
     if name in assigned_names:
         name = f"{name}${len(assigned_names)}"
@@ -429,6 +448,7 @@ class Design:
             else:
                 self._use_signal(fragment, conn)
         self._assign_names(fragment, hierarchy)
+        self._check_domain_requires()
 
     def _compute_fragment_depth_parent(self, fragment: Fragment, parent: "Fragment | None", depth: int):
         """Recursively computes every fragment's depth and parent."""
@@ -576,6 +596,8 @@ class Design:
                 self._use_signal(fragment, domain.clk)
                 if domain.rst is not None:
                     self._use_signal(fragment, domain.rst)
+        elif isinstance(fragment, _ir.RequirePosedge):
+            pass
         else:
             for domain_name, statements in fragment.statements.items():
                 if domain_name != "comb":
@@ -662,6 +684,18 @@ class Design:
             subfragment_name = _add_name(frag_info.assigned_names, subfragment_name)
             self._assign_names(subfragment, hierarchy=(*hierarchy, subfragment_name))
 
+    def _check_domain_requires(self):
+        for fragment, fragment_info in self.fragments.items():
+            if isinstance(fragment, RequirePosedge):
+                domain = fragment.domains[fragment._domain]
+                if domain.clk_edge != "pos":
+                    if fragment.src_loc is None:
+                        src_loc = "<unknown>:0"
+                    else:
+                        src_loc = f"{fragment.src_loc[0]}:{fragment.src_loc[1]}"
+                    fragment_name = ".".join(fragment_info.name)
+                    raise DomainRequirementFailed(f"Domain {domain.name} has a negedge clock, but posedge clock is required by {fragment_name} at {src_loc}")
+
     def lookup_domain(self, domain, context):
         if domain == "comb":
             raise KeyError("comb")
@@ -1491,6 +1525,8 @@ class NetlistEmitter:
             assert parent_module_idx is not None
             self.emit_iobuffer(parent_module_idx, fragment)
             self.fragment_module_idx[fragment] = parent_module_idx
+        elif isinstance(fragment, _ir.RequirePosedge):
+            pass
         elif type(fragment) is _ir.Fragment:
             module_idx = self.netlist.add_module(parent_module_idx, fragment_name, src_loc=fragment.src_loc, cell_src_loc=cell_src_loc)
             self.fragment_module_idx[fragment] = module_idx
diff --git a/amaranth/hdl/_xfrm.py b/amaranth/hdl/_xfrm.py
index 062cddc..92d0705 100644
--- a/amaranth/hdl/_xfrm.py
+++ b/amaranth/hdl/_xfrm.py
@@ -314,6 +314,8 @@ class FragmentTransformer:
                     oe=fragment.oe,
                     src_loc=fragment.src_loc,
                 )
+        elif isinstance(fragment, RequirePosedge):
+            new_fragment = RequirePosedge(fragment._domain, src_loc=fragment.src_loc)
         else:
             new_fragment = Fragment(src_loc=fragment.src_loc)
         new_fragment.attrs = OrderedDict(fragment.attrs)
@@ -445,6 +447,8 @@ class DomainCollector(ValueVisitor, StatementVisitor):
                 self.on_value(port._data)
                 self.on_value(port._en)
                 self._add_used_domain(port._domain)
+        if isinstance(fragment, RequirePosedge):
+            self._add_used_domain(fragment._domain)
 
         if isinstance(fragment, Instance):
             for name, (value, dir) in fragment.ports.items():
@@ -535,6 +539,12 @@ class DomainRenamer(FragmentTransformer, ValueTransformer, StatementTransformer)
             if port._domain in self.domain_map:
                 port._domain = self.domain_map[port._domain]
 
+    def on_fragment(self, fragment):
+        new_fragment = super().on_fragment(fragment)
+        if isinstance(new_fragment, RequirePosedge) and new_fragment._domain in self.domain_map:
+            new_fragment._domain = self.domain_map[new_fragment._domain]
+        return new_fragment
+
 
 class DomainLowerer(FragmentTransformer, ValueTransformer, StatementTransformer):
     def __init__(self, domains=None):
diff --git a/amaranth/lib/cdc.py b/amaranth/lib/cdc.py
index b2a7cdd..1afcc76 100644
--- a/amaranth/lib/cdc.py
+++ b/amaranth/lib/cdc.py
@@ -1,6 +1,7 @@
 import warnings
 
 from .. import *
+from ..hdl._ir import RequirePosedge
 
 
 __all__ = ["FFSynchronizer", "AsyncFFSynchronizer", "ResetSynchronizer", "PulseSynchronizer"]
@@ -187,6 +188,7 @@ class AsyncFFSynchronizer(Elaboratable):
             ClockSignal("async_ff").eq(ClockSignal(self._o_domain)),
             self.o.eq(flops[-1])
         ]
+        m.submodules += RequirePosedge(self._o_domain)
 
         return m
 
diff --git a/amaranth/lib/io.py b/amaranth/lib/io.py
index 2d94bfb..8a1dbe2 100644
--- a/amaranth/lib/io.py
+++ b/amaranth/lib/io.py
@@ -6,6 +6,7 @@ from collections.abc import Iterable
 from ..hdl import *
 from ..lib import wiring, data
 from ..lib.wiring import In, Out
+from .._utils import deprecated, _ignore_deprecated
 from .. import tracer
 
 
@@ -17,24 +18,22 @@ __all__ = [
 
 
 class Direction(enum.Enum):
-    """Represents a direction of an I/O port, or of an I/O buffer."""
+    """Represents direction of a library I/O port, or of an I/O buffer component."""
 
-    #: Input direction (from world to Amaranth design)
+    #: Input direction (from outside world to Amaranth design).
     Input  = "i"
-    #: Output direction (from Amaranth design to world)
+    #: Output direction (from Amaranth design to outside world).
     Output = "o"
-    #: Bidirectional (can be switched between input and output)
+    #: Bidirectional (can be switched between input and output).
     Bidir  = "io"
 
-    def __or__(self, other):
-        if not isinstance(other, Direction):
-            return NotImplemented
-        if self == other:
-            return self
-        else:
-            return Direction.Bidir
-
     def __and__(self, other):
+        """Narrow the set of possible directions.
+
+        * :py:`self & self` returns :py:`self`.
+        * :py:`Bidir & other` returns :py:`other`.
+        * :py:`Input & Output` raises :exc:`ValueError`.
+        """
         if not isinstance(other, Direction):
             return NotImplemented
         if self == other:
@@ -48,66 +47,95 @@ class Direction(enum.Enum):
 
 
 class PortLike(metaclass=ABCMeta):
-    """Represents an abstract port that can be passed to a buffer.
+    """Represents an abstract library I/O port that can be passed to a buffer.
 
     The port types supported by most platforms are :class:`SingleEndedPort` and
-    :class:`DifferentialPort`. Platforms may define additional custom port types as appropriate.
+    :class:`DifferentialPort`. Platforms may define additional port types where appropriate.
+
+    .. note::
+
+        :class:`amaranth.hdl.IOPort` is not an instance of :class:`amaranth.lib.io.PortLike`.
     """
 
     @property
     @abstractmethod
     def direction(self):
-        """The direction of this port, as :class:`Direction`."""
+        """Direction of the port.
+
+        Returns
+        -------
+        :class:`Direction`
+        """
         raise NotImplementedError # :nocov:
 
     @abstractmethod
     def __len__(self):
-        """Returns the width of this port in bits."""
+        """Computes the width of the port.
+
+        Returns
+        -------
+        :class:`int`
+            The number of wires (for single-ended library I/O ports) or wire pairs (for differential
+            library I/O ports) this port consists of.
+        """
         raise NotImplementedError # :nocov:
 
     @abstractmethod
-    def __getitem__(self, index):
-        """Slices the port, returning another :class:`PortLike` with a subset
-        of its bits.
-
-        The index can be a :class:`slice` or :class:`int`. If the index is
-        an :class:`int`, the result is a single-bit :class:`PortLike`."""
+    def __getitem__(self, key):
+        """Slices the port.
+
+        Returns
+        -------
+        :class:`PortLike`
+            A new :class:`PortLike` instance of the same type as :py:`self`, containing a selection
+            of wires of this port according to :py:`key`. Its width is the same as the length of
+            the slice (if :py:`key` is a :class:`slice`); or 1 (if :py:`key` is an :class:`int`).
+        """
         raise NotImplementedError # :nocov:
 
     @abstractmethod
     def __invert__(self):
-        """Returns a new :class:`PortLike` object like this one, but with inverted polarity.
+        """Inverts polarity of the port.
+
+        Inverting polarity of a library I/O port has the same effect as adding inverters to
+        the :py:`i` and :py:`o` members of an I/O buffer component for that port.
 
-        The result should be such that using :class:`Buffer` on it is equivalent to using
-        :class:`Buffer` on the original, with added inverters on the :py:`i` and :py:`o` ports."""
+        Returns
+        -------
+        :class:`PortLike`
+            A new :class:`PortLike` instance of the same type as :py:`self`, containing the same
+            wires as this port, but with polarity inverted.
+        """
         raise NotImplementedError # :nocov:
 
 
 class SingleEndedPort(PortLike):
-    """Represents a single-ended I/O port with optional inversion.
+    """Represents a single-ended library I/O port.
+
+    Implements the :class:`PortLike` interface.
 
     Parameters
     ----------
     io : :class:`IOValue`
-        The raw I/O value being wrapped.
+        Underlying core I/O value.
     invert : :class:`bool` or iterable of :class:`bool`
-        If true, the electrical state of the physical pin will be opposite from the Amaranth value
-        (the ``*Buffer`` classes will insert inverters on :py:`o` and :py:`i` pins, as appropriate).
-
-        This can be used for various purposes:
-
-        - Normalizing active-low pins (such as ``CS_B``) to be active-high in Amaranth code
-        - Compensating for boards where an inverting level-shifter (or similar circuitry) was used
-          on the pin
-
-        If the value is a simple :class:`bool`, it is used for all bits of this port. If the value
-        is an iterable of :class:`bool`, the iterable must have the same length as :py:`io`, and
-        the inversion is specified per-bit.
+        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:`io`, and the inversion is specified for individual wires.
     direction : :class:`Direction` or :class:`str`
-        Represents the allowed directions of this port. 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 a string is passed, it is cast to :class:`Direction`.
+        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.
+
+    Attributes
+    ----------
+    io : :class:`IOValue`
+        The :py:`io` parameter.
+    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, io, *, invert=False, direction=Direction.Bidir):
         self._io = IOValue.cast(io)
@@ -126,52 +154,53 @@ class SingleEndedPort(PortLike):
 
     @property
     def io(self):
-        """The :py:`io` argument passed to the constructor."""
         return self._io
 
     @property
     def invert(self):
-        """The :py:`invert` argument passed to the constructor, normalized to a :class:`tuple`
-        of :class:`bool`."""
         return self._invert
 
     @property
     def direction(self):
-        """The :py:`direction` argument passed to the constructor, normalized to :class:`Direction`."""
         return self._direction
 
     def __len__(self):
-        """Returns the width of this port in bits. Equal to :py:`len(self.io)`."""
         return len(self._io)
 
     def __invert__(self):
-        """Returns a new :class:`SingleEndedPort` with the opposite value of :py:`invert`."""
         return SingleEndedPort(self._io, invert=tuple(not inv for inv in self._invert),
                                direction=self._direction)
 
     def __getitem__(self, index):
-        """Slices the port, returning another :class:`SingleEndedPort` with a subset
-        of its bits.
-
-        The index can be a :class:`slice` or :class:`int`. If the index is
-        an :class:`int`, the result is a single-bit :class:`SingleEndedPort`."""
         return SingleEndedPort(self._io[index], invert=self._invert[index],
                                direction=self._direction)
 
     def __add__(self, other):
-        """Concatenates two :class:`SingleEndedPort` objects together, returning a new
-        :class:`SingleEndedPort` object.
-
-        When the concatenated ports have different directions, the conflict is resolved as follows:
-
-        - If a bidirectional port is concatenated with an input port, the result is an input port.
-        - If a bidirectional port is concatenated with an output port, the result is an output port.
-        - If an input port is concatenated with an output port, :exc:`ValueError` is raised.
+        """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,
-                               direction=self._direction | other._direction)
+                               direction=self._direction & other._direction)
 
     def __repr__(self):
         if all(self._invert):
@@ -184,32 +213,38 @@ class SingleEndedPort(PortLike):
 
 
 class DifferentialPort(PortLike):
-    """Represents a differential I/O port with optional inversion.
+    """Represents a differential library I/O port.
+
+    Implements the :class:`PortLike` interface.
 
     Parameters
     ----------
     p : :class:`IOValue`
-        The raw I/O value used as positive (true) half of the port.
+        Underlying core I/O value for the true (positive) half of the port.
     n : :class:`IOValue`
-        The raw I/O value used as negative (complemented) half of the port. Must have the same
-        length as :py:`p`.
-    invert : :class:`bool` or iterable of :class`bool`
-        If true, the electrical state of the physical pin will be opposite from the Amaranth value
-        (the ``*Buffer`` classes will insert inverters on :py:`o` and :py:`i` pins, as appropriate).
-
-        This can be used for various purposes:
-
-        - Normalizing active-low pins (such as ``CS_B``) to be active-high in Amaranth code
-        - Compensating for boards where the P and N pins are swapped (e.g. for easier routing)
-
-        If the value is a simple :class:`bool`, it is used for all bits of this port. If the value
-        is an iterable of :class:`bool`, the iterable must have the same length as :py:`io`, and
-        the inversion is specified per-bit.
+        Underlying core I/O value for the complement (negative) half of the port.
+        Must have the same width as :py:`p`.
+    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.
     direction : :class:`Direction` or :class:`str`
-        Represents the allowed directions of this port. 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 a string is passed, it is cast to :class:`Direction`.
+        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.
+
+    Attributes
+    ----------
+    p : :class:`IOValue`
+        The :py:`p` parameter.
+    n : :class:`IOValue`
+        The :py:`n` parameter.
+    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, p, n, *, invert=False, direction=Direction.Bidir):
         self._p = IOValue.cast(p)
@@ -232,58 +267,58 @@ class DifferentialPort(PortLike):
 
     @property
     def p(self):
-        """The :py:`p` argument passed to the constructor."""
         return self._p
 
     @property
     def n(self):
-        """The :py:`n` argument passed to the constructor."""
         return self._n
 
     @property
     def invert(self):
-        """The :py:`invert` argument passed to the constructor, normalized to a :class:`tuple`
-        of :class:`bool`."""
         return self._invert
 
     @property
     def direction(self):
-        """The :py:`direction` argument passed to the constructor, normalized to :class:`Direction`."""
         return self._direction
 
     def __len__(self):
-        """Returns the width of this port in bits. Equal to :py:`len(self.p)` (and :py:`len(self.n)`)."""
         return len(self._p)
 
     def __invert__(self):
-        """Returns a new :class:`DifferentialPort` with the opposite value of :py:`invert`."""
         return DifferentialPort(self._p, self._n, invert=tuple(not inv for inv in self._invert),
-                               direction=self._direction)
+                                direction=self._direction)
 
     def __getitem__(self, index):
-        """Slices the port, returning another :class:`DifferentialPort` with a subset
-        of its bits.
-
-        The index can be a :class:`slice` or :class:`int`. If the index is
-        an :class:`int`, the result is a single-bit :class:`DifferentialPort`."""
         return DifferentialPort(self._p[index], self._n[index], invert=self._invert[index],
-                               direction=self._direction)
+                                direction=self._direction)
 
     def __add__(self, other):
-        """Concatenates two :class:`DifferentialPort` objects together, returning a new
-        :class:`DifferentialPort` object.
-
-        When the concatenated ports have different directions, the conflict is resolved as follows:
-
-        - If a bidirectional port is concatenated with an input port, the result is an input port.
-        - If a bidirectional port is concatenated with an output port, the result is an output port.
-        - If an input port is concatenated with an output port, :exc:`ValueError` is raised.
+        """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),
-                               invert=self._invert + other._invert,
-                               direction=self._direction | other._direction)
+                                invert=self._invert + other._invert,
+                                direction=self._direction & other._direction)
 
     def __repr__(self):
         if not any(self._invert):
@@ -292,36 +327,74 @@ class DifferentialPort(PortLike):
             invert = True
         else:
             invert = self._invert
-        return f"DifferentialPort({self._p!r}, {self._n!r}, invert={invert!r}, direction={self._direction})"
+        return (f"DifferentialPort({self._p!r}, {self._n!r}, invert={invert!r}, "
+                f"direction={self._direction})")
 
 
 class Buffer(wiring.Component):
-    """A combinational I/O buffer.
+    """A combinational I/O buffer component.
+
+    This buffer can be used on any platform; if the platform does not specialize its implementation,
+    an :ref:`I/O buffer instance <lang-iobufferinstance>` is used.
+
+    The following diagram defines the timing relationship between the underlying core I/O value
+    (for differential ports, the core I/O value of the true half) and the :py:`i`, :py:`o`, and
+    :py:`oe` members:
+
+    .. wavedrom:: io/buffer
+
+        {
+            "signal": [
+                {"name": "clk",  "wave": "p....."},
+                {"name": "o",    "wave": "x345x.", "data": ["A", "B", "C"]},
+                {"name": "oe",   "wave": "01..0."},
+                {},
+                {"name": "port", "wave": "z345z.", "data": ["A", "B", "C"]},
+                {},
+                {"name": "i",    "wave": "x345x.", "data": ["A", "B", "C"]}
+            ],
+            "config": {
+                "hscale": 2
+            }
+        }
 
     Parameters
     ----------
     direction : :class:`Direction`
+        Direction of the buffer.
     port : :class:`PortLike`
+        Port driven by the buffer.
+
+    Raises
+    ------
+    :exc:`ValueError`
+        Unless :py:`port.direction in (direction, Bidir)`.
 
     Attributes
     ----------
     signature : :class:`Buffer.Signature`
-        Created based on constructor arguments.
+        :py:`Signature(direction, len(port)).flip()`.
     """
     class Signature(wiring.Signature):
-        """A signature of a combinational I/O buffer.
+        """Signature of a combinational I/O buffer.
 
         Parameters
         ----------
         direction : :class:`Direction`
+            Direction of the buffer.
         width : :class:`int`
-
-        Attributes
-        ----------
-        i: :py:`unsigned(width)` (if :py:`direction in (Direction.Input, Direction.Bidir)`)
-        o: :py:`unsigned(width)` (if :py:`direction in (Direction.Output, Direction.Bidir)`)
-        oe: :py:`unsigned(1, init=0)` (if :py:`direction is Direction.Bidir`)
-        oe: :py:`unsigned(1, init=1)` (if :py:`direction is Direction.Output`)
+            Width of the buffer.
+
+        Members
+        -------
+        i: :py:`In(width)`
+            Present if :py:`direction in (Input, Bidir)`.
+        o: :py:`Out(width)`
+            Present if :py:`direction in (Output, Bidir)`.
+        oe: :py:`Out(1, init=0)`
+            Present if :py:`direction is Bidir`.
+        oe: :py:`Out(1, init=1)`
+            Present if :py:`direction is Output`.
         """
         def __init__(self, direction, width):
             self._direction = Direction(direction)
@@ -343,7 +416,8 @@ class Buffer(wiring.Component):
             return self._width
 
         def __eq__(self, other):
-            return type(self) is type(other) and self.direction == other.direction and self.width == other.width
+            return (type(self) is type(other) and self.direction == other.direction and
+                    self.width == other.width)
 
         def __repr__(self):
             return f"Buffer.Signature({self.direction}, {self.width})"
@@ -403,47 +477,84 @@ class Buffer(wiring.Component):
                 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)
         else:
-            raise TypeError("Cannot elaborate generic 'Buffer' with port {self._port!r}")
+            raise TypeError("Cannot elaborate generic 'Buffer' with port {self._port!r}") # :nocov:
 
         return m
 
 
 class FFBuffer(wiring.Component):
-    """A registered I/O buffer.
+    """A registered I/O buffer component.
+
+    This buffer can be used on any platform; if the platform does not specialize its implementation,
+    an :ref:`I/O buffer instance <lang-iobufferinstance>` is used, combined with reset-less
+    registers on :py:`i`, :py:`o`, and  :py:`oe` members.
+
+    The following diagram defines the timing relationship between the underlying core I/O value
+    (for differential ports, the core I/O value of the true half) and the :py:`i`, :py:`o`, and
+    :py:`oe` members:
+
+    .. wavedrom:: io/ff-buffer
+
+        {
+            "signal": [
+                {"name": "clk",  "wave": "p......"},
+                {"name": "o",    "wave": "x345x..", "data": ["A", "B", "C"]},
+                {"name": "oe",   "wave": "01..0.."},
+                {},
+                {"name": "port", "wave": "z.345z.", "data": ["A", "B", "C"]},
+                {},
+                {"name": "i",    "wave": "x..345x", "data": ["A", "B", "C"]}
+            ],
+            "config": {
+                "hscale": 2
+            }
+        }
+
+    .. warning::
+
+        On some platforms, this buffer can only be used with rising edge clock domains, and will
+        raise an exception during conversion of the design to a netlist otherwise.
 
-    Equivalent to a plain :class:`Buffer` combined with reset-less registers on :py:`i`, :py:`o`,
-    :py:`oe`.
+        This limitation will be lifted in the future.
 
     Parameters
     ----------
     direction : :class:`Direction`
+        Direction of the buffer.
     port : :class:`PortLike`
+        Port driven by the buffer.
     i_domain : :class:`str`
-        Domain for input register. Only used when :py:`direction in (Direction.Input, Direction.Bidir)`.
-        Defaults to :py:`"sync"`
+        Name of the input register's clock domain. Used when :py:`direction in (Input, Bidir)`.
+        Defaults to :py:`"sync"`.
     o_domain : :class:`str`
-        Domain for output and output enable registers. Only used when
-        :py:`direction in (Direction.Output, Direction.Bidir)`. Defaults to :py:`"sync"`
+        Name of the output and output enable registers' clock domain. Used when
+        :py:`direction in (Output, Bidir)`. Defaults to :py:`"sync"`.
 
     Attributes
     ----------
-    signature : FFBuffer.Signature
-        Created based on constructor arguments.
+    signature : :class:`FFBuffer.Signature`
+        :py:`Signature(direction, len(port)).flip()`.
     """
     class Signature(wiring.Signature):
-        """A signature of a registered I/O buffer.
+        """Signature of a registered I/O buffer.
 
         Parameters
         ----------
         direction : :class:`Direction`
+            Direction of the buffer.
         width : :class:`int`
-
-        Attributes
-        ----------
-        i: :py:`unsigned(width)` (if :py:`direction in (Direction.Input, Direction.Bidir)`)
-        o: :py:`unsigned(width)` (if :py:`direction in (Direction.Output, Direction.Bidir)`)
-        oe: :py:`unsigned(1, init=0)` (if :py:`direction is Direction.Bidir`)
-        oe: :py:`unsigned(1, init=1)` (if :py:`direction is Direction.Output`)
+            Width of the buffer.
+
+        Members
+        -------
+        i: :py:`In(width)`
+            Present if :py:`direction in (Input, Bidir)`.
+        o: :py:`Out(width)`
+            Present if :py:`direction in (Output, Bidir)`.
+        oe: :py:`Out(1, init=0)`
+            Present if :py:`direction is Bidir`.
+        oe: :py:`Out(1, init=1)`
+            Present if :py:`direction is Output`.
         """
         def __init__(self, direction, width):
             self._direction = Direction(direction)
@@ -465,7 +576,8 @@ class FFBuffer(wiring.Component):
             return self._width
 
         def __eq__(self, other):
-            return type(self) is type(other) and self.direction == other.direction and self.width == other.width
+            return (type(self) is type(other) and self.direction == other.direction and
+                    self.width == other.width)
 
         def __repr__(self):
             return f"FFBuffer.Signature({self.direction}, {self.width})"
@@ -533,48 +645,117 @@ class FFBuffer(wiring.Component):
 
 
 class DDRBuffer(wiring.Component):
-    """A double data rate registered I/O buffer.
-
-    In the input direction, the port is sampled at both edges of the input clock domain.
-    The data sampled on the active clock edge of the domain appears on :py:`i[0]` with a delay
-    of 1 clock cycle. The data sampled on the opposite clock edge appears on :py:`i[1]` with a delay
-    of 0.5 clock cycle. Both :py:`i[0]` and :py:`i[1]` thus change on the active clock edge of the domain.
-
-    In the output direction, both :py:`o[0]` and :py:`o[1]` are sampled on the active clock edge
-    of the domain.  The value of :py:`o[0]` immediately appears on the output port.  The value
-    of :py:`o[1]` then appears on the output port on the opposite edge, with a delay of 0.5 clock cycle.
-
-    Support for this compoment is platform-specific, and may be missing on some platforms.
+    """A double data rate I/O buffer component.
+
+    This buffer is only available on platforms that support double data rate I/O.
+
+    The following diagram defines the timing relationship between the underlying core I/O value
+    (for differential ports, the core I/O value of the true half) and the :py:`i`, :py:`o`, and
+    :py:`oe` members:
+
+    ..
+        This diagram should have `port` phase shifted, but it hits wavedrom/wavedrom#416.
+        It is also affected by wavedrom/wavedrom#417.
+
+    .. wavedrom:: io/ddr-buffer
+
+        {
+            "head": {
+                "tick": 0
+            },
+            "signal": [
+                {"name": "clk",  "wave": "p......."},
+                {"name": "o[0]", "wave": "x357x...", "node": ".a",
+                 "data": ["A", "C", "E"]},
+                {"name": "o[1]", "wave": "x468x...", "node": ".b",
+                 "data": ["B", "D", "F"]},
+                {"name": "oe",   "wave": "01..0..."},
+                {                                            "node": "........R.S",
+                 "period": 0.5},
+                {"name": "port", "wave": "z...345678z.....", "node": "....123456",
+                 "data": ["A", "B", "C", "D", "E", "F"],
+                 "period": 0.5},
+                {                                            "node": "..P.Q",
+                 "period": 0.5},
+                {"name": "i[0]", "wave": "x...468x", "node": ".....d",
+                 "data": ["B", "D", "F"]},
+                {"name": "i[1]", "wave": "x..357x.", "node": ".....e",
+                 "data": ["A", "C", "E"]}
+            ],
+            "edge": [
+                "a~1", "b-~2", "P+Q t1",
+                "5~-d", "6~e", "R+S t2"
+            ],
+            "config": {
+                "hscale": 2
+            }
+        }
+
+    The output data (labelled *a*, *b*) is input from :py:`o` into internal registers at
+    the beginning of clock cycle 2, and transmitted at points labelled *1*, *2* during the same
+    clock cycle. The output latency *t1* is defined as the amount of cycles between the time of
+    capture of :py:`o` and the time of transmission of rising edge data plus one cycle, and is 1
+    for this diagram.
+
+    The received data is captured into internal registers during the clock cycle 4 at points
+    labelled *5*, *6*, and output to :py:`i` during the next clock cycle (labelled *d*, *e*).
+    The input latency *t2* is defined as the amount of cycles between the time of reception of
+    rising edge data and the time of update of :py:`i`, and is 1 for this diagram.
+
+    The output enable signal is input from :py:`oe` once per cycle and affects the entire cycle it
+    applies to. Its latency is defined in the same way as the output latency, and is equal to *t1*.
+
+    .. warning::
+
+        Some platforms include additional pipeline registers that may cause latencies *t1* and *t2*
+        to be higher than one cycle. At the moment there is no way to query these latencies.
+
+        This limitation will be lifted in the future.
+
+    .. warning::
+
+        On all supported platforms, this buffer can only be used with rising edge clock domains,
+        and will raise an exception during conversion of the design to a netlist otherwise.
+
+        This limitation may be lifted in the future.
 
     Parameters
     ----------
     direction : :class:`Direction`
+        Direction of the buffer.
     port : :class:`PortLike`
+        Port driven by the buffer.
     i_domain : :class:`str`
-        Domain for input register. Only used when :py:`direction in (Direction.Input, Direction.Bidir)`.
+        Name of the input registers' clock domain. Only used when :py:`direction in (Input, Bidir)`.
     o_domain : :class:`str`
-        Domain for output and output enable registers. Only used when
-        :py:`direction in (Direction.Output, Direction.Bidir)`.
+        Name of the output and output enable registers' clock domain. Only used when
+        :py:`direction in (Output, Bidir)`.
 
     Attributes
     ----------
-    signature : DDRBuffer.Signature
-        Created based on constructor arguments.
+    signature : :class:`DDRBuffer.Signature`
+        :py:`Signature(direction, len(port)).flip()`.
     """
     class Signature(wiring.Signature):
-        """A signature of a double data rate registered I/O buffer.
+        """Signature of a double data rate I/O buffer.
 
         Parameters
         ----------
         direction : :class:`Direction`
+            Direction of the buffer.
         width : :class:`int`
-
-        Attributes
-        ----------
-        i: :py:`unsigned(ArrayLayout(width, 2))` (if :py:`direction in (Direction.Input, Direction.Bidir)`)
-        o: :py:`unsigned(ArrayLayout(width, 2))` (if :py:`direction in (Direction.Output, Direction.Bidir)`)
-        oe: :py:`unsigned(1, init=0)` (if :py:`direction is Direction.Bidir`)
-        oe: :py:`unsigned(1, init=1)` (if :py:`direction is Direction.Output`)
+            Width of the buffer.
+
+        Members
+        -------
+        i: :py:`In(ArrayLayout(width, 2))`
+            Present if :py:`direction in (Input, Bidir)`.
+        o: :py:`Out(ArrayLayout(width, 2))`
+            Present if :py:`direction in (Output, Bidir)`.
+        oe: :py:`Out(1, init=0)`
+            Present if :py:`direction is Bidir`.
+        oe: :py:`Out(1, init=1)`
+            Present if :py:`direction is Output`.
         """
         def __init__(self, direction, width):
             self._direction = Direction(direction)
@@ -596,7 +777,8 @@ class DDRBuffer(wiring.Component):
             return self._width
 
         def __eq__(self, other):
-            return type(self) is type(other) and self.direction == other.direction and self.width == other.width
+            return (type(self) is type(other) and self.direction == other.direction and
+                    self.width == other.width)
 
         def __repr__(self):
             return f"DDRBuffer.Signature({self.direction}, {self.width})"
@@ -643,7 +825,7 @@ class DDRBuffer(wiring.Component):
         if hasattr(platform, "get_io_buffer"):
             return platform.get_io_buffer(self)
 
-        raise NotImplementedError("DDR buffers cannot be elaborated without a supported platform")
+        raise NotImplementedError(f"DDR buffers are not supported on {platform!r}") # :nocov:
 
 
 class Pin(wiring.PureInterface):
@@ -708,6 +890,8 @@ class Pin(wiring.PureInterface):
         """A signature for :class:`Pin`.  The parameters are as defined on the ``Pin`` class,
         and are accessible as attributes.
         """
+        # TODO(amaranth-0.6): remove
+        @deprecated("`amaranth.lib.io.Pin` is deprecated, use `amaranth.lib.io.*Buffer` instead")
         def __init__(self, width, dir, *, xdr=0):
             if not isinstance(width, int) or width < 0:
                 raise TypeError("Width must be a non-negative integer, not {!r}"
@@ -761,17 +945,20 @@ class Pin(wiring.PureInterface):
         def create(self, *, path=None, src_loc_at=0):
             return Pin(self.width, self.dir, xdr=self.xdr, path=path, src_loc_at=1 + src_loc_at)
 
+    # TODO(amaranth-0.6): remove
+    @deprecated("`amaranth.lib.io.Pin` is deprecated, use `amaranth.lib.io.*Buffer` instead")
     def __init__(self, width, dir, *, xdr=0, name=None, path=None, src_loc_at=0):
         if name is not None:
             if path is not None:
                 raise ValueError("Cannot pass both name and path")
             path = (name,)
         if path is None:
-            name = tracer.get_var_name(depth=2 + src_loc_at, default="$pin")
+            name = tracer.get_var_name(depth=3 + src_loc_at, default="$pin")
             path = (name,)
         self.path = tuple(path)
         self.name = "__".join(path)
-        signature = Pin.Signature(width, dir, xdr=xdr)
+        with _ignore_deprecated():
+            signature = Pin.Signature(width, dir, xdr=xdr)
         super().__init__(signature, path=path, src_loc_at=src_loc_at + 1)
 
     @property
diff --git a/amaranth/sim/core.py b/amaranth/sim/core.py
index 668870d..d3f5581 100644
--- a/amaranth/sim/core.py
+++ b/amaranth/sim/core.py
@@ -35,14 +35,15 @@ class Simulator:
         self._engine   = engine(self._design)
         self._clocked  = set()
 
-    def _check_process(self, process):
-        if not (inspect.isgeneratorfunction(process) or inspect.iscoroutinefunction(process)):
-            raise TypeError("Cannot add a process {!r} because it is not a generator function"
-                            .format(process))
-        return process
+    def _check_function(self, function, *, kind):
+        if not (inspect.isgeneratorfunction(function) or inspect.iscoroutinefunction(function)):
+            raise TypeError(
+                f"Cannot add a {kind} {function!r} because it is not an async function or "
+                f"generator function")
+        return function
 
     def add_process(self, process):
-        process = self._check_process(process)
+        process = self._check_function(process, kind="process")
         if inspect.iscoroutinefunction(process):
             self._engine.add_async_process(self, process)
         else:
@@ -54,16 +55,17 @@ class Simulator:
             wrap_process = coro_wrapper(wrapper, testbench=False)
             self._engine.add_async_process(self, wrap_process)
 
-    def add_testbench(self, process, *, background=False):
-        if inspect.iscoroutinefunction(process):
-            self._engine.add_async_testbench(self, process, background=background)
+    def add_testbench(self, testbench, *, background=False):
+        testbench = self._check_function(testbench, kind="testbench")
+        if inspect.iscoroutinefunction(testbench):
+            self._engine.add_async_testbench(self, testbench, background=background)
         else:
-            process = coro_wrapper(process, testbench=True)
-            self._engine.add_async_testbench(self, process, background=background)
+            testbench = coro_wrapper(testbench, testbench=True)
+            self._engine.add_async_testbench(self, testbench, background=background)
 
     @deprecated("The `add_sync_process` method is deprecated per RFC 27. Use `add_process` or `add_testbench` instead.")
     def add_sync_process(self, process, *, domain="sync"):
-        process = self._check_process(process)
+        process = self._check_function(process, kind="process")
         def wrapper():
             # Only start a sync process after the first clock edge (or reset edge, if the domain
             # uses an asynchronous reset). This matches the behavior of synchronous FFs.
@@ -210,17 +212,25 @@ class Simulator:
                     file.close()
             raise ValueError("Cannot start writing waveforms after advancing simulation time")
 
-        for trace in traces:
-            if isinstance(trace, ValueLike):
-                trace_cast = Value.cast(trace)
+        def traverse_traces(traces):
+            if isinstance(traces, ValueLike):
+                trace_cast = Value.cast(traces)
                 if isinstance(trace_cast, MemoryData._Row):
-                    continue
+                    return
                 for trace_signal in trace_cast._rhs_signals():
                     if trace_signal.name == "":
-                        if trace_signal is trace:
+                        if trace_signal is traces:
                             raise TypeError("Cannot trace signal with private name")
                         else:
-                            raise TypeError(f"Cannot trace signal with private name (within {trace!r})")
+                            raise TypeError(f"Cannot trace signal with private name (within {traces!r})")
+            elif isinstance(traces, (list, tuple)):
+                for trace in traces:
+                    traverse_traces(trace)
+            elif isinstance(traces, dict):
+                for trace in traces.values():
+                    traverse_traces(trace)
+
+        traverse_traces(traces)
 
         return self._engine.write_vcd(vcd_file=vcd_file, gtkw_file=gtkw_file,
                                       traces=traces, fs_per_delta=fs_per_delta)
diff --git a/amaranth/sim/pysim.py b/amaranth/sim/pysim.py
index da49aa0..1a9c876 100644
--- a/amaranth/sim/pysim.py
+++ b/amaranth/sim/pysim.py
@@ -5,7 +5,9 @@ import os.path
 import enum as py_enum
 
 from ..hdl import *
+from ..hdl._mem import MemoryInstance
 from ..hdl._ast import SignalDict
+from ..lib import data, wiring
 from ._base import *
 from ._async import *
 from ._pyeval import eval_format, eval_value, eval_assign
@@ -49,7 +51,7 @@ class _VCDWriter:
         self.gtkw_file = gtkw_file
         self.gtkw_save = gtkw_file and vcd.gtkw.GTKWSave(self.gtkw_file)
 
-        self.traces = []
+        self.traces = traces
 
         signal_names = SignalDict()
         memories = {}
@@ -64,9 +66,9 @@ class _VCDWriter:
 
         trace_names = SignalDict()
         assigned_names = set()
-        for trace in traces:
-            if isinstance(trace, ValueLike):
-                trace = Value.cast(trace)
+        def traverse_traces(traces):
+            if isinstance(traces, ValueLike):
+                trace = Value.cast(traces)
                 if isinstance(trace, MemoryData._Row):
                     memory = trace._memory
                     if not memory in memories:
@@ -77,7 +79,6 @@ class _VCDWriter:
                             assert name not in assigned_names
                         memories[memory] = ("bench", name)
                         assigned_names.add(name)
-                    self.traces.append(trace)
                 else:
                     for trace_signal in trace._rhs_signals():
                         if trace_signal not in signal_names:
@@ -88,19 +89,27 @@ class _VCDWriter:
                                 assert name not in assigned_names
                             trace_names[trace_signal] = {("bench", name)}
                             assigned_names.add(name)
-                        self.traces.append(trace_signal)
-            elif isinstance(trace, MemoryData):
-                if not trace in memories:
-                    if trace.name not in assigned_names:
-                        name = trace.name
+            elif isinstance(traces, MemoryData):
+                if not traces in memories:
+                    if traces.name not in assigned_names:
+                        name = traces.name
                     else:
-                        name = f"{trace.name}${len(assigned_names)}"
+                        name = f"{traces.name}${len(assigned_names)}"
                         assert name not in assigned_names
-                    memories[trace] = ("bench", name)
+                    memories[traces] = ("bench", name)
                     assigned_names.add(name)
-                self.traces.append(trace)
+            elif hasattr(traces, "signature") and isinstance(traces.signature, wiring.Signature):
+                for name in traces.signature.members:
+                    traverse_traces(getattr(traces, name))
+            elif isinstance(traces, list) or isinstance(traces, tuple):
+                for trace in traces:
+                    traverse_traces(trace)
+            elif isinstance(traces, dict):
+                for trace in traces.values():
+                    traverse_traces(trace)
             else:
-                raise TypeError(f"{trace!r} is not a traceable object")
+                raise TypeError(f"{traces!r} is not a traceable object")
+        traverse_traces(traces)
 
         if self.vcd_writer is None:
             return
@@ -277,19 +286,40 @@ class _VCDWriter:
             self.gtkw_save.dumpfile_size(self.vcd_file.tell())
 
             self.gtkw_save.treeopen("top")
-            for trace in self.traces:
-                if isinstance(trace, Signal):
-                    for name in self.gtkw_signal_names[trace]:
+
+            def traverse_traces(traces):
+                if isinstance(traces, Signal):
+                    for name in self.gtkw_signal_names[traces]:
                         self.gtkw_save.trace(name)
-                elif isinstance(trace, MemoryData):
-                    for row_names in self.gtkw_memory_names[trace]:
+                elif isinstance(traces, data.View):
+                    with self.gtkw_save.group("view"):
+                        trace = Value.cast(traces)
+                        for trace_signal in trace._rhs_signals():
+                            for name in self.gtkw_signal_names[trace_signal]:
+                                self.gtkw_save.trace(name)
+                elif isinstance(traces, ValueLike):
+                    traverse_traces(Value.cast(traces))
+                elif isinstance(traces, MemoryData):
+                    for row_names in self.gtkw_memory_names[traces]:
                         for name in row_names:
                             self.gtkw_save.trace(name)
-                elif isinstance(trace, MemoryData._Row):
-                    for name in self.gtkw_memory_names[trace._memory][trace._index]:
+                elif isinstance(traces, MemoryData._Row):
+                    for name in self.gtkw_memory_names[traces._memory][traces._index]:
                         self.gtkw_save.trace(name)
+                elif hasattr(traces, "signature") and isinstance(traces.signature, wiring.Signature):
+                    with self.gtkw_save.group("interface"):
+                        for _, _, member in traces.signature.flatten(traces):
+                            traverse_traces(member)
+                elif isinstance(traces, list) or isinstance(traces, tuple):
+                    for trace in traces:
+                        traverse_traces(trace)
+                elif isinstance(traces, dict):
+                    for name, trace in traces.items():
+                        with self.gtkw_save.group(name):
+                            traverse_traces(trace)
                 else:
                     assert False # :nocov:
+            traverse_traces(self.traces)
 
         if self.close_vcd:
             self.vcd_file.close()
diff --git a/amaranth/vendor/_altera.py b/amaranth/vendor/_altera.py
index 0b206a5..8da033e 100644
--- a/amaranth/vendor/_altera.py
+++ b/amaranth/vendor/_altera.py
@@ -2,6 +2,7 @@ from abc import abstractmethod
 
 from ..hdl import *
 from ..hdl import _ast
+from ..hdl._ir import RequirePosedge
 from ..lib import io, wiring
 from ..build import *
 
@@ -152,6 +153,7 @@ class DDRBuffer(io.DDRBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i0_reg = Signal(len(self.port))
             i0_inv = Signal(len(self.port))
             i1_inv = Signal(len(self.port))
@@ -167,6 +169,7 @@ class DDRBuffer(io.DDRBuffer):
             m.d.comb += self.i[1].eq(i1_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             m.submodules.o_ddr = Instance("altddio_out",
                 p_width=len(self.port),
                 o_dataout=buf.o,
@@ -507,6 +510,7 @@ class AlteraPlatform(TemplatedPlatform):
     def get_async_ff_sync(self, async_ff_sync):
         m = Module()
         sync_output = Signal()
+        m.submodules += RequirePosedge(async_ff_sync._o_domain)
         if async_ff_sync._edge == "pos":
             m.submodules += Instance("altera_std_synchronizer",
                 p_depth=async_ff_sync._stages,
diff --git a/amaranth/vendor/_gowin.py b/amaranth/vendor/_gowin.py
index beae85a..11c4fbf 100644
--- a/amaranth/vendor/_gowin.py
+++ b/amaranth/vendor/_gowin.py
@@ -4,6 +4,7 @@ import math
 import re
 
 from ..hdl import *
+from ..hdl._ir import RequirePosedge
 from ..lib import io, wiring
 from ..lib.cdc import ResetSynchronizer
 from ..build import *
@@ -131,6 +132,7 @@ class DDRBuffer(io.DDRBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i0_inv = Signal(len(self.port))
             i1_inv = Signal(len(self.port))
             for bit in range(len(self.port)):
@@ -144,6 +146,7 @@ class DDRBuffer(io.DDRBuffer):
             m.d.comb += self.i[1].eq(i1_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o0_inv = self.o[0] ^ inv_mask
             o1_inv = self.o[1] ^ inv_mask
             for bit in range(len(self.port)):
diff --git a/amaranth/vendor/_lattice.py b/amaranth/vendor/_lattice.py
index 94d03d7..d926c0c 100644
--- a/amaranth/vendor/_lattice.py
+++ b/amaranth/vendor/_lattice.py
@@ -1,6 +1,7 @@
 from abc import abstractmethod
 
 from ..hdl import *
+from ..hdl._ir import RequirePosedge
 from ..lib import io, wiring
 from ..build import *
 
@@ -107,6 +108,7 @@ class FFBufferECP5(io.FFBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i_inv = Signal.like(self.i)
             for bit in range(len(self.port)):
                 m.submodules[f"i_ff{bit}"] = Instance("IFS1P3DX",
@@ -119,6 +121,7 @@ class FFBufferECP5(io.FFBuffer):
             m.d.comb += self.i.eq(i_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o_inv = Signal.like(self.o)
             m.d.comb += o_inv.eq(self.o ^ inv_mask)
             for bit in range(len(self.port)):
@@ -142,6 +145,7 @@ class FFBufferNexus(io.FFBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i_inv = Signal.like(self.i)
             for bit in range(len(self.port)):
                 m.submodules[f"i_ff{bit}"] = Instance("IFD1P3DX",
@@ -154,6 +158,7 @@ class FFBufferNexus(io.FFBuffer):
             m.d.comb += self.i.eq(i_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o_inv = Signal.like(self.o)
             m.d.comb += o_inv.eq(self.o ^ inv_mask)
             for bit in range(len(self.port)):
@@ -177,6 +182,7 @@ class DDRBufferECP5(io.DDRBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i0_inv = Signal(len(self.port))
             i1_inv = Signal(len(self.port))
             for bit in range(len(self.port)):
@@ -191,6 +197,7 @@ class DDRBufferECP5(io.DDRBuffer):
             m.d.comb += self.i[1].eq(i1_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o0_inv = Signal(len(self.port))
             o1_inv = Signal(len(self.port))
             m.d.comb += [
@@ -218,6 +225,7 @@ class DDRBufferMachXO2(io.DDRBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i0_inv = Signal(len(self.port))
             i1_inv = Signal(len(self.port))
             for bit in range(len(self.port)):
@@ -232,6 +240,7 @@ class DDRBufferMachXO2(io.DDRBuffer):
             m.d.comb += self.i[1].eq(i1_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o0_inv = Signal(len(self.port))
             o1_inv = Signal(len(self.port))
             m.d.comb += [
@@ -259,6 +268,7 @@ class DDRBufferNexus(io.DDRBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i0_inv = Signal(len(self.port))
             i1_inv = Signal(len(self.port))
             for bit in range(len(self.port)):
@@ -273,6 +283,7 @@ class DDRBufferNexus(io.DDRBuffer):
             m.d.comb += self.i[1].eq(i1_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o0_inv = Signal(len(self.port))
             o1_inv = Signal(len(self.port))
             m.d.comb += [
diff --git a/amaranth/vendor/_siliconblue.py b/amaranth/vendor/_siliconblue.py
index 36c1501..08cc966 100644
--- a/amaranth/vendor/_siliconblue.py
+++ b/amaranth/vendor/_siliconblue.py
@@ -4,6 +4,7 @@
 from abc import abstractmethod
 
 from ..hdl import *
+from ..hdl._ir import RequirePosedge
 from ..lib.cdc import ResetSynchronizer
 from ..lib import io
 from ..build import *
@@ -506,10 +507,12 @@ class SiliconBluePlatform(TemplatedPlatform):
                 else:
                     io_args.append(("o", "D_IN_0", i[bit]))
             elif isinstance(buffer, io.FFBuffer):
+                m.submodules += RequirePosedge(self.i_domain)
                 i_type =     0b00 # PIN_INPUT_REGISTERED aka PIN_INPUT_DDR
                 io_args.append(("i", "INPUT_CLK", ClockSignal(buffer.i_domain)))
                 io_args.append(("o", "D_IN_0", i[bit]))
             elif isinstance(buffer, io.DDRBuffer):
+                m.submodules += RequirePosedge(self.i_domain)
                 i_type =     0b00 # PIN_INPUT_REGISTERED aka PIN_INPUT_DDR
                 io_args.append(("i", "INPUT_CLK", ClockSignal(buffer.i_domain)))
                 io_args.append(("o", "D_IN_0", i0[bit]))
@@ -521,10 +524,12 @@ class SiliconBluePlatform(TemplatedPlatform):
                 o_type = 0b1010   # PIN_OUTPUT_TRISTATE
                 io_args.append(("i", "D_OUT_0", o[bit]))
             elif isinstance(buffer, io.FFBuffer):
+                m.submodules += RequirePosedge(self.o_domain)
                 o_type = 0b1101   # PIN_OUTPUT_REGISTERED_ENABLE_REGISTERED
                 io_args.append(("i", "OUTPUT_CLK", ClockSignal(buffer.o_domain)))
                 io_args.append(("i", "D_OUT_0", o[bit]))
             elif isinstance(buffer, io.DDRBuffer):
+                m.submodules += RequirePosedge(self.o_domain)
                 o_type = 0b1100   # PIN_OUTPUT_DDR_ENABLE_REGISTERED
                 io_args.append(("i", "OUTPUT_CLK", ClockSignal(buffer.o_domain)))
                 io_args.append(("i", "D_OUT_0", o0[bit]))
diff --git a/amaranth/vendor/_xilinx.py b/amaranth/vendor/_xilinx.py
index e5dec54..9a44c62 100644
--- a/amaranth/vendor/_xilinx.py
+++ b/amaranth/vendor/_xilinx.py
@@ -2,6 +2,7 @@ import re
 from abc import abstractmethod
 
 from ..hdl import *
+from ..hdl._ir import RequirePosedge
 from ..lib.cdc import ResetSynchronizer
 from ..lib import io, wiring
 from ..build import *
@@ -125,11 +126,13 @@ class FFBuffer(io.FFBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i_inv = Signal.like(self.i)
             _make_dff(m, "i", self.i_domain, buf.i, i_inv, iob=True)
             m.d.comb += self.i.eq(i_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o_inv = Signal.like(self.o)
             m.d.comb += o_inv.eq(self.o ^ inv_mask)
             _make_dff(m, "o", self.o_domain, o_inv, buf.o, iob=True)
@@ -146,6 +149,7 @@ class DDRBufferVirtex2(io.DDRBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i0_inv = Signal(len(self.port))
             i1_inv = Signal(len(self.port))
             # First-generation input DDR register: basically just two FFs with opposite
@@ -161,6 +165,7 @@ class DDRBufferVirtex2(io.DDRBuffer):
             m.d.comb += self.i[1].eq(i1_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o0_inv = Signal(len(self.port))
             o1_inv = Signal(len(self.port))
             o1_ff = Signal(len(self.port))
@@ -210,6 +215,7 @@ class DDRBufferSpartan3E(io.DDRBuffer):
         }
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i0_inv = Signal(len(self.port))
             i1_inv = Signal(len(self.port))
             if platform.family == "spartan6" or isinstance(self.port, io.DifferentialPort):
@@ -257,6 +263,7 @@ class DDRBufferSpartan3E(io.DDRBuffer):
             m.d.comb += self.i[1].eq(i1_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o0_inv = Signal(len(self.port))
             o1_inv = Signal(len(self.port))
             m.d.comb += [
@@ -333,6 +340,7 @@ class DDRBufferVirtex4(io.DDRBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i0_inv = Signal(len(self.port))
             i1_inv = Signal(len(self.port))
             for bit in range(len(self.port)):
@@ -352,6 +360,7 @@ class DDRBufferVirtex4(io.DDRBuffer):
             m.d.comb += self.i[1].eq(i1_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o0_inv = Signal(len(self.port))
             o1_inv = Signal(len(self.port))
             m.d.comb += [
@@ -384,6 +393,7 @@ class DDRBufferUltrascale(io.DDRBuffer):
         inv_mask = sum(inv << bit for bit, inv in enumerate(self.port.invert))
 
         if self.direction is not io.Direction.Output:
+            m.submodules += RequirePosedge(self.i_domain)
             i0_inv = Signal(len(self.port))
             i1_inv = Signal(len(self.port))
             for bit in range(len(self.port)):
@@ -402,6 +412,7 @@ class DDRBufferUltrascale(io.DDRBuffer):
             m.d.comb += self.i[1].eq(i1_inv ^ inv_mask)
 
         if self.direction is not io.Direction.Input:
+            m.submodules += RequirePosedge(self.o_domain)
             o0_inv = Signal(len(self.port))
             o1_inv = Signal(len(self.port))
             m.d.comb += [
@@ -1234,6 +1245,7 @@ class XilinxPlatform(TemplatedPlatform):
 
     def get_async_ff_sync(self, async_ff_sync):
         m = Module()
+        m.submodules += RequirePosedge(async_ff_sync._o_domain)
         m.domains += ClockDomain("async_ff", async_reset=True, local=True)
         # Instantiate a chain of async_ff_sync._stages FDPEs with all
         # their PRE pins connected to either async_ff_sync.i or
diff --git a/docs/changes.rst b/docs/changes.rst
index e2e7ce0..070cfb5 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -41,7 +41,8 @@ Apply the following changes to code written against Amaranth 0.4 to migrate it t
 * Remove any usage of ``name=`` with assertions, possibly replacing them with custom messages
 * Ensure all elaboratables are subclasses of :class:`Elaboratable`
 * Ensure clock domains aren't used outside the module that defines them, or its submodules; move clock domain definitions upwards in the hierarchy as necessary
-* Remove uses of ``amaranth.lib.coding.*`` by inlining or copying the implementation of the modules.
+* Remove uses of ``amaranth.lib.coding.*`` by inlining or copying the implementation of the modules
+* Update uses of ``platform.request`` to pass ``dir="-"`` and use :mod:`amaranth.lib.io` buffers
 
 
 Implemented RFCs
@@ -74,6 +75,7 @@ Implemented RFCs
 * `RFC 50`_: ``Print`` statement and string formatting
 * `RFC 51`_: Add ``ShapeCastable.from_bits`` and ``amaranth.lib.data.Const``
 * `RFC 53`_: Low-level I/O primitives
+* `RFC 55`_: New ``lib.io`` components
 * `RFC 58`_: Core support for ``ValueCastable`` formatting
 * `RFC 59`_: Get rid of upwards propagation of clock domains
 * `RFC 62`_: The ``MemoryData`` class
diff --git a/docs/conf.py b/docs/conf.py
index 964c54c..146eb1b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -37,6 +37,7 @@ autodoc_default_options = {
     "members": True
 }
 autodoc_preserve_defaults = True
+autodoc_inherit_docstrings = False
 
 napoleon_google_docstring = False
 napoleon_numpy_docstring = True
@@ -71,3 +72,9 @@ linkcheck_anchors_ignore_for_url = [
     # React page with README content included as a JSON payload.
     r"^https://github\.com/[^/]+/[^/]+/$",
 ]
+
+
+# Silence the warnings globally; otherwise they may fire on object destruction and crash completely
+# unrelated tests.
+import amaranth._unused
+amaranth._unused.MustUse._MustUse__silence = True
diff --git a/docs/guide.rst b/docs/guide.rst
index 6e0b6d6..71838a9 100644
--- a/docs/guide.rst
+++ b/docs/guide.rst
@@ -1673,9 +1673,9 @@ Amaranth provides support for memories in the standard library module :mod:`amar
 I/O values
 ==========
 
-To interoperate with external circuitry, Amaranth provides *I/O values*, which represent bundles of wires carrying uninterpreted signals. Unlike regular :ref:`values <lang-values>`, which represent binary numbers and can be :ref:`assigned <lang-assigns>` to create a unidirectional connection or used in computations, I/O values represent electrical signals that may be digital or analog and have no :ref:`shape <lang-shapes>`, cannot be assigned, used in computations, or simulated.
+To interoperate with external circuitry, Amaranth provides *core I/O values*, which represent bundles of wires carrying uninterpreted signals. Unlike regular :ref:`values <lang-values>`, which represent binary numbers and can be :ref:`assigned <lang-assigns>` to create a unidirectional connection or used in computations, core I/O values represent electrical signals that may be digital or analog and have no :ref:`shape <lang-shapes>`, cannot be assigned, used in computations, or simulated.
 
-I/O values are only used to define connections between non-Amaranth building blocks that traverse an Amaranth design, including :ref:`instances <lang-instance>` and :ref:`I/O buffer instances <lang-iobufferinstance>`.
+Core I/O values are only used to define connections between non-Amaranth building blocks that traverse an Amaranth design, including :ref:`instances <lang-instance>` and :ref:`I/O buffer instances <lang-iobufferinstance>`.
 
 
 .. _lang-ioports:
@@ -1683,7 +1683,7 @@ I/O values are only used to define connections between non-Amaranth building blo
 I/O ports
 ---------
 
-An *I/O port* is an I/O value representing a connection to a port of the topmost module in the :ref:`design hierarchy <lang-submodules>`. It can be created with an explicitly specified width.
+A *core I/O port* is a core I/O value representing a connection to a port of the topmost module in the :ref:`design hierarchy <lang-submodules>`. It can be created with an explicitly specified width.
 
 .. testcode::
 
@@ -1695,7 +1695,7 @@ An *I/O port* is an I/O value representing a connection to a port of the topmost
     >>> port.width
     4
 
-I/O ports can be named in the same way as :ref:`signals <lang-signalname>`:
+Core I/O ports can be named in the same way as :ref:`signals <lang-signalname>`:
 
 .. doctest::
 
@@ -1703,7 +1703,7 @@ I/O ports can be named in the same way as :ref:`signals <lang-signalname>`:
     >>> clk_port.name
     'clk'
 
-If two I/O ports with the same name exist in a design, one of them will be renamed to remove the ambiguity. Because the name of an I/O port is significant, they should be named unambiguously.
+If two core I/O ports with the same name exist in a design, one of them will be renamed to remove the ambiguity. Because the name of a core I/O port is significant, they should be named unambiguously.
 
 
 .. _lang-ioops:
@@ -1711,7 +1711,7 @@ If two I/O ports with the same name exist in a design, one of them will be renam
 I/O operators
 -------------
 
-I/O values support only a limited set of :ref:`sequence <python:typesseq>` operators, all of which return another I/O value. The following table lists the I/O operators provided by Amaranth:
+Core I/O values support only a limited set of :ref:`sequence <python:typesseq>` operators, all of which return another core I/O value. The following table lists the operators provided by Amaranth for core I/O values:
 
 =============== ============================== ===================
 Operation       Description                    Notes
@@ -1747,9 +1747,9 @@ An instance can be added as a submodule using the :py:`m.submodules.name = Insta
 
 * An attribute is specified using the :py:`a_ANAME=attr` or :py:`("a", "ANAME", attr)` syntaxes. The :py:`attr` must be an :class:`int`, a :class:`str`, or a :class:`Const`.
 * A parameter is specified using the :py:`p_PNAME=param` or :py:`("p", "PNAME", param)` syntaxes. The :py:`param` must be an :class:`int`, a :class:`str`, or a :class:`Const`.
-* An input is specified using the :py:`i_INAME=in_val` or :py:`("i", "INAME", in_val)` syntaxes. The :py:`in_val` must be an :ref:`I/O value <lang-iovalues>` or a :ref:`value-like <lang-valuelike>` object.
-* An output is specified using the :py:`o_ONAME=out_val` or :py:`("o", "ONAME", out_val)` syntaxes. The :py:`out_val` must be an :ref:`I/O value <lang-iovalues>` or a :ref:`value-like <lang-valuelike>` object that casts to a :ref:`signal <lang-signals>`, a concatenation of signals, or a slice of a signal.
-* An inout is specified using the :py:`io_IONAME=inout_val` or :py:`("io", "IONAME", inout_val)` syntaxes. The :py:`inout_val` must be an :ref:`I/O value <lang-iovalues>`.
+* An input is specified using the :py:`i_INAME=in_val` or :py:`("i", "INAME", in_val)` syntaxes. The :py:`in_val` must be a :ref:`core I/O value <lang-iovalues>` or a :ref:`value-like <lang-valuelike>` object.
+* An output is specified using the :py:`o_ONAME=out_val` or :py:`("o", "ONAME", out_val)` syntaxes. The :py:`out_val` must be a :ref:`core I/O value <lang-iovalues>` or a :ref:`value-like <lang-valuelike>` object that casts to a :ref:`signal <lang-signals>`, a concatenation of signals, or a slice of a signal.
+* An inout is specified using the :py:`io_IONAME=inout_val` or :py:`("io", "IONAME", inout_val)` syntaxes. The :py:`inout_val` must be a :ref:`core I/O value <lang-iovalues>`.
 
 The two following examples use both syntaxes to add the same instance of type ``external`` as a submodule named ``processor``:
 
@@ -1833,7 +1833,11 @@ Although an :class:`Instance` is not an elaboratable, as a special case, it can
 I/O buffer instances
 ====================
 
-An *I/O buffer instance* is a submodule that allows connecting :ref:`I/O values <lang-iovalues>` and regular :ref:`values <lang-values>` without the use of an external, toolchain- and technology-dependent :ref:`instance <lang-instance>`. It can be created in four configurations: input, output, tristatable output, and bidirectional (input/output).
+.. note::
+
+    I/O buffer instances are a low-level primitive which is documented to ensure that the standard library does not rely on private interfaces in the core language. Most designers should use the :mod:`amaranth.lib.io` module instead.
+
+An *I/O buffer instance* is a submodule that allows connecting :ref:`core I/O values <lang-iovalues>` and regular :ref:`values <lang-values>` without the use of an external, toolchain- and technology-dependent :ref:`instance <lang-instance>`. It can be created in four configurations: input, output, tristatable output, and bidirectional (input/output).
 
 .. testcode::
 
@@ -1841,7 +1845,7 @@ An *I/O buffer instance* is a submodule that allows connecting :ref:`I/O values
 
     m = Module()
 
-In the input configuration, the buffer combinationally drives a signal :py:`i` by the port:
+In the input configuration, the buffer instance combinationally drives a signal :py:`i` by the port:
 
 .. testcode::
 
@@ -1849,7 +1853,7 @@ In the input configuration, the buffer combinationally drives a signal :py:`i` b
     port_i = Signal(4)
     m.submodules += IOBufferInstance(port, i=port_i)
 
-In the output configuration, the buffer combinationally drives the port by a value :py:`o`:
+In the output configuration, the buffer instance combinationally drives the port by a value :py:`o`:
 
 .. testcode::
 
@@ -1857,7 +1861,7 @@ In the output configuration, the buffer combinationally drives the port by a val
     port_o = Signal(4)
     m.submodules += IOBufferInstance(port, o=port_o)
 
-In the tristatable output configuration, the buffer combinationally drives the port by a value :py:`o` if :py:`oe` is asserted, and does not drive (leaves in a high-impedance state, or tristates) the port otherwise:
+In the tristatable output configuration, the buffer instance combinationally drives the port by a value :py:`o` if :py:`oe` is asserted, and does not drive (leaves in a high-impedance state, or tristates) the port otherwise:
 
 .. testcode::
 
@@ -1866,7 +1870,7 @@ In the tristatable output configuration, the buffer combinationally drives the p
     port_oe = Signal()
     m.submodules += IOBufferInstance(port, o=port_o, oe=port_oe)
 
-In the bidirectional (input/output) configuration, the buffer combinationally drives a signal :py:`i` by the port, combinationally drives the port by a value :py:`o` if :py:`oe` is asserted, and does not drive (leaves in a high-impedance state, or tristates) the port otherwise:
+In the bidirectional (input/output) configuration, the buffer instance combinationally drives a signal :py:`i` by the port, combinationally drives the port by a value :py:`o` if :py:`oe` is asserted, and does not drive (leaves in a high-impedance state, or tristates) the port otherwise:
 
 .. testcode::
 
diff --git a/docs/stdlib.rst b/docs/stdlib.rst
index add7c51..4fbc510 100644
--- a/docs/stdlib.rst
+++ b/docs/stdlib.rst
@@ -19,6 +19,7 @@ The Amaranth standard library is separate from the Amaranth language: everything
    stdlib/wiring
    stdlib/meta
    stdlib/memory
+   stdlib/io
    stdlib/cdc
    stdlib/coding
    stdlib/fifo
diff --git a/docs/stdlib/io.rst b/docs/stdlib/io.rst
new file mode 100644
index 0000000..3740ccf
--- /dev/null
+++ b/docs/stdlib/io.rst
@@ -0,0 +1,209 @@
+Input/output buffers
+====================
+
+.. py:module:: amaranth.lib.io
+
+The :mod:`amaranth.lib.io` module provides a platform-independent way to instantiate platform-specific input/output buffers: combinational, synchronous, and double data rate (DDR).
+
+
+Introduction
+------------
+
+The Amaranth language provides :ref:`core I/O values <lang-iovalues>` that designate connections to external devices, and :ref:`I/O buffer instances <lang-iobufferinstance>` that implement platform-independent combinational I/O buffers. This low-level mechanism is foundational to all I/O in Amaranth and must be used whenever a device-specific platform is unavailable, but is limited in its capabilities. The :mod:`amaranth.lib.io` module builds on top of it to provide *library I/O ports* that specialize and annotate I/O values, and *buffer components* that connect ports to logic.
+
+.. note::
+
+    Unfortunately, the terminology related to I/O has several ambiguities:
+
+    * A "port" could refer to an *interface port* (:class:`.Signal` objects created by the :mod:`amaranth.lib.wiring` module), a *core I/O port* (:class:`amaranth.hdl.IOPort` object), or a *library I/O port* (:class:`amaranth.lib.io.PortLike` object).
+    * A "I/O buffer" could refer to an *I/O buffer instance* (:class:`amaranth.hdl.IOBufferInstance`) or a *I/O buffer component* (:class:`amaranth.lib.io.Buffer`, :class:`.FFBuffer`, or :class:`.DDRBuffer` objects).
+
+    Amaranth documentation always uses the least ambiguous form of these terms.
+
+
+Examples
+--------
+
+.. testsetup::
+
+    from amaranth import *
+
+    class MockPlatform:
+        def request(self, name, *, dir):
+            from amaranth.hdl import IOPort
+            from amaranth.lib import io
+            if name == "led":
+                return io.SingleEndedPort(IOPort(1, name=name), direction="o")
+            if name == "clk24":
+                return io.SingleEndedPort(IOPort(1, name=name), direction="i")
+            if name == "d":
+                return io.SingleEndedPort(IOPort(8, name=name), direction="io")
+            if name == "re":
+                return io.SingleEndedPort(IOPort(1, name=name), direction="i")
+            if name == "we":
+                return io.SingleEndedPort(IOPort(1, name=name), direction="i")
+            if name == "dclk":
+                return io.SingleEndedPort(IOPort(1, name=name), direction="o")
+            if name == "dout":
+                return io.SingleEndedPort(IOPort(8, name=name), direction="o")
+            raise NameError
+
+        def get_io_buffer(self, buffer):
+            return Fragment()
+
+        def build(self, top):
+            from amaranth.back import rtlil
+            return rtlil.convert(Fragment.get(top, self), ports=[])
+
+
+All of the following examples assume that one of the built-in FPGA platforms is used.
+
+.. testcode::
+
+    from amaranth.lib import io, wiring
+    from amaranth.lib.wiring import In, Out
+
+
+LED output
+++++++++++
+
+In this example, a library I/O port for a LED is requested from the platform and driven to blink the LED:
+
+.. testcode::
+
+    class Toplevel(Elaboratable):
+        def elaborate(self, platform):
+            m = Module()
+
+            delay = Signal(24)
+            state = Signal()
+            with m.If(delay == 0):
+                m.d.sync += delay.eq(~0)
+                m.d.sync += state.eq(~state)
+            with m.Else():
+                m.d.sync += delay.eq(delay - 1)
+
+            m.submodules.led = led = io.Buffer("o", platform.request("led", dir="-"))
+            m.d.comb += led.o.eq(state)
+
+            return m
+
+.. testcode::
+    :hide:
+
+    MockPlatform().build(Toplevel())
+
+
+Clock input
++++++++++++
+
+In this example, a clock domain is created and driven from an external clock source:
+
+.. testcode::
+
+    class Toplevel(Elaboratable):
+        def elaborate(self, platform):
+            m = Module()
+
+            m.domains.sync = cd_sync = ClockDomain(local=True)
+
+            m.submodules.clk24 = clk24 = io.Buffer("i", platform.request("clk24", dir="-"))
+            m.d.comb += cd_sync.clk.eq(clk24.i)
+
+            ...
+
+            return m
+
+.. testcode::
+    :hide:
+
+    MockPlatform().build(Toplevel())
+
+
+Bidirectional bus
++++++++++++++++++
+
+This example implements a peripheral for a clocked parallel bus. This peripheral can store and recall one byte of data. The data is stored with a write enable pulse, and recalled with a read enable pulse:
+
+.. testcode::
+
+    class Toplevel(Elaboratable):
+        def elaborate(self, platform):
+            m = Module()
+
+            m.submodules.bus_d = bus_d = io.FFBuffer("io", platform.request("d", dir="-"))
+            m.submodules.bus_re = bus_re = io.Buffer("i", platform.request("re", dir="-"))
+            m.submodules.bus_we = bus_we = io.Buffer("i", platform.request("we", dir="-"))
+
+            data = Signal.like(bus_d.i)
+            with m.If(bus_re.i):
+                m.d.comb += bus_d.oe.eq(1)
+                m.d.comb += bus_d.o.eq(data)
+            with m.Elif(bus_we.i):
+                m.d.sync += data.eq(bus_d.i)
+
+            return m
+
+.. testcode::
+    :hide:
+
+    MockPlatform().build(Toplevel())
+
+This bus requires a turn-around time of at least 1 cycle to avoid electrical contention.
+
+Note that data appears on the bus one cycle after the read enable input is asserted, and that the write enable input stores the data present on the bus in the *previous* cycle. This is called *pipelining* and is typical for clocked buses; see :class:`.FFBuffer` for a waveform diagram. Although it increases the maximum clock frequency at which the bus can run, it also makes the bus signaling more complicated.
+
+
+Clock forwarding
+++++++++++++++++
+
+In this example of a `source-synchronous interface <https://en.wikipedia.org/wiki/Source-synchronous>`__, a clock signal is generated with the same phase as the DDR data signals associated with it:
+
+.. testcode::
+
+    class SourceSynchronousOutput(wiring.Component):
+        dout: In(16)
+
+        def elaborate(self, platform):
+            m = Module()
+
+            m.submodules.bus_dclk = bus_dclk = \
+                io.DDRBuffer("o", platform.request("dclk", dir="-"))
+            m.d.comb += [
+                bus_dclk.o[0].eq(1),
+                bus_dclk.o[1].eq(0),
+            ]
+
+            m.submodules.bus_dout = bus_dout = \
+                io.DDRBuffer("o", platform.request("dout", dir="-"))
+            m.d.comb += [
+                bus_dout.o[0].eq(self.dout[:8]),
+                bus_dout.o[1].eq(self.dout[8:]),
+            ]
+
+            return m
+
+.. testcode::
+    :hide:
+
+    MockPlatform().build(SourceSynchronousOutput())
+
+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.
+
+
+Ports
+-----
+
+.. autoclass:: Direction()
+
+.. autoclass:: PortLike
+.. autoclass:: SingleEndedPort
+.. autoclass:: DifferentialPort
+
+
+Buffers
+-------
+
+.. autoclass:: Buffer(direction, port)
+.. autoclass:: FFBuffer(direction, port, *, i_domain=None, o_domain=None)
+.. autoclass:: DDRBuffer(direction, port, *, i_domain=None, o_domain=None)
diff --git a/pyproject.toml b/pyproject.toml
index dcfdec6..9f8b75c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ name = "amaranth"
 description = "Amaranth hardware definition language"
 readme = "README.md"
 authors = [{name = "Amaranth HDL contributors"}]
-license = {file = "LICENSE.txt"}
+license = { text = "BSD-2-clause" }
 
 requires-python = "~=3.8"
 dependencies = [
@@ -44,7 +44,7 @@ amaranth-rpc = "amaranth.rpc:main"
 # Build system configuration
 
 [build-system]
-requires = ["pdm-backend @ git+https://github.com/pdm-project/pdm-backend@885bc8d9b75d068a34b2df86c6324c5fdf5c0cc8"]
+requires = ["pdm-backend~=2.3.0"]
 build-backend = "pdm.backend"
 
 [tool.pdm]


Test patch:
diff --git a/tests/test_build_res.py b/tests/test_build_res.py
index 923e6ec..bda61b4 100644
--- a/tests/test_build_res.py
+++ b/tests/test_build_res.py
@@ -5,6 +5,7 @@ from amaranth.lib.wiring import *
 from amaranth.lib.io import *
 from amaranth.build.dsl import *
 from amaranth.build.res import *
+from amaranth._utils import _ignore_deprecated
 
 from .utils import *
 
@@ -56,7 +57,8 @@ class ResourceManagerTestCase(FHDLTestCase):
 
     def test_request_basic(self):
         r = self.cm.lookup("user_led", 0)
-        user_led = self.cm.request("user_led", 0)
+        with _ignore_deprecated():
+            user_led = self.cm.request("user_led", 0)
 
         self.assertIsInstance(flipped(user_led), Pin)
         self.assertEqual(user_led.o.name, "user_led_0__o")
@@ -74,7 +76,8 @@ class ResourceManagerTestCase(FHDLTestCase):
         self.assertEqual(port.invert, (False,))
 
     def test_request_with_dir(self):
-        i2c = self.cm.request("i2c", 0, dir={"sda": "o"})
+        with _ignore_deprecated():
+            i2c = self.cm.request("i2c", 0, dir={"sda": "o"})
         self.assertIsInstance(flipped(i2c.sda), Pin)
         self.assertEqual(i2c.sda.dir, "o")
         ((_, _, scl_buffer), (_, _, sda_buffer)) = self.cm.iter_pins()
@@ -82,7 +85,8 @@ class ResourceManagerTestCase(FHDLTestCase):
         sda_buffer._MustUse__silence = True
 
     def test_request_tristate(self):
-        i2c = self.cm.request("i2c", 0)
+        with _ignore_deprecated():
+            i2c = self.cm.request("i2c", 0)
         self.assertEqual(i2c.sda.dir, "io")
 
         ((scl_pin, scl_port, scl_buffer), (sda_pin, sda_port, sda_buffer)) = self.cm.iter_pins()
@@ -97,7 +101,8 @@ class ResourceManagerTestCase(FHDLTestCase):
         self.assertEqual(sda_port.io.metadata[0].name, "N11")
 
     def test_request_diffpairs(self):
-        clk100 = self.cm.request("clk100", 0)
+        with _ignore_deprecated():
+            clk100 = self.cm.request("clk100", 0)
         self.assertIsInstance(flipped(clk100), Pin)
         self.assertEqual(clk100.dir, "i")
         self.assertEqual(clk100.width, 1)
@@ -120,8 +125,10 @@ class ResourceManagerTestCase(FHDLTestCase):
         ]
         self.cm.add_resources(new_resources)
 
-        cs = self.cm.request("cs")
-        clk = self.cm.request("clk")
+        with _ignore_deprecated():
+            cs = self.cm.request("cs")
+            clk = self.cm.request("clk")
+
         (
             (cs_pin, cs_port, cs_buffer),
             (clk_pin, clk_port, clk_buffer),
@@ -154,7 +161,8 @@ class ResourceManagerTestCase(FHDLTestCase):
                 Subsignal("copi", Pins("4", conn=("pmod", 0))),
             )
         ])
-        spi0 = self.cm.request("spi", 0)
+        with _ignore_deprecated():
+            spi0 = self.cm.request("spi", 0)
         (
             (cs_pin, cs_port, cs_buffer),
             (clk_pin, clk_port, clk_buffer),
@@ -187,7 +195,8 @@ class ResourceManagerTestCase(FHDLTestCase):
                 Subsignal("copi", Pins("4", conn=("pmod_extension", 0))),
             )
         ])
-        spi0 = self.cm.request("spi", 0)
+        with _ignore_deprecated():
+            spi0 = self.cm.request("spi", 0)
         (
             (cs_pin, cs_port, cs_buffer),
             (clk_pin, clk_port, clk_buffer),
@@ -208,8 +217,9 @@ class ResourceManagerTestCase(FHDLTestCase):
         self.assertEqual(copi_port.io.metadata[0].name, "B3")
 
     def test_request_clock(self):
-        clk100 = self.cm.request("clk100", 0)
-        clk50 = self.cm.request("clk50", 0, dir="i")
+        with _ignore_deprecated():
+            clk100 = self.cm.request("clk100", 0)
+            clk50 = self.cm.request("clk50", 0, dir="i")
         (
             (clk100_pin, clk100_port, clk100_buffer),
             (clk50_pin, clk50_port, clk50_buffer),
@@ -222,7 +232,8 @@ class ResourceManagerTestCase(FHDLTestCase):
         ])
 
     def test_add_clock(self):
-        i2c = self.cm.request("i2c")
+        with _ignore_deprecated():
+            i2c = self.cm.request("i2c")
         self.cm.add_clock_constraint(i2c.scl.o, 100e3)
         self.assertEqual(list(self.cm.iter_clock_constraints()), [
             (i2c.scl.o, None, 100e3)
@@ -267,24 +278,28 @@ class ResourceManagerTestCase(FHDLTestCase):
             self.cm.add_clock_constraint(Signal(), None)
 
     def test_wrong_request_duplicate(self):
-        self.cm.request("user_led", 0)
+        with _ignore_deprecated():
+            self.cm.request("user_led", 0)
         (pin, port, buffer), = self.cm.iter_pins()
         buffer._MustUse__silence = True
         with self.assertRaisesRegex(ResourceError,
                 r"^Resource user_led#0 has already been requested$"):
-            self.cm.request("user_led", 0)
+            with _ignore_deprecated():
+                self.cm.request("user_led", 0)
 
     def test_wrong_request_duplicate_physical(self):
         self.cm.add_resources([
             Resource("clk20", 0, Pins("H1", dir="i")),
         ])
-        self.cm.request("clk100", 0)
+        with _ignore_deprecated():
+            self.cm.request("clk100", 0)
         (pin, port, buffer), = self.cm.iter_pins()
         buffer._MustUse__silence = True
         with self.assertRaisesRegex(ResourceError,
                 (r"^Resource component clk20_0 uses physical pin H1, but it is already "
                     r"used by resource component clk100_0 that was requested earlier$")):
-            self.cm.request("clk20", 0)
+            with _ignore_deprecated():
+                self.cm.request("clk20", 0)
 
     def test_wrong_request_with_dir(self):
         with self.assertRaisesRegex(TypeError,
@@ -319,7 +334,8 @@ class ResourceManagerTestCase(FHDLTestCase):
             i2c = self.cm.request("i2c", 0, xdr=2)
 
     def test_wrong_clock_constraint_twice(self):
-        clk100 = self.cm.request("clk100")
+        with _ignore_deprecated():
+            clk100 = self.cm.request("clk100")
         (pin, port, buffer), = self.cm.iter_pins()
         buffer._MustUse__silence = True
         with self.assertRaisesRegex(ValueError,
diff --git a/tests/test_hdl_ir.py b/tests/test_hdl_ir.py
index 34d5987..5b6a682 100644
--- a/tests/test_hdl_ir.py
+++ b/tests/test_hdl_ir.py
@@ -3598,3 +3598,28 @@ class DomainLookupTestCase(FHDLTestCase):
         self.assertIs(design.lookup_domain("sync", xm5), m1_c)
         self.assertIs(design.lookup_domain("d", xm4), m4_d)
         self.assertIs(design.lookup_domain("d", xm5), m5_d)
+
+
+class RequirePosedgeTestCase(FHDLTestCase):
+    def test_require_ok(self):
+        m = Module()
+        m.domains.sync = ClockDomain()
+        m.submodules += RequirePosedge("sync")
+        Fragment.get(m, None).prepare()
+
+    def test_require_fail(self):
+        m = Module()
+        m.domains.sync = ClockDomain(clk_edge="neg")
+        m.submodules += RequirePosedge("sync")
+        with self.assertRaisesRegex(DomainRequirementFailed,
+                r"^Domain sync has a negedge clock, but posedge clock is required by top.U\$0 at .*$"):
+            Fragment.get(m, None).prepare()
+
+    def test_require_renamed(self):
+        m = Module()
+        m.domains.sync = ClockDomain(clk_edge="pos")
+        m.domains.test = ClockDomain(clk_edge="neg")
+        m.submodules += DomainRenamer("test")(RequirePosedge("sync"))
+        with self.assertRaisesRegex(DomainRequirementFailed,
+                r"^Domain test has a negedge clock, but posedge clock is required by top.U\$0 at .*$"):
+            Fragment.get(m, None).prepare()
\ No newline at end of file
diff --git a/tests/test_lib_io.py b/tests/test_lib_io.py
index aabf945..9dc208e 100644
--- a/tests/test_lib_io.py
+++ b/tests/test_lib_io.py
@@ -6,24 +6,12 @@ from amaranth.hdl._ir import build_netlist
 from amaranth.lib.io import *
 from amaranth.lib.wiring import *
 from amaranth.lib import wiring, data
+from amaranth._utils import _ignore_deprecated
 
 from .utils import *
 
 
 class DirectionTestCase(FHDLTestCase):
-    def test_or(self):
-        self.assertIs(Direction.Input | Direction.Input, Direction.Input)
-        self.assertIs(Direction.Input | Direction.Output, Direction.Bidir)
-        self.assertIs(Direction.Input | Direction.Bidir, Direction.Bidir)
-        self.assertIs(Direction.Output | Direction.Input, Direction.Bidir)
-        self.assertIs(Direction.Output | Direction.Output, Direction.Output)
-        self.assertIs(Direction.Output | Direction.Bidir, Direction.Bidir)
-        self.assertIs(Direction.Bidir | Direction.Input, Direction.Bidir)
-        self.assertIs(Direction.Bidir | Direction.Output, Direction.Bidir)
-        self.assertIs(Direction.Bidir | Direction.Bidir, Direction.Bidir)
-        with self.assertRaises(TypeError):
-            Direction.Bidir | 3
-
     def test_and(self):
         self.assertIs(Direction.Input & Direction.Input, Direction.Input)
         self.assertIs(Direction.Input & Direction.Bidir, Direction.Input)
@@ -714,49 +702,57 @@ class PinSignatureTestCase(FHDLTestCase):
 
 class PinSignatureCombTestCase(PinSignatureTestCase):
     def test_signature_i(self):
-        sig_1 = Pin.Signature(1, dir="i")
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="i")
         self.assertSignatureEqual(sig_1, {
             "i": In(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="i")
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="i")
         self.assertSignatureEqual(sig_2, {
             "i": In(2),
         })
 
     def test_signature_o(self):
-        sig_1 = Pin.Signature(1, dir="o")
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="o")
         self.assertSignatureEqual(sig_1, {
             "o": Out(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="o")
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="o")
         self.assertSignatureEqual(sig_2, {
             "o": Out(2),
         })
 
     def test_signature_oe(self):
-        sig_1 = Pin.Signature(1, dir="oe")
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="oe")
         self.assertSignatureEqual(sig_1, {
             "o":  Out(1),
             "oe": Out(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="oe")
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="oe")
         self.assertSignatureEqual(sig_2, {
             "o":  Out(2),
             "oe": Out(1),
         })
 
     def test_signature_io(self):
-        sig_1 = Pin.Signature(1, dir="io")
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="io")
         self.assertSignatureEqual(sig_1, {
             "i":  In(1),
             "o":  Out(1),
             "oe": Out(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="io")
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="io")
         self.assertSignatureEqual(sig_2, {
             "i":  In(2),
             "o":  Out(2),
@@ -766,40 +762,46 @@ class PinSignatureCombTestCase(PinSignatureTestCase):
 
 class PinSignatureSDRTestCase(PinSignatureTestCase):
     def test_signature_i(self):
-        sig_1 = Pin.Signature(1, dir="i", xdr=1)
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="i", xdr=1)
         self.assertSignatureEqual(sig_1, {
             "i_clk": Out(1),
             "i": In(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="i", xdr=1)
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="i", xdr=1)
         self.assertSignatureEqual(sig_2, {
             "i_clk": Out(1),
             "i": In(2),
         })
 
     def test_signature_o(self):
-        sig_1 = Pin.Signature(1, dir="o", xdr=1)
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="o", xdr=1)
         self.assertSignatureEqual(sig_1, {
             "o_clk": Out(1),
             "o": Out(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="o", xdr=1)
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="o", xdr=1)
         self.assertSignatureEqual(sig_2, {
             "o_clk": Out(1),
             "o": Out(2),
         })
 
     def test_signature_oe(self):
-        sig_1 = Pin.Signature(1, dir="oe", xdr=1)
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="oe", xdr=1)
         self.assertSignatureEqual(sig_1, {
             "o_clk": Out(1),
             "o":  Out(1),
             "oe": Out(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="oe", xdr=1)
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="oe", xdr=1)
         self.assertSignatureEqual(sig_2, {
             "o_clk": Out(1),
             "o":  Out(2),
@@ -807,7 +809,8 @@ class PinSignatureSDRTestCase(PinSignatureTestCase):
         })
 
     def test_signature_io(self):
-        sig_1 = Pin.Signature(1, dir="io", xdr=1)
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="io", xdr=1)
         self.assertSignatureEqual(sig_1, {
             "i_clk": Out(1),
             "i":  In(1),
@@ -816,7 +819,8 @@ class PinSignatureSDRTestCase(PinSignatureTestCase):
             "oe": Out(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="io", xdr=1)
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="io", xdr=1)
         self.assertSignatureEqual(sig_2, {
             "i_clk": Out(1),
             "i":  In(2),
@@ -828,14 +832,16 @@ class PinSignatureSDRTestCase(PinSignatureTestCase):
 
 class PinSignatureDDRTestCase(PinSignatureTestCase):
     def test_signature_i(self):
-        sig_1 = Pin.Signature(1, dir="i", xdr=2)
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="i", xdr=2)
         self.assertSignatureEqual(sig_1, {
             "i_clk": Out(1),
             "i0": In(1),
             "i1": In(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="i", xdr=2)
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="i", xdr=2)
         self.assertSignatureEqual(sig_2, {
             "i_clk": Out(1),
             "i0": In(2),
@@ -843,14 +849,16 @@ class PinSignatureDDRTestCase(PinSignatureTestCase):
         })
 
     def test_signature_o(self):
-        sig_1 = Pin.Signature(1, dir="o", xdr=2)
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="o", xdr=2)
         self.assertSignatureEqual(sig_1, {
             "o_clk": Out(1),
             "o0": Out(1),
             "o1": Out(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="o", xdr=2)
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="o", xdr=2)
         self.assertSignatureEqual(sig_2, {
             "o_clk": Out(1),
             "o0": Out(2),
@@ -858,7 +866,8 @@ class PinSignatureDDRTestCase(PinSignatureTestCase):
         })
 
     def test_signature_oe(self):
-        sig_1 = Pin.Signature(1, dir="oe", xdr=2)
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="oe", xdr=2)
         self.assertSignatureEqual(sig_1, {
             "o_clk": Out(1),
             "o0": Out(1),
@@ -866,7 +875,8 @@ class PinSignatureDDRTestCase(PinSignatureTestCase):
             "oe": Out(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="oe", xdr=2)
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="oe", xdr=2)
         self.assertSignatureEqual(sig_2, {
             "o_clk": Out(1),
             "o0": Out(2),
@@ -875,7 +885,8 @@ class PinSignatureDDRTestCase(PinSignatureTestCase):
         })
 
     def test_signature_io(self):
-        sig_1 = Pin.Signature(1, dir="io", xdr=2)
+        with _ignore_deprecated():
+            sig_1 = Pin.Signature(1, dir="io", xdr=2)
         self.assertSignatureEqual(sig_1, {
             "i_clk": Out(1),
             "i0": In(1),
@@ -886,7 +897,8 @@ class PinSignatureDDRTestCase(PinSignatureTestCase):
             "oe": Out(1),
         })
 
-        sig_2 = Pin.Signature(2, dir="io", xdr=2)
+        with _ignore_deprecated():
+            sig_2 = Pin.Signature(2, dir="io", xdr=2)
         self.assertSignatureEqual(sig_2, {
             "i_clk": Out(1),
             "i0": In(2),
@@ -900,17 +912,21 @@ class PinSignatureDDRTestCase(PinSignatureTestCase):
 
 class PinSignatureReprCase(FHDLTestCase):
     def test_repr(self):
-        sig_0 = Pin.Signature(1, dir="i")
+        with _ignore_deprecated():
+            sig_0 = Pin.Signature(1, dir="i")
         self.assertRepr(sig_0, "Pin.Signature(1, dir='i')")
-        sig_0 = Pin.Signature(2, dir="o", xdr=1)
+        with _ignore_deprecated():
+            sig_0 = Pin.Signature(2, dir="o", xdr=1)
         self.assertRepr(sig_0, "Pin.Signature(2, dir='o', xdr=1)")
-        sig_0 = Pin.Signature(3, dir="io", xdr=2)
+        with _ignore_deprecated():
+            sig_0 = Pin.Signature(3, dir="io", xdr=2)
         self.assertRepr(sig_0, "Pin.Signature(3, dir='io', xdr=2)")
 
 
 class PinTestCase(FHDLTestCase):
     def test_attributes(self):
-        pin = Pin(2, dir="io", xdr=2)
+        with _ignore_deprecated():
+            pin = Pin(2, dir="io", xdr=2)
         self.assertEqual(pin.width, 2)
         self.assertEqual(pin.dir,   "io")
         self.assertEqual(pin.xdr,   2)
@@ -920,11 +936,13 @@ class PinTestCase(FHDLTestCase):
         self.assertEqual(pin.name, "pin")
         self.assertEqual(pin.path, ("pin",))
         self.assertEqual(pin.i0.name, "pin__i0")
-        pin = Pin(2, dir="io", xdr=2, name="testpin")
+        with _ignore_deprecated():
+            pin = Pin(2, dir="io", xdr=2, name="testpin")
         self.assertEqual(pin.name, "testpin")
         self.assertEqual(pin.path, ("testpin",))
         self.assertEqual(pin.i0.name, "testpin__i0")
-        pin = Pin(2, dir="io", xdr=2, path=["a", "b"])
+        with _ignore_deprecated():
+            pin = Pin(2, dir="io", xdr=2, path=["a", "b"])
         self.assertEqual(pin.name, "a__b")
         self.assertEqual(pin.path, ("a", "b"))
         self.assertEqual(pin.i0.name, "a__b__i0")
diff --git a/tests/test_sim.py b/tests/test_sim.py
index 678e557..4179a17 100644
--- a/tests/test_sim.py
+++ b/tests/test_sim.py
@@ -16,7 +16,7 @@ from amaranth.hdl._ir import *
 from amaranth.sim import *
 from amaranth.sim._pyeval import eval_format
 from amaranth.lib.memory import Memory
-from amaranth.lib import enum, data
+from amaranth.lib import enum, data, wiring
 
 from .utils import *
 from amaranth._utils import _ignore_deprecated
@@ -718,17 +718,35 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
     def test_add_process_wrong(self):
         with self.assertSimulation(Module()) as sim:
             with self.assertRaisesRegex(TypeError,
-                    r"^Cannot add a process 1 because it is not a generator function$"):
+                    r"^Cannot add a process 1 because it is not an async function or "
+                    r"generator function$"):
                 sim.add_process(1)
 
     def test_add_process_wrong_generator(self):
         with self.assertSimulation(Module()) as sim:
             with self.assertRaisesRegex(TypeError,
-                    r"^Cannot add a process <.+?> because it is not a generator function$"):
+                    r"^Cannot add a process <.+?> because it is not an async function or "
+                    r"generator function$"):
                 def process():
                     yield Delay()
                 sim.add_process(process())
 
+    def test_add_testbench_wrong(self):
+        with self.assertSimulation(Module()) as sim:
+            with self.assertRaisesRegex(TypeError,
+                    r"^Cannot add a testbench 1 because it is not an async function or "
+                    r"generator function$"):
+                sim.add_testbench(1)
+
+    def test_add_testbench_wrong_generator(self):
+        with self.assertSimulation(Module()) as sim:
+            with self.assertRaisesRegex(TypeError,
+                    r"^Cannot add a testbench <.+?> because it is not an async function or "
+                    r"generator function$"):
+                def testbench():
+                    yield Delay()
+                sim.add_testbench(testbench())
+
     def test_add_clock_wrong_twice(self):
         m = Module()
         s = Signal()
@@ -1375,6 +1393,49 @@ class SimulatorIntegrationTestCase(FHDLTestCase):
             sim.add_testbench(testbench)
 
 
+class SimulatorTracesTestCase(FHDLTestCase):
+    def assertDef(self, traces, flat_traces):
+        frag = Fragment()
+
+        def process():
+            yield Delay(1e-6)
+
+        sim = Simulator(frag)
+        sim.add_testbench(process)
+        with sim.write_vcd("test.vcd", "test.gtkw", traces=traces):
+            sim.run()
+
+    def test_signal(self):
+        a = Signal()
+        self.assertDef(a, [a])
+
+    def test_list(self):
+        a = Signal()
+        self.assertDef([a], [a])
+
+    def test_tuple(self):
+        a = Signal()
+        self.assertDef((a,), [a])
+
+    def test_dict(self):
+        a = Signal()
+        self.assertDef({"a": a}, [a])
+
+    def test_struct_view(self):
+        a = Signal(data.StructLayout({"a": 1, "b": 3}))
+        self.assertDef(a, [a])
+
+    def test_interface(self):
+        sig = wiring.Signature({
+            "a": wiring.In(1),
+            "b": wiring.Out(3),
+            "c": wiring.Out(2).array(4),
+            "d": wiring.In(wiring.Signature({"e": wiring.In(5)}))
+        })
+        a = sig.create()
+        self.assertDef(a, [a])
+
+
 class SimulatorRegressionTestCase(FHDLTestCase):
     def test_bug_325(self):
         dut = Module()
@@ -1935,3 +1996,12 @@ class SimulatorRegressionTestCase(FHDLTestCase):
 
         self.assertTrue(reached_tb)
         self.assertTrue(reached_proc)
+
+    def test_bug_1363(self):
+        sim = Simulator(Module())
+        with self.assertRaisesRegex(TypeError,
+                r"^Cannot add a testbench <.+?> because it is not an async function or "
+                r"generator function$"):
+            async def testbench():
+                yield Delay()
+            sim.add_testbench(testbench())