"""BBA register file — EXI domain.

Decodes EXI transactions (2-byte header + N data bytes), reads/writes the BBA
register space, and owns all AsyncFIFO / PulseSynchronizer CDC primitives.

Transaction header format
--------------------------
Byte 0  [7]    write_flag
        [6:0]  addr[12:6]
Byte 1  [7:2]  addr[5:0]
        [1:0]  xfer_len−1   (0=1B, 1=2B, 2=3B, 3=4B)

Addresses 0x0000–0x00FF : register file (sparse individual Signals, exi domain).
Addresses 0x0100–0x1FFF : SPRAM ring buffer (sync domain, prefetch FIFOs).
"""

from amaranth import *
from amaranth.lib.cdc import PulseSynchronizer
from amaranth.lib.fifo import AsyncFIFO

__all__ = ["BBARegisterFile"]

# Register addresses
_NCRA    = 0x00
_IMR     = 0x08
_IR      = 0x09
_RWP_LO  = 0x16
_RWP_HI  = 0x17
_RRP_LO  = 0x18
_RRP_HI  = 0x19
_PAR0    = 0x20
_PAR1    = 0x21
_PAR2    = 0x22
_PAR3    = 0x23
_PAR4    = 0x24
_PAR5    = 0x25
_NWAYS   = 0x31
_HIPR    = 0x3A
_TWD_LO  = 0x34
_TWD_HI  = 0x35
_TXDATA  = 0x48

# Read-only hardcoded values
_NWAYS_VAL = 0x17
_HIPR_VAL  = 0x01

# Device ID returned on first 4-byte read of addr 0x0000
_DEVICE_ID = [0x04, 0x02, 0x02, 0x00]


