147ef8fcfc
Compat TODO item 2, the last silent-corruption risk. RXFrameAssembler took the RX ring as a compile-time constant (pages 1-15), so a driver configuring a SMALLER ring was ignored and we kept writing pages it would never read, with nothing reporting an error. The bounds now come from bp/rhbp, wired in BBATop from the GC's BP (0x0A/0B) and RHBP (0x1A/1B) registers, and both the page wrap and the free-space calculation use them. Garbage is rejected rather than obeyed: the driver's values are used only when bp >= 1 (page 0 is the TX buffer), rhbp <= 0x0F and rhbp > bp. Otherwise the hardcoded layout stands and geom_honoured goes low, so a mismatch is observable. Without that check BP=0 or an RHBP below BP gives a zero or negative ring size and the assembler writes outside it. ⚠️ Recorded as a known limitation: a ring must be LARGER than _MAX_FRAME_PAGES (6) or nothing is ever stored, because a frame's length is unknown until EOF so the worst case (1518+4 = 6 pages) is reserved up front. Found the honest way -- the test was written with RHBP=4 first and passed VACUOUSLY, storing nothing at all. Making that work needs a length-aware reservation, which is a bigger change than this one. T7 (8-page ring honoured, frames only in pages 1-8, and at least one actually stored) and T7b (all three garbage cases rejected, fallback still stores). Mutation-tested: wrapping at the hardcoded page 15 writes pages 9-11 and fails T7; removing the sanity check fails all four T7b assertions. Item 1 (RX filtering) remains the only open gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
755 lines
36 KiB
Python
755 lines
36 KiB
Python
"""BBATop — top-level elaboratable for the GC BBA FPGA replacement.
|
||
|
||
Clock domains
|
||
-------------
|
||
sync : 24 MHz, from the iCE40UP5K internal SB_HFOSC (÷2, CLKHF_DIV=0b01).
|
||
Everything except the EXI bit engine. No PLL, no crystal.
|
||
exiclk : the GC's EXI clock pin itself (~27 MHz), posedge — created inside
|
||
exiclk_n : the same pin, negedge ................ `ExiCapture`.
|
||
exififo_* the same pin, posedge, reset-less — the byte-FIFO sides.
|
||
|
||
Submodule instantiation and signal wiring
|
||
-----------------------------------------
|
||
See CLAUDE.md "Module Breakdown" and "CDC Signal Inventory" for the full list.
|
||
"""
|
||
|
||
from amaranth import *
|
||
|
||
from exi_bba.exi_capture import ExiCapture
|
||
from exi_bba.bba_register_file import BBARegisterFile
|
||
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. Wires all submodules and defines clock domains.
|
||
|
||
External ports (exposed for platform or testbench connection)
|
||
-------------------------------------------------------------
|
||
EXI / GC interface (SPI Mode 3)
|
||
exi_clk / exi_mosi / exi_cs_n : inputs from GC
|
||
exi_miso : output to GC
|
||
int_n : interrupt output (active low)
|
||
|
||
W5500 SPI interface (SPI Mode 0)
|
||
w5500_clk / w5500_mosi / w5500_cs_n : outputs to W5500
|
||
w5500_miso : input from W5500
|
||
w5500_int_n : W5500 interrupt (input, active low)
|
||
w5500_rst_n : W5500 hardware reset (output, active low)
|
||
"""
|
||
|
||
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):
|
||
# Ethernet back-end: "w5100" (indirect parallel bus, reaches the EXI
|
||
# ceiling) or "w5500" (SPI, ~12 Mbit/s). Both expose the identical
|
||
# tx/rx/init/par interface, so only the physical pins differ.
|
||
self._eth = eth
|
||
self._phase_meter = phase_meter # diagnostic, see SPIMode3Slave
|
||
# MR-reset settle wait passed to the ethernet master (~1 ms on hardware;
|
||
# the testbench overrides with a small value for fast simulation).
|
||
self._reset_cycles = reset_cycles
|
||
# Optional bring-up status panel (drives onboard LEDs/button on the
|
||
# iCEbreaker — see synth.py). panel_led bit order matches StatusPanel.
|
||
self._status_panel = status_panel
|
||
# Optional UART debug console (8N1 115200, sync domain).
|
||
# uart_tx → FT2232H Channel B (net UART_RXD, pin 19);
|
||
# uart_rx ← FT2232H Channel B (net UART_TXD, pin 18) — net names are
|
||
# FTDI-perspective, so this is not a naming mismatch, see synth.py.
|
||
self._uart_console = uart_console
|
||
# Optional interactive UART command shell (bring-up UDP test on the
|
||
# W5100's socket 1). Shares the UART pins with the console, so only one
|
||
# may be enabled; the UDP path needs the W5100 back-end. See uart_shell.
|
||
self._uart_shell = uart_shell
|
||
if uart_console and uart_shell:
|
||
raise ValueError("uart_console and uart_shell share the UART pins; "
|
||
"enable only one")
|
||
if uart_shell and eth != "w5100":
|
||
raise ValueError("uart_shell UDP test requires eth='w5100'")
|
||
self._udp_cfg = dict(udp_socket=udp_socket, src_ip=udp_src_ip,
|
||
subnet=udp_subnet, gateway=udp_gateway,
|
||
dst_ip=udp_dst_ip, src_port=udp_src_port,
|
||
dst_port=udp_dst_port)
|
||
|
||
# EXI (GC side)
|
||
self.exi_miso_next = Signal(init=1) # for an SB_IO output register
|
||
self.exi_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":
|
||
# W5500 SPI
|
||
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:
|
||
# W5100 indirect parallel bus. data_o/data_oe/data_i are the FPGA
|
||
# side of a bidirectional D[7:0] (wrapped in a tristate SB_IO at the
|
||
# platform level); a board ties the upper address lines to 0 so only
|
||
# A[1:0] are wired.
|
||
self.w5100_addr = Signal(2)
|
||
self.w5100_data_o = Signal(8)
|
||
self.w5100_data_oe = Signal()
|
||
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) # to onboard LEDs (see StatusPanel)
|
||
self.panel_btn = Signal(3) # from onboard button(s)
|
||
|
||
if uart_console or uart_shell:
|
||
self.uart_tx = Signal(init=1) # FPGA → PC (FT2232H Channel B)
|
||
self.uart_rx = Signal(init=1) # PC → FPGA
|
||
|
||
def elaborate(self, platform):
|
||
m = Module()
|
||
|
||
# ── Clock domain generation ───────────────────────────────────────
|
||
# ONE generated domain, one physical source:
|
||
# sync @ 24 MHz (internal SB_HFOSC ÷2) — register file, SPRAM,
|
||
# RX/TX engines, ethernet master. Everything that is not the EXI
|
||
# bit engine lives here.
|
||
#
|
||
# The EXI front-end is NOT clocked from here: `ExiCapture` builds its
|
||
# own exiclk/exiclk_n domains directly from the GC's EXI clock pin.
|
||
# That is what removed the old 54 MHz PLL-fed `capture` domain, which
|
||
# oversampled the EXI clock 2× and put BIT-level timing across a CDC
|
||
# boundary (~55 ns of synchroniser latency against a 37 ns bit) — the
|
||
# cause of the `0x17` -> `05 C0` MISO shift on hardware. With the
|
||
# engine clocked by the pin, MISO is clock-to-Q and the only crossings
|
||
# left are byte-level FIFOs with a full byte-time of slack.
|
||
#
|
||
# Consequence for timing closure: `capture_clk` is GONE as a
|
||
# constraint. It was the binding path for this whole design and the
|
||
# reason builds only closed on a minority of seeds.
|
||
if platform is not None:
|
||
# sync @ 24 MHz from the internal oscillator. No PLL and no
|
||
# external crystal are needed any more, so the 12 MHz input (pin
|
||
# 35) and the PLL hard macro are both free.
|
||
# The register-file / SPRAM / ethernet logic is routing-bound at
|
||
# ~33–44 MHz on the UP5K; 24 MHz closes with large margin. The byte
|
||
# rate (27 MHz EXI ÷ 8 ≈ 3.4 MHz) leaves ~7 sync cycles per byte.
|
||
m.domains += ClockDomain("sync")
|
||
m.submodules.hfosc = Instance(
|
||
"SB_HFOSC",
|
||
p_CLKHF_DIV = "0b01", # 48 ÷ 2 → 24 MHz
|
||
i_CLKHFEN = Const(1, 1),
|
||
i_CLKHFPU = Const(1, 1),
|
||
o_CLKHF = ClockSignal("sync"),
|
||
)
|
||
# (simulation: the test harness drives the EXI pins and `sync` directly)
|
||
|
||
# ── Submodules ────────────────────────────────────────────────────
|
||
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
|
||
|
||
# ── External pin connections ──────────────────────────────────────
|
||
m.d.comb += [
|
||
# EXI inputs (to capture-domain front-end)
|
||
cap.spi_clk .eq(self.exi_clk), # drives the exiclk domains
|
||
cap.spi_mosi.eq(self.exi_mosi),
|
||
cap.spi_cs_n.eq(self.exi_cs_n),
|
||
# EXI outputs
|
||
self.exi_miso.eq(cap.spi_miso),
|
||
self.exi_miso_next.eq(cap.miso_next),
|
||
cap.id_phase.eq(reg.id_phase),
|
||
# Ring-overflow protection: the assembler needs the GC's read
|
||
# pointer to know what it must not overwrite, and reports a
|
||
# dropped frame back as RBFI (IR[7]).
|
||
asm.rrp.eq(reg.rrp),
|
||
# The GC's own ring geometry (BP / RHBP). Previously hardcoded in
|
||
# the assembler, so a driver configuring a smaller RX ring was
|
||
# ignored and we kept writing pages it would never read. The
|
||
# assembler sanity-checks these and falls back to pages 1-15 if
|
||
# they are unusable -- see docs/bba_compat_todo.md item 2.
|
||
asm.bp .eq(reg.bp),
|
||
asm.rhbp.eq(reg.rhbp),
|
||
asm.ncra_rst.eq(reg.ncra_rst_o),
|
||
reg.rx_overflow.eq(asm.rx_overflow),
|
||
self.int_n .eq(reg.exi_int_n),
|
||
]
|
||
|
||
# Ethernet back-end physical pins
|
||
if self._eth == "w5500":
|
||
m.d.comb += [
|
||
self.w5500_clk .eq(eth.spi_clk),
|
||
self.w5500_mosi.eq(eth.spi_mosi),
|
||
self.w5500_cs_n.eq(eth.spi_cs_n),
|
||
eth.spi_miso .eq(self.w5500_miso),
|
||
eth.w5500_int_n.eq(self.w5500_int_n),
|
||
self.w5500_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),
|
||
]
|
||
|
||
# ── ExiCapture byte stream ↔ BBARegisterFile (exi domain) ────────
|
||
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),
|
||
|
||
reg.cs_active.eq(cap.cs_active), # transaction-active (for DMA reads)
|
||
]
|
||
|
||
# ── BBARegisterFile ↔ SPRAMArbiter (sync domain FIFO sides) ──────
|
||
# SPRAM request: reg exi→sync FIFO read side → arb
|
||
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),
|
||
]
|
||
# SPRAM response: arb result → reg sync→exi FIFO write side
|
||
m.d.comb += [
|
||
reg.spram_rsp_data .eq(arb.exi_rsp_data),
|
||
reg.spram_rsp_valid.eq(arb.exi_rsp_valid),
|
||
]
|
||
|
||
# ── BBARegisterFile ↔ TXFrameDrain (sync domain FIFO sides) ──────
|
||
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),
|
||
]
|
||
|
||
# ── TXFrameDrain ↔ ethernet master (sync domain) ──────────────────
|
||
m.d.comb += [
|
||
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),
|
||
]
|
||
|
||
# ── ethernet master → RXFrameAssembler (sync domain) ─────────────
|
||
m.d.comb += [
|
||
asm.rx_data .eq(eth.rx_data),
|
||
asm.rx_valid.eq(eth.rx_valid),
|
||
eth.rx_ready.eq(asm.rx_ready),
|
||
asm.rx_sof .eq(eth.rx_sof),
|
||
asm.rx_eof .eq(eth.rx_eof),
|
||
]
|
||
|
||
# ── RXFrameAssembler → SPRAMArbiter (ETH write, sync domain) ─────
|
||
m.d.comb += [
|
||
arb.eth_wr_addr .eq(asm.eth_wr_addr),
|
||
arb.eth_wr_data .eq(asm.eth_wr_data),
|
||
arb.eth_wr_valid.eq(asm.eth_wr_valid),
|
||
asm.eth_wr_ready.eq(arb.eth_wr_ready),
|
||
]
|
||
|
||
# ── RXFrameAssembler → BBARegisterFile (rx_wptr FIFO write side) ─
|
||
m.d.comb += [
|
||
reg.rx_wptr_w_data.eq(asm.rx_wptr_w_data),
|
||
reg.rx_wptr_w_en .eq(asm.rx_wptr_w_en),
|
||
asm.rx_wptr_w_rdy .eq(reg.rx_wptr_w_rdy),
|
||
]
|
||
|
||
# ── Pulse synchronizer connections ────────────────────────────────
|
||
m.d.comb += [
|
||
# RX irq: sync → exi (RXFrameAssembler → reg → PS → exi domain)
|
||
reg.rx_irq_i.eq(asm.rx_irq),
|
||
# TX irq: sync → exi
|
||
reg.tx_irq_i.eq(drain.tx_irq),
|
||
# MAC address (PAR0–5) → SHAR. exi and sync share the HFOSC net,
|
||
# and par is quasi-static (sampled by the master at init_req).
|
||
eth.par.eq(reg.par),
|
||
]
|
||
|
||
# ── RX enabled gate (NCRA SR / start-receive bit) ─────────────────
|
||
# The RX ring-buffer path is active only after the GC sets NCRA[3].
|
||
m.d.comb += asm.rx_enabled.eq(reg.ncra_sr)
|
||
|
||
# ── Optional bring-up peripherals (sync domain) ───────────────────
|
||
# Build init_req as an OR of all reinit sources (NCRA pulse plus any
|
||
# manual re-init from the status panel and/or the UART 'r' command).
|
||
# "ready" is latched high by eth.init_done and cleared by any init_req.
|
||
# It is computed only when at least one peripheral needs it.
|
||
|
||
init_req = reg.ncra_rst_o # base: GC-issued NCRA 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
|
||
|
||
# cs_active lives in the exi domain; bring it to sync for the LED.
|
||
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:
|
||
# Interactive command shell driving the W5100 socket-1 UDP test.
|
||
# `eth` is the W5100 master built with enable_udp_test above.
|
||
shell = UARTShell()
|
||
m.submodules.shell = shell
|
||
m.d.comb += [
|
||
self.uart_tx .eq(shell.uart_tx),
|
||
shell.uart_rx .eq(self.uart_rx),
|
||
# send
|
||
eth.udp_send_req .eq(shell.udp_send_req),
|
||
eth.udp_dst_ip .eq(shell.udp_dst_ip),
|
||
eth.udp_pl_data .eq(shell.udp_pl_data),
|
||
eth.udp_pl_valid .eq(shell.udp_pl_valid),
|
||
eth.udp_pl_last .eq(shell.udp_pl_last),
|
||
shell.udp_pl_ready .eq(eth.udp_pl_ready),
|
||
shell.udp_test_busy .eq(eth.udp_test_busy),
|
||
# receive (reply)
|
||
eth.udp_rx_req .eq(shell.udp_rx_req),
|
||
shell.udp_rx_busy .eq(eth.udp_rx_busy),
|
||
shell.udp_rx_none .eq(eth.udp_rx_none),
|
||
shell.udp_rx_data .eq(eth.udp_rx_data),
|
||
shell.udp_rx_valid .eq(eth.udp_rx_valid),
|
||
shell.udp_rx_eof .eq(eth.udp_rx_eof),
|
||
eth.udp_rx_ready .eq(shell.udp_rx_ready),
|
||
]
|
||
|
||
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)
|
||
|
||
return m
|
||
|
||
|
||
# ── Integration testbench ─────────────────────────────────────────────────
|
||
# Drives real EXI Mode-3 transactions on the GC-facing pins and checks the
|
||
# response — exercising the full chain ExiCapture (capture domain) ↔ byte FIFOs
|
||
# ↔ BBARegisterFile (exi domain) ↔ sync modules, across all three clock domains.
|
||
|
||
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 = []
|
||
|
||
# Capture ticks per SPI half-period -> EXI rate = 54 MHz / (2 * HALF).
|
||
# HALF=8 3.4 MHz fast regression default
|
||
# HALF=2 13.5 MHz
|
||
# HALF=1 27 MHz THE HARDWARE RATE
|
||
# ⚠️ The default is 8, which clocks EXI EIGHT TIMES SLOWER than the real
|
||
# GC. At that rate the whole CDC response latency hides inside a single
|
||
# bit time, so this suite passed green while hardware failed outright --
|
||
# that is how both the header-format and pop-threshold bugs reached a
|
||
# console. Run `EXI_HALF=1 python -m exi_bba.bba_top` before trusting any
|
||
# timing-related change; at 27 MHz reads currently come back shifted right
|
||
# by one bit (04 02 02 00 -> 02 01 01 00, NWAYS 0x17 -> 0x0B), which is an
|
||
# open bug.
|
||
HALF = int(os.environ.get("EXI_HALF", "8"))
|
||
# The bit engine is now clocked by the EXI pin itself, so there is no
|
||
# `capture` domain to count ticks against. Drive the SPI clock with real
|
||
# delays: one half-period = HALF * (1/54 MHz), preserving the old meaning
|
||
# of HALF so the rates in the table above still hold.
|
||
HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n
|
||
# Gap between transactions (CS high). cs_active reaches the exi domain
|
||
# through a 2-stage FFSynchronizer at 24 MHz, so CS must stay high for
|
||
# ~3 exi cycles or the register file never sees the transaction end and
|
||
# swallows the next one's header as data. At EXI_HALF=1 one HP is only
|
||
# 18.5 ns, far too short -- a real host leaves microseconds between
|
||
# exi_deselect() and the next exi_select().
|
||
CS_GAP = Period(MHz=24) * 6
|
||
|
||
async def spi_byte(ctx, mosi_val):
|
||
"""Drive one EXI Mode-3 byte; return the assembled MISO byte."""
|
||
# Real Mode 3 master (CPOL=1, CPHA=1), which is what the GameCube is:
|
||
# drive MOSI on the FALLING edge, sample MISO on the RISING edge.
|
||
# The old version drove MOSI then fell -- a Mode 2 master -- which is
|
||
# what the Pi probe did and why it never exposed the mismatch.
|
||
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 (W5500 model) → W5500 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))
|
||
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.")
|