bd4c69c5f2
Reading the frame body at 0x104 (first byte a known 0xDE) instead of the
descriptor at 0x100 (first byte 0x00, indistinguishable from a stale byte)
shows what actually happens:
good: DE AD BE EF 01 02
bad: DE DE AD BE EF 01 <-- the FIRST BYTE IS DUPLICATED
So it is a tx FIFO UNDERRUN, not an inserted byte. When the FIFO runs dry for
one byte-time the bit engine reloads the stale FIFO head and sends the previous
byte again, shifting the rest of the read.
The register file must sustain one byte per 296 ns at 27 MHz. The round trip
is request -> spram_req SyncFIFO -> arbiter (ETH has absolute priority and
preempts it) -> SPRAM -> response -> push -> tx AsyncFIFO CDC (2-3 EXI cycles).
That is marginal even idle and over budget during a frame write, and only ONE
request may be in flight so the latency is never hidden.
THREE FIXES TRIED AND FALSIFIED, recorded in CLAUDE.md so they are not
repeated:
- Fair round-robin arbitration instead of strict ETH priority: still 2/6.
Bounds the wait to ~2 cycles but does not close the gap, and breaks
spram_arbiter T3. Reverted.
- Deepen tx_fifo 2 -> 8 for cushion: 0/6 with leading zeros. The engine
drains leftovers with ONE POP PER HEADER BYTE, so a deeper FIFO leaks
stale bytes into the data phase. tx_depth=2 is deliberate. Reverted.
- Allow 2 outstanding requests to pipeline: 0/6 at EVERY rate, including
HALF=8 which previously passed. With no response FIFO, responses must be
consumed the cycle they are valid; two in flight loses bytes. The
existing comment was right. Reverted.
Tree is back to the T9-reproduction baseline: 6/6 at HALF=8, 1-2/6 at HALF=1.
PROMISING DIRECTION, not attempted: SPRAM is 16 BITS WIDE and the arbiter
reads a word then returns one byte selected by address bit 0. Returning both
bytes per request would push two bytes per round trip, doubling the budget to
592 ns and making it comfortable. That is the only approach identified that
addresses the constraint rather than working around it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
743 lines
35 KiB
Python
743 lines
35 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),
|
||
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
|
||
got = await exi_read(ctx, 0x0100, total_len) # descriptor + frame
|
||
want = [0x00, 0x00, (total_len >> 8) & 0xFF, total_len & 0xFF] + 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.")
|