Repository: amaranth-lang/amaranth
Pull request #1215 (2024-03-19 02:42:45)

lib.io: Implement `*Buffer` from RFC 55.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/1215?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) Report
Attention: Patch coverage is `73.55769%` with `55 lines` in your changes are missing coverage. Please review.
> Project coverage is 89.10%. Comparing base [(`598cf8d`)](https://app.codecov.io/gh/amaranth-lang/amaranth/commit/598cf8db287c42e9ea97aa82fda8d8f9d7fb68b6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) to head [(`de0846e`)](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/1215?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang).

| [Files](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/1215?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) | Patch % | Lines |
|---|---|---|
| [amaranth/lib/io.py](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/1215?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang#diff-YW1hcmFudGgvbGliL2lvLnB5) | 73.55% | [55 Missing :warning: ](https://app.codecov.io/gh/amaranth-lang/amaranth/pull/1215?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=amaranth-lang) |

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


```diff
@@            Coverage Diff             @@
##             main    #1215      +/-   ##
==========================================
- Coverage   89.50%   89.10%   -0.41%     
==========================================
  Files          43       43              
  Lines        9616     9822     +206     
  Branches     2314     2111     -203     
==========================================
+ Hits         8607     8752     +145     
- Misses        820      880      +60     
- Partials      189      190       +1     
```



</details>

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


Patch:
diff --git a/amaranth/back/verilog.py b/amaranth/back/verilog.py
index 545a4ed..50bc41d 100644
--- a/amaranth/back/verilog.py
+++ b/amaranth/back/verilog.py
@@ -8,7 +8,7 @@ __all__ = ["YosysError", "convert", "convert_fragment"]
 
 
 def _convert_rtlil_text(rtlil_text, *, strip_internal_attrs=False, write_verilog_opts=()):
-    # this version requirement needs to be synchronized with the one in pyproject.toml!
+    # This version requirement needs to be synchronized with the one in pyproject.toml!
     yosys = find_yosys(lambda ver: ver >= (0, 38))
 
     script = []
diff --git a/amaranth/lib/io.py b/amaranth/lib/io.py
index fc9f918..815bb5b 100644
--- a/amaranth/lib/io.py
+++ b/amaranth/lib/io.py
@@ -1,13 +1,19 @@
 import enum
+import operator
+from abc import ABCMeta, abstractmethod
 from collections.abc import Iterable
 
 from ..hdl import *
-from ..lib import wiring
+from ..lib import wiring, data
 from ..lib.wiring import In, Out
 from .. import tracer
 
 
-__all__ = ["Direction", "SingleEndedPort", "DifferentialPort", "Pin"]
+__all__ = [
+    "Direction", "PortLike", "SingleEndedPort", "DifferentialPort",
+    "Buffer", "FFBuffer", "DDRBuffer",
+    "Pin",
+]
 
 
 class Direction(enum.Enum):
@@ -41,16 +47,52 @@ class Direction(enum.Enum):
             raise ValueError("Cannot combine input port with output port")
 
 
-class SingleEndedPort:
+class PortLike(metaclass=ABCMeta):
+    """Represents an abstract 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.
+    """
+
+    @property
+    @abstractmethod
+    def direction(self):
+        """The direction of this port, as :class:`Direction`."""
+        raise NotImplementedError # :nocov:
+
+    @abstractmethod
+    def __len__(self):
+        """Returns the width of this port in bits."""
+        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`."""
+        raise NotImplementedError # :nocov:
+
+    @abstractmethod
+    def __invert__(self):
+        """Returns a new :class:`PortLike` object like this one, but with inverted polarity.
+
+        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."""
+        raise NotImplementedError # :nocov:
+
+
+class SingleEndedPort(PortLike):
     """Represents a single-ended I/O port with optional inversion.
 
     Parameters
     ----------
-    io : IOValue
+    io : :class:`IOValue`
         The raw I/O value being wrapped.
-    invert : bool or iterable of bool
+    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 ``o`` and ``i`` pins, as appropriate).
+        (the ``*Buffer`` classes will insert inverters on :py:`o` and :py:`i` pins, as appropriate).
 
         This can be used for various purposes:
 
@@ -59,9 +101,9 @@ class SingleEndedPort:
           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 ``io``, and
+        is an iterable of :class:`bool`, the iterable must have the same length as :py:`io`, and
         the inversion is specified per-bit.
-    direction : Direction or str
+    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.
@@ -84,18 +126,18 @@ class SingleEndedPort:
 
     @property
     def io(self):
-        """The ``io`` argument passed to the constructor."""
+        """The :py:`io` argument passed to the constructor."""
         return self._io
 
     @property
     def invert(self):
-        """The ``invert`` argument passed to the constructor, normalized to a :class:`tuple`
+        """The :py:`invert` argument passed to the constructor, normalized to a :class:`tuple`
         of :class:`bool`."""
         return self._invert
 
     @property
     def direction(self):
-        """The ``direction`` argument passed to the constructor, normalized to :class:`Direction`."""
+        """The :py:`direction` argument passed to the constructor, normalized to :class:`Direction`."""
         return self._direction
 
     def __len__(self):
@@ -103,7 +145,7 @@ class SingleEndedPort:
         return len(self._io)
 
     def __invert__(self):
-        """Returns a new :class:`SingleEndedPort` with the opposite value of ``invert``."""
+        """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)
 
@@ -141,19 +183,19 @@ class SingleEndedPort:
         return f"SingleEndedPort({self._io!r}, invert={invert!r}, direction={self._direction})"
 
 
-class DifferentialPort:
+class DifferentialPort(PortLike):
     """Represents a differential I/O port with optional inversion.
 
     Parameters
     ----------
-    p : IOValue
+    p : :class:`IOValue`
         The raw I/O value used as positive (true) half of the port.
-    n : IOValue
+    n : :class:`IOValue`
         The raw I/O value used as negative (complemented) half of the port. Must have the same
-        length as ``p``.
-    invert : bool or iterable of bool
+        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 ``o`` and ``i`` pins, as appropriate).
+        (the ``*Buffer`` classes will insert inverters on :py:`o` and :py:`i` pins, as appropriate).
 
         This can be used for various purposes:
 
@@ -161,9 +203,9 @@ class DifferentialPort:
         - 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 ``io``, and
+        is an iterable of :class:`bool`, the iterable must have the same length as :py:`io`, and
         the inversion is specified per-bit.
-    direction : Direction or str
+    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.
@@ -190,23 +232,23 @@ class DifferentialPort:
 
     @property
     def p(self):
-        """The ``p`` argument passed to the constructor."""
+        """The :py:`p` argument passed to the constructor."""
         return self._p
 
     @property
     def n(self):
-        """The ``n`` argument passed to the constructor."""
+        """The :py:`n` argument passed to the constructor."""
         return self._n
 
     @property
     def invert(self):
-        """The ``invert`` argument passed to the constructor, normalized to a :class:`tuple`
+        """The :py:`invert` argument passed to the constructor, normalized to a :class:`tuple`
         of :class:`bool`."""
         return self._invert
 
     @property
     def direction(self):
-        """The ``direction`` argument passed to the constructor, normalized to :class:`Direction`."""
+        """The :py:`direction` argument passed to the constructor, normalized to :class:`Direction`."""
         return self._direction
 
     def __len__(self):
@@ -214,7 +256,7 @@ class DifferentialPort:
         return len(self._p)
 
     def __invert__(self):
-        """Returns a new :class:`DifferentialPort` with the opposite value of ``invert``."""
+        """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)
 
@@ -253,6 +295,357 @@ class DifferentialPort:
         return f"DifferentialPort({self._p!r}, {self._n!r}, invert={invert!r}, direction={self._direction})"
 
 
+class Buffer(wiring.Component):
+    """A combinatorial I/O buffer.
+
+    Parameters
+    ----------
+    direction : :class:`Direction`
+    port : :class:`PortLike`
+
+    Attributes
+    ----------
+    signature : :class:`Buffer.Signature`
+        Created based on constructor arguments.
+    """
+    class Signature(wiring.Signature):
+        """A signature of a combinatorial I/O buffer.
+
+        Parameters
+        ----------
+        direction : :class:`Direction`
+        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`)
+        """
+        def __init__(self, direction, width):
+            self._direction = Direction(direction)
+            self._width = operator.index(width)
+            members = {}
+            if self._direction is not Direction.Output:
+                members["i"] = wiring.In(self._width)
+            if self._direction is not Direction.Input:
+                members["o"] = wiring.Out(self._width)
+                members["oe"] = wiring.Out(1, init=int(self._direction is Direction.Output))
+            super().__init__(members)
+
+        @property
+        def direction(self):
+            return self._direction
+
+        @property
+        def width(self):
+            return self._width
+
+        def __eq__(self, other):
+            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})"
+
+    def __init__(self, direction, port):
+        if not isinstance(port, PortLike):
+            raise TypeError(f"'port' must be a 'PortLike', not {port!r}")
+        self._port = port
+        super().__init__(Buffer.Signature(direction, len(port)).flip())
+        if port.direction is Direction.Input and self.direction is not Direction.Input:
+            raise ValueError(f"Input port cannot be used with {self.direction.name} buffer")
+        if port.direction is Direction.Output and self.direction is not Direction.Output:
+            raise ValueError(f"Output port cannot be used with {self.direction.name} buffer")
+
+    @property
+    def port(self):
+        return self._port
+
+    @property
+    def direction(self):
+        return self.signature.direction
+
+    def elaborate(self, platform):
+        if hasattr(platform, "get_io_buffer"):
+            return platform.get_io_buffer(self)
+
+        m = Module()
+
+        invert = sum(bit << idx for idx, bit in enumerate(self._port.invert))
+        if self.direction is not Direction.Input:
+            if invert != 0:
+                o_inv = Signal.like(self.o)
+                m.d.comb += o_inv.eq(self.o ^ invert)
+            else:
+                o_inv = self.o
+        if self.direction is not Direction.Output:
+            if invert:
+                i_inv = Signal.like(self.i)
+                m.d.comb += self.i.eq(i_inv ^ invert)
+            else:
+                i_inv = self.i
+
+        if isinstance(self._port, SingleEndedPort):
+            if self.direction is Direction.Input:
+                m.submodules += IOBufferInstance(self._port.io, i=i_inv)
+            elif self.direction is Direction.Output:
+                m.submodules += IOBufferInstance(self._port.io, o=o_inv, oe=self.oe)
+            else:
+                m.submodules += IOBufferInstance(self._port.io, o=o_inv, oe=self.oe, i=i_inv)
+        elif isinstance(self._port, DifferentialPort):
+            if self.direction is Direction.Input:
+                m.submodules += IOBufferInstance(self._port.p, i=i_inv)
+            elif self.direction is Direction.Output:
+                m.submodules += IOBufferInstance(self._port.p, o=o_inv, oe=self.oe)
+                m.submodules += IOBufferInstance(self._port.n, o=~o_inv, oe=self.oe)
+            else:
+                m.submodules += IOBufferInstance(self._port.p, o=o_inv, oe=self.oe, i=i_inv)
+                m.submodules += IOBufferInstance(self._port.n, o=~o_inv, oe=self.oe)
+        else:
+            raise TypeError("Cannot elaborate generic 'Buffer' with port {self._port!r}")
+
+        return m
+
+
+class FFBuffer(wiring.Component):
+    """A registered I/O buffer.
+
+    Equivalent to a plain :class:`Buffer` combined with reset-less registers on :py:`i`, :py:`o`,
+    :py:`oe`.
+
+    Parameters
+    ----------
+    direction : :class:`Direction`
+    port : :class:`PortLike`
+    i_domain : :class:`str`
+        Domain for input register. Only used when :py:`direction in (Direction.Input, Direction.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"`
+
+    Attributes
+    ----------
+    signature : FFBuffer.Signature
+        Created based on constructor arguments.
+    """
+    class Signature(wiring.Signature):
+        """A signature of a registered I/O buffer.
+
+        Parameters
+        ----------
+        direction : :class:`Direction`
+        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`)
+        """
+        def __init__(self, direction, width):
+            self._direction = Direction(direction)
+            self._width = operator.index(width)
+            members = {}
+            if self._direction is not Direction.Output:
+                members["i"] = wiring.In(self._width)
+            if self._direction is not Direction.Input:
+                members["o"] = wiring.Out(self._width)
+                members["oe"] = wiring.Out(1, init=int(self._direction is Direction.Output))
+            super().__init__(members)
+
+        @property
+        def direction(self):
+            return self._direction
+
+        @property
+        def width(self):
+            return self._width
+
+        def __eq__(self, other):
+            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})"
+
+    def __init__(self, direction, port, *, i_domain=None, o_domain=None):
+        if not isinstance(port, PortLike):
+            raise TypeError(f"'port' must be a 'PortLike', not {port!r}")
+        self._port = port
+        super().__init__(FFBuffer.Signature(direction, len(port)).flip())
+        if self.signature.direction is not Direction.Output:
+            self._i_domain = i_domain or "sync"
+        elif i_domain is not None:
+            raise ValueError("Output buffer doesn't have an input domain")
+        if self.signature.direction is not Direction.Input:
+            self._o_domain = o_domain or "sync"
+        elif o_domain is not None:
+            raise ValueError("Input buffer doesn't have an output domain")
+        if port.direction is Direction.Input and self.direction is not Direction.Input:
+            raise ValueError(f"Input port cannot be used with {self.direction.name} buffer")
+        if port.direction is Direction.Output and self.direction is not Direction.Output:
+            raise ValueError(f"Output port cannot be used with {self.direction.name} buffer")
+
+    @property
+    def port(self):
+        return self._port
+
+    @property
+    def direction(self):
+        return self.signature.direction
+
+    @property
+    def i_domain(self):
+        if self.direction is Direction.Output:
+            raise AttributeError("Output buffer doesn't have an input domain")
+        return self._i_domain
+
+    @property
+    def o_domain(self):
+        if self.direction is Direction.Input:
+            raise AttributeError("Input buffer doesn't have an output domain")
+        return self._o_domain
+
+    def elaborate(self, platform):
+        if hasattr(platform, "get_io_buffer"):
+            return platform.get_io_buffer(self)
+
+        m = Module()
+
+        m.submodules.io_buffer = io_buffer = Buffer(self.direction, self.port)
+
+        if self.direction is not Direction.Output:
+            i_ff = Signal(len(self.port), reset_less=True)
+            m.d[self.i_domain] += i_ff.eq(io_buffer.i)
+            m.d.comb += self.i.eq(i_ff)
+
+        if self.direction is not Direction.Input:
+            o_ff = Signal(len(self.port), reset_less=True)
+            oe_ff = Signal(reset_less=True)
+            m.d[self.o_domain] += o_ff.eq(self.o)
+            m.d[self.o_domain] += oe_ff.eq(self.oe)
+            m.d.comb += io_buffer.o.eq(o_ff)
+            m.d.comb += io_buffer.oe.eq(oe_ff)
+
+        return m
+
+
+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.
+
+    Parameters
+    ----------
+    direction : :class:`Direction`
+    port : :class:`PortLike`
+    i_domain : :class:`str`
+        Domain for input register. Only used when :py:`direction in (Direction.Input, Direction.Bidir)`.
+    o_domain : :class:`str`
+        Domain for output and output enable registers. Only used when
+        :py:`direction in (Direction.Output, Direction.Bidir)`.
+
+    Attributes
+    ----------
+    signature : DDRBuffer.Signature
+        Created based on constructor arguments.
+    """
+    class Signature(wiring.Signature):
+        """A signature of a double data rate registered I/O buffer.
+
+        Parameters
+        ----------
+        direction : :class:`Direction`
+        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`)
+        """
+        def __init__(self, direction, width):
+            self._direction = Direction(direction)
+            self._width = operator.index(width)
+            members = {}
+            if self._direction is not Direction.Output:
+                members["i"] = wiring.In(data.ArrayLayout(self._width, 2))
+            if self._direction is not Direction.Input:
+                members["o"] = wiring.Out(data.ArrayLayout(self._width, 2))
+                members["oe"] = wiring.Out(1, init=int(self._direction is Direction.Output))
+            super().__init__(members)
+
+        @property
+        def direction(self):
+            return self._direction
+
+        @property
+        def width(self):
+            return self._width
+
+        def __eq__(self, other):
+            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})"
+
+    def __init__(self, direction, port, *, i_domain=None, o_domain=None):
+        if not isinstance(port, PortLike):
+            raise TypeError(f"'port' must be a 'PortLike', not {port!r}")
+        self._port = port
+        super().__init__(DDRBuffer.Signature(direction, len(port)).flip())
+        if self.signature.direction is not Direction.Output:
+            self._i_domain = i_domain or "sync"
+        elif i_domain is not None:
+            raise ValueError("Output buffer doesn't have an input domain")
+        if self.signature.direction is not Direction.Input:
+            self._o_domain = o_domain or "sync"
+        elif o_domain is not None:
+            raise ValueError("Input buffer doesn't have an output domain")
+        if port.direction is Direction.Input and self.direction is not Direction.Input:
+            raise ValueError(f"Input port cannot be used with {self.direction.name} buffer")
+        if port.direction is Direction.Output and self.direction is not Direction.Output:
+            raise ValueError(f"Output port cannot be used with {self.direction.name} buffer")
+
+    @property
+    def port(self):
+        return self._port
+
+    @property
+    def direction(self):
+        return self.signature.direction
+
+    @property
+    def i_domain(self):
+        if self.direction is Direction.Output:
+            raise AttributeError("Output buffer doesn't have an input domain")
+        return self._i_domain
+
+    @property
+    def o_domain(self):
+        if self.direction is Direction.Input:
+            raise AttributeError("Input buffer doesn't have an output domain")
+        return self._o_domain
+
+    def elaborate(self, platform):
+        if hasattr(platform, "get_io_buffer"):
+            return platform.get_io_buffer(self)
+
+        raise NotImplementedError("DDR buffers cannot be elaborated without a supported platform")
+
+
 class Pin(wiring.PureInterface):
     """
     An interface to an I/O buffer or a group of them that provides uniform access to input, output,
