Files
rebbarb/exi_bba/bba_register_file.py
T
Roflin b175cb0e77 bba_register_file: document the gateware, drop the history and client talk
First file of the documentation cleanup.  Comments 403 -> 83 lines (46% ->
12% of the module), total 1563 -> 1266, with the real documentation moved into
structured docstrings instead of inline prose.

What went:
  * every account of how a bug was found, what it looked like on a console,
    and which fix attempts were falsified -- all of that lives in CLAUDE.md
  * every description of how Swiss or libogc is written internally; the file
    now documents what the GATEWAY does and what a host observes, not how any
    particular host is implemented.  Zero occurrences of Swiss/libogc/gcif
    remain, in comments, docstrings, helper names or test output
  * the module docstring, which still described the GUESSED header format
    (addr[12:6] plus a 2-bit length field) replaced long ago

What arrived:
  * a register table giving each address its name AND its purpose, bit
    meanings included (NCRA reset/ST0/ST1/SR, IR's RI/TI/RBFI, the page
    pointers, which side owns RWP vs RRP)
  * a command-register section, likewise
  * named IR/IMR and NCRB bit constants instead of bare indices
  * a class docstring grouping the ports into EXI stream / ethernet side /
    register taps / events
  * an FSM docstring listing all 13 states in visit order with what each one
    is for, and the invariant that every data phase ends on ~cs_active

Structure: elaborate() is now a table of contents -- _make_fifos,
_make_registers, _register_taps, _interrupts, _read_mux, _transaction_fsm,
_spram_responses, _command_write, _ethernet_events -- with register storage,
transaction state and the FIFOs passed as three namespaces so each block's
inputs are visible.  The register write decode is its own method rather than
80 lines nested inside a state.

Dead code removed: sp_req, byte_ctr, drain_ctr, SP_LIMIT and _DEVICE_ID were
all write-only or declaration-only.

Behaviour preserved, checked four ways: all 15 module tests pass, the full
module suite passes, bba_top passes at EXI_HALF=1/2/8, and the hardware is
materially identical -- flip-flops exactly equal (1177 SB_DFFE, 671 SB_CARRY),
RAM/IO/global buffers unchanged, ICESTORM_LC 4350 -> 4380 (+0.7%).  That rise
is the read mux: its Switch cases are now in address order, which changes how
the mux tree maps.  All cases are distinct constants so the decode is
semantically identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:18:38 +02:00

1267 lines
57 KiB
Python

