bba_top: document the integration, and retract a comment claiming an open bug

778 -> 837 lines, 25% -> 17% comment-only.  The module docstring now carries
what was scattered or missing: a data-flow diagram, all four clock domains with
what each hosts, and the three remaining crossings with their primitives.

The worst thing in this file was a testbench comment asserting a bug that no
longer exists:

    "at 27 MHz reads currently come back shifted right by one bit
     (04 02 02 00 -> 02 01 01 00, NWAYS 0x17 -> 0x0B), which is an open bug."

That is the MISO latency solved by the full-cycle-early launch.  EXI_HALF=1
passes, and has for some time.  A stale comment claiming a KNOWN FAILURE is
worse than a verbose one: it tells the next reader the suite's hardest case is
expected to be wrong.

Other stale text: the class docstring documented only the W5500 pins while
W5100 is the default back-end; "Pulse synchronizer connections" and
"reg exi->sync FIFO read side" described clock domains that were merged; the
testbench header claimed "all three clock domains" and a "capture domain"; and
the RX loop's model was called a W5500 when it is the W5100 bus model.

Structure: elaborate() is now _clock_domains, _submodules, _wire_pins,
_wire_datapath and _wire_bringup, with the submodules passed as a bundle.

Also documented, from an AST audit of the tree: w5500_clk/mosi/cs_n are
written and read by nothing, because synth.py declares only W5100 pins.  They
are not dead -- they are the alternate back-end's interface, exercised by
w5500_spi_master's own testbench -- so the docstring now says that wiring them
up is a synth.py change rather than leaving a reader to wonder.

MEASURED AND REJECTED: the FFSynchronizer on cs_active into the status panel
is NOT a clock-domain crossing, since ExiCapture already synchronises CS, so I
removed it as redundant.  Building it showed why it should stay -- area went
4305 -> 4330 LC, because the panel's combinational cone then reaches back to
ExiCapture's own synchroniser.  `sync` is the binding timing domain here, so
25 LC is not worth two flip-flops.  Reverted, and the comment now explains
what those registers actually buy instead of mislabelling them as a CDC.