diff --git a/docs/changes.rst b/docs/changes.rst
index 9cba7a2..55c6ca8 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -102,6 +102,7 @@ Standard library changes
 * Added: :class:`amaranth.lib.data.Const` class. (`RFC 51`_)
 * Changed: :meth:`amaranth.lib.data.Layout.const` returns a :class:`amaranth.lib.data.Const`, not a view (`RFC 51`_)
 * Added: :class:`amaranth.lib.io.SingleEndedPort`, :class:`amaranth.lib.io.DifferentialPort`. (`RFC 55`_)
+* Added: :class:`amaranth.lib.io.Buffer`, :class:`amaranth.lib.io.FFBuffer`, :class:`amaranth.lib.io.DDRBuffer`. (`RFC 55`_)
 * Removed: (deprecated in 0.4) :mod:`amaranth.lib.scheduler`. (`RFC 19`_)
 * Removed: (deprecated in 0.4) :class:`amaranth.lib.fifo.FIFOInterface` with ``fwft=False``. (`RFC 20`_)
 * Removed: (deprecated in 0.4) :class:`amaranth.lib.fifo.SyncFIFO` with ``fwft=False``. (`RFC 20`_)
diff --git a/docs/install.rst b/docs/install.rst
index 3879eee..6fc53b9 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -1,18 +1,35 @@
 Installation
 ############
 
