Files
rebbarb/CLAUDE.md
T
Roflin d35688390e Take the TX bit phase from the rising-edge counter, not a falling-edge one
The bit engine had TWO independent phase references: rx_bit counting rising
edges and tx_bit counting falling edges.  Those only stay in step while the
host emits exactly as many falls as rises, and it does not -- a transfer that
begins on a RISING edge (CS asserted while CLK is already low, or the clock
gating between two back-to-back host transfers) delivers a rise with no
matching fall.  tx_bit then slips one bit and never recovers.

RX never notices, because the rising count always equals the host's bit count.
So the header decodes perfectly, the right register is selected, and ONLY MISO
is wrong -- which is what made this look like an output-edge or output-delay
problem for two rounds of debugging.

Measured on a GameCube: every MISO byte shifted right by 2 bits, identically
at every EXI speed index.  NWAYS read 05 C0 (= 0x17 with two leading zero
bits), device ID 01 00 80 80, speed sweep ok=[0,0,0,0,0,0].

Two diagnoses were wrong before this one, and both were ruled out by evidence
rather than argument:
  - NOT propagation delay.  Index 0 is 1 MHz, a 1000 ns bit time; no output
    path fails there.  Speed-independence is the tell.
  - NOT the output edge.  The old rising-edge build and the new falling-edge
    build returned BYTE-IDENTICAL 05 C0.  Flipping the edge would have moved
    it; it did not, so the slip is upstream of the output stage.

What settled it was a measurement.  PHASE_METER=1 builds a diagnostic,
non-functional bitstream whose engine transmits a constant 0x80 per byte
instead of the FIFO head, taking the register file and both byte FIFOs out of
the MISO path so the host reads the engine's byte PHASE directly: 0x80
aligned, 0x40 one bit late, 0x20 two bits late.  Hardware read 0x20 in all
eight slots, localising the fault to the bit engine in a single run.

Fix: the shift register loads on rx_bit == 0.  One phase reference, on the
edge the host counts, so the two cannot drift.

Regression test (spi_mode3_slave T5) asserts CS while CLK is LOW so the
transaction begins on a rising edge.  The old counter fails it by exactly one
bit: A5 3C comes back as 52 9E.  EVERY earlier testbench asserted CS with CLK
high, so falls and rises always matched and the slip was structurally
invisible -- the same blind spot as the Mode 2/3 mix-up, which was invisible
to a master that parked MOSI early.  Vary the starting edge in SPI tests.

Timing after the fix, EXI_J4=1, --seeds 8: clk 26.9-31.1 MHz, EXI 45.8-61.7
MHz, 8/8 PASS.  Best seed 4 (clk 31.07, EXI 56.70), flashed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:04:52 +02:00

949 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):**
```bash
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):**
```bash
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:**
```bash
# 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)
```bash
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.
### ⚠️ TX bit phase MUST come from the rising-edge counter (fixed 2026-10-03)
**Never give the TX shift register its own free-running falling-edge counter.**
`SPIMode3Slave` takes its byte phase from `rx_bit`, the RISING-edge counter —
the same edge the host counts bits on. A separate `tx_bit` counting falling
edges only stays in step while the host emits exactly as many falls as rises,
and it does not: a transfer that begins on a RISING edge (CS asserted while
CLK is already low, or the clock gating between two back-to-back host
transfers) delivers a rise with no matching fall. `tx_bit` then slips one bit
and never recovers. RX never notices, because the rising count always equals
the host's bit count — so the header decodes perfectly and only MISO is wrong.
**Symptom on console:** every MISO byte shifted right by 2 bits, *identically
at every EXI speed index*. A NWAYS read returned `05 C0` (= `0x17` with two
leading zero bits) and the device ID `01 00 80 80`. The speed sweep read
`ok=[0,0,0,0,0,0]`.
**How it was found, after two wrong diagnoses.** It is not propagation delay:
index 0 is 1 MHz, a 1000 ns bit time, and no output path fails there —
speed-independence is the tell. It is not the output edge either: the old
rising-edge build and the new falling-edge build returned *byte-identical*
`05 C0`, and an edge flip would have moved it. What settled it was a
measurement, not more theory — see PHASE_METER below.
**`PHASE_METER=1 python -m exi_bba.synth`** builds a DIAGNOSTIC,
NON-FUNCTIONAL bitstream in which the bit engine transmits a constant `0x80`
for every byte instead of the FIFO head. The register file and both byte FIFOs
drop out of the MISO path, so whatever the host reads back is purely the
engine's byte phase: `0x80` aligned, `0x40` one bit late, `0x20` two bits late.
On hardware it read `0x20` in all 8 slots, which localised the fault to the bit
engine and ruled out everything upstream in one run. Re-flash a normal build
afterwards.
**Regression test:** `spi_mode3_slave` T5 asserts CS while CLK is LOW, so the
transaction begins on a rising edge and carries one fewer falling edge. The
old counter fails it by exactly one bit (`A5 3C` → `52 9E`). Every earlier
testbench asserted CS with CLK high, so falls and rises always matched and the
slip was *structurally invisible* — the same failure of imagination as the
Mode 2/3 mix-up, which was invisible to a master that parked MOSI early. When
adding SPI tests, vary the edge the transaction starts on.
### 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/` — LED blink demo (unchanged)
- `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 `SyncFIFO`s) and three
`PulseSynchronizer`s (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.