VERIFIED EQUIVALENT: synthesised cell counts identical to the original in
every category (5971 cells; 134 SB_DFF, 3382 SB_LUT4, 669 SB_CARRY, 70
SB_RAM40_4K), so this commit changes no hardware at all.  Full module suite
passes and bba_top T9 is 6/6 at EXI_HALF=1, 2 and 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 20:17:22 +02:00
parent 20d64ac416
commit 8825cfb3d6
+263 -204
View File
@@ -1,18 +1,47 @@
"""BBATop — top-level elaboratable for the GC BBA FPGA replacement.
"""BBATop — the whole adapter: GameCube EXI on one side, ethernet on the other.
Data flow
---------
GC ──EXI──> ExiCapture ──bytes──> BBARegisterFile ─┬─> TXFrameDrain ──> eth
│
GC <─EXI─── ExiCapture <─bytes─── BBARegisterFile <─┴── SPRAMArbiter <─┐
│
RXFrameAssembler <──frames── ethernet master <─┘
The FPGA holds no network stack. The GC's own TCP/IP runs on its CPU and this
is a MAC bridge: register reads and writes on one side, whole frames on the
other, with the RX ring in SPRAM between them.
Clock domains
-------------
sync : 24 MHz, from the iCE40UP5K internal SB_HFOSC (÷2, CLKHF_DIV=0b01).
Everything except the EXI bit engine. No PLL, no crystal.
exiclk : the GC's EXI clock pin itself (~27 MHz), posedge — created inside
exiclk_n : the same pin, negedge ................ `ExiCapture`.
exififo_* the same pin, posedge, reset-less — the byte-FIFO sides.
sync 24 MHz, from the internal SB_HFOSC (÷2, CLKHF_DIV=0b01).
EVERYTHING except the EXI bit engine: register file, SPRAM
arbiter, RX/TX engines, ethernet master, bring-up peripherals.
exiclk the GC's EXI clock pin itself, ~27 MHz, posedge. Hosts only
the SPI bit engine, and is async-reset by CS.
exififo_w the same pin, posedge, reset-less -- the byte FIFOs' sides.
exififo_r
Submodule instantiation and signal wiring
-----------------------------------------
See CLAUDE.md "Module Breakdown" and "CDC Signal Inventory" for the full list.
All four are created inside `ExiCapture`, which owns the pin-clocked front
end; this module creates only `sync`. There is no PLL, no external crystal
and no oversampling domain: bit-level timing across a CDC boundary is what
made MISO arrive two bits late on hardware, so the engine is clocked by the
pin and the only crossings left are byte-level.
Crossings
---------
Just three, all with a full byte time (~296 ns at 27 MHz) of slack:
RX byte stream exififo_w → sync AsyncFIFO, 8-bit, depth 4
TX byte stream sync → exififo_r AsyncFIFO, 8-bit, depth 2
cs_active EXI pin → sync FFSynchronizer, 2 stages
Everything that once crossed a separate `exi` domain is now same-domain: those
FIFOs became `SyncFIFO`s and the pulse synchronisers became plain wires.
"""
from types import SimpleNamespace
from amaranth import *
from exi_bba.exi_capture import ExiCapture
@@ -32,20 +61,48 @@ __all__ = ["BBATop"]
class BBATop(Elaboratable):
"""Top-level module. Wires all submodules and defines clock domains.
"""Top-level module: creates `sync`, builds every submodule and wires them.
External ports (exposed for platform or testbench connection)
-------------------------------------------------------------
EXI / GC interface (SPI Mode 3)
exi_clk / exi_mosi / exi_cs_n : inputs from GC
exi_miso : output to GC
int_n : interrupt output (active low)
GC side (EXI, SPI Mode 3)
-------------------------
exi_clk, exi_mosi, exi_cs_n
Inputs from the GC. `exi_clk` becomes a clock and `exi_cs_n` an async
reset inside `ExiCapture`.
exi_miso
Serial output to the GC, driven from the fabric. The synthesised
build does not use this -- see `exi_miso_next` -- but the testbench
reads it, and `spi_mode3_slave` T6 proves the two agree.
exi_miso_next
The same bit one edge early, for an SB_IO output register in the IO
tile. This is what `synth.py` actually drives the pad from.
int_n
Interrupt to the GC, active low.
W5500 SPI interface (SPI Mode 0)
w5500_clk / w5500_mosi / w5500_cs_n : outputs to W5500
w5500_miso : input from W5500
w5500_int_n : W5500 interrupt (input, active low)
w5500_rst_n : W5500 hardware reset (output, active low)
Ethernet side -- ONE of these two exists, chosen by `eth=`
----------------------------------------------------------
eth="w5100" (default): indirect parallel bus, reaches the ~27 Mbit/s EXI
ceiling.
w5100_addr A[1:0]; the board ties A[14:2] low
w5100_data_o, w5100_data_oe, w5100_data_i FPGA side of a bidirectional
D[7:0], a tristate SB_IO at
the platform level
w5100_cs_n, w5100_rd_n, w5100_wr_n bus strobes
w5100_int_n, w5100_rst_n
eth="w5500": SPI Mode 0, ~12 Mbit/s -- below the EXI ceiling, so it is the
alternate rather than the default.
w5500_clk, w5500_mosi, w5500_cs_n, w5500_miso
w5500_int_n, w5500_rst_n
⚠️ `synth.py` only declares W5100 pins, so the W5500 outputs reach no pad
in a real build. They are the module's interface, exercised by the
`w5500_spi_master` testbench; wiring them up is a synth.py change.
Optional bring-up peripherals
-----------------------------
panel_led, panel_btn with status_panel=True; see `StatusPanel`
uart_tx, uart_rx with uart_console=True or uart_shell=True. Both
share the one UART, so only one may be enabled.
"""
def __init__(self, eth="w5100", reset_cycles=2_000_000,
@@ -55,38 +112,32 @@ class BBATop(Elaboratable):
udp_dst_ip="192.168.1.100",
udp_src_port=40000, udp_dst_port=6464,
phase_meter=False):
# Ethernet back-end: "w5100" (indirect parallel bus, reaches the EXI
# ceiling) or "w5500" (SPI, ~12 Mbit/s). Both expose the identical
# tx/rx/init/par interface, so only the physical pins differ.
# Both masters expose an identical tx/rx/init/par interface, so only
# the physical pins below differ.
self._eth = eth
self._phase_meter = phase_meter # diagnostic, see SPIMode3Slave
# MR-reset settle wait passed to the ethernet master (~1 ms on hardware;
# the testbench overrides with a small value for fast simulation).
self._phase_meter = phase_meter # diagnostic; see SPIMode3Slave
# MR-reset settle wait handed to the ethernet master (~1 ms of cycles
# on hardware; the testbench passes a tiny value to keep sim short).
self._reset_cycles = reset_cycles
# Optional bring-up status panel (drives onboard LEDs/button on the
# iCEbreaker — see synth.py). panel_led bit order matches StatusPanel.
self._status_panel = status_panel
# Optional UART debug console (8N1 115200, sync domain).
# uart_tx → FT2232H Channel B (net UART_RXD, pin 19);
# uart_rx ← FT2232H Channel B (net UART_TXD, pin 18) — net names are
# FTDI-perspective, so this is not a naming mismatch, see synth.py.
self._uart_console = uart_console
# Optional interactive UART command shell (bring-up UDP test on the
# W5100's socket 1). Shares the UART pins with the console, so only one
# may be enabled; the UDP path needs the W5100 back-end. See uart_shell.
# Interactive shell driving a UDP test on a second ethernet socket,
# which needs the W5100's multi-socket support.
self._uart_shell = uart_shell
if uart_console and uart_shell:
raise ValueError("uart_console and uart_shell share the UART pins; "
"enable only one")
if uart_shell and eth != "w5100":
raise ValueError("uart_shell UDP test requires eth='w5100'")
self._udp_cfg = dict(udp_socket=udp_socket, src_ip=udp_src_ip,
subnet=udp_subnet, gateway=udp_gateway,
dst_ip=udp_dst_ip, src_port=udp_src_port,
dst_port=udp_dst_port)
# EXI (GC side)
self.exi_miso_next = Signal(init=1) # for an SB_IO output register
self.exi_miso_next = Signal(init=1)
self.exi_clk = Signal(init=1)
self.exi_mosi = Signal()
self.exi_cs_n = Signal(init=1)
@@ -94,7 +145,6 @@ class BBATop(Elaboratable):
self.int_n = Signal(init=1)
if eth == "w5500":
# W5500 SPI
self.w5500_clk = Signal()
self.w5500_mosi = Signal()
self.w5500_miso = Signal()
@@ -102,10 +152,6 @@ class BBATop(Elaboratable):
self.w5500_int_n = Signal(init=1)
self.w5500_rst_n = Signal(init=1)
else:
# W5100 indirect parallel bus. data_o/data_oe/data_i are the FPGA
# side of a bidirectional D[7:0] (wrapped in a tristate SB_IO at the
# platform level); a board ties the upper address lines to 0 so only
# A[1:0] are wired.
self.w5100_addr = Signal(2)
self.w5100_data_o = Signal(8)
self.w5100_data_oe = Signal()
@@ -117,52 +163,54 @@ class BBATop(Elaboratable):
self.w5100_rst_n = Signal(init=1)
if status_panel:
self.panel_led = Signal(5) # to onboard LEDs (see StatusPanel)
self.panel_btn = Signal(3) # from onboard button(s)
self.panel_led = Signal(5)
self.panel_btn = Signal(3)
if uart_console or uart_shell:
self.uart_tx = Signal(init=1) # FPGA → PC (FT2232H Channel B)
# Net names on the board are from the FT2232H's perspective, so
# the FPGA's TX pin carries the net called UART_RXD. See synth.py.
self.uart_tx = Signal(init=1) # FPGA → PC
self.uart_rx = Signal(init=1) # PC → FPGA
def elaborate(self, platform):
m = Module()
self._clock_domains(m, platform)
mods = self._submodules(m)
self._wire_pins(m, mods)
self._wire_datapath(m, mods)
self._wire_bringup(m, mods)
return m
# ── Clock domain generation ───────────────────────────────────────
# ONE generated domain, one physical source:
# sync @ 24 MHz (internal SB_HFOSC ÷2) — register file, SPRAM,
# RX/TX engines, ethernet master. Everything that is not the EXI
# bit engine lives here.
#
# The EXI front-end is NOT clocked from here: `ExiCapture` builds its
# own exiclk/exiclk_n domains directly from the GC's EXI clock pin.
# That is what removed the old 54 MHz PLL-fed `capture` domain, which
# oversampled the EXI clock 2× and put BIT-level timing across a CDC
# boundary (~55 ns of synchroniser latency against a 37 ns bit) — the
# cause of the `0x17` -> `05 C0` MISO shift on hardware. With the
# engine clocked by the pin, MISO is clock-to-Q and the only crossings
# left are byte-level FIFOs with a full byte-time of slack.
#
# Consequence for timing closure: `capture_clk` is GONE as a
# constraint. It was the binding path for this whole design and the
# reason builds only closed on a minority of seeds.
if platform is not None:
# sync @ 24 MHz from the internal oscillator. No PLL and no
# external crystal are needed any more, so the 12 MHz input (pin
# 35) and the PLL hard macro are both free.
# The register-file / SPRAM / ethernet logic is routing-bound at
# ~33–44 MHz on the UP5K; 24 MHz closes with large margin. The byte
# rate (27 MHz EXI ÷ 8 ≈ 3.4 MHz) leaves ~7 sync cycles per byte.
m.domains += ClockDomain("sync")
m.submodules.hfosc = Instance(
"SB_HFOSC",
p_CLKHF_DIV = "0b01", # 48 ÷ 2 → 24 MHz
i_CLKHFEN = Const(1, 1),
i_CLKHFPU = Const(1, 1),
o_CLKHF = ClockSignal("sync"),
)
# (simulation: the test harness drives the EXI pins and `sync` directly)
def _clock_domains(self, m, platform):
"""Create `sync` from the internal oscillator.
# ── Submodules ────────────────────────────────────────────────────
This module generates ONE domain from ONE physical source. The EXI
front end is deliberately not clocked from here: `ExiCapture` builds
its domains straight off the GC's clock pin, which is what removed the
old PLL-fed 54 MHz oversampling domain -- that domain was the binding
timing path for the entire design and the reason builds closed on only
a minority of seeds.
24 MHz is chosen with margin: the register file, SPRAM and ethernet
logic are routing-bound around 33-44 MHz on this part, and the EXI byte
rate (27 MHz / 8 ≈ 3.4 MHz) leaves roughly seven sync cycles per byte.
In simulation the harness drives `sync` and the EXI pins itself, so
nothing is instantiated.
"""
if platform is None:
return
m.domains += ClockDomain("sync")
m.submodules.hfosc = Instance(
"SB_HFOSC",
p_CLKHF_DIV = "0b01", # 48 ÷ 2 → 24 MHz
i_CLKHFEN = Const(1, 1),
i_CLKHFPU = Const(1, 1),
o_CLKHF = ClockSignal("sync"),
)
def _submodules(self, m):
"""Build every submodule and register it. Returns them as a bundle."""
cap = ExiCapture(phase_meter=self._phase_meter)
reg = BBARegisterFile()
arb = SPRAMArbiter()
@@ -181,39 +229,29 @@ class BBATop(Elaboratable):
m.submodules.drain = drain
m.submodules.eth = eth
# ── External pin connections ──────────────────────────────────────
return SimpleNamespace(cap=cap, reg=reg, arb=arb, asm=asm,
drain=drain, eth=eth)
def _wire_pins(self, m, mods):
"""Connect the external pins to the front end and the ethernet chip."""
cap, eth = mods.cap, mods.eth
m.d.comb += [
# EXI inputs (to capture-domain front-end)
cap.spi_clk .eq(self.exi_clk), # drives the exiclk domains
cap.spi_clk .eq(self.exi_clk), # becomes the exiclk domain
cap.spi_mosi.eq(self.exi_mosi),
cap.spi_cs_n.eq(self.exi_cs_n),
# EXI outputs
self.exi_miso.eq(cap.spi_miso),
cap.spi_cs_n.eq(self.exi_cs_n), # becomes its async reset
self.exi_miso .eq(cap.spi_miso),
self.exi_miso_next.eq(cap.miso_next),
# Ring-overflow protection: the assembler needs the GC's read
# pointer to know what it must not overwrite, and reports a
# dropped frame back as RBFI (IR[7]).
asm.rrp.eq(reg.rrp),
# The GC's own ring geometry (BP / RHBP). Previously hardcoded in
# the assembler, so a driver configuring a smaller RX ring was
# ignored and we kept writing pages it would never read. The
# assembler sanity-checks these and falls back to pages 1-15 if
# they are unusable -- see docs/bba_compat_todo.md item 2.
asm.bp .eq(reg.bp),
asm.rhbp.eq(reg.rhbp),
asm.ncra_rst.eq(reg.ncra_rst_o),
reg.rx_overflow.eq(asm.rx_overflow),
self.int_n .eq(reg.exi_int_n),
self.int_n .eq(mods.reg.exi_int_n),
]
# Ethernet back-end physical pins
if self._eth == "w5500":
m.d.comb += [
self.w5500_clk .eq(eth.spi_clk),
self.w5500_mosi.eq(eth.spi_mosi),
self.w5500_cs_n.eq(eth.spi_cs_n),
eth.spi_miso .eq(self.w5500_miso),
eth.w5500_int_n.eq(self.w5500_int_n),
self.w5500_clk .eq(eth.spi_clk),
self.w5500_mosi .eq(eth.spi_mosi),
self.w5500_cs_n .eq(eth.spi_cs_n),
eth.spi_miso .eq(self.w5500_miso),
eth.w5500_int_n .eq(self.w5500_int_n),
self.w5500_rst_n.eq(eth.w5500_rst_n),
]
else:
@@ -229,33 +267,45 @@ class BBATop(Elaboratable):
self.w5100_rst_n .eq(eth.w5100_rst_n),
]
# ── ExiCapture byte stream ↔ BBARegisterFile (exi domain) ────────
def _wire_datapath(self, m, mods):
"""Wire the EXI byte streams, SPRAM, and both frame directions.
Everything below `ExiCapture` is one `sync` domain, so these are plain
combinational connections and same-domain FIFO sides -- no CDC.
"""
cap, reg, arb = mods.cap, mods.reg, mods.arb
asm, drain, eth = mods.asm, mods.drain, mods.eth
# ExiCapture byte streams ↔ register file
m.d.comb += [
reg.rx_data .eq(cap.rx_data),
reg.rx_rdy .eq(cap.rx_rdy),
cap.rx_en .eq(reg.rx_en),
reg.rx_data.eq(cap.rx_data),
reg.rx_rdy .eq(cap.rx_rdy),
cap.rx_en .eq(reg.rx_en),
cap.tx_data .eq(reg.tx_data),
cap.tx_en .eq(reg.tx_en),
reg.tx_rdy .eq(cap.tx_rdy),
cap.tx_data.eq(reg.tx_data),
cap.tx_en .eq(reg.tx_en),
reg.tx_rdy .eq(cap.tx_rdy),
reg.cs_active.eq(cap.cs_active), # transaction-active (for DMA reads)
# Transaction-in-progress level: a register-space read at or above
# 0x100 streams from SPRAM until CS rises, so the register file
# needs to see the end of the transaction.
reg.cs_active.eq(cap.cs_active),
]
# ── BBARegisterFile ↔ SPRAMArbiter (sync domain FIFO sides) ──────
# SPRAM request: reg exi→sync FIFO read side → arb
# Register file ↔ SPRAM arbiter. Requests come through a SyncFIFO;
# responses are COMBINATIONAL and must stay that way -- see the note
# on BBARegisterFile._read_mux.
m.d.comb += [
arb.exi_req_addr .eq(reg.spram_req_r_data),
arb.exi_req_valid.eq(reg.spram_req_r_rdy),
arb.exi_req_addr .eq(reg.spram_req_r_data),
arb.exi_req_valid .eq(reg.spram_req_r_rdy),
reg.spram_req_r_en.eq(arb.exi_req_ready),
]
# SPRAM response: arb result → reg sync→exi FIFO write side
m.d.comb += [
reg.spram_rsp_data .eq(arb.exi_rsp_data),
reg.spram_rsp_valid.eq(arb.exi_rsp_valid),
]
# ── BBARegisterFile ↔ TXFrameDrain (sync domain FIFO sides) ──────
# TX: register file → drain → ethernet. tx_bytes carries the frame and
# tx_ctrl its length, so the drain can start before the frame ends.
m.d.comb += [
drain.tx_bytes_r_data.eq(reg.tx_bytes_r_data),
drain.tx_bytes_r_rdy .eq(reg.tx_bytes_r_rdy),
@@ -264,70 +314,74 @@ class BBATop(Elaboratable):
drain.tx_ctrl_r_data.eq(reg.tx_ctrl_r_data),
drain.tx_ctrl_r_rdy .eq(reg.tx_ctrl_r_rdy),
reg.tx_ctrl_r_en .eq(drain.tx_ctrl_r_en),
]
# ── TXFrameDrain ↔ ethernet master (sync domain) ──────────────────
m.d.comb += [
eth.tx_data .eq(drain.tx_data),
eth.tx_valid.eq(drain.tx_valid),
eth.tx_data .eq(drain.tx_data),
eth.tx_valid .eq(drain.tx_valid),
drain.tx_ready.eq(eth.tx_ready),
eth.tx_sof .eq(drain.tx_sof),
eth.tx_eof .eq(drain.tx_eof),
eth.tx_sof .eq(drain.tx_sof),
eth.tx_eof .eq(drain.tx_eof),
]
# ── ethernet master → RXFrameAssembler (sync domain) ─────────────
# RX: ethernet → assembler → SPRAM ring, then the pointer back to the
# register file so the GC can see the new frame.
m.d.comb += [
asm.rx_data .eq(eth.rx_data),
asm.rx_valid.eq(eth.rx_valid),
eth.rx_ready.eq(asm.rx_ready),
asm.rx_sof .eq(eth.rx_sof),
asm.rx_eof .eq(eth.rx_eof),
]
# ── RXFrameAssembler → SPRAMArbiter (ETH write, sync domain) ─────
m.d.comb += [
arb.eth_wr_addr .eq(asm.eth_wr_addr),
arb.eth_wr_data .eq(asm.eth_wr_data),
arb.eth_wr_valid.eq(asm.eth_wr_valid),
asm.eth_wr_ready.eq(arb.eth_wr_ready),
]
# ── RXFrameAssembler → BBARegisterFile (rx_wptr FIFO write side) ─
m.d.comb += [
reg.rx_wptr_w_data.eq(asm.rx_wptr_w_data),
reg.rx_wptr_w_en .eq(asm.rx_wptr_w_en),
asm.rx_wptr_w_rdy .eq(reg.rx_wptr_w_rdy),
]
# ── Pulse synchronizer connections ────────────────────────────────
# Interrupt sources, and the ring state the assembler needs from the
# GC: its read pointer (so unread pages are not overwritten) and the
# ring geometry it asked for.
m.d.comb += [
# RX irq: sync → exi (RXFrameAssembler → reg → PS → exi domain)
reg.rx_irq_i.eq(asm.rx_irq),
# TX irq: sync → exi
reg.tx_irq_i.eq(drain.tx_irq),
# MAC address (PAR0–5) → SHAR. exi and sync share the HFOSC net,
# and par is quasi-static (sampled by the master at init_req).
reg.rx_overflow.eq(asm.rx_overflow),
asm.rrp .eq(reg.rrp),
asm.bp .eq(reg.bp),
asm.rhbp.eq(reg.rhbp),
asm.ncra_rst.eq(reg.ncra_rst_o),
# The MAC the GC holds in PAR0-5 becomes the ethernet chip's own
# source address. Quasi-static: the master samples it at init.
eth.par.eq(reg.par),
# RX runs only once the GC sets NCRA's SR (start-receive) bit.
asm.rx_enabled.eq(reg.ncra_sr),
]
# ── RX enabled gate (NCRA SR / start-receive bit) ─────────────────
# The RX ring-buffer path is active only after the GC sets NCRA[3].
m.d.comb += asm.rx_enabled.eq(reg.ncra_sr)
# NCRB.PR (bit 0) -> MACRAW MAC filter. Low means the ethernet chip
# delivers own-MAC + broadcast only, matching the real BBA; the GC can
# ask for promiscuous by setting PR. See the S0_MODE comment in
# w5100_parallel_master.py -- running unfiltered hung Swiss.
# NCRB's PR bit asks for promiscuous receive. Low -- the normal case
# -- leaves the chip filtering to own-MAC plus broadcast, which is what
# the real BBA does; running unfiltered livelocked the GC's own
# interrupt handler. See the S0_MR note in w5100_parallel_master.
if self._eth == "w5100":
m.d.comb += eth.promisc.eq(reg.ncrb[NCRB_PR])
m.d.comb += mods.eth.promisc.eq(reg.ncrb[NCRB_PR])
# ── Optional bring-up peripherals (sync domain) ───────────────────
# Build init_req as an OR of all reinit sources (NCRA pulse plus any
# manual re-init from the status panel and/or the UART 'r' command).
# "ready" is latched high by eth.init_done and cleared by any init_req.
# It is computed only when at least one peripheral needs it.
def _wire_bringup(self, m, mods):
"""Optional status panel, UART console and UART shell.
init_req = reg.ncra_rst_o # base: GC-issued NCRA reset
`init_req` to the ethernet master is the OR of every re-init source:
the GC's NCRA reset, plus any manual request from the panel button or
the console's 'r' command. `ready` is latched by `init_done` and
cleared by any new request, and is only built when something displays
it.
"""
cap, reg, asm, drain, eth = (mods.cap, mods.reg, mods.asm,
mods.drain, mods.eth)
init_req = reg.ncra_rst_o # the GC's own reset
need_ready = self._status_panel or self._uart_console
if need_ready:
ready = Signal()
@@ -337,11 +391,18 @@ class BBATop(Elaboratable):
m.submodules.panel = panel
init_req = init_req | panel.reinit
# cs_active lives in the exi domain; bring it to sync for the LED.
# NOT a clock-domain crossing, despite the primitive:
# `cap.cs_active` is already a sync-domain signal, because
# ExiCapture synchronises CS itself. What these two registers buy
# is a short input path into the panel's logic, and that is why
# they stay -- deleting them cost 25 LC (4305 -> 4330) when
# measured, since the panel's combinational cone then reaches back
# to ExiCapture's own synchroniser. `sync` is the binding timing
# domain in this design, so that is not a trade worth making for
# two flip-flops.
cs_a_sync = Signal()
m.submodules.panel_cs = FFSynchronizer(
cap.cs_active, cs_a_sync, o_domain="sync")
m.d.comb += [
panel.cs_active.eq(cs_a_sync),
panel.rx_pulse .eq(asm.rx_irq),
@@ -366,29 +427,31 @@ class BBATop(Elaboratable):
]
if self._uart_shell:
# Interactive command shell driving the W5100 socket-1 UDP test.
# `eth` is the W5100 master built with enable_udp_test above.
# `eth` here is the W5100 master, built with enable_udp_test so it
# carries the extra socket the shell drives. The UDP test is the
# lowest-priority branch of that master's bus arbiter, so a GC
# frame always preempts it.
shell = UARTShell()
m.submodules.shell = shell
m.d.comb += [
self.uart_tx .eq(shell.uart_tx),
shell.uart_rx .eq(self.uart_rx),
# send
eth.udp_send_req .eq(shell.udp_send_req),
eth.udp_dst_ip .eq(shell.udp_dst_ip),
eth.udp_pl_data .eq(shell.udp_pl_data),
eth.udp_pl_valid .eq(shell.udp_pl_valid),
eth.udp_pl_last .eq(shell.udp_pl_last),
shell.udp_pl_ready .eq(eth.udp_pl_ready),
shell.udp_test_busy .eq(eth.udp_test_busy),
# receive (reply)
eth.udp_rx_req .eq(shell.udp_rx_req),
shell.udp_rx_busy .eq(eth.udp_rx_busy),
shell.udp_rx_none .eq(eth.udp_rx_none),
shell.udp_rx_data .eq(eth.udp_rx_data),
shell.udp_rx_valid .eq(eth.udp_rx_valid),
shell.udp_rx_eof .eq(eth.udp_rx_eof),
eth.udp_rx_ready .eq(shell.udp_rx_ready),
self.uart_tx .eq(shell.uart_tx),
shell.uart_rx.eq(self.uart_rx),
eth.udp_send_req .eq(shell.udp_send_req),
eth.udp_dst_ip .eq(shell.udp_dst_ip),
eth.udp_pl_data .eq(shell.udp_pl_data),
eth.udp_pl_valid .eq(shell.udp_pl_valid),
eth.udp_pl_last .eq(shell.udp_pl_last),
shell.udp_pl_ready .eq(eth.udp_pl_ready),
shell.udp_test_busy.eq(eth.udp_test_busy),
eth.udp_rx_req .eq(shell.udp_rx_req),
shell.udp_rx_busy .eq(eth.udp_rx_busy),
shell.udp_rx_none .eq(eth.udp_rx_none),
shell.udp_rx_data .eq(eth.udp_rx_data),
shell.udp_rx_valid.eq(eth.udp_rx_valid),
shell.udp_rx_eof .eq(eth.udp_rx_eof),
eth.udp_rx_ready .eq(shell.udp_rx_ready),
]
if need_ready:
@@ -399,13 +462,11 @@ class BBATop(Elaboratable):
m.d.comb += eth.init_req.eq(init_req)
return m
# ── Integration testbench ─────────────────────────────────────────────────
# Drives real EXI Mode-3 transactions on the GC-facing pins and checks the
# response — exercising the full chain ExiCapture (capture domain) ↔ byte FIFOs
# ↔ BBARegisterFile (exi domain) ↔ sync modules, across all three clock domains.
# Drives real EXI Mode-3 transactions on the GC-facing pins and checks what
# comes back, exercising the whole chain: the pin-clocked bit engine, the two
# byte FIFOs that cross into `sync`, and every sync-domain module behind them.
if __name__ == "__main__":
import os
@@ -416,38 +477,36 @@ if __name__ == "__main__":
status_panel=True) # also exercise the panel wiring
errors = []
# Capture ticks per SPI half-period -> EXI rate = 54 MHz / (2 * HALF).
# Delay units per SPI half-period -> EXI rate = 54 MHz / (2 * HALF).
# HALF=8 3.4 MHz fast regression default
# HALF=2 13.5 MHz
# HALF=1 27 MHz THE HARDWARE RATE
# ⚠️ The default is 8, which clocks EXI EIGHT TIMES SLOWER than the real
# GC. At that rate the whole CDC response latency hides inside a single
# bit time, so this suite passed green while hardware failed outright --
# that is how both the header-format and pop-threshold bugs reached a
# console. Run `EXI_HALF=1 python -m exi_bba.bba_top` before trusting any
# timing-related change; at 27 MHz reads currently come back shifted right
# by one bit (04 02 02 00 -> 02 01 01 00, NWAYS 0x17 -> 0x0B), which is an
# open bug.
# GC. At that rate the whole response latency hides inside a single bit
# time, so this suite can pass green while hardware fails outright -- that
# is how both the header-format and pop-threshold bugs reached a console.
# Run `EXI_HALF=1 python -m exi_bba.bba_top` before trusting any
# timing-related change. All three rates pass today.
HALF = int(os.environ.get("EXI_HALF", "8"))
# The bit engine is now clocked by the EXI pin itself, so there is no
# `capture` domain to count ticks against. Drive the SPI clock with real
# delays: one half-period = HALF * (1/54 MHz), preserving the old meaning
# of HALF so the rates in the table above still hold.
HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n
# Gap between transactions (CS high). cs_active reaches the exi domain
# through a 2-stage FFSynchronizer at 24 MHz, so CS must stay high for
# ~3 exi cycles or the register file never sees the transaction end and
# swallows the next one's header as data. At EXI_HALF=1 one HP is only
# The bit engine is clocked by the EXI pin, so there is no faster domain
# to count ticks against. The SPI clock is driven with real delays
# instead: one half-period is HALF * (1/54 MHz), which keeps the rates in
# the table above meaning what they say.
HP = Period(MHz=54) # the unit of delay; half-periods are HP * HALF
# Gap between transactions (CS high). cs_active reaches `sync` through a
# 2-stage FFSynchronizer at 24 MHz, so CS must stay high for ~3 sync
# cycles or the register file never sees the transaction end and swallows
# the next one's header as data. At EXI_HALF=1 one HP is only
# 18.5 ns, far too short -- a real host leaves microseconds between
# exi_deselect() and the next exi_select().
CS_GAP = Period(MHz=24) * 6
async def spi_byte(ctx, mosi_val):
"""Drive one EXI Mode-3 byte; return the assembled MISO byte."""
# Real Mode 3 master (CPOL=1, CPHA=1), which is what the GameCube is:
# drive MOSI on the FALLING edge, sample MISO on the RISING edge.
# The old version drove MOSI then fell -- a Mode 2 master -- which is
# what the Pi probe did and why it never exposed the mismatch.
# A real Mode 3 master (CPOL=1, CPHA=1), which is what the GameCube
# is: drive MOSI on the FALLING edge, and sample MISO in the clock-LOW
# phase. A master that parks MOSI early cannot tell Mode 2 from
# Mode 3, which is how that mix-up once reached hardware.
miso = 0
for bit in range(7, -1, -1):
ctx.set(dut.exi_clk, 0) # falling: master drives
@@ -621,7 +680,7 @@ if __name__ == "__main__":
await ctx.delay(HP * (HALF))
# ── T6: FULL ETHERNET→SPRAM→GC LOOP ──────────────────────────────
# A frame arrives from the network (W5500 model) → W5500 master reads it
# A frame arrives from the network (W5100 bus model) → the master reads it
# → RXFrameAssembler writes it to the SPRAM ring → GC reads RWP then
# DMA-reads the descriptor+frame back. Exercises the entire RX path.
# The W5100 needs its init sequence (which sets MR.AI / opens socket 0)