"""BBA register file — the GameCube-facing side of the adapter.
Sits between the EXI byte stream (`ExiCapture`) and the ethernet side, and does
three jobs:
* decode EXI transactions and drive the register space;
* buffer TX frame bytes and RX ring pointers across to the ethernet engines
through `SyncFIFO`s;
* raise the two levels of BBA interrupt.
Everything here runs in the `sync` domain (24 MHz). The per-bit SPI cadence is
`SPIMode3Slave`'s problem; this module only ever sees whole bytes.
EXI transaction formats
-----------------------
Byte 0 selects the address space (bit 7) and the direction (bit 6). There is
no length field: the data phase runs for as long as the host keeps the clock
running, and ends when CS rises.
command space (6-bit register) 2-byte header, then a data phase
read [reg, 0x00] + N bytes out
write [0x40|reg, 0x00] + N bytes in
register space (16-bit register) 4-byte header, then a data phase
read [0x80, hi, lo, 0x00] + N bytes out
write [0xC0, hi, lo, 0x00] + N bytes in
The trailing `0x00` of a register-space header is the device's prefetch window:
the address is complete one whole byte before the data phase, which is the only
slack available to fetch the first response byte.
Address spaces
--------------
command 0x00-0x3F EXI-level registers (interrupt mask/status, revision)
register 0x0000-0x00FF MAC registers (this file)
register 0x0100-0x1FFF SPRAM RX ring; any read here streams until CS
Device-ID queries (command space, read, register 0) are answered by
`SPIMode3Slave`, not here — a 2-byte header leaves too little time to fetch a
reply through the byte FIFOs. This file only consumes the bytes.
"""
from types import SimpleNamespace
from amaranth import *
from amaranth.lib.fifo import SyncFIFO
__all__ = ["BBARegisterFile"]
# ─────────────────────────── register space ──────────────────────────────
# MAC-level registers. Multi-byte registers are a LO/HI pair of consecutive
# addresses; only the low byte of each page pointer is ever non-zero here
# because the ring is 15 pages.
_NCRA = 0x00 # Network Control A: [0] RESET (self-clearing),
# [1] ST0 / [2] ST1 start transmit, [3] SR start receive
_NCRB = 0x01 # Network Control B: RX filtering (PR/PM/AB) and the
# receive-interrupt packet counter
_IMR = 0x08 # Interrupt Mask: enables the matching IR bits
_IR = 0x09 # Interrupt status, write-1-to-clear:
# [1] RI frame received, [2] TI frame transmitted,
# [7] RBFI receive buffer full (a frame was dropped)
_BP_LO = 0x0A # Boundary Page: first page of the RX ring
_BP_HI = 0x0B
_TLBP_LO = 0x0C # TX Low Boundary Page: start of the TX buffer region
_TLBP_HI = 0x0D
_RXINTT_LO = 0x14 # RX Interrupt Timer: interrupt coalescing delay
_RXINTT_HI = 0x15
_RWP_LO = 0x16 # RX Write Pointer (page) — device-owned, follows the
_RWP_HI = 0x17 # frame assembler
_RRP_LO = 0x18 # RX Read Pointer (page) — driver-owned, written once the
_RRP_HI = 0x19 # host has consumed a frame
_RHBP_LO = 0x1A # RX High Boundary Page: last page of the RX ring
_RHBP_HI = 0x1B
_PAR0 = 0x20 # Physical Address Registers 0-5: the MAC, PAR0 first on
_PAR1 = 0x21 # the wire. Also forwarded to the ethernet chip as its
_PAR2 = 0x22 # own source address.
_PAR3 = 0x23
_PAR4 = 0x24
_PAR5 = 0x25
_NWAYC = 0x30 # NWAY (autonegotiation) configuration
_NWAYS = 0x31 # NWAY status — read-only, see _NWAYS_VAL
_GCA = 0x32 # GMAC Configuration A
_TWD_LO = 0x34 # Transmit frame length, written before streaming TXDATA.
_TWD_HI = 0x35 # Optional: see the TX length note on `txdata_cnt`.
_HIPR = 0x3A # Adapter-present sentinel — read-only, see _HIPR_VAL
_MISC = 0x3D # MISC control 1
_TXDATA = 0x48 # TX FIFO data port: the host streams frame bytes here
_MISC2 = 0x50 # MISC control 2
# First page of the SPRAM RX ring. Page 0 holds the register region, so the
# ring is pages 1-15. Must match rx_frame_assembler._RX_PAGE_FIRST.
_RX_PAGE_FIRST = 0x01
# Lowest address that reads from SPRAM rather than the register file.
_SPRAM_BASE = 0x100
# Read-only register values.
_NWAYS_VAL = 0x17 # 100 Mbit full duplex, link up, autonegotiation complete
_HIPR_VAL = 0x01 # adapter present
# Reset values. NWAYC and MISC are the documented hardware defaults; BP and
# RHBP describe OUR ring layout, so a host that reads them without writing
# sees bounds that match where frames actually land.
_NWAYC_VAL = 0x84
_MISC_VAL = 0x3C
_BP_VAL = _RX_PAGE_FIRST
_RHBP_VAL = 0x0F
# NCRB bit positions.
_NCRB_PR = 0 # promiscuous: accept every frame
_NCRB_PM = 2 # pass multicast
_NCRB_AB = 4 # accept broadcast
# IR / IMR bit positions.
_IR_RI = 1 # frame received
_IR_TI = 2 # frame transmitted
_IR_RBFI = 7 # receive buffer full, frame dropped
# ─────────────────────────── command space ──────────────────────────────
# EXI-level registers, addressed with the 2-byte header. Only three have
# behaviour; the rest are storage so that a write is not silently lost.
_CMD_REVID = 0x01 # revision ID — read-only
_CMD_IMR = 0x02 # EXI interrupt mask, gates INT_N
_CMD_IR = 0x03 # EXI interrupt status, write-1-to-clear.
# Bit 7 is a live level: "a MAC interrupt is pending".
_CMD_LAST = 0x0F # highest command register with storage; above this reads 0
_CMD_REVID_VAL = 0x00 # the BBA was never revised past 0
_CMD_IR_MAC = 7 # bit 7 of _CMD_IR
# Interrupt-mask reset value: unmasked. Hosts set this explicitly during
# init, so a permissive default costs them nothing and leaves INT_N usable for
# software that never writes it.
_CMD_IMR_VAL = 0xF8
class BBARegisterFile(Elaboratable):
"""EXI transaction decoder, BBA register space, and the FIFO bridges.
Ports fall into four groups:
EXI byte stream — `rx_*`/`tx_*`/`cs_active`, driven by `ExiCapture`.
`rx_*` is a first-word-fallthrough read side; `tx_*` is a write side.
`cs_active` is high for the duration of one EXI transaction and is what
ends every data phase.
Ethernet side — the `*_r_*` / `*_w_*` groups are the far sides of this
module's FIFOs, wired by `BBATop` to the frame assembler, the TX drain
and the SPRAM arbiter.
Register taps — `par`, `ncra_sr`, `ncrb`, `bp`, `rhbp`, `rrp` expose
register contents that other modules need directly.
Events — `rx_irq_i`, `tx_irq_i`, `rx_overflow` in; `ncra_rst_o` and
`exi_int_n` out.
"""
def __init__(self):
# ── EXI byte stream (from/to ExiCapture) ─────────────────────────
self.rx_data = Signal(8)
self.rx_rdy = Signal()
self.rx_en = Signal()
self.tx_data = Signal(8)
self.tx_en = Signal()
self.tx_rdy = Signal()
self.cs_active = Signal()
# ── Register taps consumed by other modules ──────────────────────
self.par = Signal(48) # PAR0-5 packed, PAR0 in the low byte
self.ncra_sr = Signal() # NCRA[3]: gates the RX ring path
self.ncrb = Signal(8) # RX filtering / interrupt-counter bits
self.bp = Signal(12) # first page of the RX ring
self.rhbp = Signal(12) # last page of the RX ring
self.rrp = Signal(8, init=_RX_PAGE_FIRST) # host read pointer
# ── Events ───────────────────────────────────────────────────────
self.rx_overflow = Signal() # in: a frame was dropped (RBFI)
self.rx_irq_i = Signal() # in: frame received (RI)
self.tx_irq_i = Signal() # in: frame transmitted (TI)
self.ncra_rst_o = Signal() # out: host wrote the NCRA reset bit
self.exi_int_n = Signal(init=1) # out: EXI interrupt, active low
# ── FIFO far sides, wired by BBATop ──────────────────────────────
# SPRAM read requests out to the arbiter.
self.spram_req_r_data = Signal(16)
self.spram_req_r_en = Signal()
self.spram_req_r_rdy = Signal()
# SPRAM read data back in. No FIFO: the response must be consumed in
# the cycle it is valid, which is why only one request is in flight.
self.spram_rsp_data = Signal(8)
self.spram_rsp_valid = Signal()
# TX frame bytes and the frame length, out to TXFrameDrain.
self.tx_bytes_r_data = Signal(8)
self.tx_bytes_r_en = Signal()
self.tx_bytes_r_rdy = Signal()
self.tx_ctrl_r_data = Signal(16)
self.tx_ctrl_r_en = Signal()
self.tx_ctrl_r_rdy = Signal()
# RX ring write pointer in from the assembler, read pointer out to it.
self.rx_wptr_w_data = Signal(8)
self.rx_wptr_w_en = Signal()
self.rx_wptr_w_rdy = Signal()
self.rx_rptr_r_data = Signal(8)
self.rx_rptr_r_en = Signal()
self.rx_rptr_r_rdy = Signal()
# ──────────────────────────── structure ─────────────────────────────
def _make_fifos(self, m):
"""Create the FIFOs and connect their far sides to this module's ports."""
f = SimpleNamespace(
spram_req=SyncFIFO(width=16, depth=4),
# Deep enough for a whole maximum-size frame: SPI cannot
# back-pressure, so a byte that arrives when the FIFO is full is
# dropped and the frame goes out corrupt. The depth is what
# stands between a drain stall and a bad frame, not a tuning knob.
tx_bytes=SyncFIFO(width=8, depth=2048),
tx_ctrl=SyncFIFO(width=16, depth=4),
rx_wptr=SyncFIFO(width=8, depth=4),
rx_rptr=SyncFIFO(width=8, depth=4),
)
m.submodules.spram_req = f.spram_req
m.submodules.tx_bytes = f.tx_bytes
m.submodules.tx_ctrl = f.tx_ctrl
m.submodules.rx_wptr = f.rx_wptr
m.submodules.rx_rptr = f.rx_rptr
m.d.comb += [
self.spram_req_r_data.eq(f.spram_req.r_data),
f.spram_req.r_en .eq(self.spram_req_r_en),
self.spram_req_r_rdy .eq(f.spram_req.r_rdy),
self.tx_bytes_r_data.eq(f.tx_bytes.r_data),
f.tx_bytes.r_en .eq(self.tx_bytes_r_en),
self.tx_bytes_r_rdy .eq(f.tx_bytes.r_rdy),
self.tx_ctrl_r_data.eq(f.tx_ctrl.r_data),
f.tx_ctrl.r_en .eq(self.tx_ctrl_r_en),
self.tx_ctrl_r_rdy .eq(f.tx_ctrl.r_rdy),
f.rx_wptr.w_data .eq(self.rx_wptr_w_data),
f.rx_wptr.w_en .eq(self.rx_wptr_w_en),
self.rx_wptr_w_rdy .eq(f.rx_wptr.w_rdy),
self.rx_rptr_r_data.eq(f.rx_rptr.r_data),
f.rx_rptr.r_en .eq(self.rx_rptr_r_en),
self.rx_rptr_r_rdy .eq(f.rx_rptr.r_rdy),
]
return f
def _make_registers(self, m):
"""Create register storage and the taps that read straight out of it."""
# PAR resets to a valid Nintendo OUI so the adapter has a usable
# source MAC before any host writes one. Hosts generally READ the MAC
# from here rather than setting it, so this value is the one that ends
# up on the wire.
par_reset = [0x00, 0x09, 0xBF, 0x00, 0x00, 0x01]
r = SimpleNamespace(
ncra=Signal(8),
ncrb=Signal(8),
imr=Signal(8),
ir=Signal(8),
# Both ring pointers reset to the ring's first page, matching the
# frame assembler. RRP == RWP == first page means "empty".
rwp_lo=Signal(8, init=_RX_PAGE_FIRST),
rrp_lo=Signal(8, init=_RX_PAGE_FIRST),
par=Array([Signal(8, name=f"par{i}", init=par_reset[i])
for i in range(6)]),
twd_lo=Signal(8),
twd_hi=Signal(8),
bp_lo=Signal(8, init=_BP_VAL),
bp_hi=Signal(8),
tlbp_lo=Signal(8),
tlbp_hi=Signal(8),
rxintt_lo=Signal(8),
rxintt_hi=Signal(8),
rhbp_lo=Signal(8, init=_RHBP_VAL),
rhbp_hi=Signal(8),
nwayc=Signal(8, init=_NWAYC_VAL),
gca=Signal(8),
misc=Signal(8, init=_MISC_VAL),
misc2=Signal(8),
# Command-space registers.
cmd_imr=Signal(8, init=_CMD_IMR_VAL),
cmd_ir=Signal(8),
cmd_misc=Array([Signal(8, name=f"cmd_misc_{i}") for i in range(16)]),
)
return r
def _register_taps(self, m, r):
"""Drive the ports that mirror register contents."""
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])),
self.ncra_sr.eq(r.ncra[3]),
self.ncrb .eq(r.ncrb),
self.rrp .eq(r.rrp_lo),
self.bp .eq(Cat(r.bp_lo, r.bp_hi[0:4])),
self.rhbp .eq(Cat(r.rhbp_lo, r.rhbp_hi[0:4])),
]
def _read_mux(self, m, r, t):
"""Combinational read data for the current register address.
Returns `rd_val`, valid for both address spaces: `t.cmd_space` selects
which decode applies. SPRAM reads do not come through here.
"""
reg_rd = Signal(8, name="reg_rdval")
with m.Switch(t.rd_sel):
with m.Case(_NCRA): m.d.comb += reg_rd.eq(r.ncra)
with m.Case(_NCRB): m.d.comb += reg_rd.eq(r.ncrb)
with m.Case(_IMR): m.d.comb += reg_rd.eq(r.imr)
with m.Case(_IR): m.d.comb += reg_rd.eq(r.ir)
with m.Case(_BP_LO): m.d.comb += reg_rd.eq(r.bp_lo)
with m.Case(_BP_HI): m.d.comb += reg_rd.eq(r.bp_hi)
with m.Case(_TLBP_LO): m.d.comb += reg_rd.eq(r.tlbp_lo)
with m.Case(_TLBP_HI): m.d.comb += reg_rd.eq(r.tlbp_hi)
with m.Case(_RXINTT_LO): m.d.comb += reg_rd.eq(r.rxintt_lo)
with m.Case(_RXINTT_HI): m.d.comb += reg_rd.eq(r.rxintt_hi)
with m.Case(_RWP_LO): m.d.comb += reg_rd.eq(r.rwp_lo)
with m.Case(_RRP_LO): m.d.comb += reg_rd.eq(r.rrp_lo)
with m.Case(_RHBP_LO): m.d.comb += reg_rd.eq(r.rhbp_lo)
with m.Case(_RHBP_HI): m.d.comb += reg_rd.eq(r.rhbp_hi)
with m.Case(_PAR0, _PAR1, _PAR2, _PAR3, _PAR4, _PAR5):
# PAR0 is 0x20, so the low three address bits are the index.
m.d.comb += reg_rd.eq(r.par[t.eff_addr[0:3]])
with m.Case(_NWAYC): m.d.comb += reg_rd.eq(r.nwayc)
with m.Case(_NWAYS): m.d.comb += reg_rd.eq(_NWAYS_VAL)
with m.Case(_GCA): m.d.comb += reg_rd.eq(r.gca)
with m.Case(_TWD_LO): m.d.comb += reg_rd.eq(r.twd_lo)
with m.Case(_TWD_HI): m.d.comb += reg_rd.eq(r.twd_hi)
with m.Case(_HIPR): m.d.comb += reg_rd.eq(_HIPR_VAL)
with m.Case(_MISC): m.d.comb += reg_rd.eq(r.misc)
with m.Case(_MISC2): m.d.comb += reg_rd.eq(r.misc2)
with m.Default(): m.d.comb += reg_rd.eq(0)
cmd_rd = Signal(8, name="cmd_rdval")
with m.Switch(t.cmd_reg):
with m.Case(_CMD_REVID): m.d.comb += cmd_rd.eq(_CMD_REVID_VAL)
with m.Case(_CMD_IMR): m.d.comb += cmd_rd.eq(r.cmd_imr)
with m.Case(_CMD_IR): m.d.comb += cmd_rd.eq(t.cmd_ir_val)
with m.Default():
with m.If(t.cmd_reg <= _CMD_LAST):
m.d.comb += cmd_rd.eq(r.cmd_misc[t.cmd_reg[0:4]])
with m.Else():
m.d.comb += cmd_rd.eq(0)
rd_val = Signal(8, name="rd_val")
with m.If(t.cmd_space):
m.d.comb += rd_val.eq(cmd_rd)
with m.Else():
m.d.comb += rd_val.eq(reg_rd)
return rd_val
def _register_write(self, m, r, f, t):
"""Apply one register-space write of `rx_data` to address `rd_sel`.
Called from the WRITE state. Addresses with no case are ignored.
"""
with m.Switch(t.rd_sel):
with m.Case(_NCRA):
m.d.sync += r.ncra.eq(self.rx_data)
with m.If(self.rx_data[0]): # RESET: self-clearing
m.d.sync += [
r.ncra[0].eq(0),
t.ncra_rst.eq(1),
# The reset rewinds the ring, so both pointers must
# follow the assembler back to the first page.
r.rwp_lo.eq(_RX_PAGE_FIRST),
r.rrp_lo.eq(_RX_PAGE_FIRST),
]
with m.If(self.rx_data[1:3].any() & ~t.tx_queued):
# ST0/ST1: start transmit. Either bit will do; the buffer
# select they encode is not implemented. The length is
# the number of bytes the host actually streamed, since a
# host that reaches this path never wrote TWD.
with m.If(f.tx_ctrl.w_rdy):
m.d.sync += [
f.tx_ctrl.w_data.eq(t.txdata_cnt),
f.tx_ctrl.w_en.eq(1),
t.tx_queued.eq(1),
t.txdata_cnt.eq(0),
]
with m.Case(_IMR):
m.d.sync += r.imr.eq(self.rx_data)
with m.Case(_IR):
m.d.sync += r.ir.eq(r.ir & ~self.rx_data) # write-1-to-clear
with m.Case(_RRP_LO):
m.d.sync += r.rrp_lo.eq(self.rx_data)
with m.If(f.rx_rptr.w_rdy): # forward to the assembler
m.d.sync += [f.rx_rptr.w_data.eq(self.rx_data),
f.rx_rptr.w_en.eq(1)]
with m.Case(_PAR0, _PAR1, _PAR2, _PAR3, _PAR4, _PAR5):
m.d.sync += r.par[t.eff_addr[0:3]].eq(self.rx_data)
with m.Case(_TWD_LO):
m.d.sync += [r.twd_lo.eq(self.rx_data),
t.tx_frame_len[0:8].eq(self.rx_data)]
with m.Case(_TWD_HI):
# Arms the deferred queue one cycle later, by which point both
# halves of the length are present.
m.d.sync += [r.twd_hi.eq(self.rx_data),
t.tx_frame_len[8:16].eq(self.rx_data),
t.twd_armed.eq(1)]
with m.Case(_TXDATA):
with m.If(f.tx_bytes.w_rdy):
m.d.sync += [f.tx_bytes.w_data.eq(self.rx_data),
f.tx_bytes.w_en.eq(1),
t.txdata_cnt.eq(t.txdata_cnt + 1)]
with m.Case(_NCRB): m.d.sync += r.ncrb.eq(self.rx_data)
with m.Case(_BP_LO): m.d.sync += r.bp_lo.eq(self.rx_data)
with m.Case(_BP_HI): m.d.sync += r.bp_hi.eq(self.rx_data)
with m.Case(_TLBP_LO): m.d.sync += r.tlbp_lo.eq(self.rx_data)
with m.Case(_TLBP_HI): m.d.sync += r.tlbp_hi.eq(self.rx_data)
with m.Case(_RXINTT_LO): m.d.sync += r.rxintt_lo.eq(self.rx_data)
with m.Case(_RXINTT_HI): m.d.sync += r.rxintt_hi.eq(self.rx_data)
with m.Case(_RHBP_LO): m.d.sync += r.rhbp_lo.eq(self.rx_data)
with m.Case(_RHBP_HI): m.d.sync += r.rhbp_hi.eq(self.rx_data)
with m.Case(_NWAYC): m.d.sync += r.nwayc.eq(self.rx_data)
with m.Case(_GCA): m.d.sync += r.gca.eq(self.rx_data)
with m.Case(_MISC): m.d.sync += r.misc.eq(self.rx_data)
with m.Case(_MISC2): m.d.sync += r.misc2.eq(self.rx_data)
# TXDATA is a FIFO port, not a register: the address must not advance
# across it or a multi-byte frame write would spill into 0x49 upwards.
with m.If(t.rd_sel != _TXDATA):
m.d.sync += t.eff_addr.eq(t.eff_addr + 1)
def _transaction_fsm(self, m, r, f, t, rd_val):
"""Decode one EXI transaction.
States, in the order a transaction visits them:
HEADER0 byte 0: pick address space and direction
RHDR_HI register space, address high byte
RHDR_LO register space, address low byte. The address is now
complete, so an SPRAM read is issued here to use the
pad byte as its prefetch window.
RHDR_PAD register space, trailing pad byte. A register read
pushes its first response byte during it.
CHDR_1 command space, header byte 1
CMD_READ command space read data phase
CMD_WRITE command space write data phase
WRITE register space write data phase
REG_READ register space read data phase
SPRAM_STREAM register space read from the RX ring, one request in
flight, streaming until CS rises
SPRAM_END drain the in-flight SPRAM request after CS
DEVID device-ID query; the reply comes from SPIMode3Slave so
this only consumes the clocked bytes
TRAILER consume anything else until CS rises
Every data phase ends on `~cs_active`, never on a byte count.
"""
with m.FSM(domain="sync", name="exi_fsm"):
with m.State("HEADER0"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
m.d.sync += [
t.is_write.eq(self.rx_data[6]),
t.prefetched.eq(0),
t.outstanding.eq(0),
t.sp_pending.eq(0),
t.cmd_have.eq(0),
t.cmd_pushes.eq(0),
]
with m.If(self.rx_data[7]):
m.d.sync += t.cmd_space.eq(0)
m.next = "RHDR_HI"
with m.Else():
m.d.sync += [
t.cmd_space.eq(1),
t.cmd_reg.eq(self.rx_data[0:6]),
t.addr.eq(self.rx_data[0:6]),
t.eff_addr.eq(self.rx_data[0:6]),
]
m.next = "CHDR_1"
with m.State("RHDR_HI"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
m.d.sync += t.addr[8:16].eq(self.rx_data)
m.next = "RHDR_LO"
with m.State("RHDR_LO"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
full = Cat(self.rx_data, t.addr[8:16])
m.d.sync += [t.addr.eq(full), t.eff_addr.eq(full),
t.sp_addr.eq(full)]
with m.If(~t.is_write & (full >= _SPRAM_BASE)
& f.spram_req.w_rdy):
# Combinational w_en: the pad byte is the whole budget
# for this round trip and a registered strobe does not
# fit in it.
m.d.comb += [f.spram_req.w_data.eq(full),
f.spram_req.w_en.eq(1)]
m.d.sync += [t.sp_addr.eq(full + 1),
t.outstanding.eq(1)]
m.next = "RHDR_PAD"
with m.State("RHDR_PAD"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(t.is_write):
m.next = "WRITE"
with m.Elif(t.addr >= _SPRAM_BASE):
m.next = "SPRAM_STREAM"
with m.Else():
m.next = "REG_READ"
with m.Elif(~t.is_write & (t.addr < _SPRAM_BASE)
& self.tx_rdy & ~t.prefetched):
# One-shot: this branch is re-evaluated every cycle the pad
# byte has not arrived, and repeating it would consume the
# register stream early.
m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)]
m.d.sync += [t.eff_addr.eq(t.eff_addr + 1),
t.prefetched.eq(1)]
with m.State("CHDR_1"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(~t.is_write & (t.cmd_reg == 0)):
m.next = "DEVID"
with m.Elif(t.is_write):
m.next = "CMD_WRITE"
with m.Else():
m.next = "CMD_READ"
with m.Elif(~t.is_write & self.tx_rdy & (t.cmd_pushes == 0)):
m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)]
m.d.sync += t.cmd_pushes.eq(1)
with m.State("CMD_READ"):
# The response is pushed TWICE so it appears in both of the
# byte positions hosts read it from.
with m.If(self.tx_rdy & (t.cmd_pushes < 2)):
m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)]
m.d.sync += t.cmd_pushes.eq(t.cmd_pushes + 1)
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(~self.cs_active):
m.next = "HEADER0"
with m.State("CMD_WRITE"):
# The LAST data byte before CS rise is the value: hosts send
# either one data byte or a pad plus the value, and taking the
# last one serves both without counting bytes.
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
m.d.sync += [t.cmd_wdata.eq(self.rx_data),
t.cmd_wreg.eq(t.cmd_reg),
t.cmd_have.eq(1)]
with m.If(~self.cs_active):
with m.If(t.cmd_have):
m.d.sync += t.cmd_wr.eq(1)
m.next = "HEADER0"
with m.State("WRITE"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
self._register_write(m, r, f, t)
with m.If(~self.cs_active):
m.next = "HEADER0"
with m.State("REG_READ"):
with m.If(self.tx_rdy & self.cs_active):
m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)]
m.d.sync += t.eff_addr.eq(t.eff_addr + 1)
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(~self.cs_active):
m.next = "HEADER0"
with m.State("DEVID"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(~self.cs_active):
m.next = "HEADER0"
with m.State("SPRAM_STREAM"):
# One request in flight: the response has nowhere to wait, so
# it must be consumed in the cycle it is valid.
with m.If(self.cs_active & f.spram_req.w_rdy & self.tx_rdy
& (t.outstanding == 0) & ~t.sp_pending):
m.d.comb += [f.spram_req.w_data.eq(t.sp_addr),
f.spram_req.w_en.eq(1)]
m.d.sync += [t.sp_addr.eq(t.sp_addr + 1),
t.outstanding.eq(t.outstanding + 1)]
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(~self.cs_active):
m.next = "SPRAM_END"
with m.State("SPRAM_END"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If((t.outstanding == 0) & ~t.sp_pending & ~self.rx_rdy):
m.next = "HEADER0"
with m.State("TRAILER"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(~self.cs_active):
m.next = "HEADER0"
def _spram_responses(self, m, t):
"""Forward SPRAM read data to the EXI TX stream.
Runs outside the FSM because a response can arrive while the FSM is
still in a header state. `sp_hold` carries one byte for the case
where the TX FIFO is momentarily full.
"""
mine = self.spram_rsp_valid & (t.outstanding != 0)
with m.If(mine & self.tx_rdy & ~t.sp_pending):
m.d.comb += [self.tx_data.eq(self.spram_rsp_data),
self.tx_en.eq(1)]
m.d.sync += t.outstanding.eq(t.outstanding - 1)
with m.Elif(mine):
m.d.sync += [t.sp_hold.eq(self.spram_rsp_data),
t.sp_pending.eq(1),
t.outstanding.eq(t.outstanding - 1)]
with m.Elif(t.sp_pending & self.tx_rdy):
m.d.comb += [self.tx_data.eq(t.sp_hold), self.tx_en.eq(1)]
m.d.sync += t.sp_pending.eq(0)
def _command_write(self, m, r, t):
"""Apply a pending command-space write, one cycle after the data byte."""
with m.If(t.cmd_wr):
m.d.sync += t.cmd_wr.eq(0)
with m.Switch(t.cmd_wreg):
with m.Case(_CMD_REVID):
pass # read-only
with m.Case(_CMD_IMR):
m.d.sync += r.cmd_imr.eq(t.cmd_wdata)
with m.Case(_CMD_IR):
m.d.sync += r.cmd_ir.eq(r.cmd_ir & ~t.cmd_wdata)
with m.Default():
with m.If(t.cmd_wreg <= _CMD_LAST):
m.d.sync += r.cmd_misc[t.cmd_wreg[0:4]].eq(t.cmd_wdata)
def _interrupts(self, m, r, t):
"""The two interrupt levels.
MAC level: `IR & IMR`, the per-event bits a host clears by writing 1.
EXI level: command register `_CMD_IR`, whose bit 7 is a live level
meaning "a MAC interrupt is pending", masked by `_CMD_IMR`. INT_N
comes from the EXI level, so masking there silences the line while a
host works through its handler.
Bit 7 is a level rather than a latch because hosts clear the MAC IR
first and the EXI status afterwards; a latch would lose an event that
arrived in between.
"""
mac_int = Signal()
m.d.comb += [
mac_int.eq((r.ir & r.imr).any()),
t.cmd_ir_val.eq(Cat(r.cmd_ir[0:_CMD_IR_MAC], mac_int)),
]
m.d.sync += self.exi_int_n.eq(~(t.cmd_ir_val & r.cmd_imr).any())
def _ethernet_events(self, m, r, f, t):
"""Register-side effects of things the ethernet engines report."""
# The assembler's write pointer, so the host can see new frames.
with m.If(f.rx_wptr.r_rdy):
m.d.sync += [f.rx_wptr.r_en.eq(1),
r.rwp_lo.eq(f.rx_wptr.r_data)]
with m.If(self.rx_irq_i):
m.d.sync += r.ir[_IR_RI].eq(1)
with m.If(self.rx_overflow):
m.d.sync += r.ir[_IR_RBFI].eq(1)
# TX done: flag it, release the start bits, and allow the next frame
# to be queued.
with m.If(self.tx_irq_i):
m.d.sync += [r.ir[_IR_TI].eq(1),
r.ncra[1:3].eq(0),
t.tx_queued.eq(0)]
# Deferred TX queue, armed by a TWD_HI write. Held until tx_ctrl has
# room so a full control FIFO cannot drop the trigger.
with m.If(t.twd_armed & f.tx_ctrl.w_rdy):
m.d.sync += [f.tx_ctrl.w_data.eq(t.tx_frame_len),
f.tx_ctrl.w_en.eq(1),
t.twd_armed.eq(0),
t.tx_queued.eq(1),
t.txdata_cnt.eq(0)]
# ──────────────────────────── elaborate ─────────────────────────────
def elaborate(self, platform):
m = Module()
f = self._make_fifos(m)
r = self._make_registers(m)
# Transaction and TX-path state, shared across the blocks below.
t = SimpleNamespace(
# Current transaction.
addr=Signal(16), # register address from the header
eff_addr=Signal(16), # running address of the current data byte
is_write=Signal(),
cmd_space=Signal(),
cmd_reg=Signal(6),
prefetched=Signal(), # first response byte already pushed
# Command-space write, applied a cycle after the data byte.
cmd_wr=Signal(),
cmd_wreg=Signal(6),
cmd_wdata=Signal(8),
cmd_have=Signal(), # at least one data byte seen
cmd_pushes=Signal(2), # command-read responses pushed so far
cmd_ir_val=Signal(8), # command IR including the live MAC bit
# SPRAM streaming reads.
sp_addr=Signal(16), # next SPRAM byte address to request
outstanding=Signal(4), # requests issued but not yet answered
sp_hold=Signal(8), # response held while the TX FIFO was full
sp_pending=Signal(),
# TX frame length. `txdata_cnt` counts what the host actually
# streamed and is what the start-transmit path uses, because a
# host need not write TWD at all.
tx_frame_len=Signal(16),
txdata_cnt=Signal(16),
twd_armed=Signal(),
tx_queued=Signal(),
ncra_rst=Signal(),
)
t.rd_sel = t.eff_addr[0:8]
m.d.comb += self.ncra_rst_o.eq(t.ncra_rst)
self._register_taps(m, r)
# Strobes default low; the blocks below assert them for one cycle.
m.d.sync += [
f.tx_bytes.w_en.eq(0),
f.tx_ctrl.w_en .eq(0),
f.rx_rptr.w_en .eq(0),
f.rx_wptr.r_en .eq(0),
t.ncra_rst .eq(0),
]
m.d.comb += [
f.spram_req.w_en .eq(0),
f.spram_req.w_data.eq(0),
self.rx_en .eq(0),
self.tx_en .eq(0),
self.tx_data.eq(0),
]
self._interrupts(m, r, t)
rd_val = self._read_mux(m, r, t)
self._transaction_fsm(m, r, f, t, rd_val)
self._spram_responses(m, t)
self._command_write(m, r, t)
self._ethernet_events(m, r, f, t)
return m
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
tx_log = []
def sample_tx(ctx):
if ctx.get(reg.tx_en):
tx_log.append(ctx.get(reg.tx_data))
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)
sample_tx(ctx)
await ctx.tick("sync")
if en:
break
ctx.set(reg.rx_rdy, 0)
async def collect_tx(ctx, n):
"""Collect n response bytes, including any pushed during the header."""
out = []
while tx_log and len(out) < n:
out.append(tx_log.pop(0))
for _ in range(3000):
if len(out) >= n:
break
if ctx.get(reg.tx_en):
out.append(ctx.get(reg.tx_data))
await ctx.tick("sync")
return out
# Real protocol (docs/exi_protocol_real.md): register space is a 4-byte
# header [0x80|w<<6, ah, al, 0x00] followed by a data phase that runs
# until CS rises -- there is no length field.
async def exi_read(ctx, addr, length=1):
tx_log.clear()
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0x80)
await push_rx(ctx, (addr >> 8) & 0xFF)
await push_rx(ctx, addr & 0xFF)
await push_rx(ctx, 0x00) # turnaround / prefetch window
result = await collect_tx(ctx, length)
ctx.set(reg.cs_active, 0)
await ctx.tick("sync").repeat(6)
return result
async def exi_write(ctx, addr, data):
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0xC0)
await push_rx(ctx, (addr >> 8) & 0xFF)
await push_rx(ctx, addr & 0xFF)
await push_rx(ctx, 0x00)
for b in data:
await push_rx(ctx, b)
ctx.set(reg.cs_active, 0)
await ctx.tick("sync").repeat(6)
async def drain_tx_bytes(ctx, limit=80):
"""Pop everything the register file pushed into tx_bytes."""
out = []
for _ in range(limit):
if not ctx.get(reg.tx_bytes_r_rdy):
break
out.append(ctx.get(reg.tx_bytes_r_data))
ctx.set(reg.tx_bytes_r_en, 1)
await ctx.tick("sync").repeat(1)
ctx.set(reg.tx_bytes_r_en, 0)
await ctx.tick("sync").repeat(1)
return out
async def exi_devid(ctx):
"""EXI_GetID: write 0x0000 (2 bytes), then clock 4 read bytes."""
tx_log.clear()
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0x00) # command space, reg 0, read
await push_rx(ctx, 0x00)
result = await collect_tx(ctx, 4)
ctx.set(reg.cs_active, 0)
await ctx.tick("sync").repeat(6)
return result
async def cmd_write(ctx, creg, data):
"""Command-space write, short form: a 2-byte header
[0x40|reg, 0x00] then the data bytes -- three bytes total for a
single-byte write, NOT a 4-byte transfer."""
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0x40 | (creg & 0x3F))
await push_rx(ctx, 0x00)
for b in data:
await push_rx(ctx, b)
ctx.set(reg.cs_active, 0)
await ctx.tick("sync").repeat(6)
async def cmd_read(ctx, creg, length=1):
"""Command-space read: 2-byte header, then clock `length` bytes."""
tx_log.clear()
ctx.set(reg.cs_active, 1)
await push_rx(ctx, creg & 0x3F)
# Idle while header byte 1 is "on the wire". The device must push its
for _ in range(4):
sample_tx(ctx) # the push lands HERE; a bare tick loses it
await ctx.tick("sync")
await push_rx(ctx, 0x00)
result = await collect_tx(ctx, length)
ctx.set(reg.cs_active, 0)
await ctx.tick("sync").repeat(6)
return result
async def cmd_write_long(ctx, creg, val):
"""Command-space write, long form: ONE 4-byte transfer
[0x40|reg, 0x00, 0x00, val] -- exi_imm_read_write(..., 4)."""
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0x40 | (creg & 0x3F))
await push_rx(ctx, 0x00)
await push_rx(ctx, 0x00) # padding, NOT the value
await push_rx(ctx, val) # the value is the LAST byte
ctx.set(reg.cs_active, 0)
await ctx.tick("sync").repeat(6)
async def cmd_read_long(ctx, creg):
"""Command-space read, long form: a 4-byte read/write whose byte 3
is the response. Returns (byte2, byte3)."""
tx_log.clear()
ctx.set(reg.cs_active, 1)
await push_rx(ctx, creg & 0x3F)
for _ in range(4):
sample_tx(ctx)
await ctx.tick("sync")
await push_rx(ctx, 0x00)
got = await collect_tx(ctx, 2)
ctx.set(reg.cs_active, 0)
await ctx.tick("sync").repeat(6)
return got
# 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 + arbiter as a request/grant port.
The response FIFO is gone: the arbiter drives spram_rsp_valid directly
for exactly ONE cycle, one cycle after it takes a request. Mirror that
precisely -- a response held high for two cycles makes the register
file push the same byte twice.
"""
busy = False
held = 0
async for vals in ctx.tick("sync").sample(
reg.spram_req_r_rdy, reg.spram_req_r_data):
rdy, addr = vals[-2:]
ctx.set(reg.spram_req_r_en, 0)
ctx.set(reg.spram_rsp_valid, 0)
if busy: # deliver, one cycle after accept
ctx.set(reg.spram_rsp_data, held)
ctx.set(reg.spram_rsp_valid, 1)
busy = False
elif rdy: # accept one request
held = spram_mem.get(addr, 0)
ctx.set(reg.spram_req_r_en, 1)
busy = True
errors = []
async def testbench(ctx):
ctx.set(reg.tx_rdy, 1) # tx_fifo always has room in this model
await ctx.tick("sync").repeat(8)
# T1: a device-ID query must push NOTHING.
result = await exi_devid(ctx)
if result != []:
errors.append(f"T1 device-ID query must push nothing, got {result}")
print(f"T1 device-ID query pushes nothing: {result == []}")
# T1b: both ring pointers must come out of reset at the ring's FIRST
for name, a in (("RWP", _RWP_LO), ("RRP", _RRP_LO)):
got = await exi_read(ctx, a, 1)
if got != [_RX_PAGE_FIRST]:
errors.append(f"T1b {name} reset: expected "
f"[{_RX_PAGE_FIRST}], got {got}")
print(f"T1b {name} at reset: {got} (want [{_RX_PAGE_FIRST}])")
# T1c: an NCRA reset must put BOTH pointers back to page 1, even after
await exi_write(ctx, _RRP_LO, [7]) # move RRP off page 1
got = await exi_read(ctx, _RRP_LO, 1)
if got != [7]:
errors.append(f"T1c RRP should be writable, got {got}")
# Move RWP too, the way the assembler does -- otherwise this half of
# the test has no teeth, because RWP would read 1 whether or not the
# reset clears it.
ctx.set(reg.rx_wptr_w_data, 9)
ctx.set(reg.rx_wptr_w_en, 1)
await ctx.tick("sync").repeat(1)
ctx.set(reg.rx_wptr_w_en, 0)
await ctx.tick("sync").repeat(4)
got = await exi_read(ctx, _RWP_LO, 1)
if got != [9]:
errors.append(f"T1c RWP should track the assembler, got {got}")
await exi_write(ctx, _NCRA, [0x01]) # NCRA reset
await ctx.tick("sync").repeat(4)
for name, a in (("RWP", _RWP_LO), ("RRP", _RRP_LO)):
got = await exi_read(ctx, a, 1)
if got != [_RX_PAGE_FIRST]:
errors.append(f"T1c {name} after NCRA reset: expected "
f"[{_RX_PAGE_FIRST}], got {got}")
print(f"T1c {name} after NCRA reset: {got} "
f"(want [{_RX_PAGE_FIRST}])")
# ...and the constant must not drift from the assembler's, which owns
# the write pointer. Two copies of a page number is exactly how the
# off-by-one got in.
from exi_bba.rx_frame_assembler import _RX_PAGE_FIRST as _ASM_FIRST
if _RX_PAGE_FIRST != _ASM_FIRST:
errors.append(f"T1b ring-first-page mismatch: register file "
f"{_RX_PAGE_FIRST} vs assembler {_ASM_FIRST}")
print(f"T1b ring first page agrees with assembler: "
f"{_RX_PAGE_FIRST == _ASM_FIRST}")
await ctx.tick("sync").repeat(4)
# T2: Write and read back PAR0-PAR3
await exi_write(ctx, _PAR0, [0xDE, 0xAD, 0xBE, 0xEF])
await ctx.tick("sync").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("sync").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("sync").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("sync").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("sync").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("sync").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("sync").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) — the real protocol has no length
# field, so the data phase simply runs for as long as CS is held.
N = 12
tx_log.clear()
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0x80) # register space, read
await push_rx(ctx, 0x01) # addr[15:8] -> 0x0100
await push_rx(ctx, 0x00) # addr[7:0]
await push_rx(ctx, 0x00) # turnaround byte
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("sync")
ctx.set(reg.cs_active, 0) # end the transaction
await ctx.tick("sync").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}")
# T8: a MULTI-BYTE write to TXDATA must push EVERY byte.
payload = [(0x11 + i * 3) & 0xFF for i in range(64)]
await exi_write(ctx, _TXDATA, payload)
got = await drain_tx_bytes(ctx)
if got != payload:
errors.append(f"T8 multi-byte TXDATA write: got {got}, "
f"want {payload}")
print(f"T8 multi-byte TXDATA write: {len(got)}/{len(payload)} bytes "
f"{'ok' if got == payload else 'MISMATCH'}")
# T9: TX completion must clear the ST bits and re-arm for the next
await exi_write(ctx, _NCRA, [0x08 | 0x02]) # SR + ST0
await ctx.tick("sync").repeat(2)
ctx.set(reg.tx_irq_i, 1) # frame completed
await ctx.tick("sync").repeat(1)
ctx.set(reg.tx_irq_i, 0)
await ctx.tick("sync").repeat(2)
got = await exi_read(ctx, _NCRA, 1)
if got and (got[0] & 0x06):
errors.append(f"T9 ST bits not cleared on TX done: NCRA=0x{got[0]:02X}")
print(f"T9 NCRA after TX done: 0x{got[0]:02X} (ST bits must be clear)")
# ...and a SECOND ST-triggered transmit must still queue, which it
# cannot if tx_queued was left set.
tx_ctrl_before = ctx.get(reg.tx_ctrl_r_rdy)
await exi_write(ctx, _NCRA, [0x08 | 0x02])
await ctx.tick("sync").repeat(4)
if not ctx.get(reg.tx_ctrl_r_rdy):
errors.append("T9 second ST trigger did not queue a frame")
print(f"T9 second ST trigger queues: {bool(ctx.get(reg.tx_ctrl_r_rdy))} "
f"(was {bool(tx_ctrl_before)})")
# ── T10: command write, 2-byte header + a 1-byte data phase ──────
await cmd_write(ctx, 0x02, [0xF8]) # unmask
got = await cmd_read(ctx, 0x02, 1)
print(f"T10 cmd write reg2=0xF8 -> read back {[hex(x) for x in got]}")
if got != [0xF8]:
errors.append(f"T10 command-space write dropped: read back {got}")
await cmd_write(ctx, 0x02, [0x00]) # ... and IRMASKALL
got2 = await cmd_read(ctx, 0x02, 1)
print(f"T10b cmd write reg2=0x00 -> read back {[hex(x) for x in got2]}")
if got2 != [0x00]:
errors.append(f"T10b command-space rewrite failed: {got2}")
# ── T11: start transmit with NO TWD write; length must be counted ─
await drain_tx_bytes(ctx) # clear anything pending
while ctx.get(reg.tx_ctrl_r_rdy): # and any queued length
ctx.set(reg.tx_ctrl_r_en, 1)
await ctx.tick("sync")
ctx.set(reg.tx_ctrl_r_en, 0)
await ctx.tick("sync")
ctx.set(reg.tx_irq_i, 1) # TX done -> clears tx_queued
await ctx.tick("sync")
ctx.set(reg.tx_irq_i, 0)
await ctx.tick("sync").repeat(2)
# ── T15: reset defaults, and every stored register reads back ────
defaults = ((_NWAYC, _NWAYC_VAL), (_MISC, _MISC_VAL),
(_BP_LO, _BP_VAL), (_RHBP_LO, _RHBP_VAL))
for addr, want in defaults:
got = (await exi_read(ctx, addr, 1))[0]
if got != want:
errors.append(f"T15 reg 0x{addr:02X} default 0x{got:02X}, "
f"want 0x{want:02X}")
print(f"T15 defaults: NWAYC/MISC/BP/RHBP = "
f"{[hex((await exi_read(ctx, a, 1))[0]) for a, _ in defaults]}")
# A host's init sequence, in order.
init_writes = ((_NCRB, 0x52), (_RXINTT_LO, 0x00), (_RXINTT_HI, 0x06),
(_MISC2, 0x80), (_TLBP_LO, 0x00), (_TLBP_HI, 0x00),
(_BP_LO, 0x01), (_BP_HI, 0x00),
(_RHBP_LO, 0x0F), (_RHBP_HI, 0x00),
(_GCA, 0x08), (_NWAYC, 0x0C))
for addr, val in init_writes:
await exi_write(ctx, addr, [val])
for addr, val in init_writes:
got = (await exi_read(ctx, addr, 1))[0]
if got != val:
errors.append(f"T15 reg 0x{addr:02X}: wrote 0x{val:02X}, "
f"read 0x{got:02X}")
print(f"T15 all {len(init_writes)} init writes read back")
# NCRB is what the RX filter will be steered from, so the bits must
# survive: 0x52 = AB | CA | 2_PACKETS_PER_INT, with PR and PM CLEAR
# (so the real BBA delivers own-MAC + broadcast only).
ncrb = ctx.get(reg.ncrb)
pr = (ncrb >> _NCRB_PR) & 1
pm = (ncrb >> _NCRB_PM) & 1
ab = (ncrb >> _NCRB_AB) & 1
print(f"T15 ncrb export = 0x{ncrb:02X} (PR={pr} PM={pm} AB={ab})")
if ncrb != 0x52 or pr or pm or not ab:
errors.append(f"T15 ncrb export 0x{ncrb:02X}, want 0x52 "
f"(PR=0 PM=0 AB=1)")
if ctx.get(reg.rhbp) != 0x00F or ctx.get(reg.bp) != 0x001:
errors.append(f"T15 ring geometry exports: bp="
f"0x{ctx.get(reg.bp):03X} rhbp="
f"0x{ctx.get(reg.rhbp):03X}, want 0x001/0x00F")
print(f"T15 geometry exports: bp=0x{ctx.get(reg.bp):03X} "
f"rhbp=0x{ctx.get(reg.rhbp):03X}")
# ── T14: the command-register map ────────────────────────────────
rev = (await cmd_read(ctx, _CMD_REVID, 1))[0]
print(f"T14 cmd 0x01 revision ID = 0x{rev:02X} (want 0x00, real HW)")
if rev != _CMD_REVID_VAL:
errors.append(f"T14 revision ID 0x{rev:02X}, want "
f"0x{_CMD_REVID_VAL:02X}")
# Read-only: a write must not change it.
await cmd_write(ctx, _CMD_REVID, [0xA5])
rev2 = (await cmd_read(ctx, _CMD_REVID, 1))[0]
if rev2 != _CMD_REVID_VAL:
errors.append(f"T14 revision ID writable: became 0x{rev2:02X}")
# The write-only ones must at least STICK, so software that writes
# unconditionally.
for creg, val in ((0x04, 0xD1), (0x05, 0x4E), (0x09, 0x3C),
(0x0B, 0x77), (0x0F, 0x11)):
await cmd_write(ctx, creg, [val])
rb = (await cmd_read(ctx, creg, 1))[0]
if rb != val:
errors.append(f"T14 cmd 0x{creg:02X}: wrote 0x{val:02X}, "
f"read 0x{rb:02X}")
print(f"T14 cmd 0x04/05/09/0B/0F all read back what was written")
# Out of range still reads 0, so a stray high register cannot alias
# onto a real one.
hi = (await cmd_read(ctx, 0x20, 1))[0]
if hi != 0x00:
errors.append(f"T14 cmd 0x20 reads 0x{hi:02X}, want 0x00")
# ── T13: the command-level interrupt and its mask ────────────────
await exi_write(ctx, _IR, [0xFF]) # clear MAC IR
await exi_write(ctx, _IMR, [0x86]) # enable RBFI|TI|RI
await cmd_write(ctx, 0x02, [0xF8]) # IRMASKNONE
before = (await cmd_read(ctx, 0x03, 1))[0]
int_idle = ctx.get(reg.exi_int_n)
ctx.set(reg.rx_irq_i, 1) # a frame arrives
await ctx.tick("sync")
ctx.set(reg.rx_irq_i, 0)
await ctx.tick("sync").repeat(3)
after = (await cmd_read(ctx, 0x03, 1))[0]
int_pend = ctx.get(reg.exi_int_n)
print(f"T13 cmd 0x03: idle=0x{before:02X} after RI=0x{after:02X}; "
f"INT_N {int_idle} -> {int_pend}")
if before & 0x80:
errors.append(f"T13 cmd 0x03 bit7 set while idle: 0x{before:02X}")
if not (after & 0x80):
errors.append(f"T13 cmd 0x03 bit7 NOT set after RI: 0x{after:02X} "
f"- both drivers would skip bba_interrupt()")
if int_pend:
errors.append("T13 INT_N not asserted with an unmasked MAC event")
# Masking at the COMMAND level must drop INT_N -- this is how both
# drivers stop the line re-asserting while their handler runs.
await cmd_write(ctx, 0x02, [0x00]) # IRMASKALL
await ctx.tick("sync").repeat(3)
masked = ctx.get(reg.exi_int_n)
still = (await cmd_read(ctx, 0x03, 1))[0]
print(f"T13b IRMASKALL: INT_N={masked} (want 1), "
f"status still 0x{still:02X}")
if not masked:
errors.append("T13b INT_N still asserted after IRMASKALL")
if not (still & 0x80):
errors.append("T13b masking must not clear the pending status")
await cmd_write(ctx, 0x02, [0xF8])
await exi_write(ctx, _IR, [0xFF]) # tidy up
# ── T12: command write as ONE 4-byte transfer, value in byte 3 ───
await cmd_write_long(ctx, 0x02, 0x5A)
both = await cmd_read_long(ctx, 0x02)
print(f"T12 4-byte cmd write 0x5A -> bytes 2,3 = "
f"{[hex(x) for x in both]}")
if len(both) < 2 or both[1] != 0x5A:
errors.append(f"T12 long-form cmd write: byte 3 = "
f"{[hex(x) for x in both]}, want 0x5A")
if len(both) >= 1 and both[0] != 0x5A:
errors.append(f"T12 byte-2 slot also wrong: {hex(both[0])}")
await cmd_write_long(ctx, 0x02, 0x00) # restore
frame11 = [0x40 + i for i in range(24)]
await exi_write(ctx, _TXDATA, frame11) # the frame, in ONE write
await exi_write(ctx, _NCRA, [0x04]) # ST trigger, no TWD
qlen = ctx.get(reg.tx_ctrl_r_data) if ctx.get(reg.tx_ctrl_r_rdy) else None
body = await drain_tx_bytes(ctx)
print(f"T11 TX without TWD: queued len={qlen} "
f"bytes={len(body)}")
if qlen != len(frame11):
errors.append(f"T11 queued length {qlen}, want {len(frame11)} "
f"(ST path must use the COUNTED length)")
if body != frame11:
errors.append(f"T11 frame bytes wrong: {len(body)} of "
f"{len(frame11)}")
# NO T10 FOR THE LATE-RESPONSE LEAK -- and that is deliberate.
sim = Simulator(reg)
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.")