class BBARegisterFile(Elaboratable):
    """EXI transaction decoder and BBA register file with CDC bridges.

    Sync-domain FIFO/pulse ports are wired by BBATop to the sync-domain modules.
    """

    def __init__(self):
        # ── EXI byte-stream interface (exi domain, from/to ExiCapture) ────
        # RX: received bytes (header + write data + read dummies) — FWFT read
        # side of ExiCapture's rx_fifo.
        self.rx_data = Signal(8)
        self.rx_rdy  = Signal()
        self.rx_en   = Signal()
        # TX: response bytes pushed proactively into ExiCapture's tx_fifo.
        self.tx_data = Signal(8)
        self.tx_en   = Signal()
        self.tx_rdy  = Signal()

        # High while an EXI transaction is in progress (from ExiCapture).
        # SPRAM reads stream until this deasserts → supports variable-length
        # (DMA) bulk reads, not just ≤4-byte immediate transfers.
        self.cs_active = Signal()

        # ── Interrupt (exi domain) ────────────────────────────────────────
        self.exi_int_n = Signal(init=1)

        # ── PAR output (for forwarding to W5500 as source MAC) ───────────
        self.par = Signal(48)   # PAR0-5 packed: PAR0 in low byte par[0:8]

        # NCRA[3] = SR (start receive) bit — gates the RX ring-buffer path.
        self.ncra_sr = Signal()

        # ── CDC FIFO sync-domain sides (wired by BBATop) ──────────────────
        # SPRAM request  exi→sync: sync reads these
        self.spram_req_r_data = Signal(16)
        self.spram_req_r_en   = Signal()
        self.spram_req_r_rdy  = Signal()

        # SPRAM response sync→exi: sync writes these
        self.spram_rsp_w_data = Signal(8)
        self.spram_rsp_w_en   = Signal()
        self.spram_rsp_w_rdy  = Signal()

        # TX bytes  exi→sync: sync reads these
        self.tx_bytes_r_data  = Signal(8)
        self.tx_bytes_r_en    = Signal()
        self.tx_bytes_r_rdy   = Signal()

        # TX ctrl (frame length)  exi→sync: sync reads these
        self.tx_ctrl_r_data   = Signal(16)
        self.tx_ctrl_r_en     = Signal()
        self.tx_ctrl_r_rdy    = Signal()

        # RX write-pointer update  sync→exi: sync writes these
        self.rx_wptr_w_data   = Signal(8)
        self.rx_wptr_w_en     = Signal()
        self.rx_wptr_w_rdy    = Signal()

        # RX read-pointer update  exi→sync: sync reads these
        self.rx_rptr_r_data   = Signal(8)
        self.rx_rptr_r_en     = Signal()
        self.rx_rptr_r_rdy    = Signal()

        # PulseSynchronizer ports (exi↔sync)
        self.ncra_rst_o = Signal()   # exi→sync
        self.rx_irq_i   = Signal()   # sync→exi
        self.tx_irq_i   = Signal()   # sync→exi

    def elaborate(self, platform):
        m = Module()

        # ── CDC FIFOs ────────────────────────────────────────────────────
        spram_req = AsyncFIFO(width=16, depth=4,  w_domain="exi",  r_domain="sync")
        spram_rsp = AsyncFIFO(width=8,  depth=4,  w_domain="sync", r_domain="exi")
        tx_bytes  = AsyncFIFO(width=8,  depth=16, w_domain="exi",  r_domain="sync")
        tx_ctrl   = AsyncFIFO(width=16, depth=4,  w_domain="exi",  r_domain="sync")
        rx_wptr   = AsyncFIFO(width=8,  depth=4,  w_domain="sync", r_domain="exi")
        rx_rptr   = AsyncFIFO(width=8,  depth=4,  w_domain="exi",  r_domain="sync")

        m.submodules.spram_req = spram_req
        m.submodules.spram_rsp = spram_rsp
        m.submodules.tx_bytes  = tx_bytes
        m.submodules.tx_ctrl   = tx_ctrl
        m.submodules.rx_wptr   = rx_wptr
        m.submodules.rx_rptr   = rx_rptr

        # Expose sync-domain FIFO sides
        m.d.comb += [
            self.spram_req_r_data .eq(spram_req.r_data),
            spram_req.r_en        .eq(self.spram_req_r_en),
            self.spram_req_r_rdy  .eq(spram_req.r_rdy),

            spram_rsp.w_data      .eq(self.spram_rsp_w_data),
            spram_rsp.w_en        .eq(self.spram_rsp_w_en),
            self.spram_rsp_w_rdy  .eq(spram_rsp.w_rdy),

            self.tx_bytes_r_data  .eq(tx_bytes.r_data),
            tx_bytes.r_en         .eq(self.tx_bytes_r_en),
            self.tx_bytes_r_rdy   .eq(tx_bytes.r_rdy),

            self.tx_ctrl_r_data   .eq(tx_ctrl.r_data),
            tx_ctrl.r_en          .eq(self.tx_ctrl_r_en),
            self.tx_ctrl_r_rdy    .eq(tx_ctrl.r_rdy),

            rx_wptr.w_data        .eq(self.rx_wptr_w_data),
            rx_wptr.w_en          .eq(self.rx_wptr_w_en),
            self.rx_wptr_w_rdy    .eq(rx_wptr.w_rdy),

            self.rx_rptr_r_data   .eq(rx_rptr.r_data),
            rx_rptr.r_en          .eq(self.rx_rptr_r_en),
            self.rx_rptr_r_rdy    .eq(rx_rptr.r_rdy),
        ]

        # ── PulseSynchronizers ───────────────────────────────────────────
        ncra_rst_ps = PulseSynchronizer(i_domain="exi",  o_domain="sync")
        rx_irq_ps   = PulseSynchronizer(i_domain="sync", o_domain="exi")
        tx_irq_ps   = PulseSynchronizer(i_domain="sync", o_domain="exi")

        m.submodules.ncra_rst_ps = ncra_rst_ps
        m.submodules.rx_irq_ps   = rx_irq_ps
        m.submodules.tx_irq_ps   = tx_irq_ps

        m.d.comb += [
            self.ncra_rst_o .eq(ncra_rst_ps.o),
            rx_irq_ps.i     .eq(self.rx_irq_i),
            tx_irq_ps.i     .eq(self.tx_irq_i),
        ]

        # ── Register file (sparse individual Signals, exi domain) ────────
        # Only the registers actually read/written by the GC or sync domain.
        # Writes to unknown addresses are silently ignored; reads return 0.
        r_ncra   = Signal(8)
        r_imr    = Signal(8)
        r_ir     = Signal(8)
        r_rwp_lo = Signal(8)
        r_rrp_lo = Signal(8)
        # PAR0–5 reset to a valid Nintendo OUI MAC (00:09:BF:00:00:01) so the
        # device has a sane source MAC even before the GC driver programs its
        # own.  PAR0 is the first MAC octet.
        _par_reset = [0x00, 0x09, 0xBF, 0x00, 0x00, 0x01]
        r_par    = Array([Signal(8, name=f"par{i}", init=_par_reset[i])
                          for i in range(6)])
        r_twd_lo = Signal(8)
        r_twd_hi = Signal(8)

        # PAR packed output: PAR0 in the LOW byte (par[0:8]).  The W5500 master
        # reads mac_shadow[i] = par[i*8:(i+1)*8], so this puts PAR0 first in the
        # SHAR write — i.e. PAR0 is the first MAC octet on the wire.
        m.d.comb += self.par.eq(Cat(
            r_par[0], r_par[1], r_par[2], r_par[3], r_par[4], r_par[5],
        ))
        m.d.comb += self.ncra_sr.eq(r_ncra[3])   # start-receive bit

        # ── Transaction state ────────────────────────────────────────────
        hdr0         = Signal(8)
        addr         = Signal(13)
        is_write     = Signal()
        xfer_len     = Signal(2)     # 0=1B … 3=4B
        byte_ctr     = Signal(2)
        tx_frame_len = Signal(16)

        # True until first NCRA reset write: return device ID on addr=0 reads
        id_phase = Signal(init=1)

        # Per-byte SPRAM read handshake (register-read path): sp_req marks a
        # request in flight; drain_ctr counts the read-phase dummy bytes.
        sp_req    = Signal()
        drain_ctr = Signal(2)

        # SPRAM streaming-read state (DMA / variable-length reads):
        #   sp_addr     — next SPRAM byte address to request (auto-increments)
        #   outstanding — SPRAM requests issued but whose responses are not yet
        #                 popped (bounds prefetch and is drained at end)
        sp_addr     = Signal(13)
        outstanding = Signal(4)
        SP_LIMIT    = 4              # max prefetch depth in flight

        # Effective address of the current data byte — a REGISTERED running
        # pointer (set to the base in HEADER1, incremented per byte).  Keeping
        # it registered keeps the 13-bit adder off the combinational path that
        # feeds the read-response mux → tx_fifo write data.
        eff_addr = Signal(13)
        rd_sel = eff_addr[0:8]

        # ── Combinational read-response value (non-SPRAM) ────────────────
        reg_rdval = Signal(8)
        with m.Switch(rd_sel):
            with m.Case(_NCRA):   m.d.comb += reg_rdval.eq(r_ncra)
            with m.Case(_IMR):    m.d.comb += reg_rdval.eq(r_imr)
            with m.Case(_IR):     m.d.comb += reg_rdval.eq(r_ir)
            with m.Case(_RWP_LO): m.d.comb += reg_rdval.eq(r_rwp_lo)
            with m.Case(_RRP_LO): m.d.comb += reg_rdval.eq(r_rrp_lo)
            with m.Case(_PAR0, _PAR1, _PAR2, _PAR3, _PAR4, _PAR5):
                                  m.d.comb += reg_rdval.eq(r_par[eff_addr[0:3]])
            with m.Case(_TWD_LO): m.d.comb += reg_rdval.eq(r_twd_lo)
            with m.Case(_TWD_HI): m.d.comb += reg_rdval.eq(r_twd_hi)
            with m.Case(_NWAYS):  m.d.comb += reg_rdval.eq(_NWAYS_VAL)
            with m.Case(_HIPR):   m.d.comb += reg_rdval.eq(_HIPR_VAL)
            with m.Default():     m.d.comb += reg_rdval.eq(0)

        # Device-ID bytes (addr 0 read while id_phase): 0x04 0x02 0x02 0x00
        devid = Signal(8)
        with m.Switch(byte_ctr):
            with m.Case(0): m.d.comb += devid.eq(0x04)
            with m.Case(1): m.d.comb += devid.eq(0x02)
            with m.Case(2): m.d.comb += devid.eq(0x02)
            with m.Case(3): m.d.comb += devid.eq(0x00)

        rd_val = Signal(8)   # response for the current non-SPRAM read byte
        with m.If((addr == 0) & id_phase):
            m.d.comb += rd_val.eq(devid)
        with m.Else():
            m.d.comb += rd_val.eq(reg_rdval)

        # ── Default strobes ──────────────────────────────────────────────
        m.d.exi += [
            spram_req.w_en  .eq(0),
            tx_bytes.w_en   .eq(0),
            tx_ctrl.w_en    .eq(0),
            rx_rptr.w_en    .eq(0),
            rx_wptr.r_en    .eq(0),
            ncra_rst_ps.i   .eq(0),
        ]
        m.d.comb += [
            self.rx_en  .eq(0),
            self.tx_en  .eq(0),
            self.tx_data.eq(0),
            # Combinational so the FIFO advances in the SAME cycle as the pop —
            # a registered r_en would let `pop` re-fire on the same byte.
            spram_rsp.r_en.eq(0),
        ]

        # ── Transaction FSM (proactive push/pull over byte FIFOs) ────────
        # The SPI bit cadence lives in the capture domain; here we just consume
        # received bytes and, for reads, push response bytes into tx_fifo during
        # the EXI clock-idle gap before the GC clocks the data phase.
        with m.FSM(domain="exi", name="exi_fsm"):

            with m.State("HEADER0"):
                with m.If(self.rx_rdy):
                    m.d.comb += self.rx_en.eq(1)
                    m.d.exi += hdr0.eq(self.rx_data)
                    m.next = "HEADER1"

            with m.State("HEADER1"):
                with m.If(self.rx_rdy):
                    m.d.comb += self.rx_en.eq(1)
                    new_addr  = Cat(self.rx_data[2:8], hdr0[0:7])  # 13-bit addr
                    new_len   = self.rx_data[0:2]
                    new_write = hdr0[7]

                    m.d.exi += addr.eq(new_addr)
                    m.d.exi += eff_addr.eq(new_addr)   # running pointer init
                    m.d.exi += xfer_len.eq(new_len)
                    m.d.exi += is_write.eq(new_write)
                    m.d.exi += byte_ctr.eq(0)
                    m.d.exi += sp_req.eq(0)
                    m.d.exi += drain_ctr.eq(0)

                    with m.If(new_write):
                        m.next = "WRITE"
                    with m.Elif(new_addr >= 0x100):
                        # SPRAM region: stream until CS deasserts (DMA-capable).
                        m.d.exi += sp_addr.eq(new_addr)
                        m.d.exi += outstanding.eq(0)
                        m.next = "SPRAM_STREAM"
                    with m.Else():
                        m.next = "REG_READ"

            with m.State("WRITE"):
                # Consume xfer_len+1 data bytes, writing the register file.
                with m.If(self.rx_rdy):
                    m.d.comb += self.rx_en.eq(1)
                    with m.Switch(rd_sel):
                        with m.Case(_NCRA):
                            m.d.exi += r_ncra.eq(self.rx_data)
                            with m.If(self.rx_data[0]):
                                m.d.exi += r_ncra[0].eq(0)  # RESET self-clears
                                m.d.exi += ncra_rst_ps.i.eq(1)
                                m.d.exi += id_phase.eq(0)
                            with m.If(self.rx_data[1:3].any()):
                                with m.If(tx_ctrl.w_rdy):
                                    m.d.exi += tx_ctrl.w_data.eq(tx_frame_len)
                                    m.d.exi += tx_ctrl.w_en.eq(1)
                        with m.Case(_IMR):
                            m.d.exi += r_imr.eq(self.rx_data)
                        with m.Case(_IR):
                            m.d.exi += r_ir.eq(r_ir & ~self.rx_data)  # write-1-clear
                        with m.Case(_RRP_LO):
                            m.d.exi += r_rrp_lo.eq(self.rx_data)
                            with m.If(rx_rptr.w_rdy):
                                m.d.exi += rx_rptr.w_data.eq(self.rx_data)
                                m.d.exi += rx_rptr.w_en.eq(1)
                        with m.Case(_PAR0, _PAR1, _PAR2, _PAR3, _PAR4, _PAR5):
                            m.d.exi += r_par[eff_addr[0:3]].eq(self.rx_data)
                        with m.Case(_TWD_LO):
                            m.d.exi += r_twd_lo.eq(self.rx_data)
                            m.d.exi += tx_frame_len[0:8].eq(self.rx_data)
                        with m.Case(_TWD_HI):
                            m.d.exi += r_twd_hi.eq(self.rx_data)
                            m.d.exi += tx_frame_len[8:16].eq(self.rx_data)
                        with m.Case(_TXDATA):
                            with m.If(tx_bytes.w_rdy):
                                m.d.exi += tx_bytes.w_data.eq(self.rx_data)
                                m.d.exi += tx_bytes.w_en.eq(1)
                        # All other addresses silently ignored

                    with m.If(byte_ctr == xfer_len):
                        m.next = "HEADER0"
                    with m.Else():
                        m.d.exi += byte_ctr.eq(byte_ctr + 1)
                        m.d.exi += eff_addr.eq(eff_addr + 1)

            with m.State("REG_READ"):
                # Register / device-ID read (addr < 0x100): value available
                # immediately, bounded by the header's xfer_len (≤4 bytes).
                with m.If(self.tx_rdy):
                    m.d.comb += self.tx_data.eq(rd_val)
                    m.d.comb += self.tx_en.eq(1)
                    with m.If(byte_ctr == xfer_len):
                        m.next = "READ_DRAIN"
                    with m.Else():
                        m.d.exi += byte_ctr.eq(byte_ctr + 1)
                        m.d.exi += eff_addr.eq(eff_addr + 1)

            with m.State("READ_DRAIN"):
                # Discard the xfer_len+1 dummy bytes the GC clocks while reading.
                with m.If(self.rx_rdy):
                    m.d.comb += self.rx_en.eq(1)
                    with m.If(drain_ctr == xfer_len):
                        m.next = "HEADER0"
                    with m.Else():
                        m.d.exi += drain_ctr.eq(drain_ctr + 1)

            with m.State("SPRAM_STREAM"):
                # Stream SPRAM bytes until CS deasserts — handles both ≤4-byte
                # immediate reads and arbitrary-length DMA reads uniformly.
                # Issue read requests ahead (prefetch, bounded by SP_LIMIT) and
                # push responses into tx_fifo; the capture domain pops them as
                # the GC clocks.  Drain rx dummies as they arrive.
                issue = Signal()
                pop   = Signal()
                m.d.comb += issue.eq(self.cs_active & spram_req.w_rdy
                                     & (outstanding < SP_LIMIT))
                m.d.comb += pop.eq(spram_rsp.r_rdy & self.tx_rdy)

                with m.If(issue):
                    m.d.exi += spram_req.w_data.eq(sp_addr)
                    m.d.exi += spram_req.w_en.eq(1)
                    m.d.exi += sp_addr.eq(sp_addr + 1)
                with m.If(pop):
                    m.d.comb += self.tx_data.eq(spram_rsp.r_data)
                    m.d.comb += self.tx_en.eq(1)
                    m.d.comb += spram_rsp.r_en.eq(1)
                m.d.exi += outstanding.eq(outstanding + issue - pop)

                with m.If(self.rx_rdy):
                    m.d.comb += self.rx_en.eq(1)          # drain dummy bytes

                with m.If(~self.cs_active):
                    m.next = "SPRAM_END"

            with m.State("SPRAM_END"):
                # CS deasserted: drain in-flight SPRAM responses and rx dummies,
                # then idle.  Leftover prefetch in tx_fifo is flushed by
                # ExiCapture on the next CS assertion.
                with m.If(spram_rsp.r_rdy):
                    m.d.comb += spram_rsp.r_en.eq(1)
                    m.d.exi += outstanding.eq(outstanding - 1)
                with m.If(self.rx_rdy):
                    m.d.comb += self.rx_en.eq(1)
                with m.If((outstanding == 0) & ~self.rx_rdy & ~spram_rsp.r_rdy):
                    m.next = "HEADER0"

        # ── Interrupt output ─────────────────────────────────────────────
        m.d.exi += self.exi_int_n.eq(~(r_ir & r_imr).any())

        # ── Consume RWP updates from sync domain ──────────────────────────
        with m.If(rx_wptr.r_rdy):
            m.d.exi += rx_wptr.r_en.eq(1)
            m.d.exi += r_rwp_lo.eq(rx_wptr.r_data)

        # ── PulseSynchronizer arrivals ────────────────────────────────────
        with m.If(rx_irq_ps.o):
            m.d.exi += r_ir[1].eq(1)   # RI bit
        with m.If(tx_irq_ps.o):
            m.d.exi += r_ir[2].eq(1)   # TI bit
            m.d.exi += r_ncra[1:3].eq(0)  # clear ST bits

        return m