+.. _install-playground:
+
+In-browser playground
+=====================
+
+You can try Amaranth out without installing anything by visiting the `Amaranth Playground`_. The playground webpage contains a `fully functional Python interpreter <Pyodide_>`_ and an Amaranth toolchain that can simulate a design, display waveforms, and generate Verilog code. It works on all modern browsers that support WebAssembly_, including Firefox, Chrome, and Edge.
+
+.. _Amaranth Playground: https://amaranth-lang.org/play/
+.. _Pyodide: https://pyodide.org/en/stable/
+.. _WebAssembly: https://webassembly.org/
+
+
+.. _install-sysreqs:
+
 System requirements
 ===================
 
-.. |yosys-version| replace:: 0.35 (or newer)
+..
+   This version requirement needs to be synchronized with the one in pyproject.toml!
+
+.. |yosys-version| replace:: 0.38 (or newer)
 
 Amaranth HDL requires Python 3.8; it works on CPython_ 3.8 (or newer), and works faster on PyPy3.8_ 7.3.7 (or newer). Installation requires pip_ 23.0 (or newer).
 
-For most workflows, Amaranth requires Yosys_ |yosys-version|. A compatible version of Yosys is distributed via PyPI_ for most popular platforms.
+For most workflows, Amaranth requires Yosys_ |yosys-version|. A `compatible version of Yosys <amaranth-yosys_>`_ is distributed via PyPI_ for most popular platforms, so it is usually not necessary to install Yosys separately.
 
