From b175cb0e7799dbdfee9aa3c329e897a7e5fcdfa7 Mon Sep 17 00:00:00 2001 From: Dennis Brentjes Date: Sat, 10 Oct 2026 19:18:38 +0200 Subject: [PATCH] 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 --- exi_bba/bba_register_file.py | 1571 ++++++++++++++-------------------- 1 file changed, 637 insertions(+), 934 deletions(-) diff --git a/exi_bba/bba_register_file.py b/exi_bba/bba_register_file.py index 2d257d4..aef860b 100644 --- a/exi_bba/bba_register_file.py +++ b/exi_bba/bba_register_file.py @@ -1,945 +1,762 @@ -"""BBA register file — EXI domain. +"""BBA register file — the GameCube-facing side of the adapter. -Decodes EXI transactions (2-byte header + N data bytes), reads/writes the BBA -register space, and owns all AsyncFIFO / PulseSynchronizer CDC primitives. +Sits between the EXI byte stream (`ExiCapture`) and the ethernet side, and does +three jobs: -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) + * 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. -Addresses 0x0000–0x00FF : register file (sparse individual Signals, exi domain). -Addresses 0x0100–0x1FFF : SPRAM ring buffer (sync domain, prefetch FIFOs). +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 addresses -_NCRA = 0x00 -_IMR = 0x08 -_IR = 0x09 -_RWP_LO = 0x16 -_RWP_HI = 0x17 -_RRP_LO = 0x18 -_RRP_HI = 0x19 -# First page of the SPRAM RX ring (pages 1-15; page 0 is the register region). -# Must match rx_frame_assembler._RX_PAGE_FIRST. -_RX_PAGE_FIRST = 0x01 -_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 -# ── Registers libogc2 writes that were previously ignored ──────────── -# Addresses and hardware defaults from the official map (the BBA_* defines in -# libogc2's lwip/arch/gc/netif/gcif.c). See docs/bba_compat_todo.md items 3 -# and 4: these used to read 0x00 and drop writes, so a driver doing -# read-modify-write or sanity-checking a default saw zero. libogc2 DOES read -# NWAYC (0x30) before writing it. -_NCRB = 0x01 # Network Control Register B: filtering + RX int counter -_BP_LO = 0x0A # Boundary page pointer (TX/RX split) +# ─────────────────────────── 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 +_TLBP_LO = 0x0C # TX Low Boundary Page: start of the TX buffer region _TLBP_HI = 0x0D -_RXINTT_LO = 0x14 # RX interrupt timer (coalescing) +_RXINTT_LO = 0x14 # RX Interrupt Timer: interrupt coalescing delay _RXINTT_HI = 0x15 -_RHBP_LO = 0x1A # RX high boundary page +_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 -_NWAYC = 0x30 # NWAY configuration -_GCA = 0x32 # GMAC configuration A +_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 -_NWAYC_VAL = 0x84 # hardware reset default -_MISC_VAL = 0x3C # hardware reset default -_BP_VAL = 0x01 # our TX/RX split: TX = page 0, RX starts at page 1 -_RHBP_VAL = 0x0F # our ring ends at page 15 +# 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 -# NCRB bit positions (libogc2 writes AB|CA|2_PACKETS_PER_INT = 0x52) -_NCRB_PR = 0 # promiscuous -_NCRB_PM = 2 # pass multicast -_NCRB_AB = 4 # accept broadcast +# Lowest address that reads from SPRAM rather than the register file. +_SPRAM_BASE = 0x100 -_CMD_REVID = 0x01 # command-space revision ID (read-only) -_CMD_REVID_VAL = 0x00 # real-hardware value: never revised +# Read-only register values. +_NWAYS_VAL = 0x17 # 100 Mbit full duplex, link up, autonegotiation complete +_HIPR_VAL = 0x01 # adapter present -_NWAYS_VAL = 0x17 -_HIPR_VAL = 0x01 +# 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 -# Device ID returned on first 4-byte read of addr 0x0000 -_DEVICE_ID = [0x04, 0x02, 0x02, 0x00] +# 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 and BBA register file with CDC bridges. + """EXI transaction decoder, BBA register space, and the FIFO bridges. - Sync-domain FIFO/pulse ports are wired by BBATop to the sync-domain modules. + 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 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. + # ── 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() - # ── Ring-overflow protection (RBFI) ─────────────────────────────── - # RRP out to RXFrameAssembler so it can tell how much of the ring is - # still unread and DROP frames instead of overwriting them. - self.rrp = Signal(8, init=_RX_PAGE_FIRST) - # Pulse in: a frame was dropped because the ring was full. Sets - # IR[7] (RBFI), which the GC's IMR = 0x86 enables alongside RI and TI, - # so the driver learns it lost a frame instead of silently reading - # corrupted data. - self.rx_overflow = 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 - # ── Interrupt (exi domain) ──────────────────────────────────────── - self.exi_int_n = Signal(init=1) + # ── 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 - # ── 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() - # Exported for the RX-filtering work (compat TODO item 1) and the ring - # geometry check (item 2). Nothing consumes them yet; they are here so - # the register decode and the behaviour that uses it land separately. - self.ncrb = Signal(8) # filtering / RX-interrupt-counter bits - self.bp = Signal(12) # boundary page: where the RX ring starts - self.rhbp = Signal(12) # RX high boundary page: where it ends - - # ── CDC FIFO sync-domain sides (wired by BBATop) ────────────────── - # SPRAM request exi→sync: sync reads these + # ── 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 response sync→exi: sync writes these - # SPRAM response, DIRECT from the arbiter -- no FIFO. That FIFO cost - # one 24 MHz cycle (42 ns), and the round trip only fits inside the - # protocol's turnaround byte without it (333 ns -> 292 ns against a - # 315 ns deadline). Responses must therefore be consumed the cycle - # they are valid, which is why only ONE request may be outstanding. + # 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() - # 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() + # ──────────────────────────── structure ───────────────────────────── - # 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() + 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 - # 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 ──────────────────────────────────────────────────── - # Plain SyncFIFOs: `exi` and `sync` were always the SAME clock net - # (bba_top wired ClockSignal("sync") to ClockSignal("exi")), so the - # AsyncFIFOs between them bought nothing and cost ~3 cycles each way. - # On the SPRAM path that latency exceeded the protocol's turnaround - # byte and shifted every DMA read by one byte. One slow domain now. - spram_req = SyncFIFO(width=16, depth=4) - # TX byte FIFO: deep enough for a WHOLE maximum-size frame. - # - # SPI cannot back-pressure, so BBARegisterFile silently drops bytes - # when w_rdy is low -- there is no mechanism to tell the GC to wait. - # The depth is therefore not a buffering choice, it is the only thing - # standing between a drain stall and a corrupted frame. - # - # At 16 deep it held 16 bytes = ~4.7 us at the EXI byte rate (296 - # ns/byte). TXFrameDrain shares the W5100 bus arbiter with RX and the - # socket-3 UDP test, so any stall longer than that dropped bytes - # mid-frame. Swiss writes an ENTIRE frame in one register-space - # transaction (bba_outs to BBA_WRTXFIFOD), so 16 could never work for - # real traffic; the on-console test app only got away with it by - # writing one byte per transaction, which paced the drain by accident. - # - # 2048 covers the 1518-byte maximum frame with margin, so a frame - # cannot be truncated even if the drain makes no progress at all for - # the whole transaction. Costs ~4 of 27 free BRAMs. - 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 = spram_req - 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), + 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(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_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(tx_ctrl.r_data), - tx_ctrl.r_en .eq(self.tx_ctrl_r_en), - self.tx_ctrl_r_rdy .eq(tx_ctrl.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), - 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), + 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(rx_rptr.r_data), - rx_rptr.r_en .eq(self.rx_rptr_r_en), - self.rx_rptr_r_rdy .eq(rx_rptr.r_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])), ] - # ── PulseSynchronizers ─────────────────────────────────────────── - # Single slow domain, so these are plain wires rather than - # PulseSynchronizers. A pulse generated here is already in the only - # domain that exists. - ncra_rst_pulse = Signal() - m.d.comb += self.ncra_rst_o.eq(ncra_rst_pulse) + def _read_mux(self, m, r, t): + """Combinational read data for the current register address. - # ── 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) - # Both ring pointers reset to the ring's FIRST PAGE, not to 0. - # The SPRAM ring is pages 1-15 (page 0 holds the register region), and - # RXFrameAssembler's own write pointer resets to page 1 -- so a 0 here - # disagreed with the assembler and, worse, told the GC driver to read - # its first frame from page 0. The frame is at page 1; page 0 is the - # register page. Hardware symptom: "RRP page 0 outside ring pages - # 1-15" from the test app's DMA descriptor check, on a board whose RX - # path was otherwise working. - # RRP is driver-owned (the GC writes it after consuming frames), but - # its RESET value is ours, and RRP == RWP == 1 is the correct initial - # state: empty, and pointing at the start of the ring. - r_rwp_lo = Signal(8, init=_RX_PAGE_FIRST) - r_rrp_lo = Signal(8, init=_RX_PAGE_FIRST) - # 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 ──────────────────────────────────────────── - # Real GC<->BBA header (see docs/exi_protocol_real.md), verified - # against libogc and Swiss. Byte 0 bit7 selects the address space and - # bit6 is the write flag; there is NO length field -- the data phase - # runs until CS. - # command (bit7=0): [reg&0x3F, 00, 00, data] one 4-byte transfer - # register (bit7=1): [0x80|w<<6, ah, al, 00] + data phase until CS - addr = Signal(16) - is_write = Signal() - cmd_space = Signal() # this transaction is a command access - cmd_reg = Signal(6) - cmd_wreg = Signal(6) # target of a pending command-space write - cmd_have = Signal() # at least one data byte seen this txn - cmd_pushes = Signal(2) # command-read responses pushed - byte_ctr = Signal(3) # response-byte index (device-ID sequence) - cmd_wr = Signal() # command-space write pending - prefetched = Signal() # first response byte already pushed - cmd_wdata = Signal(8) - # EXI-side command registers. Swiss uses exactly two: 0x02 is the EXI - # interrupt mask and 0x03 the EXI interrupt status (write-1-to-clear). - r_cmd_imr = Signal(8, init=0xF8) - # Registers that exist on real hardware and were previously ignored. - # Reset values are the documented hardware defaults where known, and - # OUR layout where the register describes it (BP/RHBP), so a driver - # that reads them back without writing sees something consistent. - r_ncrb = Signal(8) - r_bp_lo = Signal(8, init=_BP_VAL) - r_bp_hi = Signal(8) - r_tlbp_lo = Signal(8) - r_tlbp_hi = Signal(8) - r_rxintt_lo = Signal(8) - r_rxintt_hi = Signal(8) - r_rhbp_lo = Signal(8, init=_RHBP_VAL) - r_rhbp_hi = Signal(8) - r_nwayc = Signal(8, init=_NWAYC_VAL) - r_gca = Signal(8) - r_misc = Signal(8, init=_MISC_VAL) - r_misc2 = Signal(8) - # Generic storage for the remaining command registers, so a write - # sticks and a read returns it instead of 0x00. Every command - # register the real drivers touch is in 0x00-0x0F; anything above - # reads 0. See the table in CLAUDE.md for who touches what. - cmd_misc = Array([Signal(8, name=f"cmd_misc_{i}") for i in range(16)]) - # Reset UNMASKED: the real device's power-on value is unknown, and - # both drivers set this explicitly (IRMASKALL first thing in init), so - # the permissive default costs them nothing while keeping INT_N usable - # for software that never writes it. - r_cmd_ir = Signal(8) - tx_frame_len = Signal(16) - # TX trigger bookkeeping. `twd_armed` fires one cycle AFTER TWD_HI is - # written, so tx_frame_len has both halves by then; `tx_queued` records - # that the drain already has a length for this frame. - twd_armed = Signal() - tx_queued = Signal() - # Bytes actually streamed into TXDATA since the last trigger. - # - # ⚠️ Needed because libogc DOES NOT WRITE TWD AT ALL. Its - # __bba_link_tx is four transactions -- read NWAYS, one register-space - # write of the whole frame to 0x0048 (EXI_Dma for the 32-byte-aligned - # bulk plus EXI_ImmEx for the remainder), read NCRA, write NCRA = 0x04 - # -- with no TWD_LO/TWD_HI anywhere. The real device must therefore - # count what it was given, which is what this does. Using - # tx_frame_len on the ST fallback path would transmit a stale or zero - # length for any driver that skips TWD. - txdata_cnt = Signal(16) - - # 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(16) - outstanding = Signal(4) - sp_hold = Signal(8) # response held when tx_fifo was full - sp_pending = Signal() - 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(16) - 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) + 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): - 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(_NCRB): m.d.comb += reg_rdval.eq(r_ncrb) - with m.Case(_BP_LO): m.d.comb += reg_rdval.eq(r_bp_lo) - with m.Case(_BP_HI): m.d.comb += reg_rdval.eq(r_bp_hi) - with m.Case(_TLBP_LO): m.d.comb += reg_rdval.eq(r_tlbp_lo) - with m.Case(_TLBP_HI): m.d.comb += reg_rdval.eq(r_tlbp_hi) - with m.Case(_RXINTT_LO): m.d.comb += reg_rdval.eq(r_rxintt_lo) - with m.Case(_RXINTT_HI): m.d.comb += reg_rdval.eq(r_rxintt_hi) - with m.Case(_RHBP_LO): m.d.comb += reg_rdval.eq(r_rhbp_lo) - with m.Case(_RHBP_HI): m.d.comb += reg_rdval.eq(r_rhbp_hi) - with m.Case(_NWAYC): m.d.comb += reg_rdval.eq(r_nwayc) - with m.Case(_GCA): m.d.comb += reg_rdval.eq(r_gca) - with m.Case(_MISC): m.d.comb += reg_rdval.eq(r_misc) - with m.Case(_MISC2): m.d.comb += reg_rdval.eq(r_misc2) - 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) + # 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) - # The device-ID constant lives in SPIMode3Slave now (it must be - # answered with zero CDC latency), so there is no copy here. - - # ── Command-level interrupt status (reg 0x03) ──────────────────── - # ⚠️ THE BBA HAS TWO INTERRUPT LEVELS and both drivers depend on it. - # Their handlers are, identically: - # - # status = bba_cmd_in8(0x03); /* COMMAND level */ - # if (status & 0x80) bba_interrupt(); /* only then the MAC level */ - # - # and bba_interrupt() is what reads register-space IR/IMR and calls - # bba_start_rx(). Bit 7 of command register 0x03 means "a MAC - # interrupt is pending". This file implemented only the MAC level and - # never set that bit, so a command read of 0x03 returned 0x00, every - # branch of both handlers fell through, and NEITHER DRIVER WOULD EVER - # PROCESS A RECEIVED FRAME. Our own tooling never noticed because - # gc_bba_test and netboot poll RWP/IR directly and do not use the - # interrupt path at all. - # - # Bit 7 is a LIVE LEVEL, not a latched bit, deliberately: both drivers - # clear the MAC IR inside bba_interrupt() and only then write-1-clear - # command 0x03. A latched bit would be cleared by that write even if - # a new frame had arrived in between, losing the event; a level stays - # asserted exactly as long as the MAC condition does. - mac_int = Signal() - m.d.comb += mac_int.eq((r_ir & r_imr).any()) - - cmd_ir_val = Signal(8) - m.d.comb += cmd_ir_val.eq(Cat(r_cmd_ir[0:7], mac_int)) - - cmd_rdval = Signal(8) - with m.Switch(cmd_reg): - with m.Case(_CMD_REVID): - # Revision ID. REAL HARDWARE VALUE: 0x00 -- the BBA never - # received a functional firmware revision that incremented - # it. libogc2 pre-seeds priv->revid = 0xF0 and then - # overwrites it with whatever this read returns, so 0xF0 is - # its placeholder, NOT the device's value. - m.d.comb += cmd_rdval.eq(_CMD_REVID_VAL) - with m.Case(0x02): m.d.comb += cmd_rdval.eq(r_cmd_imr) - with m.Case(0x03): m.d.comb += cmd_rdval.eq(cmd_ir_val) + 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(cmd_reg <= 0x0F): - m.d.comb += cmd_rdval.eq(cmd_misc[cmd_reg[0:4]]) + 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_rdval.eq(0) + m.d.comb += cmd_rd.eq(0) - rd_val = Signal(8) # response for the current non-SPRAM read byte - with m.If(cmd_space): - m.d.comb += rd_val.eq(cmd_rdval) + 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_rdval) + m.d.comb += rd_val.eq(reg_rd) + return rd_val - # ── Default strobes ────────────────────────────────────────────── - m.d.sync += [ - 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_pulse .eq(0), - ] - # The SPRAM request write port is COMBINATIONAL (see RHDR_LO): a - # registered w_en costs a 24 MHz cycle the turnaround byte cannot - # spare. Default it off here and let the states assert it. - m.d.comb += [ - spram_req.w_en .eq(0), - spram_req.w_data.eq(0), - 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. - ] + def _register_write(self, m, r, f, t): + """Apply one register-space write of `rx_data` to address `rd_sel`. - # ── 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. + 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"): - # Byte 0 selects the space and direction. The REAL protocol - # (docs/exi_protocol_real.md) has no length field: the GC - # clocks as many data bytes as it wants and ends with CS. with m.If(self.rx_rdy): m.d.comb += self.rx_en.eq(1) - m.d.sync += [is_write.eq(self.rx_data[6]), prefetched.eq(0), - byte_ctr.eq(0), sp_req.eq(0), outstanding.eq(0), - # sp_pending too: a byte still held from the - # PREVIOUS transaction would be pushed as this - # one's first response and shift the whole - # read by one. - sp_pending.eq(0), - cmd_have.eq(0), cmd_pushes.eq(0)] - with m.If(self.rx_data[7]): # register space - m.d.sync += cmd_space.eq(0) + 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(): # command space - m.d.sync += [cmd_space.eq(1), - cmd_reg.eq(self.rx_data[0:6]), - addr.eq(self.rx_data[0:6]), - eff_addr.eq(self.rx_data[0:6])] - # NOTE: the device-ID reply is NOT produced here. - # A 2-byte command header leaves one byte time for a - # round trip through both AsyncFIFOs, which measured - # too slow on a console (the ID came back one byte - # late). SPIMode3Slave answers it from the EXI clock - # domain instead -- see its device-ID fast path. + 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" - # ── register space: [0x80|w<<6, ah, al, 0x00] ────────────────── with m.State("RHDR_HI"): with m.If(self.rx_rdy): m.d.comb += self.rx_en.eq(1) - m.d.sync += addr[8:16].eq(self.rx_data) + m.d.sync += t.addr[8:16].eq(self.rx_data) m.next = "RHDR_LO" with m.State("RHDR_LO"): - # The address completes HERE, one whole byte before the data - # phase. That trailing 0x00 byte is the device's prefetch - # window -- the old 2-byte-plus-length format spent those bits - # on the length field and left zero slack, which is why the - # first on-console read came back 22 bit-times late. Kick the - # fetch off now so the answer is ready for the first data slot. with m.If(self.rx_rdy): m.d.comb += self.rx_en.eq(1) - full = Cat(self.rx_data, addr[8:16]) # 16-bit address - m.d.sync += [addr.eq(full), eff_addr.eq(full), - sp_addr.eq(full)] - with m.If(~is_write & (full >= 0x100) & spram_req.w_rdy): - # COMBINATIONAL write: the turnaround byte gives only - # ~296 ns and the SPRAM round trip needs most of it, so - # a registered w_en (one more 24 MHz cycle, ~42 ns) is - # enough to miss the window and shift the whole DMA - # read by a byte. Same-domain SyncFIFO now, so this is - # legal. - m.d.comb += [spram_req.w_data.eq(full), - spram_req.w_en.eq(1)] - m.d.sync += [sp_addr.eq(full + 1), outstanding.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"): - # Trailing header byte. For a register read, push the first - # response byte during this byte-time so it has crossed the - # tx CDC before the GC clocks data slot 0. with m.If(self.rx_rdy): m.d.comb += self.rx_en.eq(1) - with m.If(is_write): + with m.If(t.is_write): m.next = "WRITE" - with m.Elif(addr >= 0x100): + with m.Elif(t.addr >= _SPRAM_BASE): m.next = "SPRAM_STREAM" with m.Else(): m.next = "REG_READ" - with m.Elif(~is_write & (addr < 0x100) & self.tx_rdy & ~prefetched): - # Prefetch the FIRST response byte (once!) while the pad - # byte is still on the wire, so it has crossed the tx CDC - # before the GC clocks data slot 0. Must be one-shot: this - # branch is re-evaluated every cycle the pad byte has not - # arrived, and repeating it would consume the register - # stream early (symptom: reads shifted by a byte). + 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 += [eff_addr.eq(eff_addr + 1), prefetched.eq(1)] + m.d.sync += [t.eff_addr.eq(t.eff_addr + 1), + t.prefetched.eq(1)] - # ── command space: 2-byte header, then a data phase ─────────── - # - # ⚠️ THE TWO REAL DRIVERS DISAGREE ON THE LENGTH, and both must - # work. Checked against the source, not guessed: - # - # Swiss (cube/patches/bba/bba.c, bba_cmd_out8 / bba_cmd_in8): - # exi_imm_read_write(0x40<<24 | reg<<24 | val, 4) - # -> ONE 4-byte transfer [0x40|reg, 00, 00, val], and a read - # takes the LOW byte of the 32-bit result, so the response - # must land in BYTE 3. - # - # libogc (libbba.a, gcif.o, __bba_init): - # EXI_Imm (len=2, write) -> 42 00 the whole header - # EXI_ImmEx(len=1, write) -> 00 the data - # -> THREE bytes, data in byte 2, and a read takes byte 2. - # - # Both share bytes 0 and 1, so one decoder serves both: - # - # WRITE: latch every data byte and apply the LAST one at CS - # rise. Swiss sends [00, val] so the last is val; - # libogc sends [val] so the last is val. Hence NO - # auto-increment of cmd_reg -- that would apply Swiss's - # padding 00 to reg and val to reg+1. - # READ : push the response TWICE so byte 2 AND byte 3 carry it. - # Swiss clocks 4 bytes and pops both; libogc clocks 3 and - # leaves one, which the next transaction's header pops - # drain. A 2-byte header gives exactly one drain slot, - # so the leftover is bounded at one and cannot build up. - # - # This file previously had only the 4-byte form (Swiss's), and the - # decoder waited for a fourth byte -- so libogc's writes were - # dropped. Changing it to libogc's form alone then broke Swiss. - # Neither driver is "the" driver; support both. with m.State("CHDR_1"): with m.If(self.rx_rdy): m.d.comb += self.rx_en.eq(1) - # The device-ID query (command space, read, reg 0) is - # answered by SPIMode3Slave's fast path, not here: pushing - # anything for it would leave stale bytes in the tx FIFO - # that a 2-byte header has too few pops to drain. - # - # ⚠️ NO LONGER GATED ON A ONE-SHOT. This used to be - # `& id_phase`, a bit cleared by the GC's first NCRA - # reset, after which the device stopped identifying - # itself. Real hardware answers EXI_GetID whenever the - # GC asks -- that is how EXI enumerates devices -- and - # Swiss calls it from bba_exists() and again from - # bba_requires_init() long after init. With the one-shot - # spent, EXI_GetID returned 0x00000000 and Swiss reported - # NO ADAPTER AT ALL. - # - # The old justification was that the fast path would - # "hijack address 0 forever so the GC could never read - # NCRA back". That was wrong: NCRA is REGISTER space - # (0x80/0xC0 header) and this path requires byte 0 == - # 0x00, i.e. COMMAND space. They were never ambiguous, - # and no driver reads command register 0 for anything - # else (libogc2 reads 0x01/0x03/0x0B/0x0F, Swiss 0x03). - with m.If(~is_write & (cmd_reg == 0)): + with m.If(~t.is_write & (t.cmd_reg == 0)): m.next = "DEVID" - with m.Elif(is_write): + with m.Elif(t.is_write): m.next = "CMD_WRITE" with m.Else(): m.next = "CMD_READ" - with m.Elif(~is_write & self.tx_rdy & (cmd_pushes == 0)): - # Push #1, during header byte 1, so the byte-boundary pop - # presents it in byte 2 -- what libogc reads. + 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 += cmd_pushes.eq(1) + m.d.sync += t.cmd_pushes.eq(1) with m.State("CMD_READ"): - # Push #2 lands in byte 3 -- what Swiss reads. It has a whole - # byte-time to get there, unlike push #1. - with m.If(self.tx_rdy & (cmd_pushes < 2)): + # 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 += cmd_pushes.eq(cmd_pushes + 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) # Swiss's padding bytes + m.d.comb += self.rx_en.eq(1) with m.If(~self.cs_active): m.next = "HEADER0" with m.State("CMD_WRITE"): - # Data phase. Latch each byte; the LAST one before CS rise is - # the value, which is what lets Swiss's 4-byte form and - # libogc's 3-byte form both decode correctly. - with m.If(self.rx_rdy): - m.d.comb += self.rx_en.eq(1) - m.d.sync += [cmd_wdata.eq(self.rx_data), - cmd_wreg.eq(cmd_reg), - cmd_have.eq(1)] - with m.If(~self.cs_active): - with m.If(cmd_have): - m.d.sync += cmd_wr.eq(1) - m.next = "HEADER0" - - with m.State("TRAILER"): - # Soak up anything else the GC clocks, then wait for CS to rise. + # 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"): - # 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.sync += r_ncra.eq(self.rx_data) - with m.If(self.rx_data[0]): - m.d.sync += r_ncra[0].eq(0) # RESET self-clears - m.d.sync += ncra_rst_pulse.eq(1) - # An NCRA reset resets the RX ring, so the - # pointers the GC reads MUST follow -- the - # assembler's own write pointer goes back to - # page 1, and without this the register file - # keeps whatever the last frame left. - # - # The GC then reads a stale RWP pointing at a - # page the assembler is not using, so its - # first RX after a reset reads the wrong page. - # Found via netboot: after Swiss's own reset, - # syncing RRP to RWP landed on a stale page - # and the descriptor there was garbage. This - # affects any driver that resets and then - # trusts RWP, Swiss included. - m.d.sync += [ - r_rwp_lo.eq(_RX_PAGE_FIRST), - r_rrp_lo.eq(_RX_PAGE_FIRST), - ] - # Fallback trigger: TWD_HI normally queues the - # frame (see _TWD_HI). Only fire here if it did - # not, so software that skips TWD still transmits - # and we never double-queue one frame. - with m.If(self.rx_data[1:3].any() & ~tx_queued): - with m.If(tx_ctrl.w_rdy): - # The COUNTED length, not tx_frame_len: - # a driver reaching this path never wrote - # TWD, so tx_frame_len is stale or zero. - m.d.sync += tx_ctrl.w_data.eq(txdata_cnt) - m.d.sync += tx_ctrl.w_en.eq(1) - m.d.sync += tx_queued.eq(1) - m.d.sync += 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-clear - with m.Case(_RRP_LO): - m.d.sync += r_rrp_lo.eq(self.rx_data) - with m.If(rx_rptr.w_rdy): - m.d.sync += rx_rptr.w_data.eq(self.rx_data) - m.d.sync += rx_rptr.w_en.eq(1) - with m.Case(_PAR0, _PAR1, _PAR2, _PAR3, _PAR4, _PAR5): - m.d.sync += r_par[eff_addr[0:3]].eq(self.rx_data) - with m.Case(_TWD_LO): - m.d.sync += r_twd_lo.eq(self.rx_data) - m.d.sync += tx_frame_len[0:8].eq(self.rx_data) - with m.Case(_TWD_HI): - m.d.sync += r_twd_hi.eq(self.rx_data) - m.d.sync += tx_frame_len[8:16].eq(self.rx_data) - # Hand the length to TXFrameDrain NOW, not at the ST - # trigger. The drain blocks on tx_bytes_r_rdy, so - # starting it here lets it consume bytes AS the GC - # streams them and keeps tx_bytes a rate-matching - # buffer. Waiting for ST meant the whole frame had - # to fit in the 16-deep FIFO, and anything past 16 - # bytes was silently dropped (hardware 2026-09-30: - # frames truncated at exactly 16 bytes on the wire). - m.d.sync += twd_armed.eq(1) - with m.Case(_TXDATA): - with m.If(tx_bytes.w_rdy): - m.d.sync += tx_bytes.w_data.eq(self.rx_data) - m.d.sync += tx_bytes.w_en.eq(1) - m.d.sync += txdata_cnt.eq(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) - # All other addresses silently ignored - - # No length field in the real protocol: consume data - # bytes until CS rises. - # - # ⚠️ TXDATA IS A FIFO PORT, NOT A REGISTER. A whole frame - # is written to that ONE address, so the address must not - # advance across its data phase. Swiss does exactly this - # (bba_outs to BBA_WRTXFIFOD streams the entire frame in a - # single register-space write), so incrementing here - # delivered only the FIRST byte of every frame and silently - # dropped the rest into 0x49+ -- which the switch above - # ignores. - # - # It went unnoticed because the on-console test app wrote - # TXDATA one byte per EXI transaction, so every byte got a - # fresh header at 0x48. The moment it batched 8 bytes per - # transaction, 7 of every 8 vanished: the frame was an - # eighth of its declared length, TXFrameDrain blocked - # waiting for the rest and TI never set (hardware - # 2026-10-09: txto=16, "no ACK after 20 tries"). - # - # Every other register IS a register and must advance, so - # that bba_outs can write PAR0-5 or MAR0-7 as a block. - with m.If(rd_sel != _TXDATA): - m.d.sync += eff_addr.eq(eff_addr + 1) + self._register_write(m, r, f, t) with m.If(~self.cs_active): m.next = "HEADER0" with m.State("REG_READ"): - # Register read (addr < 0x100): stream until CS, like the SPRAM - # path. The real protocol has no length field. 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 += eff_addr.eq(eff_addr + 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) # drain dummies + m.d.comb += self.rx_en.eq(1) with m.If(~self.cs_active): m.next = "HEADER0" with m.State("DEVID"): - # The reply comes from SPIMode3Slave's device-ID fast path; - # this state only drains the GC's dummy bytes and waits for - # CS, so NOTHING is pushed into the tx FIFO here. Pushing - # would leave stale bytes a 2-byte header cannot drain. 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"): - # 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. - # With no response FIFO the reply must be taken the cycle it - # is valid, so keep exactly ONE request in flight and only - # issue when tx_fifo has somewhere to put the answer. The GC - # consumes a byte every ~296 ns and the SPRAM round trip is a - # couple of 24 MHz cycles, so serialising costs nothing. - # Only ONE request in flight (no response FIFO), and only - # when tx_fifo can take the answer. - with m.If(self.cs_active & spram_req.w_rdy & self.tx_rdy - & (outstanding == 0) & ~sp_pending): - m.d.comb += spram_req.w_data.eq(sp_addr) - m.d.comb += spram_req.w_en.eq(1) - m.d.sync += [sp_addr.eq(sp_addr + 1), - outstanding.eq(outstanding + 1)] - + # 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) # drain dummy bytes - + m.d.comb += self.rx_en.eq(1) 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(self.rx_rdy): m.d.comb += self.rx_en.eq(1) - with m.If((outstanding == 0) & ~sp_pending & ~self.rx_rdy): + with m.If((t.outstanding == 0) & ~t.sp_pending & ~self.rx_rdy): m.next = "HEADER0" - # ── SPRAM response: consumed in ANY state ──────────────────────── - # The request is issued in RHDR_LO but the reply lands ~2 cycles later, - # while the FSM is still in RHDR_PAD waiting for the turnaround byte. - # With no response FIFO there is nothing to catch it there, so handle - # it outside the FSM: push straight to tx_fifo if it has room, else - # hold it one cycle. SPRAM reads and register reads never overlap, so - # driving tx here cannot collide with the FSM's own pushes. - # ⚠️ A response is only OURS if a request is actually in flight. - # - # ETH writes preempt EXI reads in SPRAMArbiter (ETH has absolute - # priority and asserts eth_wr_ready combinationally), so a read - # response can arrive later than expected -- including after the - # transaction that asked for it has ended. Pushing it then INSERTS A - # BYTE at the start of the NEXT DMA read and shifts everything the GC - # reads by one. It also underflowed `outstanding` (4-bit: 0 - 1 = 15), - # which broke the prefetch bound for the rest of that transaction. - # - # Hardware symptom (2026-10-09): the SPRAM ring descriptor appearing at - # page offset +1 instead of +0 for 17 of 22 frames, VARYING frame to - # frame, while the assembler was writing it correctly all along. It - # only happens when the GC reads the ring while frames are being - # written into it, which no simulation did. - with m.If(self.spram_rsp_valid & (outstanding != 0) - & self.tx_rdy & ~sp_pending): - m.d.comb += [self.tx_data.eq(self.spram_rsp_data), self.tx_en.eq(1)] - m.d.sync += outstanding.eq(outstanding - 1) - with m.Elif(self.spram_rsp_valid & (outstanding != 0)): - m.d.sync += [sp_hold.eq(self.spram_rsp_data), sp_pending.eq(1), - outstanding.eq(outstanding - 1)] - with m.Elif(sp_pending & self.tx_rdy): - m.d.comb += [self.tx_data.eq(sp_hold), self.tx_en.eq(1)] - m.d.sync += sp_pending.eq(0) + 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" - # ── Command-space write, applied the cycle after the data byte ─── - with m.If(cmd_wr): - m.d.sync += cmd_wr.eq(0) - with m.Switch(cmd_wreg): + 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 # revision ID is READ-ONLY - with m.Case(0x02): m.d.sync += r_cmd_imr.eq(cmd_wdata) - with m.Case(0x03): m.d.sync += r_cmd_ir.eq(r_cmd_ir & ~cmd_wdata) + 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(): - # Everything else is plain storage. GUESSED behaviour -- - # these are write-only in both drivers and never read - # back, so read-what-you-wrote is the least surprising - # choice and lets our own tests verify the writes landed. - with m.If(cmd_wreg <= 0x0F): - m.d.sync += cmd_misc[cmd_wreg[0:4]].eq(cmd_wdata) + with m.If(t.cmd_wreg <= _CMD_LAST): + m.d.sync += r.cmd_misc[t.cmd_wreg[0:4]].eq(t.cmd_wdata) - # ── Exported register values ───────────────────────────────────── - # For the RX-filtering work (compat TODO item 1) and the ring geometry - # check (item 2). Nothing consumes them yet: the decode lands first so - # the behaviour that uses it is a separate, testable change. + 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 += [ - self.ncrb.eq(r_ncrb), - self.bp .eq(Cat(r_bp_lo, r_bp_hi[0:4])), - self.rhbp.eq(Cat(r_rhbp_lo, r_rhbp_hi[0:4])), + 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), ] - # ── Interrupt output ───────────────────────────────────────────── - m.d.comb += self.rrp.eq(r_rrp_lo) - # INT_N comes from the COMMAND level, gated by the command-level mask - # (reg 0x02). Both drivers write IRMASKALL (0x00) on entry to their - # handler and IRMASKNONE (0xF8) on exit, which is how they stop the - # line re-asserting while they work -- so honouring that mask is what - # prevents an interrupt storm during handling. - m.d.sync += self.exi_int_n.eq(~(cmd_ir_val & r_cmd_imr).any()) - - # ── Consume RWP updates from sync domain ────────────────────────── - with m.If(rx_wptr.r_rdy): - m.d.sync += rx_wptr.r_en.eq(1) - m.d.sync += r_rwp_lo.eq(rx_wptr.r_data) - - # ── PulseSynchronizer arrivals ──────────────────────────────────── - with m.If(self.rx_irq_i): - m.d.sync += r_ir[1].eq(1) # RI bit - # ── Deferred TX queue (armed by a TWD_HI write) ────────────────── - # One cycle after TWD_HI so tx_frame_len holds both bytes. Held until - # tx_ctrl has room so a full control FIFO cannot drop the trigger. - with m.If(twd_armed & tx_ctrl.w_rdy): - m.d.sync += tx_ctrl.w_data.eq(tx_frame_len) - m.d.sync += txdata_cnt.eq(0) - m.d.sync += tx_ctrl.w_en.eq(1) - m.d.sync += twd_armed.eq(0) - m.d.sync += tx_queued.eq(1) - - # TX done. Clearing the ST bits and tx_queued belongs HERE, with the - # completion that they describe. Adding the RBFI handler below split - # this block and left those two lines attached to rx_overflow -- an RX - # event -- so after the first frame tx_queued stayed set forever, which - # disables the NCRA ST fallback trigger entirely (it is guarded on - # ~tx_queued) and leaves the ST bits asserted in NCRA. Software that - # drives TX via ST instead of TWD would transmit exactly once. - with m.If(self.tx_irq_i): - m.d.sync += r_ir[2].eq(1) # TI bit - m.d.sync += r_ncra[1:3].eq(0) # clear ST bits - m.d.sync += tx_queued.eq(0) # ready for the next frame - - # RBFI: the ring was full and a frame was discarded. Reported rather - # than hidden -- the previous behaviour was to overwrite unread pages, - # so the driver got corrupted frames with no signal that anything had - # gone wrong. - with m.If(self.rx_overflow): - m.d.sync += r_ir[7].eq(1) + 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 - -# ── Testbench ───────────────────────────────────────────────────────────── - if __name__ == "__main__": import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) @@ -949,12 +766,6 @@ if __name__ == "__main__": reg = BBARegisterFile() # Drive the byte-stream interface directly (the SPI bit cadence and FIFOs - # live in ExiCapture; here we model the byte producer/consumer). - # Responses can be pushed WHILE the header is still being clocked -- the - # device prefetches during the header's turnaround byte, and the device-ID - # reply starts from header byte 0. So every helper that advances time has - # to record tx pushes, or the early ones are silently lost and reads look - # shifted. tx_log is that shared record; collect_tx drains it first. tx_log = [] def sample_tx(ctx): @@ -1037,7 +848,7 @@ if __name__ == "__main__": return result async def cmd_write(ctx, creg, data): - """Command-space write in libogc's REAL shape: a 2-byte header + """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) @@ -1054,11 +865,6 @@ if __name__ == "__main__": 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 - # response DURING that byte: the engine's byte-boundary pop for data - # slot 0 happens at the end of byte 1, which is before the FSM has even - # seen byte 1 (the rx CDC is ~125 ns). A response pushed in reaction - # to consuming byte 1 would land in slot 1 instead, so pushing both - # header bytes back-to-back here would not model the real transfer. for _ in range(4): sample_tx(ctx) # the push lands HERE; a bare tick loses it await ctx.tick("sync") @@ -1068,8 +874,8 @@ if __name__ == "__main__": await ctx.tick("sync").repeat(6) return result - async def cmd_write_swiss(ctx, creg, val): - """Swiss's command write: ONE 4-byte transfer + 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)) @@ -1079,8 +885,8 @@ if __name__ == "__main__": ctx.set(reg.cs_active, 0) await ctx.tick("sync").repeat(6) - async def cmd_read_swiss(ctx, creg): - """Swiss's command read: a 4-byte read/write whose LOW byte (byte 3) + 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) @@ -1128,22 +934,12 @@ if __name__ == "__main__": await ctx.tick("sync").repeat(8) # T1: a device-ID query must push NOTHING. - # The reply comes from SPIMode3Slave's fast path (a 2-byte command - # header leaves too little time for a round trip through both byte - # FIFOs), so anything pushed here is stale data the header has too few - # pops to drain -- which is exactly how an earlier version made the - # NEXT enumeration come back a slot late. The ID value itself is - # tested in spi_mode3_slave (T7) and end-to-end in bba_top. 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 - # PAGE, not 0. RRP == RWP == 1 is "empty, pointing at the start of - # the ring"; 0 points at the register page, and a driver that read its - # first frame from RRP=0 would get the register region instead of the - # frame -- which is what the DMA descriptor check caught on hardware. for name, a in (("RWP", _RWP_LO), ("RRP", _RRP_LO)): got = await exi_read(ctx, a, 1) if got != [_RX_PAGE_FIRST]: @@ -1152,12 +948,6 @@ if __name__ == "__main__": 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 - # they have been moved. The assembler's own write pointer returns to - # page 1 on reset, so a register file that keeps the old value hands - # the GC a RWP pointing at a page the assembler is not using -- and - # its first RX after a reset then reads the wrong page. That is a - # real-driver bug, not just a test-app one: Swiss resets and then - # trusts RWP. await exi_write(ctx, _RRP_LO, [7]) # move RRP off page 1 got = await exi_read(ctx, _RRP_LO, 1) if got != [7]: @@ -1237,7 +1027,6 @@ if __name__ == "__main__": # 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. - # Swiss reads the packet buffer exactly this way (bba_ins -> DMA). N = 12 tx_log.clear() ctx.set(reg.cs_active, 1) @@ -1267,20 +1056,6 @@ if __name__ == "__main__": print(f"T7 NWAYS after DMA read: 0x{result[0]:02X}") # T8: a MULTI-BYTE write to TXDATA must push EVERY byte. - # - # TXDATA (0x48) is a FIFO port, not a register: a whole frame goes to - # that one address. The write address used to advance across the data - # phase unconditionally, so byte 0 landed in TXDATA and bytes 1..N-1 - # went to 0x49+ and were silently ignored. - # - # Swiss writes an entire frame this way (bba_outs to BBA_WRTXFIFOD), so - # its TX would have delivered ONE BYTE per frame. It went unnoticed - # because the on-console test app wrote TXDATA one byte per EXI - # transaction; the moment it batched 8 bytes per transaction, 7 of - # every 8 vanished and TXFrameDrain blocked waiting for a frame that - # never completed (hardware: txto=16, no ACK). - # 64 bytes, not 8: this also exercises the deepened FIFO and matches - # the largest chunk bba_write() can send in one transaction. payload = [(0x11 + i * 3) & 0xFF for i in range(64)] await exi_write(ctx, _TXDATA, payload) got = await drain_tx_bytes(ctx) @@ -1291,11 +1066,6 @@ if __name__ == "__main__": f"{'ok' if got == payload else 'MISMATCH'}") # T9: TX completion must clear the ST bits and re-arm for the next - # frame. Those two actions used to sit in the rx_overflow handler by - # accident, so after ONE transmit tx_queued stayed set forever: the - # NCRA ST fallback trigger is guarded on ~tx_queued, so software that - # drives TX via ST rather than TWD transmitted exactly once and then - # silently stopped. await exi_write(ctx, _NCRA, [0x08 | 0x02]) # SR + ST0 await ctx.tick("sync").repeat(2) ctx.set(reg.tx_irq_i, 1) # frame completed @@ -1317,18 +1087,8 @@ if __name__ == "__main__": print(f"T9 second ST trigger queues: {bool(ctx.get(reg.tx_ctrl_r_rdy))} " f"(was {bool(tx_ctrl_before)})") - # ── T10: command-space write in libogc's real 3-byte shape ─────── - # The decoder used to expect [reg, 00, 00, data] and wait for a fourth - # byte, consuming the DATA byte as header byte 2 -- so every - # command-space write was silently DROPPED. libogc writes command - # register 2 (its interrupt mask) at both the start and the end of - # init, so a dropped write means that mask is never applied. - # - # Nothing caught this before because no test issued a command-space - # write at all: the device-ID READ is answered by SPIMode3Slave's fast - # path, independently of this FSM, so the only command-space traffic - # under test bypassed the decoder being tested. - await cmd_write(ctx, 0x02, [0xF8]) # libogc's IRMASKNONE + # ── 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]: @@ -1340,13 +1100,7 @@ if __name__ == "__main__": if got2 != [0x00]: errors.append(f"T10b command-space rewrite failed: {got2}") - # ── T11: TX in libogc's shape -- NO TWD WRITE AT ALL ───────────── - # __bba_link_tx is four transactions: read NWAYS, ONE register-space - # write of the whole frame to 0x0048, read NCRA, write NCRA = 0x04. - # There is no TWD_LO/TWD_HI anywhere, so the device has to count what - # it was handed. The ST fallback used to queue tx_frame_len, which a - # driver that skips TWD never sets -- so the frame would go out with a - # stale or zero length. + # ── 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) @@ -1358,11 +1112,7 @@ if __name__ == "__main__": ctx.set(reg.tx_irq_i, 0) await ctx.tick("sync").repeat(2) - # ── T15: registers libogc2 writes that used to be ignored ──────── - # compat TODO items 3 and 4. These previously read 0x00 and dropped - # writes, so a driver doing read-modify-write, or sanity-checking a - # documented default, saw zero. libogc2 DOES read NWAYC (0x30) before - # writing it. + # ── 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: @@ -1373,7 +1123,7 @@ if __name__ == "__main__": print(f"T15 defaults: NWAYC/MISC/BP/RHBP = " f"{[hex((await exi_read(ctx, a, 1))[0]) for a, _ in defaults]}") - # The exact values libogc2's __bba_init writes, in its order. + # 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), @@ -1386,7 +1136,7 @@ if __name__ == "__main__": if got != val: errors.append(f"T15 reg 0x{addr:02X}: wrote 0x{val:02X}, " f"read 0x{got:02X}") - print(f"T15 libogc2's {len(init_writes)} init writes all read back") + 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 @@ -1406,18 +1156,7 @@ if __name__ == "__main__": print(f"T15 geometry exports: bp=0x{ctx.get(reg.bp):03X} " f"rhbp=0x{ctx.get(reg.rhbp):03X}") - # ── T14: every command register the real drivers touch ─────────── - # Enumerated from libogc2's gcif.c rather than guessed at random: - # 0x01 read revision ID -> priv->revid - # 0x02 write interrupt mask (IRMASKALL/IRMASKNONE) - # 0x03 read / w1c interrupt status - # 0x04 write 2 bytes priv->devid (0xD107) - # 0x05 write 1 byte priv->acstart (0x4E) - # 0x08 read 4 bytes challenge | device-initiated path, - # 0x09 write 4 bytes response | gated on status bit 0x10 - # 0x0B read challenge/response status - # 0x0F read (slow) in __bba_reset(), result DISCARDED - # Swiss's patch driver touches only 0x02 and 0x03. + # ── 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: @@ -1430,7 +1169,6 @@ if __name__ == "__main__": errors.append(f"T14 revision ID writable: became 0x{rev2:02X}") # The write-only ones must at least STICK, so software that writes - # them is not silently ignored. libogc2 writes 0x04/0x05 in init # unconditionally. for creg, val in ((0x04, 0xD1), (0x05, 0x4E), (0x09, 0x3C), (0x0B, 0x77), (0x0F, 0x11)): @@ -1446,13 +1184,7 @@ if __name__ == "__main__": if hi != 0x00: errors.append(f"T14 cmd 0x20 reads 0x{hi:02X}, want 0x00") - # ── T13: the COMMAND-level interrupt both drivers gate on ──────── - # Both handlers are, identically: - # status = bba_cmd_in8(0x03); if (status & 0x80) bba_interrupt(); - # so with bit 7 clear NEITHER driver ever reaches the MAC-level - # handler that reads IR/IMR and starts RX. Our own tooling cannot - # catch this: gc_bba_test and netboot poll RWP/IR and never use the - # interrupt path. + # ── 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 @@ -1489,33 +1221,24 @@ if __name__ == "__main__": await cmd_write(ctx, 0x02, [0xF8]) await exi_write(ctx, _IR, [0xFF]) # tidy up - # ── T12: Swiss's command-space form, which DIFFERS from libogc's ── - # Swiss sends ONE 4-byte transfer with the value as byte 3 and reads - # the response from byte 3; libogc sends three bytes with the value as - # byte 2 and reads byte 2. Both are real drivers, so both must work: - # taking the LAST data byte as the value serves both, and pushing the - # read response twice puts it in byte 2 AND byte 3. - # - # Supporting only one of them is not a theoretical risk -- this file - # had Swiss's form only (libogc's writes were dropped), then briefly - # had libogc's form only (Swiss's writes would have been dropped). - await cmd_write_swiss(ctx, 0x02, 0x5A) - both = await cmd_read_swiss(ctx, 0x02) - print(f"T12 Swiss 4-byte cmd write 0x5A -> bytes 2,3 = " + # ── 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 Swiss cmd form: byte 3 = " + 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 libogc byte-2 slot also wrong: {hex(both[0])}") - await cmd_write_swiss(ctx, 0x02, 0x00) # restore + 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 libogc-shape TX (no TWD): queued len={qlen} " + 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)} " @@ -1525,26 +1248,6 @@ if __name__ == "__main__": f"{len(frame11)}") # NO T10 FOR THE LATE-RESPONSE LEAK -- and that is deliberate. - # - # The guard added to the response handler (outstanding != 0) and the - # sp_pending clear in HEADER0 fix a real latent bug: a response that - # arrives after its transaction ended used to be pushed anyway, and - # `outstanding - 1` from zero underflowed a 4-bit counter to 15, - # breaking the prefetch bound for the rest of the transaction. - # - # But this testbench CANNOT demonstrate the consequence. It models the - # TX side as tx_rdy/tx_en with a fresh capture list per transaction -- - # there is no persistent tx FIFO and no header drain pops, which is - # exactly the mechanism by which a leaked byte shifts the NEXT read. A - # test written here passes whether or not the guard is present (checked - # both ways), and a test that cannot fail is worse than no test. - # - # WHAT IS ACTUALLY MISSING: a bba_top-level test that performs an EXI - # DMA read from the SPRAM ring WHILE the ethernet side writes frames - # into it. ETH has absolute priority in SPRAMArbiter, so only then can - # a read response be delayed past the end of its transaction. No - # simulation does this today, which is why the +1 descriptor offset - # (17 of 22 frames on hardware, 2026-10-09) is still unexplained. sim = Simulator(reg)