Files
rebbarb/CLAUDE.md
T
Roflin 91c3bf0ece Clock the EXI bit engine from the EXI pin: timing closes on all 8 seeds
Replaces the PLL-fed 54 MHz `capture` domain, which oversampled the GC's
27 MHz EXI clock 2x and so put BIT-level timing across a CDC boundary: the
engine's view of the clock lagged the pin by 2 FFSynchronizer stages plus a
register (~55 ns) against a 37 ns bit time, so MISO came out ~2 bits late and
the GC read 0x17 as 05 C0.  No amount of oversampling fixes that -- 54 MHz
gives only 2 ticks per bit and the part cannot close the 81 MHz that 3x needs.

The engine is now clocked by the pin itself (pin 44 = global buffer input net
6, no board change), making MISO clock-to-Q.  ExiCapture creates four domains:
exiclk (posedge, samples MOSI), exiclk_n (negedge, drives MISO), both
async-reset by CS, plus reset-less exififo_w/exififo_r for the byte FIFO sides
-- reset-less because resetting one side of an AsyncFIFO desynchronises its
gray pointers, and posedge because AsyncFIFO has a posedge-only reset CDC
internally and cannot host a negedge side.

Also removes the SPRAM response FIFO.  The DMA read was one byte late because
the round trip did not fit the protocol's turnaround byte; the arbiter already
exposed valid/ready, so the FIFO bought a cycle of latency and nothing else.
The response is handled outside the FSM (the reply lands in RHDR_PAD, not
SPRAM_STREAM) with a one-entry hold for when tx_fifo is momentarily full.

The PLL was left instantiated but unused after the rewrite; removing it frees
the hard macro and pin 35 (which stays unconnected only because the board is
already built that way).  This is what lifted the pass rate:

  domain            target    before        after
  clk (sync)        24 MHz    28.1-30.2     26.4-32.2   8/8
  capture_clk       54 MHz    2 of 8 PASS   (gone)
  EXI pin domain    27 MHz    --            45.6-64.4   8/8

Best seed 3: clk 32.18 MHz, EXI 55.86 MHz.  The binding constraint has FLIPPED
-- `clk` (1.10-1.34x) is now tighter than the EXI domain (1.69-2.39x) -- so
synth.py ranks seeds by worst RELATIVE margin, not by the EXI number.

synth.py could not have scored this design at all: it required the vanished
capture_clk, and its regex '(\w+)' cannot match nextpnr's exi_clk_$glb_clk
(no $ in \w).  add_clock_constraint was also landing on the wrong net -- under
EXI_J4 the clock is the AND of two pads, not either pad -- so nextpnr silently
fell back to a 12 MHz default and under-optimised the whole EXI domain.

Testbenches:
- spi_mode3_slave had lost its testbench entirely in the rewrite.  Restored
  with a strict Mode 3 master (changes MOSI on the falling edge, samples MISO
  in the clock-low phase) and MUTATION-TESTED: flipping either clock edge in
  the RTL makes it fail.  Both of those bugs previously reached silicon
  against a lax master that parked MOSI well before the edge.
- exi_j4 T2/T3 returned [0,4,2,2].  Real bug: the engine latches the FIFO head
  at each byte boundary, so a 2-byte command header leaves only ONE drain
  slot.  DEVID streamed zeros until CS and left two stale bytes -- one too
  many -- so the NEXT enumeration came back a slot late.  DEVID now pushes
  exactly four bytes and stops.  Register-space reads may still over-push:
  their 4-byte header gives three drain slots.
- Dead ports removed from SPIMode3Slave (hdr_bytes, frame_start, cs_active,
  byte_idx) and the mode2 parameter is gone -- there is one correct edge
  convention and it is now enforced by tests.

All 14 module suites pass, plus bba_top at EXI_HALF=1/2/8.

CLAUDE.md: rewrote the clocking, CDC inventory and module tables; marked the
2026-09-30 capture_clk measurements historical; and corrected the line in
"Critical Protocol Notes" that still stated the INVERTED edge convention
("MOSI sampled on falling, MISO driven on rising" is Mode 2) -- the original
implementation followed that doc error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 18:36:51 +02:00

49 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project: GC BBA FPGA Replacement

Replace the GameCube Broadband Adapter (DOL-015 / MX98730EC) with an iCEbreaker FPGA (Lattice iCE40UP5K) written in Amaranth HDL. The FPGA emulates the BBA register interface over the GameCube EXI bus and bridges to a WIZnet ethernet chip for real 100BASE-TX ethernet — default W5100 (indirect parallel bus, reaches the EXI throughput ceiling) or W5500 (SPI Pmod, simpler wiring but ~12 Mbit/s). GC software (Swiss homebrew) sees an identical BBA. See "W5100 vs W5500 ethernet back-end".


Development Environment

Preferred: Use the devcontainer (.devcontainer/) which includes Python 3.12, nextpnr-ice40, and fpga-icestorm pre-installed.