# ── Testbench ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import sys, os
    sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

    from amaranth.sim import Simulator, Period

    reg = BBARegisterFile()

    # Drive the byte-stream interface directly (the SPI bit cadence and FIFOs
    # live in ExiCapture; here we model the byte producer/consumer).
    async def push_rx(ctx, b):
        """Present one received byte and wait for the register file to pop it."""
        ctx.set(reg.rx_data, b)
        ctx.set(reg.rx_rdy, 1)
        while True:
            en = ctx.get(reg.rx_en)
            await ctx.tick("exi")
            if en:
                break
        ctx.set(reg.rx_rdy, 0)

    async def collect_tx(ctx, n):
        """Collect n response bytes pushed by the register file (bounded)."""
        out = []
        for _ in range(3000):
            if ctx.get(reg.tx_en):
                out.append(ctx.get(reg.tx_data))
            if len(out) >= n:
                break
            await ctx.tick("exi")
        return out

    async def exi_read(ctx, addr, length=1):
        hdr0 = (addr >> 6) & 0x7F
        hdr1 = ((addr & 0x3F) << 2) | (length - 1)
        await push_rx(ctx, hdr0)
        await push_rx(ctx, hdr1)
        result = await collect_tx(ctx, length)     # READ pushes `length` bytes
        for _ in range(length):                    # READ_DRAIN dummies
            await push_rx(ctx, 0x00)
        return result

    async def exi_write(ctx, addr, data):
        hdr0 = 0x80 | ((addr >> 6) & 0x7F)
        hdr1 = ((addr & 0x3F) << 2) | (len(data) - 1)
        await push_rx(ctx, hdr0)
        await push_rx(ctx, hdr1)
        for b in data:
            await push_rx(ctx, b)

    # SPRAM contents the streaming-read test reads back (byte i = 0xA0+i).
    spram_mem = {0x100 + i: (0xA0 + i) & 0xFF for i in range(64)}

    async def spram_model(ctx):
        """Model the SPRAM (sync side): answer spram_req with mem[addr].

        One request at a time, with cleanly-pulsed r_en/w_en so the FIFO pop
        and the response push stay in lock-step (no double-response races).
        """
        state = "POP"
        held = 0
        async for vals in ctx.tick("sync").sample(
                reg.spram_req_r_rdy, reg.spram_req_r_data, reg.spram_rsp_w_rdy):
            rdy, addr, rsp_rdy = vals[-3:]
            ctx.set(reg.spram_req_r_en, 0)
            ctx.set(reg.spram_rsp_w_en, 0)
            if state == "POP":
                if rdy:
                    held = spram_mem.get(addr, 0)
                    ctx.set(reg.spram_req_r_en, 1)     # consume the request
                    state = "RESP"
            else:  # RESP
                if rsp_rdy:
                    ctx.set(reg.spram_rsp_w_data, held)
                    ctx.set(reg.spram_rsp_w_en, 1)     # deliver the response
                    state = "POP"

    errors = []

    async def testbench(ctx):
        ctx.set(reg.tx_rdy, 1)        # tx_fifo always has room in this model
        await ctx.tick("exi").repeat(8)

        # T1: Device ID (addr=0, 4-byte read)
        result = await exi_read(ctx, 0x0000, length=4)
        if result != _DEVICE_ID:
            errors.append(f"T1 device ID: expected {_DEVICE_ID}, got {result}")
        print(f"T1 device ID: {[f'0x{b:02X}' for b in result]}")
        await ctx.tick("exi").repeat(4)

        # T2: Write and read back PAR0-PAR3
        await exi_write(ctx, _PAR0, [0xDE, 0xAD, 0xBE, 0xEF])
        await ctx.tick("exi").repeat(4)
        result = await exi_read(ctx, _PAR0, length=4)
        if result != [0xDE, 0xAD, 0xBE, 0xEF]:
            errors.append(f"T2 PAR readback: {result}")
        print(f"T2 PAR0-3: {[f'0x{b:02X}' for b in result]}")
        await ctx.tick("exi").repeat(4)

        # T3: NWAYS hardcoded 0x17
        result = await exi_read(ctx, _NWAYS, length=1)
        if result != [0x17]:
            errors.append(f"T3 NWAYS: expected 0x17, got {result}")
        print(f"T3 NWAYS: 0x{result[0]:02X}")
        await ctx.tick("exi").repeat(4)

        # T4: HIPR hardcoded 0x01
        result = await exi_read(ctx, _HIPR, length=1)
        if result != [0x01]:
            errors.append(f"T4 HIPR: expected 0x01, got {result}")
        print(f"T4 HIPR: 0x{result[0]:02X}")
        await ctx.tick("exi").repeat(4)

        # T5: IMR write, rx_irq pulse, INT_N asserts, then IR clear
        await exi_write(ctx, _IMR, [0x02])   # enable RI (bit 1)
        await ctx.tick("exi").repeat(4)
        ctx.set(reg.rx_irq_i, 1)
        await ctx.tick("sync").repeat(1)
        ctx.set(reg.rx_irq_i, 0)
        await ctx.tick("exi").repeat(12)     # wait for PS propagation
        int_n = ctx.get(reg.exi_int_n)
        if int_n != 0:
            errors.append(f"T5 INT_N after RI: expected 0, got {int_n}")
        print(f"T5 INT_N after RI pulse: {int_n} (want 0)")
        await exi_write(ctx, _IR, [0x02])    # write-1-to-clear RI
        await ctx.tick("exi").repeat(4)
        int_n = ctx.get(reg.exi_int_n)
        if int_n != 1:
            errors.append(f"T5 INT_N after clear: expected 1, got {int_n}")
        print(f"T5 INT_N after IR clear: {int_n} (want 1)")

        # T6: streaming SPRAM read (DMA) — read N>4 bytes from 0x100 by holding
        # cs_active and clocking past the header's 4-byte length field.
        N = 12
        ctx.set(reg.cs_active, 1)
        await push_rx(ctx, 0x04)         # hdr0 → addr[12:6]; addr 0x100, read
        await push_rx(ctx, 0x00)         # hdr1 → addr[5:0]=0, len field ignored
        got = []
        for _ in range(5000):
            if ctx.get(reg.tx_en):
                got.append(ctx.get(reg.tx_data))
            if len(got) >= N:
                break
            await ctx.tick("exi")
        ctx.set(reg.cs_active, 0)         # end the transaction
        await ctx.tick("exi").repeat(40)  # let SPRAM_END drain/clean up
        want = [spram_mem[0x100 + i] for i in range(N)]
        print(f"T6 DMA read {N}B: {[f'0x{b:02X}' for b in got]}")
        if got != want:
            errors.append(f"T6 streaming SPRAM read: got {got}, want {want}")

        # T7: a normal register read still works after the streaming transaction
        # (FSM cleaned up and returned to HEADER0)
        result = await exi_read(ctx, _NWAYS, length=1)
        if result != [0x17]:
            errors.append(f"T7 NWAYS after DMA: got {result}")
        print(f"T7 NWAYS after DMA read: 0x{result[0]:02X}")

    sim = Simulator(reg)
    sim.add_clock(Period(MHz=24), domain="exi")
    sim.add_clock(Period(MHz=24), domain="sync")
    sim.add_testbench(testbench)
    sim.add_process(spram_model)

    sim.run()

    if errors:
        print("\nFAILURES:")
        for e in errors:
            print(" ", e)
        sys.exit(1)
    else:
        print("\nAll tests passed.")
