Files
rebbarb/exi_bba/bba_top.py
T
Roflin 8825cfb3d6 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>
2026-10-10 20:17:22 +02:00

838 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.")