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)