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
+232 -173
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 Clock domains
------------- -------------
sync : 24 MHz, from the iCE40UP5K internal SB_HFOSC (÷2, CLKHF_DIV=0b01). sync 24 MHz, from the internal SB_HFOSC (÷2, CLKHF_DIV=0b01).
Everything except the EXI bit engine. No PLL, no crystal. EVERYTHING except the EXI bit engine: register file, SPRAM
exiclk : the GC's EXI clock pin itself (~27 MHz), posedge — created inside arbiter, RX/TX engines, ethernet master, bring-up peripherals.
exiclk_n : the same pin, negedge ................ `ExiCapture`. exiclk the GC's EXI clock pin itself, ~27 MHz, posedge. Hosts only
exififo_* the same pin, posedge, reset-less — the byte-FIFO sides. 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 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
See CLAUDE.md "Module Breakdown" and "CDC Signal Inventory" for the full list. 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 amaranth import *
from exi_bba.exi_capture import ExiCapture from exi_bba.exi_capture import ExiCapture
@@ -32,20 +61,48 @@ __all__ = ["BBATop"]
class BBATop(Elaboratable): 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) GC side (EXI, SPI Mode 3)
------------------------------------------------------------- -------------------------
EXI / GC interface (SPI Mode 3) exi_clk, exi_mosi, exi_cs_n
exi_clk / exi_mosi / exi_cs_n : inputs from GC Inputs from the GC. `exi_clk` becomes a clock and `exi_cs_n` an async
exi_miso : output to GC reset inside `ExiCapture`.
int_n : interrupt output (active low) 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) Ethernet side -- ONE of these two exists, chosen by `eth=`
w5500_clk / w5500_mosi / w5500_cs_n : outputs to W5500 ----------------------------------------------------------
w5500_miso : input from W5500 eth="w5100" (default): indirect parallel bus, reaches the ~27 Mbit/s EXI
w5500_int_n : W5500 interrupt (input, active low) ceiling.
w5500_rst_n : W5500 hardware reset (output, active low) 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, 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_dst_ip="192.168.1.100",
udp_src_port=40000, udp_dst_port=6464, udp_src_port=40000, udp_dst_port=6464,
phase_meter=False): phase_meter=False):
# Ethernet back-end: "w5100" (indirect parallel bus, reaches the EXI # Both masters expose an identical tx/rx/init/par interface, so only
# ceiling) or "w5500" (SPI, ~12 Mbit/s). Both expose the identical # the physical pins below differ.
# tx/rx/init/par interface, so only the physical pins differ.
self._eth = eth self._eth = eth
self._phase_meter = phase_meter # diagnostic, see SPIMode3Slave self._phase_meter = phase_meter # diagnostic; see SPIMode3Slave
# MR-reset settle wait passed to the ethernet master (~1 ms on hardware; # MR-reset settle wait handed to the ethernet master (~1 ms of cycles
# the testbench overrides with a small value for fast simulation). # on hardware; the testbench passes a tiny value to keep sim short).
self._reset_cycles = reset_cycles 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 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 self._uart_console = uart_console
# Optional interactive UART command shell (bring-up UDP test on the # Interactive shell driving a UDP test on a second ethernet socket,
# W5100's socket 1). Shares the UART pins with the console, so only one # which needs the W5100's multi-socket support.
# may be enabled; the UDP path needs the W5100 back-end. See uart_shell.
self._uart_shell = uart_shell self._uart_shell = uart_shell
if uart_console and uart_shell: if uart_console and uart_shell:
raise ValueError("uart_console and uart_shell share the UART pins; " raise ValueError("uart_console and uart_shell share the UART pins; "
"enable only one") "enable only one")
if uart_shell and eth != "w5100": if uart_shell and eth != "w5100":
raise ValueError("uart_shell UDP test requires eth='w5100'") raise ValueError("uart_shell UDP test requires eth='w5100'")
self._udp_cfg = dict(udp_socket=udp_socket, src_ip=udp_src_ip, self._udp_cfg = dict(udp_socket=udp_socket, src_ip=udp_src_ip,
subnet=udp_subnet, gateway=udp_gateway, subnet=udp_subnet, gateway=udp_gateway,
dst_ip=udp_dst_ip, src_port=udp_src_port, dst_ip=udp_dst_ip, src_port=udp_src_port,
dst_port=udp_dst_port) dst_port=udp_dst_port)
# EXI (GC side) # 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_clk = Signal(init=1)
self.exi_mosi = Signal() self.exi_mosi = Signal()
self.exi_cs_n = Signal(init=1) self.exi_cs_n = Signal(init=1)
@@ -94,7 +145,6 @@ class BBATop(Elaboratable):
self.int_n = Signal(init=1) self.int_n = Signal(init=1)
if eth == "w5500": if eth == "w5500":
# W5500 SPI
self.w5500_clk = Signal() self.w5500_clk = Signal()
self.w5500_mosi = Signal() self.w5500_mosi = Signal()
self.w5500_miso = Signal() self.w5500_miso = Signal()
@@ -102,10 +152,6 @@ class BBATop(Elaboratable):
self.w5500_int_n = Signal(init=1) self.w5500_int_n = Signal(init=1)
self.w5500_rst_n = Signal(init=1) self.w5500_rst_n = Signal(init=1)
else: 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_addr = Signal(2)
self.w5100_data_o = Signal(8) self.w5100_data_o = Signal(8)
self.w5100_data_oe = Signal() self.w5100_data_oe = Signal()
@@ -117,41 +163,43 @@ class BBATop(Elaboratable):
self.w5100_rst_n = Signal(init=1) self.w5100_rst_n = Signal(init=1)
if status_panel: if status_panel:
self.panel_led = Signal(5) # to onboard LEDs (see StatusPanel) self.panel_led = Signal(5)
self.panel_btn = Signal(3) # from onboard button(s) self.panel_btn = Signal(3)
if uart_console or uart_shell: 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 self.uart_rx = Signal(init=1) # PC → FPGA
def elaborate(self, platform): def elaborate(self, platform):
m = Module() 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 ─────────────────────────────────────── def _clock_domains(self, m, platform):
# ONE generated domain, one physical source: """Create `sync` from the internal oscillator.
# sync @ 24 MHz (internal SB_HFOSC ÷2) — register file, SPRAM,
# RX/TX engines, ethernet master. Everything that is not the EXI This module generates ONE domain from ONE physical source. The EXI
# bit engine lives here. front end is deliberately not clocked from here: `ExiCapture` builds
# its domains straight off the GC's clock pin, which is what removed the
# The EXI front-end is NOT clocked from here: `ExiCapture` builds its old PLL-fed 54 MHz oversampling domain -- that domain was the binding
# own exiclk/exiclk_n domains directly from the GC's EXI clock pin. timing path for the entire design and the reason builds closed on only
# That is what removed the old 54 MHz PLL-fed `capture` domain, which a minority of seeds.
# 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 24 MHz is chosen with margin: the register file, SPRAM and ethernet
# cause of the `0x17` -> `05 C0` MISO shift on hardware. With the logic are routing-bound around 33-44 MHz on this part, and the EXI byte
# engine clocked by the pin, MISO is clock-to-Q and the only crossings rate (27 MHz / 8 ≈ 3.4 MHz) leaves roughly seven sync cycles per byte.
# left are byte-level FIFOs with a full byte-time of slack.
# In simulation the harness drives `sync` and the EXI pins itself, so
# Consequence for timing closure: `capture_clk` is GONE as a nothing is instantiated.
# constraint. It was the binding path for this whole design and the """
# reason builds only closed on a minority of seeds. if platform is None:
if platform is not None: return
# 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.domains += ClockDomain("sync")
m.submodules.hfosc = Instance( m.submodules.hfosc = Instance(
"SB_HFOSC", "SB_HFOSC",
@@ -160,9 +208,9 @@ class BBATop(Elaboratable):
i_CLKHFPU = Const(1, 1), i_CLKHFPU = Const(1, 1),
o_CLKHF = ClockSignal("sync"), o_CLKHF = ClockSignal("sync"),
) )
# (simulation: the test harness drives the EXI pins and `sync` directly)
# ── Submodules ──────────────────────────────────────────────────── def _submodules(self, m):
"""Build every submodule and register it. Returns them as a bundle."""
cap = ExiCapture(phase_meter=self._phase_meter) cap = ExiCapture(phase_meter=self._phase_meter)
reg = BBARegisterFile() reg = BBARegisterFile()
arb = SPRAMArbiter() arb = SPRAMArbiter()
@@ -181,39 +229,29 @@ class BBATop(Elaboratable):
m.submodules.drain = drain m.submodules.drain = drain
m.submodules.eth = eth 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 += [ m.d.comb += [
# EXI inputs (to capture-domain front-end) cap.spi_clk .eq(self.exi_clk), # becomes the exiclk domain
cap.spi_clk .eq(self.exi_clk), # drives the exiclk domains
cap.spi_mosi.eq(self.exi_mosi), cap.spi_mosi.eq(self.exi_mosi),
cap.spi_cs_n.eq(self.exi_cs_n), cap.spi_cs_n.eq(self.exi_cs_n), # becomes its async reset
# EXI outputs self.exi_miso .eq(cap.spi_miso),
self.exi_miso.eq(cap.spi_miso),
self.exi_miso_next.eq(cap.miso_next), self.exi_miso_next.eq(cap.miso_next),
# Ring-overflow protection: the assembler needs the GC's read self.int_n .eq(mods.reg.exi_int_n),
# 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),
] ]
# Ethernet back-end physical pins
if self._eth == "w5500": if self._eth == "w5500":
m.d.comb += [ m.d.comb += [
self.w5500_clk .eq(eth.spi_clk), self.w5500_clk .eq(eth.spi_clk),
self.w5500_mosi.eq(eth.spi_mosi), self.w5500_mosi .eq(eth.spi_mosi),
self.w5500_cs_n.eq(eth.spi_cs_n), self.w5500_cs_n .eq(eth.spi_cs_n),
eth.spi_miso .eq(self.w5500_miso), eth.spi_miso .eq(self.w5500_miso),
eth.w5500_int_n.eq(self.w5500_int_n), eth.w5500_int_n .eq(self.w5500_int_n),
self.w5500_rst_n.eq(eth.w5500_rst_n), self.w5500_rst_n.eq(eth.w5500_rst_n),
] ]
else: else:
@@ -229,33 +267,45 @@ class BBATop(Elaboratable):
self.w5100_rst_n .eq(eth.w5100_rst_n), 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 += [ m.d.comb += [
reg.rx_data .eq(cap.rx_data), reg.rx_data.eq(cap.rx_data),
reg.rx_rdy .eq(cap.rx_rdy), reg.rx_rdy .eq(cap.rx_rdy),
cap.rx_en .eq(reg.rx_en), cap.rx_en .eq(reg.rx_en),
cap.tx_data .eq(reg.tx_data), cap.tx_data.eq(reg.tx_data),
cap.tx_en .eq(reg.tx_en), cap.tx_en .eq(reg.tx_en),
reg.tx_rdy .eq(cap.tx_rdy), 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) ────── # Register file ↔ SPRAM arbiter. Requests come through a SyncFIFO;
# SPRAM request: reg exi→sync FIFO read side → arb # responses are COMBINATIONAL and must stay that way -- see the note
# on BBARegisterFile._read_mux.
m.d.comb += [ m.d.comb += [
arb.exi_req_addr .eq(reg.spram_req_r_data), arb.exi_req_addr .eq(reg.spram_req_r_data),
arb.exi_req_valid.eq(reg.spram_req_r_rdy), arb.exi_req_valid .eq(reg.spram_req_r_rdy),
reg.spram_req_r_en.eq(arb.exi_req_ready), 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_data .eq(arb.exi_rsp_data),
reg.spram_rsp_valid.eq(arb.exi_rsp_valid), 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 += [ m.d.comb += [
drain.tx_bytes_r_data.eq(reg.tx_bytes_r_data), drain.tx_bytes_r_data.eq(reg.tx_bytes_r_data),
drain.tx_bytes_r_rdy .eq(reg.tx_bytes_r_rdy), 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_data.eq(reg.tx_ctrl_r_data),
drain.tx_ctrl_r_rdy .eq(reg.tx_ctrl_r_rdy), drain.tx_ctrl_r_rdy .eq(reg.tx_ctrl_r_rdy),
reg.tx_ctrl_r_en .eq(drain.tx_ctrl_r_en), 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_data .eq(drain.tx_data),
eth.tx_valid.eq(drain.tx_valid), eth.tx_valid .eq(drain.tx_valid),
drain.tx_ready.eq(eth.tx_ready), drain.tx_ready.eq(eth.tx_ready),
eth.tx_sof .eq(drain.tx_sof), eth.tx_sof .eq(drain.tx_sof),
eth.tx_eof .eq(drain.tx_eof), 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 += [ m.d.comb += [
asm.rx_data .eq(eth.rx_data), asm.rx_data .eq(eth.rx_data),
asm.rx_valid.eq(eth.rx_valid), asm.rx_valid.eq(eth.rx_valid),
eth.rx_ready.eq(asm.rx_ready), eth.rx_ready.eq(asm.rx_ready),
asm.rx_sof .eq(eth.rx_sof), asm.rx_sof .eq(eth.rx_sof),
asm.rx_eof .eq(eth.rx_eof), 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_addr .eq(asm.eth_wr_addr),
arb.eth_wr_data .eq(asm.eth_wr_data), arb.eth_wr_data .eq(asm.eth_wr_data),
arb.eth_wr_valid.eq(asm.eth_wr_valid), arb.eth_wr_valid.eq(asm.eth_wr_valid),
asm.eth_wr_ready.eq(arb.eth_wr_ready), 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_data.eq(asm.rx_wptr_w_data),
reg.rx_wptr_w_en .eq(asm.rx_wptr_w_en), reg.rx_wptr_w_en .eq(asm.rx_wptr_w_en),
asm.rx_wptr_w_rdy .eq(reg.rx_wptr_w_rdy), 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 += [ m.d.comb += [
# RX irq: sync → exi (RXFrameAssembler → reg → PS → exi domain)
reg.rx_irq_i.eq(asm.rx_irq), reg.rx_irq_i.eq(asm.rx_irq),
# TX irq: sync → exi
reg.tx_irq_i.eq(drain.tx_irq), reg.tx_irq_i.eq(drain.tx_irq),
# MAC address (PAR0–5) → SHAR. exi and sync share the HFOSC net, reg.rx_overflow.eq(asm.rx_overflow),
# and par is quasi-static (sampled by the master at init_req).
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), 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) ───────────────── # NCRB's PR bit asks for promiscuous receive. Low -- the normal case
# The RX ring-buffer path is active only after the GC sets NCRA[3]. # -- leaves the chip filtering to own-MAC plus broadcast, which is what
m.d.comb += asm.rx_enabled.eq(reg.ncra_sr) # the real BBA does; running unfiltered livelocked the GC's own
# NCRB.PR (bit 0) -> MACRAW MAC filter. Low means the ethernet chip # interrupt handler. See the S0_MR note in w5100_parallel_master.
# 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.
if self._eth == "w5100": 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) ─────────────────── def _wire_bringup(self, m, mods):
# Build init_req as an OR of all reinit sources (NCRA pulse plus any """Optional status panel, UART console and UART shell.
# 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.
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 need_ready = self._status_panel or self._uart_console
if need_ready: if need_ready:
ready = Signal() ready = Signal()
@@ -337,11 +391,18 @@ class BBATop(Elaboratable):
m.submodules.panel = panel m.submodules.panel = panel
init_req = init_req | panel.reinit 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() cs_a_sync = Signal()
m.submodules.panel_cs = FFSynchronizer( m.submodules.panel_cs = FFSynchronizer(
cap.cs_active, cs_a_sync, o_domain="sync") cap.cs_active, cs_a_sync, o_domain="sync")
m.d.comb += [ m.d.comb += [
panel.cs_active.eq(cs_a_sync), panel.cs_active.eq(cs_a_sync),
panel.rx_pulse .eq(asm.rx_irq), panel.rx_pulse .eq(asm.rx_irq),
@@ -366,27 +427,29 @@ class BBATop(Elaboratable):
] ]
if self._uart_shell: if self._uart_shell:
# Interactive command shell driving the W5100 socket-1 UDP test. # `eth` here is the W5100 master, built with enable_udp_test so it
# `eth` is the W5100 master built with enable_udp_test above. # 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() shell = UARTShell()
m.submodules.shell = shell m.submodules.shell = shell
m.d.comb += [ m.d.comb += [
self.uart_tx .eq(shell.uart_tx), self.uart_tx .eq(shell.uart_tx),
shell.uart_rx .eq(self.uart_rx), shell.uart_rx.eq(self.uart_rx),
# send
eth.udp_send_req .eq(shell.udp_send_req), eth.udp_send_req .eq(shell.udp_send_req),
eth.udp_dst_ip .eq(shell.udp_dst_ip), eth.udp_dst_ip .eq(shell.udp_dst_ip),
eth.udp_pl_data .eq(shell.udp_pl_data), eth.udp_pl_data .eq(shell.udp_pl_data),
eth.udp_pl_valid .eq(shell.udp_pl_valid), eth.udp_pl_valid .eq(shell.udp_pl_valid),
eth.udp_pl_last .eq(shell.udp_pl_last), eth.udp_pl_last .eq(shell.udp_pl_last),
shell.udp_pl_ready .eq(eth.udp_pl_ready), shell.udp_pl_ready .eq(eth.udp_pl_ready),
shell.udp_test_busy .eq(eth.udp_test_busy), shell.udp_test_busy.eq(eth.udp_test_busy),
# receive (reply)
eth.udp_rx_req .eq(shell.udp_rx_req), eth.udp_rx_req .eq(shell.udp_rx_req),
shell.udp_rx_busy .eq(eth.udp_rx_busy), shell.udp_rx_busy .eq(eth.udp_rx_busy),
shell.udp_rx_none .eq(eth.udp_rx_none), shell.udp_rx_none .eq(eth.udp_rx_none),
shell.udp_rx_data .eq(eth.udp_rx_data), shell.udp_rx_data .eq(eth.udp_rx_data),
shell.udp_rx_valid .eq(eth.udp_rx_valid), shell.udp_rx_valid.eq(eth.udp_rx_valid),
shell.udp_rx_eof .eq(eth.udp_rx_eof), shell.udp_rx_eof .eq(eth.udp_rx_eof),
eth.udp_rx_ready .eq(shell.udp_rx_ready), eth.udp_rx_ready .eq(shell.udp_rx_ready),
] ]
@@ -399,13 +462,11 @@ class BBATop(Elaboratable):
m.d.comb += eth.init_req.eq(init_req) m.d.comb += eth.init_req.eq(init_req)
return m
# ── Integration testbench ───────────────────────────────────────────────── # ── Integration testbench ─────────────────────────────────────────────────
# Drives real EXI Mode-3 transactions on the GC-facing pins and checks the # Drives real EXI Mode-3 transactions on the GC-facing pins and checks what
# response — exercising the full chain ExiCapture (capture domain) ↔ byte FIFOs # comes back, exercising the whole chain: the pin-clocked bit engine, the two
# ↔ BBARegisterFile (exi domain) ↔ sync modules, across all three clock domains. # byte FIFOs that cross into `sync`, and every sync-domain module behind them.
if __name__ == "__main__": if __name__ == "__main__":
import os import os
@@ -416,38 +477,36 @@ if __name__ == "__main__":
status_panel=True) # also exercise the panel wiring status_panel=True) # also exercise the panel wiring
errors = [] 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=8 3.4 MHz fast regression default
# HALF=2 13.5 MHz # HALF=2 13.5 MHz
# HALF=1 27 MHz THE HARDWARE RATE # HALF=1 27 MHz THE HARDWARE RATE
# ⚠️ The default is 8, which clocks EXI EIGHT TIMES SLOWER than the real # ⚠️ 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 # GC. At that rate the whole response latency hides inside a single bit
# bit time, so this suite passed green while hardware failed outright -- # time, so this suite can pass green while hardware fails outright -- that
# that is how both the header-format and pop-threshold bugs reached a # is how both the header-format and pop-threshold bugs reached a console.
# console. Run `EXI_HALF=1 python -m exi_bba.bba_top` before trusting any # 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 # timing-related change. All three rates pass today.
# by one bit (04 02 02 00 -> 02 01 01 00, NWAYS 0x17 -> 0x0B), which is an
# open bug.
HALF = int(os.environ.get("EXI_HALF", "8")) HALF = int(os.environ.get("EXI_HALF", "8"))
# The bit engine is now clocked by the EXI pin itself, so there is no # The bit engine is clocked by the EXI pin, so there is no faster domain
# `capture` domain to count ticks against. Drive the SPI clock with real # to count ticks against. The SPI clock is driven with real delays
# delays: one half-period = HALF * (1/54 MHz), preserving the old meaning # instead: one half-period is HALF * (1/54 MHz), which keeps the rates in
# of HALF so the rates in the table above still hold. # the table above meaning what they say.
HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n HP = Period(MHz=54) # the unit of delay; half-periods are HP * HALF
# Gap between transactions (CS high). cs_active reaches the exi domain # Gap between transactions (CS high). cs_active reaches `sync` through a
# through a 2-stage FFSynchronizer at 24 MHz, so CS must stay high for # 2-stage FFSynchronizer at 24 MHz, so CS must stay high for ~3 sync
# ~3 exi cycles or the register file never sees the transaction end and # cycles or the register file never sees the transaction end and swallows
# swallows the next one's header as data. At EXI_HALF=1 one HP is only # 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 # 18.5 ns, far too short -- a real host leaves microseconds between
# exi_deselect() and the next exi_select(). # exi_deselect() and the next exi_select().
CS_GAP = Period(MHz=24) * 6 CS_GAP = Period(MHz=24) * 6
async def spi_byte(ctx, mosi_val): async def spi_byte(ctx, mosi_val):
"""Drive one EXI Mode-3 byte; return the assembled MISO byte.""" """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: # A real Mode 3 master (CPOL=1, CPHA=1), which is what the GameCube
# drive MOSI on the FALLING edge, sample MISO on the RISING edge. # is: drive MOSI on the FALLING edge, and sample MISO in the clock-LOW
# The old version drove MOSI then fell -- a Mode 2 master -- which is # phase. A master that parks MOSI early cannot tell Mode 2 from
# what the Pi probe did and why it never exposed the mismatch. # Mode 3, which is how that mix-up once reached hardware.
miso = 0 miso = 0
for bit in range(7, -1, -1): for bit in range(7, -1, -1):
ctx.set(dut.exi_clk, 0) # falling: master drives ctx.set(dut.exi_clk, 0) # falling: master drives
@@ -621,7 +680,7 @@ if __name__ == "__main__":
await ctx.delay(HP * (HALF)) await ctx.delay(HP * (HALF))
# ── T6: FULL ETHERNET→SPRAM→GC LOOP ────────────────────────────── # ── 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 # → RXFrameAssembler writes it to the SPRAM ring → GC reads RWP then
# DMA-reads the descriptor+frame back. Exercises the entire RX path. # DMA-reads the descriptor+frame back. Exercises the entire RX path.
# The W5100 needs its init sequence (which sets MR.AI / opens socket 0) # The W5100 needs its init sequence (which sets MR.AI / opens socket 0)