-Simulating Amaranth code requires no additional software. However, a waveform viewer like GTKWave_ is invaluable for debugging.
+Simulating Amaranth code requires no additional software. However, a waveform viewer like GTKWave_ is invaluable for debugging. As an alternative to GTKWave, the `Amaranth Playground`_ can be used to display waveforms for simple designs.
 
-Synthesizing, placing and routing an Amaranth design for an FPGA requires the FPGA family specific toolchain.
+Synthesizing, placing and routing an Amaranth design for an FPGA requires the FPGA family specific toolchain. The open source iCE40, ECP5, MachXO2/3, Nexus, and Gowin toolchains are distributed via PyPI_ for most popular platforms by the YoWASP_ project.
 
 .. TODO: Link to FPGA family docs here
 
@@ -20,8 +37,10 @@ Synthesizing, placing and routing an Amaranth design for an FPGA requires the FP
 .. _PyPy3.8: https://www.pypy.org/
 .. _pip: https://pip.pypa.io/en/stable/
 .. _Yosys: https://yosyshq.net/yosys/
+.. _amaranth-yosys: https://pypi.org/project/amaranth-yosys/
 .. _PyPI: https://pypi.org/
 .. _GTKWave: https://gtkwave.sourceforge.net/
+.. _YoWASP: https://yowasp.org/
 
 
 .. _install-deps:
@@ -117,6 +136,7 @@ Installing prerequisites
 
          $ pip3 install --user --upgrade pip
 
+
 .. _install:
 
 Installing Amaranth
diff --git a/pyproject.toml b/pyproject.toml
index baf35f2..3edd906 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,10 @@ dependencies = [
 ]
 
 [project.optional-dependencies]
-# this version requirement needs to be synchronized with the one in amaranth.back.verilog!
+# This version requirement needs to be synchronized with:
+# - pyproject.toml: tool.pdm.dev-dependencies.test
+# - amaranth/back/verilog.py: _convert_rtlil_text
+# - docs/install.rst: yosys-version
 builtin-yosys = ["amaranth-yosys>=0.38"]
 remote-build  = ["paramiko~=2.7"]
 
@@ -60,6 +63,7 @@ source-includes = [
 # Development workflow configuration
 
 [tool.pdm.dev-dependencies]
+# This version requirement needs to be synchronized with the one in pyproject.toml above!
 test = [
   "yowasp-yosys>=0.38",
   "coverage",


Test patch:
diff --git a/tests/test_lib_io.py b/tests/test_lib_io.py
index 9aa6921..6c4de8e 100644
--- a/tests/test_lib_io.py
+++ b/tests/test_lib_io.py
@@ -1,7 +1,11 @@
+# amaranth: UnusedElaboratable=no
+
 from amaranth.hdl import *
 from amaranth.sim import *
+from amaranth.hdl._ir import build_netlist
 from amaranth.lib.io import *
 from amaranth.lib.wiring import *
+from amaranth.lib import wiring, data
 
 from .utils import *
 
@@ -169,6 +173,548 @@ class DifferentialPortTestCase(FHDLTestCase):
         self.assertRepr(iport, "DifferentialPort((io-port iop), (io-port ion), invert=(False, True, False, True), direction=Direction.Output)")
 
 
+class BufferTestCase(FHDLTestCase):
+    def test_signature(self):
+        sig_i = Buffer.Signature("i", 4)
+        self.assertEqual(sig_i.direction, Direction.Input)
+        self.assertEqual(sig_i.width, 4)
+        self.assertEqual(sig_i.members, wiring.SignatureMembers({
+            "i": wiring.In(4),
+        }))
+        sig_o = Buffer.Signature("o", 4)
+        self.assertEqual(sig_o.direction, Direction.Output)
+        self.assertEqual(sig_o.width, 4)
+        self.assertEqual(sig_o.members, wiring.SignatureMembers({
+            "o": wiring.Out(4),
+            "oe": wiring.Out(1, init=1),
+        }))
+        sig_io = Buffer.Signature("io", 4)
+        self.assertEqual(sig_io.direction, Direction.Bidir)
+        self.assertEqual(sig_io.width, 4)
+        self.assertEqual(sig_io.members, wiring.SignatureMembers({
+            "i": wiring.In(4),
+            "o": wiring.Out(4),
+            "oe": wiring.Out(1, init=0),
+        }))
+        self.assertNotEqual(sig_i, sig_io)
+        self.assertEqual(sig_i, sig_i)
+        self.assertRepr(sig_io, "Buffer.Signature(Direction.Bidir, 4)")
+
+    def test_construct(self):
+        io = IOPort(4)
+        port = SingleEndedPort(io)
+        buf = Buffer("i", port)
+        self.assertEqual(buf.direction, Direction.Input)
+        self.assertIs(buf.port, port)
+        self.assertRepr(buf.signature, "Buffer.Signature(Direction.Input, 4).flip()")
+
+    def test_construct_wrong(self):
+        io = IOPort(4)
+        port_i = SingleEndedPort(io, direction="i")
+        port_o = SingleEndedPort(io, direction="o")
+        with self.assertRaisesRegex(TypeError,
+                r"^'port' must be a 'PortLike', not \(io-port io\)$"):
+            Buffer("io", io)
+        with self.assertRaisesRegex(ValueError,
+                r"^Input port cannot be used with Bidir buffer$"):
+            Buffer("io", port_i)
+        with self.assertRaisesRegex(ValueError,
+                r"^Output port cannot be used with Input buffer$"):
+            Buffer("i", port_o)
+
+    def test_elaborate(self):
+        io = IOPort(4)
+
+        port = SingleEndedPort(io)
+        buf = Buffer("io", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, buf.o, buf.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (output 'i' 1.0:4)
+                (io inout 'io' 0.0:4)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (output 'i' 1.0:4)
+            ))
+            (cell 1 0 (iob inout 0.0:4 0.2:6 0.6))
+        )
+        """)
+
+        port = SingleEndedPort(io, invert=[False, True, False, True])
+        buf = Buffer("io", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, buf.o, buf.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (output 'i' 2.0:4)
+                (io inout 'io' 0.0:4)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (output 'i' 2.0:4)
+            ))
+            (cell 1 0 (^ 0.2:6 4'd10))
+            (cell 2 0 (^ 3.0:4 4'd10))
+            (cell 3 0 (iob inout 0.0:4 1.0:4 0.6))
+        )
+        """)
+
+        buf = Buffer("i", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (output 'i' 1.0:4)
+                (io input 'io' 0.0:4)
+            )
+            (cell 0 0 (top
+                (output 'i' 1.0:4)
+            ))
+            (cell 1 0 (^ 2.0:4 4'd10))
+            (cell 2 0 (iob input 0.0:4))
+        )
+        """)
+
+        buf = Buffer("o", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.o, buf.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (io output 'io' 0.0:4)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+            ))
+            (cell 1 0 (^ 0.2:6 4'd10))
+            (cell 2 0 (iob output 0.0:4 1.0:4 0.6))
+        )
+        """)
+
+    def test_elaborate_diff(self):
+        iop = IOPort(4)
+        ion = IOPort(4)
+
+        port = DifferentialPort(iop, ion)
+        buf = Buffer("io", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, buf.o, buf.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (output 'i' 1.0:4)
+                (io inout 'iop' 0.0:4)
+                (io output 'ion' 1.0:4)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (output 'i' 1.0:4)
+            ))
+            (cell 1 0 (iob inout 0.0:4 0.2:6 0.6))
+            (cell 2 0 (~ 0.2:6))
+            (cell 3 0 (iob output 1.0:4 2.0:4 0.6))
+        )
+        """)
+
+        port = DifferentialPort(iop, ion, invert=[False, True, False, True])
+        buf = Buffer("io", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i, buf.o, buf.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (output 'i' 2.0:4)
+                (io inout 'iop' 0.0:4)
+                (io output 'ion' 1.0:4)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (output 'i' 2.0:4)
+            ))
+            (cell 1 0 (^ 0.2:6 4'd10))
+            (cell 2 0 (^ 3.0:4 4'd10))
+            (cell 3 0 (iob inout 0.0:4 1.0:4 0.6))
+            (cell 4 0 (~ 1.0:4))
+            (cell 5 0 (iob output 1.0:4 4.0:4 0.6))
+        )
+        """)
+
+        buf = Buffer("i", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (output 'i' 1.0:4)
+                (io input 'iop' 0.0:4)
+            )
+            (cell 0 0 (top
+                (output 'i' 1.0:4)
+            ))
+            (cell 1 0 (^ 2.0:4 4'd10))
+            (cell 2 0 (iob input 0.0:4))
+        )
+        """)
+
+        buf = Buffer("o", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.o, buf.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (io output 'iop' 0.0:4)
+                (io output 'ion' 1.0:4)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+            ))
+            (cell 1 0 (^ 0.2:6 4'd10))
+            (cell 2 0 (iob output 0.0:4 1.0:4 0.6))
+            (cell 3 0 (~ 1.0:4))
+            (cell 4 0 (iob output 1.0:4 3.0:4 0.6))
+        )
+        """)
+
+
+class FFBufferTestCase(FHDLTestCase):
+    def test_signature(self):
+        sig_i = FFBuffer.Signature("i", 4)
+        self.assertEqual(sig_i.direction, Direction.Input)
+        self.assertEqual(sig_i.width, 4)
+        self.assertEqual(sig_i.members, wiring.SignatureMembers({
+            "i": wiring.In(4),
+        }))
+        sig_o = FFBuffer.Signature("o", 4)
+        self.assertEqual(sig_o.direction, Direction.Output)
+        self.assertEqual(sig_o.width, 4)
+        self.assertEqual(sig_o.members, wiring.SignatureMembers({
+            "o": wiring.Out(4),
+            "oe": wiring.Out(1, init=1),
+        }))
+        sig_io = FFBuffer.Signature("io", 4)
+        self.assertEqual(sig_io.direction, Direction.Bidir)
+        self.assertEqual(sig_io.width, 4)
+        self.assertEqual(sig_io.members, wiring.SignatureMembers({
+            "i": wiring.In(4),
+            "o": wiring.Out(4),
+            "oe": wiring.Out(1, init=0),
+        }))
+        self.assertNotEqual(sig_i, sig_io)
+        self.assertEqual(sig_i, sig_i)
+        self.assertRepr(sig_io, "FFBuffer.Signature(Direction.Bidir, 4)")
+
+    def test_construct(self):
+        io = IOPort(4)
+        port = SingleEndedPort(io)
+        buf = FFBuffer("i", port)
+        self.assertEqual(buf.direction, Direction.Input)
+        self.assertIs(buf.port, port)
+        self.assertRepr(buf.signature, "FFBuffer.Signature(Direction.Input, 4).flip()")
+        self.assertEqual(buf.i_domain, "sync")
+        with self.assertRaisesRegex(AttributeError,
+                r"^Input buffer doesn't have an output domain$"):
+            buf.o_domain
+        buf = FFBuffer("i", port, i_domain="inp")
+        self.assertEqual(buf.i_domain, "inp")
+        buf = FFBuffer("o", port)
+        self.assertEqual(buf.direction, Direction.Output)
+        self.assertIs(buf.port, port)
+        self.assertRepr(buf.signature, "FFBuffer.Signature(Direction.Output, 4).flip()")
+        self.assertEqual(buf.o_domain, "sync")
+        with self.assertRaisesRegex(AttributeError,
+                r"^Output buffer doesn't have an input domain$"):
+            buf.i_domain
+        buf = FFBuffer("o", port, o_domain="out")
+        self.assertEqual(buf.o_domain, "out")
+        buf = FFBuffer("io", port)
+        self.assertEqual(buf.direction, Direction.Bidir)
+        self.assertIs(buf.port, port)
+        self.assertRepr(buf.signature, "FFBuffer.Signature(Direction.Bidir, 4).flip()")
+        self.assertEqual(buf.i_domain, "sync")
+        self.assertEqual(buf.o_domain, "sync")
+        buf = FFBuffer("io", port, i_domain="input", o_domain="output")
+        self.assertEqual(buf.i_domain, "input")
+        self.assertEqual(buf.o_domain, "output")
+
+    def test_construct_wrong(self):
+        io = IOPort(4)
+        port = SingleEndedPort(io)
+        port_i = SingleEndedPort(io, direction="i")
+        port_o = SingleEndedPort(io, direction="o")
+        with self.assertRaisesRegex(TypeError,
+                r"^'port' must be a 'PortLike', not \(io-port io\)$"):
+            FFBuffer("io", io)
+        with self.assertRaisesRegex(ValueError,
+                r"^Input port cannot be used with Bidir buffer$"):
+            FFBuffer("io", port_i)
+        with self.assertRaisesRegex(ValueError,
+                r"^Output port cannot be used with Input buffer$"):
+            FFBuffer("i", port_o)
+        with self.assertRaisesRegex(ValueError,
+                r"^Input buffer doesn't have an output domain$"):
+            FFBuffer("i", port, o_domain="output")
+        with self.assertRaisesRegex(ValueError,
+                r"^Output buffer doesn't have an input domain$"):
+            FFBuffer("o", port, i_domain="input")
+
+    def test_elaborate(self):
+        io = IOPort(4)
+
+        port = SingleEndedPort(io)
+        m = Module()
+        m.domains.inp = ClockDomain()
+        m.domains.outp = ClockDomain()
+        m.submodules.buf = buf = FFBuffer("io", port, i_domain="inp", o_domain="outp")
+        nl = build_netlist(Fragment.get(m, None), [
+            buf.i, buf.o, buf.oe,
+            ClockSignal("inp"), ResetSignal("inp"),
+            ClockSignal("outp"), ResetSignal("outp"),
+        ])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (input 'inp_clk' 0.7)
+                (input 'inp_rst' 0.8)
+                (input 'outp_clk' 0.9)
+                (input 'outp_rst' 0.10)
+                (output 'i' 2.0:4)
+                (io inout 'io' 0.0:4)
+            )
+            (module 1 0 ('top' 'buf')
+                (input 'o$11' 0.2:6)
+                (input 'oe$12' 0.6)
+                (input 'inp_clk' 0.7)
+                (input 'inp_rst' 0.8)
+                (input 'outp_clk' 0.9)
+                (input 'outp_rst' 0.10)
+                (output 'i_ff' 2.0:4)
+                (io inout 'io' 0.0:4)
+            )
+            (module 2 1 ('top' 'buf' 'io_buffer')
+                (output 'i' 1.0:4)
+                (input 'o' 3.0:4)
+                (input 'oe' 4.0)
+                (io inout 'io' 0.0:4)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (input 'inp_clk' 7:8)
+                (input 'inp_rst' 8:9)
+                (input 'outp_clk' 9:10)
+                (input 'outp_rst' 10:11)
+                (output 'i' 2.0:4)
+            ))
+            (cell 1 2 (iob inout 0.0:4 3.0:4 4.0))
+            (cell 2 1 (flipflop 1.0:4 0 pos 0.7 0))
+            (cell 3 1 (flipflop 0.2:6 0 pos 0.9 0))
+            (cell 4 1 (flipflop 0.6 0 pos 0.9 0))
+        )
+        """)
+
+        port = SingleEndedPort(io, invert=[False, True, False, True])
+        m = Module()
+        m.domains.inp = ClockDomain(reset_less=True)
+        m.domains.outp = ClockDomain(reset_less=True)
+        m.submodules.buf = buf = FFBuffer("io", port, i_domain="inp", o_domain="outp")
+        nl = build_netlist(Fragment.get(m, None), [
+            buf.i, buf.o, buf.oe,
+            ClockSignal("inp"), ClockSignal("outp"),
+        ])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (input 'inp_clk' 0.7)
+                (input 'outp_clk' 0.8)
+                (output 'i' 4.0:4)
+                (io inout 'io' 0.0:4)
+            )
+            (module 1 0 ('top' 'buf')
+                (input 'o$9' 0.2:6)
+                (input 'oe$10' 0.6)
+                (input 'inp_clk' 0.7)
+                (input 'outp_clk' 0.8)
+                (output 'i_ff' 4.0:4)
+                (io inout 'io' 0.0:4)
+            )
+            (module 2 1 ('top' 'buf' 'io_buffer')
+                (output 'i' 2.0:4)
+                (input 'o' 5.0:4)
+                (input 'oe' 6.0)
+                (io inout 'io' 0.0:4)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (input 'inp_clk' 7:8)
+                (input 'outp_clk' 8:9)
+                (output 'i' 4.0:4)
+            ))
+            (cell 1 2 (^ 5.0:4 4'd10))
+            (cell 2 2 (^ 3.0:4 4'd10))
+            (cell 3 2 (iob inout 0.0:4 1.0:4 6.0))
+            (cell 4 1 (flipflop 2.0:4 0 pos 0.7 0))
+            (cell 5 1 (flipflop 0.2:6 0 pos 0.8 0))
+            (cell 6 1 (flipflop 0.6 0 pos 0.8 0))
+        )
+        """)
+
+        buf = FFBuffer("i", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.i])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'clk' 0.2)
+                (input 'rst' 0.3)
+                (output 'i' 3.0:4)
+                (io input 'io' 0.0:4)
+            )
+            (module 1 0 ('top' 'io_buffer')
+                (output 'i' 1.0:4)
+                (io input 'io' 0.0:4)
+            )
+            (cell 0 0 (top
+                (input 'clk' 2:3)
+                (input 'rst' 3:4)
+                (output 'i' 3.0:4)
+            ))
+            (cell 1 1 (^ 2.0:4 4'd10))
+            (cell 2 1 (iob input 0.0:4))
+            (cell 3 0 (flipflop 1.0:4 0 pos 0.2 0))
+        )
+        """)
+
+        buf = FFBuffer("o", port)
+        nl = build_netlist(Fragment.get(buf, None), [buf.o, buf.oe])
+        self.assertRepr(nl, """
+        (
+            (module 0 None ('top')
+                (input 'o' 0.2:6)
+                (input 'oe' 0.6)
+                (input 'clk' 0.7)
+                (input 'rst' 0.8)
+                (io output 'io' 0.0:4)
+            )
+            (module 1 0 ('top' 'io_buffer')
+                (input 'o' 3.0:4)
+                (input 'oe' 4.0)
+                (io output 'io' 0.0:4)
+            )
+            (cell 0 0 (top
+                (input 'o' 2:6)
+                (input 'oe' 6:7)
+                (input 'clk' 7:8)
+                (input 'rst' 8:9)
+            ))
+            (cell 1 1 (^ 3.0:4 4'd10))
+            (cell 2 1 (iob output 0.0:4 1.0:4 4.0))
+            (cell 3 0 (flipflop 0.2:6 0 pos 0.7 0))
+            (cell 4 0 (flipflop 0.6 0 pos 0.7 0))
+        )
+        """)
+
+
+class DDRBufferTestCase(FHDLTestCase):
+    def test_signature(self):
+        sig_i = DDRBuffer.Signature("i", 4)
+        self.assertEqual(sig_i.direction, Direction.Input)
+        self.assertEqual(sig_i.width, 4)
+        self.assertEqual(sig_i.members, wiring.SignatureMembers({
+            "i": wiring.In(data.ArrayLayout(4, 2)),
+        }))
+        sig_o = DDRBuffer.Signature("o", 4)
+        self.assertEqual(sig_o.direction, Direction.Output)
+        self.assertEqual(sig_o.width, 4)
+        self.assertEqual(sig_o.members, wiring.SignatureMembers({
+            "o": wiring.Out(data.ArrayLayout(4, 2)),
+            "oe": wiring.Out(1, init=1),
+        }))
+        sig_io = DDRBuffer.Signature("io", 4)
+        self.assertEqual(sig_io.direction, Direction.Bidir)
+        self.assertEqual(sig_io.width, 4)
+        self.assertEqual(sig_io.members, wiring.SignatureMembers({
+            "i": wiring.In(data.ArrayLayout(4, 2)),
+            "o": wiring.Out(data.ArrayLayout(4, 2)),
+            "oe": wiring.Out(1, init=0),
+        }))
+        self.assertNotEqual(sig_i, sig_io)
+        self.assertEqual(sig_i, sig_i)
+        self.assertRepr(sig_io, "DDRBuffer.Signature(Direction.Bidir, 4)")
+
+    def test_construct(self):
+        io = IOPort(4)
+        port = SingleEndedPort(io)
+        buf = DDRBuffer("i", port)
+        self.assertEqual(buf.direction, Direction.Input)
+        self.assertIs(buf.port, port)
+        self.assertRepr(buf.signature, "DDRBuffer.Signature(Direction.Input, 4).flip()")
+        self.assertEqual(buf.i_domain, "sync")
+        with self.assertRaisesRegex(AttributeError,
+                r"^Input buffer doesn't have an output domain$"):
+            buf.o_domain
+        buf = DDRBuffer("i", port, i_domain="inp")
+        self.assertEqual(buf.i_domain, "inp")
+        buf = DDRBuffer("o", port)
+        self.assertEqual(buf.direction, Direction.Output)
+        self.assertIs(buf.port, port)
+        self.assertRepr(buf.signature, "DDRBuffer.Signature(Direction.Output, 4).flip()")
+        self.assertEqual(buf.o_domain, "sync")
+        with self.assertRaisesRegex(AttributeError,
+                r"^Output buffer doesn't have an input domain$"):
+            buf.i_domain
+        buf = DDRBuffer("o", port, o_domain="out")
+        self.assertEqual(buf.o_domain, "out")
+        buf = DDRBuffer("io", port)
+        self.assertEqual(buf.direction, Direction.Bidir)
+        self.assertIs(buf.port, port)
+        self.assertRepr(buf.signature, "DDRBuffer.Signature(Direction.Bidir, 4).flip()")
+        self.assertEqual(buf.i_domain, "sync")
+        self.assertEqual(buf.o_domain, "sync")
+        buf = DDRBuffer("io", port, i_domain="input", o_domain="output")
+        self.assertEqual(buf.i_domain, "input")
+        self.assertEqual(buf.o_domain, "output")
+
+    def test_construct_wrong(self):
+        io = IOPort(4)
+        port = SingleEndedPort(io)
+        port_i = SingleEndedPort(io, direction="i")
+        port_o = SingleEndedPort(io, direction="o")
+        with self.assertRaisesRegex(TypeError,
+                r"^'port' must be a 'PortLike', not \(io-port io\)$"):
+            DDRBuffer("io", io)
+        with self.assertRaisesRegex(ValueError,
+                r"^Input port cannot be used with Bidir buffer$"):
+            DDRBuffer("io", port_i)
+        with self.assertRaisesRegex(ValueError,
+                r"^Output port cannot be used with Input buffer$"):
+            DDRBuffer("i", port_o)
+        with self.assertRaisesRegex(ValueError,
+                r"^Input buffer doesn't have an output domain$"):
+            DDRBuffer("i", port, o_domain="output")
+        with self.assertRaisesRegex(ValueError,
+                r"^Output buffer doesn't have an input domain$"):
+            DDRBuffer("o", port, i_domain="input")
+
+
 class PinSignatureTestCase(FHDLTestCase):
     def assertSignatureEqual(self, signature, expected):
         self.assertEqual(signature.members, Signature(expected).members)