8825cfb3d6
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>
838 lines
38 KiB
Python
838 lines
38 KiB
Python
"""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 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
|
||
|
||
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
|
||
from exi_bba.bba_register_file import BBARegisterFile, NCRB_PR
|
||
from exi_bba.spram_arbiter import SPRAMArbiter
|
||
from exi_bba.rx_frame_assembler import RXFrameAssembler
|
||
from exi_bba.tx_frame_drain import TXFrameDrain
|
||
from exi_bba.w5500_spi_master import W5500SPIMaster
|
||
from exi_bba.w5100_parallel_master import W5100ParallelMaster
|
||
from exi_bba.status_panel import StatusPanel
|
||
from exi_bba.uart_console import UARTConsole
|
||
from exi_bba.uart_shell import UARTShell
|
||
|
||
from amaranth.lib.cdc import FFSynchronizer
|
||
|
||
__all__ = ["BBATop"]
|
||
|
||
|
||
class BBATop(Elaboratable):
|
||
"""Top-level module: creates `sync`, builds every submodule and wires them.
|
||
|
||
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.
|
||
|
||
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,
|
||
status_panel=False, uart_console=False, uart_shell=False,
|
||
udp_socket=3, udp_src_ip="192.168.1.123",
|
||
udp_subnet="255.255.255.0", udp_gateway="192.168.1.1",
|
||
udp_dst_ip="192.168.1.100",
|
||
udp_src_port=40000, udp_dst_port=6464,
|
||
phase_meter=False):
|
||
# 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 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
|
||
self._status_panel = status_panel
|
||
self._uart_console = uart_console
|
||
# 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)
|
||
self.exi_clk = Signal(init=1)
|
||
self.exi_mosi = Signal()
|
||
self.exi_cs_n = Signal(init=1)
|
||
self.exi_miso = Signal()
|
||
self.int_n = Signal(init=1)
|
||
|
||
if eth == "w5500":
|
||
self.w5500_clk = Signal()
|
||
self.w5500_mosi = Signal()
|
||
self.w5500_miso = Signal()
|
||
self.w5500_cs_n = Signal(init=1)
|
||
self.w5500_int_n = Signal(init=1)
|
||
self.w5500_rst_n = Signal(init=1)
|
||
else:
|
||
self.w5100_addr = Signal(2)
|
||
self.w5100_data_o = Signal(8)
|
||
self.w5100_data_oe = Signal()
|
||
self.w5100_data_i = Signal(8)
|
||
self.w5100_cs_n = Signal(init=1)
|
||
self.w5100_rd_n = Signal(init=1)
|
||
self.w5100_wr_n = Signal(init=1)
|
||
self.w5100_int_n = Signal(init=1)
|
||
self.w5100_rst_n = Signal(init=1)
|
||
|
||
if status_panel:
|
||
self.panel_led = Signal(5)
|
||
self.panel_btn = Signal(3)
|
||
|
||
if uart_console or uart_shell:
|
||
# 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
|
||
|
||
def _clock_domains(self, m, platform):
|
||
"""Create `sync` from the internal oscillator.
|
||
|
||
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()
|
||
asm = RXFrameAssembler()
|
||
drain = TXFrameDrain()
|
||
eth = (W5500SPIMaster(reset_cycles=self._reset_cycles)
|
||
if self._eth == "w5500"
|
||
else W5100ParallelMaster(reset_cycles=self._reset_cycles,
|
||
enable_udp_test=self._uart_shell,
|
||
**self._udp_cfg))
|
||
|
||
m.submodules.cap = cap
|
||
m.submodules.reg = reg
|
||
m.submodules.arb = arb
|
||
m.submodules.asm = asm
|
||
m.submodules.drain = drain
|
||
m.submodules.eth = eth
|
||
|
||
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 += [
|
||
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), # becomes its async reset
|
||
self.exi_miso .eq(cap.spi_miso),
|
||
self.exi_miso_next.eq(cap.miso_next),
|
||
self.int_n .eq(mods.reg.exi_int_n),
|
||
]
|
||
|
||
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_rst_n.eq(eth.w5500_rst_n),
|
||
]
|
||
else:
|
||
m.d.comb += [
|
||
self.w5100_addr .eq(eth.bus_addr),
|
||
self.w5100_data_o .eq(eth.bus_data_o),
|
||
self.w5100_data_oe.eq(eth.bus_data_oe),
|
||
eth.bus_data_i .eq(self.w5100_data_i),
|
||
self.w5100_cs_n .eq(eth.cs_n),
|
||
self.w5100_rd_n .eq(eth.rd_n),
|
||
self.w5100_wr_n .eq(eth.wr_n),
|
||
eth.w5100_int_n .eq(self.w5100_int_n),
|
||
self.w5100_rst_n .eq(eth.w5100_rst_n),
|
||
]
|
||
|
||
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),
|
||
|
||
cap.tx_data.eq(reg.tx_data),
|
||
cap.tx_en .eq(reg.tx_en),
|
||
reg.tx_rdy .eq(cap.tx_rdy),
|
||
|
||
# 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),
|
||
]
|
||
|
||
# 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),
|
||
reg.spram_req_r_en.eq(arb.exi_req_ready),
|
||
|
||
reg.spram_rsp_data .eq(arb.exi_rsp_data),
|
||
reg.spram_rsp_valid.eq(arb.exi_rsp_valid),
|
||
]
|
||
|
||
# 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),
|
||
reg.tx_bytes_r_en .eq(drain.tx_bytes_r_en),
|
||
|
||
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),
|
||
|
||
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),
|
||
]
|
||
|
||
# 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),
|
||
|
||
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),
|
||
|
||
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),
|
||
]
|
||
|
||
# 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 += [
|
||
reg.rx_irq_i.eq(asm.rx_irq),
|
||
reg.tx_irq_i.eq(drain.tx_irq),
|
||
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),
|
||
]
|
||
|
||
# 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 += mods.eth.promisc.eq(reg.ncrb[NCRB_PR])
|
||
|
||
def _wire_bringup(self, m, mods):
|
||
"""Optional status panel, UART console and UART shell.
|
||
|
||
`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()
|
||
|
||
if self._status_panel:
|
||
panel = StatusPanel()
|
||
m.submodules.panel = panel
|
||
init_req = init_req | panel.reinit
|
||
|
||
# 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),
|
||
panel.tx_pulse .eq(drain.tx_irq),
|
||
panel.ready .eq(ready),
|
||
panel.btn .eq(self.panel_btn),
|
||
self.panel_led .eq(panel.led),
|
||
]
|
||
|
||
if self._uart_console:
|
||
console = UARTConsole()
|
||
m.submodules.console = console
|
||
init_req = init_req | console.reinit
|
||
|
||
m.d.comb += [
|
||
console.ncra_rst.eq(reg.ncra_rst_o),
|
||
console.rx_pulse.eq(asm.rx_irq),
|
||
console.tx_pulse.eq(drain.tx_irq),
|
||
console.ready .eq(ready),
|
||
self.uart_tx .eq(console.uart_tx),
|
||
console.uart_rx .eq(self.uart_rx),
|
||
]
|
||
|
||
if self._uart_shell:
|
||
# `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),
|
||
|
||
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:
|
||
with m.If(eth.init_done):
|
||
m.d.sync += ready.eq(1)
|
||
with m.Elif(init_req):
|
||
m.d.sync += ready.eq(0)
|
||
|
||
m.d.comb += eth.init_req.eq(init_req)
|
||
|
||
|
||
# ── Integration testbench ─────────────────────────────────────────────────
|
||
# 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
|
||
import sys
|
||
from amaranth.sim import Simulator, Period
|
||
|
||
dut = BBATop(eth="w5100", reset_cycles=20, # small reset wait for sim
|
||
status_panel=True) # also exercise the panel wiring
|
||
errors = []
|
||
|
||
# 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 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 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."""
|
||
# 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
|
||
ctx.set(dut.exi_mosi, (mosi_val >> bit) & 1)
|
||
await ctx.delay(HP * (HALF))
|
||
# Sample MISO in the CLK-LOW phase -- the value the host latches
|
||
# ON the rising edge. The device launches MISO one bit EARLY, on
|
||
# the rising edge (see spi_mode3_slave), so reading AFTER raising
|
||
# the clock here would see the NEXT bit and the testbench would
|
||
# report everything one bit early while hardware was correct.
|
||
miso = (miso << 1) | ctx.get(dut.exi_miso)
|
||
ctx.set(dut.exi_clk, 1) # rising: device samples
|
||
await ctx.delay(HP * (HALF))
|
||
return miso
|
||
|
||
async def exi_read(ctx, addr, length):
|
||
"""Register-space read, REAL protocol (docs/exi_protocol_real.md):
|
||
4-byte header [0x80, ah, al, 0x00] then a data phase that runs until
|
||
CS. The trailing 0x00 is the device's prefetch window."""
|
||
ctx.set(dut.exi_cs_n, 0)
|
||
ctx.set(dut.exi_clk, 1)
|
||
await ctx.delay(HP * (HALF))
|
||
await spi_byte(ctx, 0x80)
|
||
await spi_byte(ctx, (addr >> 8) & 0xFF)
|
||
await spi_byte(ctx, addr & 0xFF)
|
||
await spi_byte(ctx, 0x00)
|
||
# EXI_Imm clock-idle gap: the core decodes the header and prefetches
|
||
# responses into the tx FIFO before the GC clocks the data phase.
|
||
await ctx.delay(HP * (HALF * 12))
|
||
result = [await spi_byte(ctx, 0x00) for _ in range(length)]
|
||
ctx.set(dut.exi_cs_n, 1)
|
||
await ctx.delay(CS_GAP)
|
||
return result
|
||
|
||
async def exi_write(ctx, addr, data):
|
||
"""Register-space write: 4-byte header [0xC0, ah, al, 0x00] + data."""
|
||
ctx.set(dut.exi_cs_n, 0)
|
||
ctx.set(dut.exi_clk, 1)
|
||
await ctx.delay(HP * (HALF))
|
||
await spi_byte(ctx, 0xC0)
|
||
await spi_byte(ctx, (addr >> 8) & 0xFF)
|
||
await spi_byte(ctx, addr & 0xFF)
|
||
await spi_byte(ctx, 0x00)
|
||
for b in data:
|
||
await spi_byte(ctx, b)
|
||
ctx.set(dut.exi_cs_n, 1)
|
||
await ctx.delay(CS_GAP)
|
||
|
||
async def exi_devid(ctx):
|
||
"""EXI_GetID: write 0x0000 (two bytes) then clock four read bytes."""
|
||
ctx.set(dut.exi_cs_n, 0)
|
||
ctx.set(dut.exi_clk, 1)
|
||
await ctx.delay(HP * (HALF))
|
||
await spi_byte(ctx, 0x00)
|
||
await spi_byte(ctx, 0x00)
|
||
await ctx.delay(HP * (HALF * 12))
|
||
out = [await spi_byte(ctx, 0x00) for _ in range(4)]
|
||
ctx.set(dut.exi_cs_n, 1)
|
||
await ctx.delay(CS_GAP)
|
||
return out
|
||
|
||
# ── W5100 indirect-bus slave model (drives w5100_data_i) ─────────────
|
||
# Pre-loads a known MACRAW packet in the RX buffer so we can verify the full
|
||
# ethernet→SPRAM→GC path. Same protocol as the W5100ParallelMaster bench.
|
||
RX_FRAME = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]
|
||
_W_RX_BASE = 0x6000 # RX memory base as seen over the IDM (15-bit) bus
|
||
_W_S0_CR = 0x0401
|
||
_W_S0_RX_RSR = 0x0426
|
||
_W_S0_RX_RD = 0x0428
|
||
_W_CR_RECV = 0x40
|
||
_A_MR, _A_AR0, _A_AR1, _A_DR = 0b00, 0b01, 0b10, 0b11
|
||
|
||
def w5100_preload():
|
||
plen = len(RX_FRAME) + 2 # MACRAW length includes its header
|
||
mem = {}
|
||
for i, b in enumerate([(plen >> 8) & 0xFF, plen & 0xFF] + RX_FRAME):
|
||
mem[_W_RX_BASE + i] = b
|
||
mem[_W_S0_RX_RSR], mem[_W_S0_RX_RSR + 1] = (plen >> 8) & 0xFF, plen & 0xFF
|
||
mem[_W_S0_RX_RD], mem[_W_S0_RX_RD + 1] = 0, 0
|
||
return mem
|
||
|
||
w5100_mem = w5100_preload()
|
||
tx_seen = [] # bytes the model saw written into the TX buffer
|
||
|
||
async def w5100_model(ctx):
|
||
idm_ar = 0
|
||
mr = 0
|
||
prev_cs = prev_rd = prev_wr = 1
|
||
async for vals in ctx.tick("sync").sample(
|
||
dut.w5100_cs_n, dut.w5100_rd_n, dut.w5100_wr_n,
|
||
dut.w5100_addr, dut.w5100_data_o):
|
||
cs, rd, wr, a, do = vals[-5:]
|
||
ai = (mr >> 1) & 1
|
||
if cs == 0 and rd == 0: # drive read data
|
||
if a == _A_MR:
|
||
val = mr
|
||
elif a == _A_AR0:
|
||
val = (idm_ar >> 8) & 0xFF
|
||
elif a == _A_AR1:
|
||
val = idm_ar & 0xFF
|
||
else:
|
||
val = w5100_mem.get(idm_ar, 0)
|
||
ctx.set(dut.w5100_data_i, val)
|
||
if cs == 0 and prev_wr == 0 and wr == 1: # latch write on /WR rising
|
||
if a == _A_MR:
|
||
mr = do
|
||
elif a == _A_AR0:
|
||
idm_ar = (idm_ar & 0x00FF) | (do << 8)
|
||
elif a == _A_AR1:
|
||
idm_ar = (idm_ar & 0xFF00) | do
|
||
else:
|
||
w5100_mem[idm_ar] = do
|
||
if 0x4000 <= idm_ar < 0x6000: # socket TX buffer region
|
||
tx_seen.append(do)
|
||
if idm_ar == _W_S0_CR and do == _W_CR_RECV:
|
||
w5100_mem[_W_S0_RX_RSR] = 0
|
||
w5100_mem[_W_S0_RX_RSR + 1] = 0
|
||
if ai:
|
||
idm_ar = (idm_ar + 1) & 0xFFFF
|
||
if cs == 0 and prev_rd == 0 and rd == 1 and a == _A_DR and ai:
|
||
idm_ar = (idm_ar + 1) & 0xFFFF
|
||
prev_cs, prev_rd, prev_wr = cs, rd, wr
|
||
|
||
async def testbench(ctx):
|
||
ctx.set(dut.exi_clk, 1)
|
||
ctx.set(dut.exi_cs_n, 1)
|
||
ctx.set(dut.panel_btn, 0b111) # all buttons released (active-low idle)
|
||
await ctx.delay(HP * (20))
|
||
|
||
# T1: device ID — read 4 bytes from addr 0 → 0x04 0x02 0x02 0x00
|
||
dev = await exi_devid(ctx)
|
||
print(f"T1 device ID: {[f'0x{b:02X}' for b in dev]}")
|
||
if dev != [0x04, 0x02, 0x02, 0x00]:
|
||
errors.append(f"T1 device ID: got {dev}")
|
||
await ctx.delay(HP * (HALF))
|
||
|
||
# T2: write PAR0–3, read them back through the full chain
|
||
await exi_write(ctx, 0x20, [0xDE, 0xAD, 0xBE, 0xEF])
|
||
await ctx.delay(HP * (HALF * 4))
|
||
par = await exi_read(ctx, 0x20, 4)
|
||
print(f"T2 PAR0-3 readback: {[f'0x{b:02X}' for b in par]}")
|
||
if par != [0xDE, 0xAD, 0xBE, 0xEF]:
|
||
errors.append(f"T2 PAR readback: got {par}")
|
||
await ctx.delay(HP * (HALF))
|
||
|
||
# T3: NWAYS must read back the hardcoded 0x17 (link-up sentinel)
|
||
nways = await exi_read(ctx, 0x31, 1)
|
||
print(f"T3 NWAYS: 0x{nways[0]:02X} (want 0x17)")
|
||
if nways != [0x17]:
|
||
errors.append(f"T3 NWAYS: got {nways}")
|
||
await ctx.delay(HP * (HALF))
|
||
|
||
# T4: DMA-style SPRAM read — clock 8 data bytes (past the 4-byte header
|
||
# limit) within one CS. Exercises the integrated streaming path:
|
||
# ExiCapture(cs_active) → register file SPRAM_STREAM → SPRAMArbiter →
|
||
# real SPRAM → MISO, plus the SPRAM_END cleanup. SPRAM is uninitialised
|
||
# here, so we check the stream completes (8 bytes, no underrun/hang)
|
||
# rather than specific data.
|
||
dma = await exi_read(ctx, 0x0100, 8)
|
||
print(f"T4 DMA read (8B from 0x100): {[f'0x{b:02X}' for b in dma]}")
|
||
if len(dma) != 8:
|
||
errors.append(f"T4 DMA read length: got {len(dma)}")
|
||
await ctx.delay(HP * (HALF))
|
||
|
||
# T5: a register read after the streaming read confirms the FSM cleaned
|
||
# up (SPRAM_END → HEADER0) and the device is responsive again.
|
||
nways2 = await exi_read(ctx, 0x31, 1)
|
||
print(f"T5 NWAYS after DMA: 0x{nways2[0]:02X} (want 0x17)")
|
||
if nways2 != [0x17]:
|
||
errors.append(f"T5 NWAYS after DMA read: got {nways2}")
|
||
await ctx.delay(HP * (HALF))
|
||
|
||
# ── T6: FULL ETHERNET→SPRAM→GC LOOP ──────────────────────────────
|
||
# 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)
|
||
# before multi-byte bus accesses work — trigger it via NCRA reset, as
|
||
# the real GC driver does, and let it run before enabling RX.
|
||
await exi_write(ctx, 0x00, [0x01]) # NCRA reset → init_req pulse
|
||
await ctx.delay(HP * (2000)) # let W5100 init run
|
||
await exi_write(ctx, 0x00, [0x08]) # NCRA SR bit → enable RX
|
||
await ctx.delay(HP * (HALF * 2))
|
||
|
||
# T1b: the device ID STILL ANSWERS after an NCRA reset.
|
||
#
|
||
# It used to be a one-shot: `id_phase` was cleared by the GC's first
|
||
# NCRA reset, after which a command-space read of reg 0 returned
|
||
# 0x00000000. Real hardware answers EXI_GetID whenever asked -- 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, Swiss reported NO ADAPTER AT ALL.
|
||
#
|
||
# T1 above reads the ID before any reset, so it cannot catch this;
|
||
# the reset two lines up is what makes this check meaningful.
|
||
dev2 = await exi_devid(ctx)
|
||
print(f"T1b device ID after NCRA reset: {[f'0x{b:02X}' for b in dev2]}")
|
||
if dev2 != [0x04, 0x02, 0x02, 0x00]:
|
||
errors.append(f"T1b device ID after NCRA reset: got {dev2} "
|
||
f"- the one-shot is back, Swiss would see no adapter")
|
||
await ctx.delay(HP * (HALF * 2))
|
||
|
||
ctx.set(dut.w5100_int_n, 0) # W5100: a packet was received
|
||
await ctx.delay(HP * (4000)) # let the W5100 RX + SPRAM write run
|
||
ctx.set(dut.w5100_int_n, 1)
|
||
await ctx.delay(HP * (HALF * 2))
|
||
|
||
rwp = await exi_read(ctx, 0x16, 1) # RX write pointer (page)
|
||
total_len = len(RX_FRAME) + 4
|
||
RX_NEXT_PAGE = 2 # one-page frame written at page 1
|
||
got = await exi_read(ctx, 0x0100, total_len) # descriptor + frame
|
||
# The descriptor is a packed little-endian word, as libogc's driver
|
||
# reads it: next_packet_ptr[11:0] | packet_len[23:12] | status[31:24].
|
||
# See the format note in rx_frame_assembler.py.
|
||
_desc_word = ((total_len & 0xFFF) << 12) | (RX_NEXT_PAGE & 0xFFF)
|
||
want = [(_desc_word >> (8 * i)) & 0xFF for i in range(4)] + RX_FRAME
|
||
print(f"T6 RWP=0x{rwp[0]:02X} (want 0x02)")
|
||
print(f"T6 SPRAM[0x100]: {[f'0x{b:02X}' for b in got]}")
|
||
print(f"T6 expected : {[f'0x{b:02X}' for b in want]}")
|
||
if rwp != [0x02]:
|
||
errors.append(f"T6 RWP: got {rwp}, want [0x02]")
|
||
if got != want:
|
||
errors.append(f"T6 RX frame mismatch:\n got {got}\n want {want}")
|
||
|
||
# T7: status-panel integration — after all the EXI traffic above, the
|
||
# EXI-activity LED (panel led[1] = stretched cs_active) must be lit,
|
||
# proving cap.cs_active → FFSync → StatusPanel → LED is wired end-to-end.
|
||
leds = ctx.get(dut.panel_led)
|
||
if not (leds >> 1) & 1:
|
||
errors.append(f"T7 panel: EXI-activity LED not lit (led=0b{leds:05b})")
|
||
print(f"T7 panel led=0b{leds:05b} (bit1=EXI activity, expect 1)")
|
||
|
||
# ── T9: EXI DMA READ **WHILE** THE ETHERNET SIDE WRITES THE RING ──
|
||
# This is the condition hardware actually runs in and that no test has
|
||
# ever modelled: T6 above waits for the frame to finish before reading,
|
||
# so reads and writes never overlap.
|
||
#
|
||
# ETH has ABSOLUTE priority in SPRAMArbiter -- eth_wr_ready is asserted
|
||
# combinationally whenever eth_wr_valid is high -- so an EXI read is
|
||
# preempted whenever a frame byte is being written. If a read request
|
||
# is lost, or a response is delayed past the end of its transaction,
|
||
# the GC's byte stream shifts.
|
||
#
|
||
# On console the ring descriptor lands at page offset +1 instead of +0
|
||
# for ~77% of frames (desc@+0=100 +1=343), varying frame to frame, and
|
||
# netboot only works because its reader probes both offsets. Swiss
|
||
# does not probe: it would read a frame length of 1 and the RX path
|
||
# would be broken for real networking. So this test exists to
|
||
# reproduce that, not merely to add coverage.
|
||
#
|
||
# Page 1 still holds T6's completed frame, so its expected contents are
|
||
# known exactly while a SECOND frame is being written behind it.
|
||
ctx.set(dut.w5100_int_n, 0) # another frame arriving
|
||
mismatches = 0
|
||
first_bad = None
|
||
# Also read the FRAME BODY at 0x104, whose first byte is 0xDE. The
|
||
# descriptor at 0x100 starts with 0x00, which is indistinguishable from
|
||
# a stale/empty byte -- so reading a region with a known NON-ZERO first
|
||
# byte is what identifies whether slot 0 holds a leftover and, from its
|
||
# value, where that leftover came from.
|
||
body_reads = []
|
||
for attempt in range(6):
|
||
again = await exi_read(ctx, 0x0100, total_len)
|
||
if again != want:
|
||
mismatches += 1
|
||
if first_bad is None:
|
||
first_bad = again
|
||
body_reads.append(await exi_read(ctx, 0x0104, 6))
|
||
print(f"T9 body reads from 0x104 (want DE AD BE EF ..):")
|
||
for i, b in enumerate(body_reads):
|
||
print(f" [{i}] {' '.join(f'{x:02X}' for x in b)}")
|
||
ctx.set(dut.w5100_int_n, 1)
|
||
await ctx.delay(HP * (HALF * 4))
|
||
|
||
print(f"T9 concurrent DMA reads: {6 - mismatches}/6 byte-exact")
|
||
if mismatches:
|
||
print(f"T9 first bad read: {[f'0x{b:02X}' for b in first_bad[:8]]}")
|
||
print(f"T9 expected : {[f'0x{b:02X}' for b in want[:8]]}")
|
||
errors.append(f"T9 DMA read corrupted by concurrent ETH writes "
|
||
f"({mismatches}/6 reads wrong)")
|
||
|
||
# T8: GC → wire TX of a frame LONGER than the 16-deep tx_bytes FIFO.
|
||
# This is the case that shipped broken: TXFrameDrain used to wait for
|
||
# the length until the NCRA ST write, i.e. until after the GC had
|
||
# streamed the whole frame, so the frame had to fit in tx_bytes and
|
||
# everything past byte 16 was silently dropped (hardware 2026-09-30:
|
||
# frames truncated at exactly 16 bytes on the wire). The length is now
|
||
# queued on the TWD_HI write so the drain runs concurrently.
|
||
TX_FRAME = [0xFF] * 6 + [0x00, 0x09, 0xBF, 0x00, 0x00, 0x01] + \
|
||
[0x88, 0xB5] + [0x40 + i for i in range(20)] # 34 bytes
|
||
assert len(TX_FRAME) > 16, "test must exceed the tx_bytes depth to be meaningful"
|
||
tx_seen.clear()
|
||
await exi_write(ctx, 0x34, [len(TX_FRAME) & 0xFF])
|
||
await exi_write(ctx, 0x35, [(len(TX_FRAME) >> 8) & 0xFF])
|
||
for b in TX_FRAME:
|
||
# One byte per transaction: eff_addr increments per data byte, so a
|
||
# multi-byte write to 0x48 would spill into 0x49+.
|
||
await exi_write(ctx, 0x48, [b])
|
||
await exi_write(ctx, 0x00, [0x08 | 0x02]) # SR + ST0 → transmit
|
||
for _ in range(4000):
|
||
if len(tx_seen) >= len(TX_FRAME):
|
||
break
|
||
await ctx.tick("sync")
|
||
got = tx_seen[:len(TX_FRAME)]
|
||
print(f"T8 TX {len(TX_FRAME)}B frame: W5100 got {len(tx_seen)} bytes")
|
||
if got != TX_FRAME:
|
||
n = min(len(got), len(TX_FRAME))
|
||
first = next((i for i in range(n) if got[i] != TX_FRAME[i]), n)
|
||
errors.append(f"T8 TX frame mismatch: {len(got)}/{len(TX_FRAME)} bytes, "
|
||
f"first bad byte at index {first}")
|
||
print(f" got {[f'0x{b:02X}' for b in got[:20]]}")
|
||
print(f" want {[f'0x{b:02X}' for b in TX_FRAME[:20]]}")
|
||
else:
|
||
print(f"T8 TX frame matches byte-for-byte ({len(TX_FRAME)} bytes)")
|
||
|
||
sim = Simulator(dut)
|
||
sim.add_clock(Period(MHz=24), domain="sync")
|
||
sim.add_testbench(testbench)
|
||
sim.add_process(w5100_model)
|
||
sim.run()
|
||
|
||
if errors:
|
||
print("\nFAILURES:")
|
||
for e in errors:
|
||
print(" ", e)
|
||
sys.exit(1)
|
||
else:
|
||
print("\nAll BBATop integration tests passed.")
|