Windows host + WSL2 devcontainer — USB flashing setup:

  1. Install usbipd-win (https://github.com/dorssel/usbipd-win/releases)
  2. Run .devcontainer/attach-icebreaker.ps1 as Administrator before opening the devcontainer
  3. The devcontainer runs --privileged AND bind-mounts /dev/bus/usb so libusb (iceprog, pyftdi) can see the FT2232H; the vscode user flashes via passwordless sudo (the USB nodes are root-owned). If you change .devcontainer/, rebuild the container for it to take effect.
  4. Flashing the re-bba-rb board (bitstream + FT2232H EEPROM) is documented as the flash-re-bba-rb skill — see .claude/skills/flash-re-bba-rb/SKILL.md.

Local venv (outside devcontainer):

python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt

Yosys is bundled in amaranth-yosys; nextpnr-ice40 and iceprog must be installed separately (via apt on Linux, or via the devcontainer).


Commands

Build and flash the iCEbreaker (must run from workspace root):

python rebbarb/rebbarb.py

Runs synthesis (yosys), place-and-route (nextpnr-ice40), and flashes via iceprog. Set ICEPROG=/path/to/iceprog env var to override the binary location. Note: rebbarb/rebbarb.py builds a 36 MHz LED blink demo. The BBA implementation (exi_bba/) clocks the SPI bit engine from the GC's EXI clock pin itself and everything else from sync @ 24 MHz (internal HFOSC); there is no PLL. Synthesize/flash the real design with python -m exi_bba.synth [--flash].

Run a simulation:

# New-API testbench style (preferred for new code):
python rebbarb/toggle_button.py     # writes ToggleButton.vcd
python rebbarb/pulse_button.py      # writes PulseButton.vcd

# Old-API process style (reference only, do not replicate in new code):
python examples/amaranth_cdc.py     # CDC primitives demo
python examples/async_fifo.py       # AsyncFIFO behaviour
python examples/icebreaker_fifo.py  # iCEbreaker-specific FIFO (Verilog dump)

Open VCD output with gtkwave. Simulations are the primary testing mechanism — there is no separate test runner.

Clock sources: there is no PLL and no external crystal any more. sync comes from the internal SB_HFOSC ÷2 = 24 MHz; the EXI bit engine is clocked by the GC's own EXI clock pin. Pin 35 (the old 12 MHz input) and the PLL hard macro are both free.


Current Implementation State

The exi_bba/ module tree is fully implemented with simulation testbenches. All modules elaborate without errors and pass their unit tests, including bba_top at the real 27 MHz EXI rate (EXI_HALF=1).

Timing closes on EVERY seed (2026-10-03). Measured with EXI_J4=1 + socket-3 UDP, python -m exi_bba.synth --seeds 8, 68% LC:

domain target result
clk (sync, HFOSC) 24 MHz 26.4–32.2 MHz — PASS on all 8 seeds
EXI pin domain 27 MHz 45.6–64.4 MHz — PASS on all 8 seeds

Best seed 3: clk 32.18 MHz, EXI 55.86 MHz (worst relative margin 1.34×). Flash build/seed<N>/top.bin.

This replaces the long-standing "closes on a minority of seeds" problem, which was entirely an artefact of the PLL-fed 54 MHz capture domain. Removing that domain (the bit engine is now clocked by the EXI pin — see "Key Architecture Decisions") took the pass rate from 2 of 8 to 8 of 8 and made the old capture-domain tuning levers moot: the Gray-code CDC loop that was the binding path, the pin co-location experiment, and the proposed ping-pong TX CDC are all obsolete. Do not reintroduce them.

⚠️ The binding constraint has FLIPPED. clk (sync, 1.10–1.34× margin) is now tighter than the EXI domain (1.69–2.39×). Rank seeds by worst relative margin, not by the EXI number — synth.py does this. Added logic now threatens sync, not the EXI front-end.

⚠️ Still sweep. clk at 26.41 MHz on seed 5 is only 10% over target, so a seed can still land uncomfortably close even though all 8 pass today.

exi_bba/ module status

Module File Tests pass
BBATop exi_bba/bba_top.py ✅ EXI integration + full W5100→SPRAM→GC RX loop, at EXI_HALF=1/2/8; synth PASS
ExiCapture exi_bba/exi_capture.py ✅ rx/tx byte-stream + over-push/flush
SPIMode3Slave exi_bba/spi_mode3_slave.py ✅ 7 checks (rx/tx edges, tx_load, MISO idle) — mutation-tested: flipping either clock edge fails it
BBARegisterFile exi_bba/bba_register_file.py ✅ 7 tests (real 4-byte header, proactive push + DMA stream)
SPRAMArbiter exi_bba/spram_arbiter.py ✅ 3 tests
RXFrameAssembler exi_bba/rx_frame_assembler.py ✅ 3 tests
TXFrameDrain exi_bba/tx_frame_drain.py ✅ 2 tests
W5100ParallelMaster exi_bba/w5100_parallel_master.py ✅ MACRAW init/TX/RX (T1–T5) + socket-N UDP send/receive (U1–U4) vs bus model, incl. ring wrap — default eth back-end
W5500SPIMaster exi_bba/w5500_spi_master.py ✅ init/TX/RX vs SPI-slave model (alt back-end)
StatusPanel exi_bba/status_panel.py ✅ 6 tests (heartbeat, stretched activity LEDs, debounced buttons, freeze)
UARTConsole exi_bba/uart_console.py ✅ 7 tests (event log + 'r' reinit) — event-logger console (--console build)
UARTShell exi_bba/uart_shell.py ✅ 8 tests (help/unicast/broadcast/reply-print/timeout/badip/backspace) — interactive UDP bring-up shell (default build)
EEPROMModel exi_bba/eeprom_model.py ✅ 4 tests

Bring-up status panel (optional): BBATop(status_panel=True) adds a StatusPanel driving onboard iCEbreaker LEDs + button (dedicated pins, so it coexists with EXI + W5100). synth.py enables it: LEDG=heartbeat, LEDR=EXI activity (the GC is talking), RGB red=rx / green=tx / ready (via SB_RGBA_DRV on pins 39/40/41), BTN_N=manual re-init. All 5 panel LEDs are now mapped on the iCEbreaker. For this build's timing see the seed table above — it does NOT close on every seed.

On the re-bba-rb board the third RGB element (led[4], "ready", pin 41) is D13 = YELLOW (LCSC C2287), not blue — synth.py's comment still calls it blue, which describes the iCEbreaker dev board, not this PCB. D11=RED (rx) and D12=GREEN (tx) are correct on both.

Ethernet back-end is selectable: BBATop(eth="w5100") (default — indirect parallel bus, reaches the ~27 Mbit/s EXI ceiling) or BBATop(eth="w5500") (SPI, ~12 Mbit/s). Both masters expose the identical tx/rx/init/par streaming interface; only the physical pins differ. See "W5100 vs W5500" below.

Run all module testbenches (from workspace root)

python -m exi_bba.spi_mode3_slave
python -m exi_bba.exi_capture
python -m exi_bba.bba_register_file
python -m exi_bba.spram_arbiter
python -m exi_bba.rx_frame_assembler
python -m exi_bba.tx_frame_drain
python -m exi_bba.w5100_parallel_master   # T1-5 MACRAW + U1-4 socket-N UDP tx/rx
python -m exi_bba.w5500_spi_master
python -m exi_bba.status_panel            # 6 tests: heartbeat/activity/buttons
python -m exi_bba.uart_console            # 7 tests: event log + 'r' reinit
python -m exi_bba.uart_shell              # 8 tests: shell cmds + reply/timeout
python -m exi_bba.eeprom_model
python -m exi_bba.bba_top        # end-to-end EXI integration test (W5100 RX loop)
EXI_HALF=1 python -m exi_bba.bba_top   # ...at the REAL 27 MHz rate — always do this
python -m exi_bba.exi_j4         # EXI_J4 pad-merge truth table + enumeration

UART shell + UDP bring-up test (default flash build)

BBATop(uart_shell=True) adds an interactive UART command shell that shares the FT2232H channel-B UART pins with (and replaces) the event-log console, and drives a UDP send/receive test on a second W5100 socket (default socket 3) alongside the BBA's socket-0 MACRAW path. synth.py builds it by default (pass --console for the old event-logger instead). EXI/BBA keeps priority: the UDP test is the lowest-priority branch in the W5100 master's bus arbiter, so a GC frame always preempts a queued send/poll.

Shell (115200 8N1, rebbarb> prompt):

  • help — list commands
  • udp unicast <ip> [msg] — send a UDP datagram to <ip>:dst_port (the W5100 ARPs the target), then wait (bounded) for a reply
  • udp broadcast [msg] — same to 255.255.255.255 (limited broadcast; no ARP)
  • after a send, the shell prints sent, waits up to reply_timeout_cycles (~1 s) for a UDP reply on the socket, and prints rx <payload> or timeout (so it never hangs). Example: udp broadcast Hello World → a responder unicasts back → rx <reply> on the console.

Config is build-time via synth.py env vars (the board's own IP identity — match your LAN): UDP_SOCKET (1–3, default 3), UDP_SRC_IP, UDP_SUBNET, UDP_GATEWAY, UDP_DST_IP, UDP_SRC_PORT, UDP_DST_PORT. Receive on the PC with nc -ul <port> or sudo tcpdump -n udp port <port>.

MACRAW + UDP coexistence is datasheet-confirmed (W5100S §4.6: "MACRAW Mode SOCKET 0 does not receive any Data Packet for other SOCKET"; feature list: "Support 4 independent SOCKETs simultaneously"). All 4 sockets get a 2 KB RX + 2 KB TX buffer at reset (RMSR/TMSR=0x55), so socket-3 frames are stored in the chip independently — no extra FPGA buffer needed. Register addresses are still datasheet-from-memory — confirm at hardware bring-up.

⚠️ Simulation rate — always re-run at the hardware rate

bba_top's testbench clocks EXI at 27 MHz / EXI_HALF. The default EXI_HALF=8 gives 3.4 MHz — eight times slower than a real GC — which is fast to simulate but hides every latency bug. Run EXI_HALF=1 python -m exi_bba.bba_top before trusting any timing-related change. The current design passes at EXI_HALF=1, 2 and 8.

Historically every testbench drove MOSI many ticks before toggling the clock, so MOSI was stable at BOTH edges and the sampling-edge convention was structurally undetectable — that is how a Mode 2/Mode 3 mix-up reached silicon with a fully green suite. Both bba_top and spi_mode3_slave now model a strict Mode 3 master (change MOSI on the falling edge, sample MISO in the clock-low phase), and spi_mode3_slave's testbench is mutation-tested: flipping either edge in the RTL makes it fail.

✅ FIXED 2026-10-03 — edge convention, and MISO latency

Two bugs, both now resolved by the pin-clocked rewrite:

1. Wrong sampling edge (was Mode 2). These notes once said "Mode 3 (CPOL=1, CPHA=1)" and then described sampling MOSI on the falling edge. Idle-high + sample-on-falling is Mode 2. Real Mode 3 samples on the second edge — the rising one — and that is what the GameCube does: it DRIVES MOSI on the falling edge, so sampling there latched the previous bit. Symptom: every byte one bit late in both directions at every clock speed; the 0x80 register-space header byte read as 0x00, which decodes as a command-space device-ID query, so NWAYS reads returned the device ID. Raw 8-slot dump after an FPGA reset: 00 00 02 01 01 00 00 00 = 04 02 02 00 >> 1.

2. MISO ~2 bits late at 27 MHz. With MOSI fixed, register reads were correct up to 4 MHz and failed from 8 MHz (hardware sweep: ok=[1,1,1,0,0,0]); at index 5 a NWAYS read returned 05 C0 = 0x17 >> 2. The MISO path was delayed ~3 capture ticks ≈ 55 ns — 2 ticks of input FFSynchronizer on EXI CLK plus 1 to register the shift — against a 37 ns bit time.

Lowering the EXI clock was never an option: Swiss hardcodes EXI_SPEED_32MHZ in exi_select() (cube/patches/bba/bba.c).

The fix was structural, not compensatory: clock the bit engine from the EXI clock pin itself (pin 44 sits at die (6,0) = global buffer input net 6, so it drives a real clock network with no board change). This removes oversampling and the synchroniser entirely, making MISO clock-to-Q. The two alternatives were rejected: output-bit lookahead would have tuned a constant that may not hold across parts and temperature, and 3× oversampling needs 81 MHz, which this part cannot close. SPIMode3Slave has no mode2 parameter any more — there is only one correct convention and it is enforced by mutation tests.

EXI on the J4 debug header (EXI_J4=1) — bench host without SP1

EXI_J4=1 lets a bench host (the Pi) drive EXI through the unpopulated J4 header instead of the SP1 edge connector. It does NOT repin the EXI resource — it merges a second set of pads onto the existing ones, so SP1 stays live and the default build is unchanged:

J4 DBG FPGA pin EXI
1 — — GND
2 DBG0 9 CLK (in)
3 DBG1 10 MOSI (in)
4 DBG2 11 MISO (out)
5 DBG3 12 CS_N (in)
6 DBG4 6 INT_N (out)

The three inputs are AND-merged with PULLUP=1 on both pad sets. AND is exact, not a hack: every EXI input is inactive-HIGH (CPOL=1 → CLK idles high, CS_N is active-low, MOSI only matters while CS_N is low), so the unplugged side idles at 1 through its pull-up and passes the other side through — no select signal needed. Outputs drive both pads. Drive one side at a time; a GC on SP1 plus a host on J4 will fight and the AND yields the mangled product. Build refuses to run alongside UART_J4 (also pins 9/10) or an ETH_D3_PIN aimed at a J4 pad.

Why merge rather than repin: all five J4 pads are on the SAME bottom die edge as the EXI pads (x=13–18 vs x=5–9, both y=0 per icestorm's pinloc_db 5k-sg48), so the EXI clock domain keeps its bottom-edge anchor. Repinning would have perturbed the one placement variable that the pin-co-location experiment showed was worth ±5 MHz. (That experiment mattered when the PLL-fed capture domain was the binding constraint; with ~2.4× margin on the EXI domain today it is no longer a concern either way.)

⚠️ The merge changes which net is the clock. On a plain build the clock is the pad itself; with EXI_J4=1 it is the AND of the two pads, a distinct net. synth.py constrains whichever one applies — constraining the pad on a J4 build misses, and nextpnr then silently falls back to a 12 MHz default and under-optimises the whole EXI domain.

HARDWARE BRING-UP 2026-09-30: the EXI capture path is PROVEN on silicon. A Raspberry Pi 4 bit-banging EXI into J4 (EXI_J4 build, seed 8) enumerated the device and exercised the register file end-to-end:

test result
device ID @0x0000, 4 B 04 02 02 00 — 21/21
NWAYS @0x31 0x17 — 21/21
HIPR @0x3A 0x01 — 21/21
PAR0–3 write DE AD BE EF + readback match; restore to 00 09 BF 00 verified
IMR @0x08 write 0x86 + readback 0x86
NCRA @0x00 write 0x01 (reset) self-cleared to 0x00 on the first poll

The full Swiss init sequence runs on hardware (2026-09-30), driven from the Pi over J4 with the board on 12 V — device ID 04 02 02 00, NCRA reset self-clearing, PAR0–5, MAR0–7, ANALOG, NWAYC, IMR 0x86 readback, NCRA SR, and NWAYS 0x17. See hardware/re-bba-rb/exi_fullpath_rpi.py.

✅ FULL ROUND TRIP ON HARDWARE 2026-09-30. The board sent 5 ARP requests over EXI and received all 5 replies back: op=2, unicast to our own MAC 00:09:bf:00:00:01, sender 10.0.0.50 / target 10.0.0.99, read back out of the SPRAM ring by EXI DMA with RWP advancing exactly one page per exchange. The PC could only reply, and only address it to our MAC, if our request reached it — so this closes the loop independently of any packet capture.

Getting there took four W5100 fixes, none of which simulation could catch because the bus model reproduces neither the chip's reset timing nor its interrupt latching. Each masked the next:

  1. tx_bytes overflow — the drain waited for a length that only arrived at the ST trigger, so frames truncated at exactly 16 bytes (see TWD fix below).
  2. Post-reset settle was 1 ms; the W5100S needs 60.3 ms. Marginal, so it sometimes worked — which is what made everything look flaky.
  3. w5100_rst_n was declared and NEVER DRIVEN. The only reset was the MR software reset, which a wedged chip cannot answer because it arrives over the stuck bus — so a wedge survived NCRA reset, reflashing and even FPGA reconfiguration, and needed a 12 V power cycle.
  4. RX_RSR_CHK returned to IDLE on RSR==0 without clearing Sn_IR. SEND_OK (bit 4) fires after every transmit and only RECV (bit 2) was ever cleared, so INT_N latched low; since IDLE tests ~w5100_int_n BEFORE tx_valid, the FSM span and TX starved. Symptom: first frame transmits, all later ones never set TI while RX keeps working.

⚠️ Note TI only means TXFrameDrain handed the frame to the W5100 — it does NOT mean the chip put it on the wire. Do not treat TI as proof of transmission; the round trip above is the real test.

✅ BOTH DIRECTIONS PROVEN ON HARDWARE 2026-09-30 — the BBA data path works.

GC → wire (after the TWD fix below): a full 60-byte ARP request, captured on the PC byte-for-byte correct — broadcast dst, 00:09:bf:00:00:01 source, ethertype 0806, sender 10.0.0.99, target 10.0.0.50, padded to 60. TI sets.

wire → GC: a real 342-byte DHCP broadcast from the PC was received by the W5100 in MACRAW mode, assembled into the SPRAM ring by RXFrameAssembler, and DMA-read back over EXI from 0x100. RI set. The ring descriptor read 00 00 01 5A = 346 = 342 + 4, and the frame's own IP total-length field (328)

  • 14 bytes of ethernet header = 342 — so the assembler's length is independently confirmed correct, not just plausible.

Together: EXI ↔ BBARegisterFile ↔ {tx_bytes→TXFrameDrain, SPRAM ring} ↔ W5100ParallelMaster ↔ PHY, in both directions, on silicon.

✅ GC→wire TX REACHES THE WIRE — the full path is proven. A frame written over EXI to TXDATA and triggered via the NCRA ST bits was captured on a PC: ff ff ff ff ff ff | 00 09 bf 00 00 01 | 08 06 | 00 01 — broadcast dst, our PAR0–5 source MAC and the ARP ethertype, all byte-perfect. So EXI → BBARegisterFile → tx_bytes → TXFrameDrain → W5100 → PHY works.

✅ FIXED 2026-09-30: TX used to truncate at exactly 16 bytes = the tx_bytes FIFO depth. Past byte 16 the captured frame restarted from its own beginning and mixed in stale bytes from earlier attempts. Cause: TXFrameDrain sat in IDLE until it had a LENGTH, and the length only reached tx_ctrl when the NCRA ST bits were written — i.e. after the GC had streamed the whole frame. So the entire frame had to fit a 16-deep FIFO, and BBARegisterFile silently drops bytes when tx_bytes.w_rdy is low (SPI cannot back-pressure).

Fix: the length is pushed into tx_ctrl when TWD_HI (0x35) is written, which the GC sets before streaming, so the drain consumes concurrently with the EXI writes and the 16-deep FIFO becomes a rate-matching buffer instead of whole-frame storage (twd_armed / tx_queued in bba_register_file.py; the NCRA ST path is now only a fallback for when TWD_HI did not queue). Verified on hardware — a full 60-byte ARP request captured byte-for-byte correct.

Simulation never caught the original bug: TXFrameDrain's own tests drive the FIFO signals directly, and the BBATop test only exercises the W5100→SPRAM→GC RX loop. That gap is still open — there is no end-to-end GC→wire TX test in simulation.

When probing TX, NCRA-reset between every attempt: a failed attempt leaves the drain waiting on a stale length, and later writes feed that pending request, which makes an unrelated frame length appear to succeed.

⚠️ The W5100 can wedge and does NOT recover on FPGA reconfigure. Symptom: the UART shell's own socket-3 udp unicast echoes the command and never prints sent, and any EXI TX then hangs waiting on the bus master. iceprog -t does not clear it (RST_N floats to its pull-up while the FPGA is unconfigured). A 12 V power cycle is required. Check ETH_3V3 and GC_ON (U12 gates the W5100 rail off GC_ON) before suspecting gateware.

id_phase is a ONE-SHOT. The device ID is returned for reads of address 0 only until the first NCRA write with the reset bit, which clears id_phase permanently (bba_register_file.py:330); after that address 0 reads back NCRA. This is correct — it matches the GC's init order — but it means a re-run of any probe that already issued an NCRA reset will read 00 00 00 00 for the device ID and look broken. Reconfigure the FPGA (iceprog -t is enough) to restore it.

Two bring-up gotchas, both cost real debugging time:

  • The master must sample MISO during the CLK-LOW phase. The FPGA drives MISO on the RISING edge (Mode 3), so sampling just after that edge races the slave's own update and lands one bit position early — 04 02 02 00 comes back as 08 .... Sample after taking CLK low, before raising it (this is exactly what the simulation's spi_byte does).
  • Clock EXACTLY the requested byte count on a register read. Over-clocking (asking for 4 bytes then clocking 8–12) leaves stale prefetched bytes queued and desynchronises following transactions — the symptom is a deterministic every-Nth-read correctness cycle that looks like a signal-integrity fault but is speed-independent. The GC driver never over-clocks a register read, so this is a probe artefact, NOT a gateware bug; reads ≥ 0x100 deliberately stream until CS (the DMA path). hardware/re-bba-rb/exi_regcheck_rpi.py clocks correctly and is the reference host-side probe.

⚠️ The two measurements below are HISTORICAL — they predate the 2026-10-03 pin-clocked rewrite and refer to the capture_clk domain, which no longer exists. Current numbers are in "Current Implementation State" above. They are kept only because they document how added sync-domain cells moved the pass rate, which still applies.

Measured 2026-09-30 AFTER the TWD TX fix, EXI_J4=1 + socket-3 UDP, --seeds 8: clk 28.1–30.2 MHz (PASS on all 8); capture_clk 2 of 8 PASS (seeds 4, 7), best seed 7 = 60.03 MHz (+6.0 MHz / 11% margin). The fix's handful of extra exi-domain cells (twd_armed/tx_queued) dropped the pass rate from 4/8 to 2/8 even though none of that logic is in the capture domain — added cells raise congestion and capture is routing-bound. Best seed is comfortably usable; just sweep and flash the winner. Earlier figures, before the fix:

Measured 2026-09-30 (pre-fix), EXI_J4=1 + socket-3 UDP, --seeds 8: clk 29.2–31.4 MHz (PASS on all 8); capture_clk 4 of 8 PASS (seeds 2, 5, 6, 8), best seed 8 = 63.09 MHz (+9.1 MHz / 17% margin). Same pass rate as the default build and a better best seed, so the merge costs no measurable capture timing — but it is still seed-dependent, so always sweep.

Pending work

  • Synthesis/timing: ✅ — see the seed table above. Removing the PLL-fed capture domain eliminated the constraint that had limited the design to a minority of passing seeds.
  • W5500 init/TX/RX: ✅ done — W5500SPIMaster has a real Mode-0 byte engine, a generic register-transaction engine (header + wbuf/stream payload), the full init sequence (MR reset, SHAR, S0_MR MACRAW, S0_CR OPEN, S0_IMR), MACRAW TX (read TX_WR → stream frame to TX buffer → advance TX_WR → SEND) and MACRAW RX (RSR → RD → 2-byte length → stream frame out → advance RD → RECV). All verified on the wire by a responding W5500 SPI-slave model in the testbench.
  • PAR0–5 → W5500 SHAR: ✅ done — reg.par wired to w5500.par in BBATop (PAR0 packed in the low byte so it is the first SHAR octet).
  • NCRA SR bit: ✅ done — BBARegisterFile.ncra_sr (= NCRA[3]) gates asm.rx_enabled in BBATop (was hard-wired to 1).
  • W5500 SPI throughput: SCK = sync÷2 = 12 MHz (~12 Mbit/s) — exceeds real-world GC BBA TCP throughput (~6–10 Mbit/s) but is below the 27 Mbit/s raw EXI ceiling. Pushing past 12 Mbit/s was investigated and found NOT achievable on this UP5K (the W5500-operating logic is distributed ~40 MHz, not just the bit-bang) — see the "Full-rate W5500 SPI" item below. W5500SPIMaster(clk_div=N) divides SCK further if signal integrity needs it.
  • EXI DMA bulk reads: ✅ done — SPRAM-region reads (addr ≥ 0x100) now STREAM until CS deasserts instead of stopping at the header's 2-bit length, so they serve both ≤4-byte immediate reads (Swiss) AND arbitrary-length DMA reads (other GC software, and a future Swiss path for loading ROMs from a network file store). Implementation:
    • SPIMode3Slave.cs_active (synchronised CS level) → ExiCapture crosses it to the exi domain (FFSynchronizer) → BBARegisterFile.cs_active.
    • BBARegisterFile SPRAM_STREAM state: auto-increments the SPRAM address, prefetches up to SP_LIMIT=4 reads in flight, pushes responses to tx_fifo; SPRAM_END drains the in-flight pipeline + rx dummies on CS-rise.
    • ExiCapture flushes tx_fifo on CS-fall to clear prefetch over-push so a truncated DMA read can't leak stale bytes into the next transaction. Tested: register-file streaming read (SPRAM model, 12 bytes), ExiCapture over-push/flush, AND the full BBATop loop — a W5500 model delivers a frame → W5500 master RX → RXFrameAssembler writes the SPRAM ring → GC reads RWP then DMA-reads the descriptor+frame back (verified byte-for-byte). Note: a DMA read header must keep length-1 within the 2-bit field; the GC driver sets it ≤3 and clocks the real length via CS (the design streams until CS regardless). (EXI DMA writes are not implemented; the GC's DMA-write engine has a 1-bit-shift bug and Swiss avoids them — see design-doc §"EXI DMA bug".)
  • S0_IR interrupt clear after RX: ✅ done — W5500SPIMaster RX_CLR_IR state writes Sn_IR[2]=1 after RECV so INT_N deasserts (else the FSM would re-enter RX_CHECK forever on real hardware).
  • Full-rate W5500 SPI (27 Mbit/s) — INVESTIGATED, NOT achievable on UP5K: the W5500 SCK is sync÷2 = 12 MHz. Raising it needs the SPI engine on a ≥54 MHz clock, but a standalone synth of W5500SPIMaster in the capture domain closes only 40 MHz — and the slack histogram shows the failure is distributed (~140 endpoints fail 54, incl. the wbuf/header mux feeding the shift register), NOT a single cuttable path. So the bottleneck is the logic that operates the SPI device (transaction FSM, byte sourcing), not the bit-bang. Consequences:
    • The "split the bit engine to capture + per-byte CDC handshake" idea nets only ~14 Mbit/s — the CDC round-trip ≈ the SPI byte time — not worth it.
    • A capture-domain "streaming executor" would still contain that distributed ~40 MHz logic, so it wouldn't close 54 either.
    • Hardware SB_SPI wouldn't help (it only offloads the bit-bang, which was never the bottleneck) and is unsimulatable.
    • There is no usable clock between 24 (HFOSC) and 54 (the one PLL, needed at 54 for the EXI front-end); PLL÷2 = 27 → SCK 13.5 MHz, a ~12% gain, not worth the fabric divider. Net: 12 Mbit/s is the practical W5500 ceiling on this part. It exceeds real-world GC BBA TCP throughput and is fine for chunked ROM streaming. Reaching 27 Mbit/s would need a faster FPGA or a much shallower W5500-operating redesign (uncertain) — OR a parallel-bus ethernet chip (see W5100 below), which is the implemented solution for the ROM-streaming throughput target.

W5100 vs W5500 ethernet back-end

The throughput insight: SPI serialises 8 bits/byte, so the W5500 byte rate is (operating-logic clock)/16 — and that logic caps ~40 MHz on this UP5K → ~12 Mbit/s. A parallel bus moves a whole byte per access, so the same ~24 MHz sync logic clears the 27 Mbit/s EXI ceiling (the real hard limit — the GC EXI bus tops out there). So W5100ParallelMaster is the throughput path and is now the BBATop default.

  • Interface: W5100 indirect parallel bus (IDM). Only A[1:0] are wired (board ties A[14:2]=0 so a power-up direct access at A=00 still hits MR): 00=MR, 01=IDM_AR0(hi), 10=IDM_AR1(lo), 11=IDM_DR. A register/buffer access = write IDM_AR (the 16-bit address) then read/write IDM_DR. With MR.AI set, IDM_DR auto-increments → a multi-byte block is one address-set + a burst.
  • Bus engine: drives A + D with /CS and /RD|/WR asserted for strobe_cycles (default 3 ≈ 125 ns at 24 MHz, ≥ the W5100's ~80 ns access). DATA[7:0] is bidirectional → an SB_IO tristate (bus_data_o/oe/i).
  • Pins (15): A[1:0]=2, D[7:0]=8, /CS,/RD,/WR=3, /INT=1, /RST=1. With EXI (5)
    • clk (1) = 21 of ~34 usable SG48 I/O — comfortable. See synth.py.
  • MR.AI requires init first: unlike the W5500 (each SPI transaction is self-framed), the W5100's multi-byte accesses depend on MR.AI, so the init sequence (triggered by the GC's NCRA reset) MUST run before any TX/RX. The BBATop test issues NCRA-reset before its RX loop for this reason; on hardware the GC driver already does. (BBATop(reset_cycles=N) shrinks the MR settle wait for sim.)
  • Ring wraparound is in fabric: the W5100 does NOT auto-wrap the IDM address at the socket-buffer boundary (the W5500 did), so the streamer re-sets IDM_AR to the buffer base when the running address reaches the 2 KB boundary. Handled in the SW/SR/RB paths (xfer_wrap/xfer_wbase/xfer_wend/cur_addr); both TX and RX wrap cases are tested.
  • Register map differs from the W5500: common regs at 0x0000 (MR, SHAR 0x09, IMR 0x16, RMSR/TMSR 0x1A/0x1B), socket 0 at 0x0400 (S0_MR/CR/IR, TX_WR 0x424, RX_RSR 0x426, RX_RD 0x428), TX buffer 0x4000, RX buffer 0x6000. MACRAW mode.
  • Status: init/TX/RX (with wrap) verified vs a bus model; BBATop full W5100→SPRAM→GC RX loop passes byte-for-byte; synth PASS. Hardware bring-up 2026-09-20: a real UDP datagram was transmitted and captured on a PC — the ethernet back-end is proven end-to-end. Register addresses confirmed on silicon, with three W5100S-specific gotchas the from-memory map got wrong:
    • NETLCKR (0x0071) must be unlocked with 0x3A before GWR/SUBR/SHAR/SIPR are writable (default 0x00 = locked). The original W5100 has no such register, so without this every network-config write is silently dropped and the chip transmits with a zero source MAC/IP.
    • The parallel/indirect (IDM) bus decodes only a 15-bit offset, so the TX/RX buffers are at the original 0x4000 / 0x6000 — NOT the 0x8000/0xC000 the W5100S datasheet pseudo-code quotes. Using 0x8000 makes payload writes alias onto 0x0000+ and overwrite SHAR/SIPR. Proven with an address-aliasing probe (0xC000 and 0x4000 read back the same cell). Do not "fix" this again.
    • The W5100S needs 60.3 ms after RSTn before it answers; polling sooner reads all-zeros and looks like a dead chip.
    • UDP unicast works (the chip ARPs the peer first, per Sn_MR FARP). A send to 255.255.255.255 sets SEND_OK but was not observed on the wire — prefer unicast or a subnet-directed broadcast for bring-up tests.
  • rebbarb.py — blinks LEDs via a PLL (36 MHz), demonstrates IceBreakerPlatform
  • debouncer.py — Debouncer(cycles) — synchronous debounce, configurable hold
  • toggle_button.py — ToggleButton — edge-to-toggle state machine (wraps Debouncer)
  • pulse_button.py — PulseButton — single-cycle pulse on rising edge (wraps Debouncer)

These components are reusable building blocks. The Debouncer and button wrappers will be needed for any physical input in exi_bba/.

Import note: rebbarb/ files use bare imports (from debouncer import Debouncer). Run them as python rebbarb/<file>.py from the workspace root so Python adds rebbarb/ to sys.path automatically.

Simulation at module level: toggle_button.py and pulse_button.py run their simulations unconditionally (no __main__ guard) — importing either file triggers a VCD write. New modules should guard simulation code with if __name__ == "__main__":.

examples/amaranth_cdc.py contains handwritten SyncFF and TogglePulseSync reference implementations — use amaranth.lib.cdc primitives (FFSynchronizer, PulseSynchronizer) in production code instead.

hardware/sp1_test_plug/ — KiCad project for a physical SP1 edge-connector test plug (schematic, PCB, custom GameCube symbol library). Used to verify pad geometry before ordering the interposer PCB; not part of the FPGA build.

hardware/re-bba-rb/ — the actual adapter PCB (FT2232H + iCE40UP5K + W5100S, powered from SP1 12 V via buck). Three companion documents, all mandatory reading before touching this board:

  • hardware/re-bba-rb/TODO.md — open action items (what still needs doing: PCB placement of the 2026-07-18 fix parts, board PCF, bench checks, JLC ordering options).
  • hardware/re-bba-rb/REVIEW.md — the review coverage record: every circuit that has been verified against its datasheet/reference design, with verdicts — including things that LOOK wrong but are settled-correct (FT2232H VPHY/VPLL on 3.3 V; D4/D5 floating VBUS + split-net flow-through; TPS562201 EN at 12 V; RSET 12.4 k; no TPS22810 CT cap; SP1 DO = GC→adapter = MOSI). Do not re-review or "fix" anything listed there without new evidence, and keep REVIEW.md updated in the same commit whenever a reviewed circuit changes or a new review is performed.
  • hardware/re-bba-rb/CPL_ROTATIONS.md — per-part assembly rotation corrections applied on top of kicad-cli's raw CPL export (KiCad's raw rotation frequently doesn't match what the assembler expects, and a generic community correction table was tried and found wrong for this board — do not reapply it). Regenerating re-bba-rb-cpl.csv "the normal way" silently wipes these corrections — always reapply the corrections in this file after any CPL regen, and read its confidence notes before trusting any specific part's rotation. The authoritative board pin map (EXI, ETH bus, UART crossover, LED/DBG pins) is in TODO.md and REVIEW.md; use it when writing the board PCF.

Amaranth Simulator API

Two API generations are present in this repo:

API Where used Status
sim.add_testbench(async_fn) + await ctx.tick() + Period(MHz=n) rebbarb/*.py Use this for new code
sim.add_sync_process(gen_fn) + sim.run_until(t) examples/ Old — reference only

New modules should use the testbench API (add_testbench, sim.write_vcd(ctx) context manager). The old process API still works but is not idiomatic in current Amaranth.

Critical testbench timing rule: ctx.get(signal) reads signal values AFTER the clock edge (post-update registered values). Combinatorial signals that depend on registered signals that were updated by the SAME tick will already reflect the new registered values. For example: if tx_sof = tx_bytes_r_rdy & is_first and is_first is cleared synchronously on the first byte, then reading tx_sof after the first byte's tick always returns 0 — read BEFORE the tick instead.

ctx.set() takes effect immediately (combinatorial, not registered). Use it AFTER await ctx.tick() to prepare inputs for the NEXT tick.

The full design specification lives in docs/gc_bba_fpga_design.md.


Key Architecture Decisions

  • No network stack in the FPGA. The GC CPU runs TCP/IP. The FPGA is a dumb MAC bridge.
  • The EXI bit engine is clocked BY the GC's EXI clock pin (2026-10-03 rewrite). There is no PLL and no oversampling.
    • exiclk / exiclk_n — the EXI clock pin itself (~27 MHz), posedge and negedge. Created inside ExiCapture; async-reset by CS. Hosts ONLY the SPI bit engine. MOSI is sampled on the posedge, MISO driven on the negedge.
    • exififo_w / exififo_r — the same pin, posedge, reset-less. The byte FIFO sides. (Reset-less because resetting one side of an AsyncFIFO desynchronises its gray pointers; and posedge because AsyncFIFO instantiates a posedge-only reset CDC internally, so a negedge domain cannot host a FIFO side.)
    • sync — 24 MHz, internal SB_HFOSC ÷2. Everything else: register file, SPRAM arbiter, RX/TX engines, ethernet master. The former separate exi domain was merged into sync (they always shared the HFOSC net).
    • Why: oversampling put BIT-level timing across a CDC boundary. The engine's view of the clock lagged the pin by 2 FFSynchronizer stages plus a register — ~55 ns against a 37 ns bit time — so MISO came out ~2 bits late and the GC read 0x17 as 05 C0. No amount of oversampling fixes it: 54 MHz gives only 2 ticks per bit and the part cannot close the 81 MHz that 3× would need. Clocking from the pin makes MISO clock-to-Q and leaves the byte-level FIFOs as the only crossing, with a whole byte-time (~296 ns) of slack instead of a fraction of a bit.
    • Timing consequence: capture_clk is gone as a constraint. It was the binding path for the whole design and the reason builds closed on only a minority of seeds.
    • EXI clock reality: the GC EXI clock tops out at ~27 MHz. libogc's EXI_SPEED32MHZ is a nominal name — the real rate is 27 MHz. The old "96 MHz = 3× 32 MHz EXI" target was doubly wrong and unreachable on UP5K (which caps ~44 MHz for non-trivial logic).
    • TX/MISO across the split: the register file PROACTIVELY pushes read responses into the tx byte FIFO during the EXI clock-idle gap (the GC pauses the clock between an EXI_Imm header-write and the data-read). The bit engine drives MISO live from the FIFO head; see ExiCapture / SPIMode3Slave.
  • All CDC via amaranth.lib.cdc. Never pass raw multi-bit signals across domains. Use FFSynchronizer for slow single bits, PulseSynchronizer for events, AsyncFIFO for data streams, ResetSynchronizer for resets.
  • Register file lives entirely in exi domain. The sync domain only communicates through AsyncFIFOs and PulseSynchronizers — never direct register reads/writes.

Critical Protocol Notes

EXI / SPI Mode 3

  • CLK idles HIGH (CPOL=1, CPHA=1).
  • The master changes MOSI on the falling edge; both ends sample on the rising edge. The device therefore samples MOSI on the RISING edge and drives MISO on the FALLING edge (MISO must settle before the master samples it on the rising edge).
  • ⚠️ This file previously stated the opposite ("MOSI sampled on falling, MISO driven on rising"), which is Mode 2, and the implementation followed the doc. Driving MISO on the rising edge is one bit late by construction — that was the 0x17 → 05 C0 hardware bug. spi_mode3_slave.py's testbench now mutation-tests both edges so neither can silently regress.
  • CS is active low, delineates each transaction, and async-resets the bit engine.

EXI Transaction Header

Implemented 2026-10-03 (bba_register_file.py), from the two drivers that actually run — libogc (libdb/uIP/bba.c) and Swiss (cube/patches/bba/bba.c). There are TWO address spaces, selected by bit 7 of the first byte, and no length field:

COMMAND space  (6-bit reg)   one 4-byte READWRITE transfer
  write: [0x40|reg, 00, 00, data]
  read : [reg,      00, 00, xx  ]   <- response is the 4th byte
REGISTER space (16-bit reg)  4-byte header, then a SEPARATE data phase
  read : [0x80, reg>>8, reg&0xFF, 0x00]  then N bytes read  (Swiss uses DMA)
  write: [0xC0, reg>>8, reg&0xFF, 0x00]  then N bytes written

NCRA, IR, NWAYS, RWP, RRP, PAR… all live in the REGISTER space (bba_in8/bba_out8/bba_ins/bba_outs). Only the device-ID query uses a short header — the single case where the two formats coincide.

What the old (guessed) format cost. The previous decoder used [7]=write, [6:0]=addr[12:6] / [7:2]=addr[5:0], [1:0]=len-1. Under it a NWAYS read (80 00 31 00) decoded as write 0x31 to NCRA — and bit 0 of 0x31 is RESET, so every register read Swiss performed would have reset the device. Enumeration still appeared to work because 00 00 decodes to address 0 either way, which is exactly why the error survived so long. Do not reintroduce a length field.

The trailing 0x00 byte is the device's prefetch window. The address is complete one full byte before the data phase, giving ~8 bit-times (≈296 ns at 27 MHz) to fetch the response. The engine pops the TX FIFO once per byte boundary, so the response the register file pushes during this turnaround byte lands in data slot 0 — see the TX comment in spi_mode3_slave.py.

⚠️ Command space has only ONE drain slot. Pops during the header drain whatever the previous transaction left in the FIFO, but a 2-byte header gives just one such slot, so a command-space response must push exactly its bytes and stop. DEVID streaming zeros until CS left two stale bytes behind, one too many to drain, and the next enumeration came back a slot late (04 02 02 00 → 00 04 02 02). Register-space reads may over-push safely: their 4-byte header gives three drain slots.

Data-phase length is whatever the GC clocks (it streams until CS), so the register path streams like the SPRAM path. bba_ins reads with EXI DMA; DMA reads are fine (the GC's DMA-write engine is the buggy one).

Device ID Query

On power-on the GC writes 0x0000 (2 bytes) then reads 4 bytes. Must return: 0x04 0x02 0x02 0x00. Hardware-confirmed 2026-10-03 on a real console.


Memory Map (abridged)

Range Region
0x0000–0x0033 MAC control registers (register file, exi domain)
0x0048 TXDATA — bulk TX data port (→ tx_bytes AsyncFIFO)
0x0100–0x0FFF RX ring buffer in SPRAM (15 × 256-byte pages, pages 1–15)
0x0100–0x1FFF any read ≥ 0x0100 streams from SPRAM (DMA path); the ring proper is pages 1–15 above

Key Registers

Addr Name Notes
0x00 NCRA [0]=RESET self-clears; pulses ncra_rst to sync domain
0x08 IMR Interrupt mask
0x09 IR Write-1-to-clear. [1]=RI, [2]=TI. INT_N asserts when IR & IMR ≠ 0
0x16–17 RWP RX write pointer — updated by sync domain via rx_wptr FIFO
0x18–19 RRP RX read pointer — GC writes after consuming frames
0x20–25 PAR0–5 MAC address; also forwarded to W5500 as SHAR
0x31 NWAYS Hardcode 0x17 (100M full-duplex link up, autoneg complete)
0x3A HIPR Hardcode 0x01 (BBA present)
0x48 TXDATA GC streams TX frame bytes here

Module Breakdown

Module Domain File
BBATop all exi_bba/bba_top.py
ExiCapture creates exiclk/exiclk_n/exififo_* (+sync side) exi_bba/exi_capture.py
SPIMode3Slave exiclk / exiclk_n exi_bba/spi_mode3_slave.py
BBARegisterFile sync exi_bba/bba_register_file.py
SPRAMArbiter sync exi_bba/spram_arbiter.py
RXFrameAssembler sync exi_bba/rx_frame_assembler.py
TXFrameDrain sync exi_bba/tx_frame_drain.py
W5100ParallelMaster sync exi_bba/w5100_parallel_master.py (default eth)
W5500SPIMaster sync exi_bba/w5500_spi_master.py (alt eth)
EEPROMModel sync exi_bba/eeprom_model.py

ExiCapture creates the four pin-driven clock domains (so the byte FIFOs can name them) and wraps SPIMode3Slave plus the rx/tx byte AsyncFIFOs. SPIMode3Slave just uses m.d.exiclk / m.d.exiclk_n; it has no domain parameter any more. BBARegisterFile consumes the rx byte stream and proactively pushes read responses into the tx byte FIFO — it never sees the per-bit SPI cadence.

Everything except the bit engine is in one sync domain. The old separate exi domain was merged into sync, which deleted six FIFOs (SPRAM req/rsp, RWP, RRP, and the TX byte/length pairs became SyncFIFOs) and three PulseSynchronizers (now plain wires). The SPRAM response FIFO was removed outright — the register file handles responses directly, which is what finally closed the DMA read offset at 27 MHz.


CDC Signal Inventory

Only two crossings remain, both byte-level, both with a full byte-time (~296 ns at 27 MHz) of slack:

Signal Direction Primitive
RX byte stream exififo_w → sync AsyncFIFO 8-bit, depth=4
TX byte stream sync → exififo_r AsyncFIFO 8-bit, depth=2
cs_active (transaction in progress) EXI pin → sync FFSynchronizer (stages=2)

Everything that used to cross exi↔sync is now a same-domain SyncFIFO or a plain wire. Note what is deliberately NOT here: the EXI CLK/MOSI/CS pins are no longer synchronised into a fast domain — CLK is the clock and CS is the async reset. Synchronising them was the bug, not the safety measure.

⚠️ The FIFO domains must stay reset-less. exififo_w/exififo_r are not reset by CS: resetting one side of an AsyncFIFO desynchronises its gray pointers against the other side. They are also both posedge, because AsyncFIFO instantiates a posedge-only reset CDC internally and cannot host a negedge side; the engine pops in exififo_r and hands the byte to its negedge output stage through a plain register (a half-period path, 18.5 ns at 27 MHz).


W5500 Configuration (on NCRA reset)

The W5500 selects the register block via the BSB field of the control byte, NOT via the address — so register addresses below are block offsets, not flat 0x4000-style addresses (see _W5500_* and _CTRL_* in w5500_spi_master.py).

1. Write MR     = 0x80   (common block, offset 0x0000)  software reset
2. Wait ~1 ms
3. Write SHAR   = MAC     (common block, offset 0x0009, 6 bytes from PAR0–5)
4. Write S0_MR  = 0x04    (socket-0 reg block, offset 0x0000)  MACRAW
5. Write S0_CR  = 0x01    (socket-0 reg block, offset 0x0001)  OPEN
6. Write S0_IMR = 0x05    (socket-0 reg block, offset 0x002C)  RECV | SEND_OK

W5500 SPI is Mode 0 (CPOL=0 CPHA=0); SCK = 12 MHz (the 24 MHz sync domain ÷ 2 via a toggle clock-enable). Connect W5500 INT_N to an FPGA input for low-latency RX detection. (The W5500 is the alternate back-end; the W5100 parallel master is the default — see "W5100 vs W5500".)


Physical Interface (SP1 Edge Connector)

  • PCB must be 1.2 mm thick, ENIG finish.
  • Staggered (not mirrored) top/bottom contact rows — same geometry as PCI/ISA.
  • Derive exact pad geometry from SP1ETH KiCad project (silverstee1/SP1ETH), cross-referenced with ETH2SP1 (LaserBear). Do not rely on YAGCD alone.
  • Add 100 µF bulk cap on the interposer near FPGA power pins (3.3 V budget is tight: iCEbreaker ~80 mA + W5500 ~150 mA ≈ 230 mA).
  • Pin 5 is 12 V — do not connect to FPGA I/O. Test point or leave open.
  • EXTIN (pin 1): tie to 3.3 V via 10 kΩ — required for GC device enumeration.
  • All signal levels are 3.3 V. No level shifting needed.

SPRAM Notes

  • iCE40UP5K has 128 KB SPRAM (SB_SPRAM256KA, 16-bit wide).
  • 1-cycle synchronous read latency — result of read at cycle N is valid at N+1.
  • Byte writes via MASKWREN: lower byte = 0b0011, upper byte = 0b1100.
  • Address to SPRAM = byte_address >> 1.
  • ETH writes take priority over EXI reads in the arbiter (safe by ring-buffer invariant: GC only reads pages the ETH engine has already finished).

GC Initialisation Sequence (Swiss/BBA driver)

1.  Write 0x0000 × 2, read 4 B → must get 0x04020200 (device ID)
2.  Write NCRA = 0x01            (reset, self-clears; resets W5500 + SPRAM ptrs)
3.  Poll NCRA bit 0 until 0      (wait reset complete)
4.  Write PAR0–5                 (MAC address)
5.  Write MAR0–7 = 0xFF          (promiscuous multicast)
6.  Write ANALOG = 0xD6          (enable PHY — no FPGA effect, just store)
7.  Write NWAYC                  (autoneg config — store only)
8.  Write IMR = 0x86             (enable RBFI | TI | RI interrupts)
9.  Write GCA (AUTOPUB bit)
10. Write NCRA SR bit = 0x08     (start receive)
11. Poll NWAYS until link up     → return hardcoded 0x17 immediately

Implementation Notes & Gotchas

  • NWAYS must return 0x17 always. GC polls it to confirm 100 Mbps link before enabling RX. Do not attempt to reflect real W5500 link status.
  • EEPROMModel can be stubbed initially. Many GC BBA drivers write their own MAC to PAR0–5 rather than using the EEPROM. Pre-populate PAR0–5 reset state with a valid Nintendo OUI MAC (00:09:BF:xx:xx:xx).
  • tx_load timing in SPIMode3Slave: pulses at CS assertion (first byte) and after each complete received byte. Upstream must register next TX byte within one exi clock.
  • No PLL. The bit engine is clocked by the EXI pin; sync is HFOSC÷2. Do not reintroduce an oversampling capture domain — see the clocking note under "Key Architecture Decisions" for why it cannot work on this part.
  • TX buffer selection (NCRA ST bits): Ignore buffer select (ST1 vs ST0). Treat any non-zero ST as a TX trigger.
  • If nextpnr fails capture-domain timing at 54 MHz: the isolated bit engine closes ~91 MHz, so 54 has margin; if a seed fails, sweep seeds (synth.py --seeds N) or instruct users to configure Swiss to a lower EXI clock index.