f2762f613d
Read from the two drivers that will actually talk to this device: libogc (libdb/uIP/bba.c) and Swiss (cube/patches/bba/bba.c). The implemented 2-byte header with a 2-bit length field does not match either. The real protocol has two address spaces selected by bit 7 of byte 0, and no length field at all -- the data phase runs until CS: COMMAND (6-bit reg) one 4-byte readwrite; response in byte 3 REGISTER (16-bit reg) [0x80|w<<6, reg>>8, reg&0xFF, 0x00] + data phase NCRA, IR, NWAYS, RWP, RRP and PAR all live in the register space. The mismatch is destructive rather than merely non-functional: Swiss reading NWAYS sends 80 00 31 00, which the current decoder reads as 'write 0x31 to NCRA' -- and bit 0 of 0x31 is RESET. Every register read would reset the device. The device-ID query is the one case where the two formats coincide, which is exactly why enumeration worked on hardware and nothing else would have. Also explains the 22-bit response latency measured on console: the trailing 0x00 byte in the real header is the device's prefetch window, and the current format spends those bits on the length field, leaving zero slack. Adopting the real format is required for compatibility anyway and recovers ~296 ns of the ~815 ns deficit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
844 lines
46 KiB
Markdown
844 lines
46 KiB
Markdown
# 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/`) uses a split-domain clock: `capture` @ 54 MHz (PLL)
|
||
for the SPI bit engine, `exi`/`sync` @ 24 MHz (HFOSC) for everything else.
|
||
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.
|
||
|
||
**Verify PLL parameters:**
|
||
```bash
|
||
icepll -i 12 -o 54 # confirms DIVR=0 DIVF=71 DIVQ=4 → 54 MHz (capture domain)
|
||
```
|
||
(`exi`/`sync` come from the internal SB_HFOSC ÷2 = 24 MHz — no PLL.)
|
||
|
||
---
|
||
|
||
## Current Implementation State
|
||
|
||
The `exi_bba/` module tree is **fully implemented** with simulation testbenches.
|
||
All modules elaborate without errors and pass their unit tests. The full design
|
||
synthesizes, places and routes on the iCE40UP5K, but **capture-domain timing is
|
||
seed-dependent and only closes on a minority of seeds** — you MUST sweep.
|
||
|
||
Measured 2026-08, DEFAULT build (`BBATopSynth(status_panel=True,
|
||
uart_shell=True)` — the interactive UART shell + socket-3 UDP test, ~67% LC),
|
||
`python -m exi_bba.synth --seeds 8`:
|
||
|
||
| domain | target | result |
|
||
|---|---|---|
|
||
| `clk` (exi/sync) | 24 MHz | 27–30 MHz — **PASS on every seed** |
|
||
| `capture_clk` | 54.02 MHz | 49.6–58.4 MHz — **PASS on 4 of 8 seeds** (3, 4, 7, 8) |
|
||
|
||
Best seed 4 = 58.36 MHz, margin +4.3 MHz (8%). Still seed-dependent — `python -m
|
||
exi_bba.synth` with no arguments (seed 1) FAILS capture (50.96 MHz); always pass
|
||
`--seeds 8` (or more) and flash the reported best seed from
|
||
`build/seed<N>/top.bin`.
|
||
|
||
**Capture-domain critical-path fix (2026-08):** capture used to close on only
|
||
2 of 8 seeds at ~55 MHz *without* the shell. The binding path was the TX byte
|
||
FIFO's read-enable gated by its own gray-coded ready
|
||
(`tx_fifo.r_en = ... | (flushing & r_rdy)`) — a `consume_ptr → gray → r_rdy →
|
||
flush → r_en → consume_ptr` loop in `exi_capture.py`. Replacing the r_rdy-based
|
||
"drain until empty" flush with a FIXED-length drain counter (the FIFO is only
|
||
`tx_depth` deep) removed the pointer feedback from `r_en`, dropping the path
|
||
from 24.3 → 19.6 ns and lifting capture to 4/8 passing (best 58.4) EVEN with the
|
||
shell integrated. **Do not re-introduce any `tx_fifo.r_rdy` dependence into
|
||
`r_en` or the flush deassert** — see the comment in `exi_capture.py`.
|
||
|
||
**Remaining capture bottleneck + future levers (measured 2026-08, best seed):**
|
||
After the flush fix the binding capture path is the **`tx_fifo` (exi→capture)
|
||
consume-pointer Gray-code CDC loop**, triggered by `spi.tx_load`:
|
||
`tx_load → consume_r_gry → consume_r_bin (gray→bin) → increment →
|
||
consume_r_gry (bin→gray) → setup`. On the board's V1 pinout it is ~6.4 ns
|
||
LOGIC + ~10.7 ns ROUTING = 17.1 ns (58.4 MHz). Two independent levers, if more
|
||
capture margin (e.g. a 64 MHz target) is ever wanted — NOT done, board V1 ships
|
||
as-is at 4/8-seed full speed:
|
||
- **Lightweight TX CDC (RTL, pinout-independent — the reliable lever):** the TX
|
||
path carries one response byte per SPI byte-time (~3.4 MHz) with huge slack,
|
||
yet pays for a full Gray-code multi-bit-pointer AsyncFIFO. Replacing `tx_fifo`
|
||
with a **2-deep ping-pong CDC** (two byte regs + 1-bit toggle handshakes,
|
||
`ready = write_sync ^ read_toggle`) makes consume a single toggle flip + mux,
|
||
cutting the ~6.4 ns pointer logic to ~2–3 ns AND shrinking the cell count
|
||
(tighter routing). Est. ~67–80 MHz — comfortably past 64. CRITICAL PATH,
|
||
handle with care: `tx_fifo` feeds MISO live and the register file prefetches
|
||
into it (≤4 in flight for DMA reads), so the ping-pong needs adequate depth +
|
||
full ExiCapture/BBATop CDC re-verification (a bad handshake mis-enumerates).
|
||
- **Pin co-location (respin-only, routing-half — ~+5 MHz):** on V1 the EXI pins
|
||
are on the BOTTOM die edge (y0) and the W5100 parallel bus is on the TOP
|
||
(y31), so the capture and exi/sync domains anchor to opposite edges and the
|
||
CDC FIFO spans the die vertically (that IS the ~10.7 ns routing). Putting the
|
||
EXI + W5100 pins on the SAME edge co-locates the domains: a confounded test
|
||
(EXI moved to top) hit 63.2 MHz best (routing 10.7→9.5 ns; logic unchanged).
|
||
Only moves the routing half and needs a full re-pinout (SG48 lacks free
|
||
top-edge pins for a clean version) — so it is a V2 consideration, not a fix.
|
||
(A simple 2-pin LED↔MISO/MOSI swap does NOT help — the EXI pins are already
|
||
clustered on the bottom edge; that swap tested WORSE, 58.4→48.8.)
|
||
|
||
There is little margin: assume added logic in (or near) the capture domain may
|
||
break capture timing until a sweep proves otherwise. The earlier "~70 MHz, both PASS" figure in this
|
||
file was wrong — it came from a sweep that silently never ran (see the
|
||
`_prepared` note in `synth.py`) and from misreading nextpnr's PRE-routing
|
||
placement estimate, which runs ~8 MHz optimistic.
|
||
|
||
### `exi_bba/` module status
|
||
|
||
| Module | File | Tests pass |
|
||
|---|---|---|
|
||
| `BBATop` | `exi_bba/bba_top.py` | ✅ EXI integration + full W5100→SPRAM→GC RX loop; synth PASS |
|
||
| `ExiCapture` | `exi_bba/exi_capture.py` | ✅ rx/tx byte-stream + over-push/flush |
|
||
| `SPIMode3Slave` | `exi_bba/spi_mode3_slave.py` | ✅ 4 tests (live-drive TX) |
|
||
| `BBARegisterFile` | `exi_bba/bba_register_file.py` | ✅ 7 tests (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)
|
||
```
|
||
|
||
### 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**.
|
||
|
||
### 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 capture domain keeps its bottom-edge anchor. Repinning would
|
||
have perturbed the one placement variable the pin-co-location experiment showed
|
||
is worth ±5 MHz — see "Remaining capture bottleneck" above.
|
||
|
||
**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.
|
||
|
||
⚠️ **OPEN BUG: TX truncates at exactly 16 bytes = the `tx_bytes` FIFO depth.**
|
||
Past byte 16 the captured frame restarts from its own beginning and mixes in
|
||
stale bytes from earlier attempts. Cause: `TXFrameDrain` sits in IDLE until it
|
||
has a LENGTH, and the length only reaches `tx_ctrl` when the NCRA ST bits are
|
||
written — i.e. *after* the GC has streamed the whole frame. So the entire frame
|
||
has to fit in a 16-deep FIFO, and `BBARegisterFile` silently drops bytes when
|
||
`tx_bytes.w_rdy` is low (SPI cannot back-pressure). Simulation never caught it:
|
||
`TXFrameDrain`'s own tests drive the FIFO signals directly, and the `BBATop`
|
||
test only exercises the W5100→SPRAM→GC **RX** loop.
|
||
Fix direction (needs design + sim, NOT done): make the drain start as soon as
|
||
data is available rather than waiting for the length — e.g. push the length 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. Alternatively give TX an
|
||
SPRAM ring like RX.
|
||
When probing this, **NCRA-reset between every TX 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.
|
||
|
||
**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**: ⚠️ partially — synthesizes and P&Rs, `clk` closes with
|
||
wide margin, but `capture_clk` closes on only **2 of 8 seeds** and the default
|
||
seed 1 FAILS (53.08 < 54.02). Always `--seeds 8`. Reducing capture-domain
|
||
logic to get a real margin is still open work — see the seed table above.
|
||
- **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.
|
||
- **Split-domain clocking — 3 domains, 2 sources (1 PLL + 1 HFOSC):**
|
||
- `capture` — 54 MHz (PLL, DIVR=0 DIVF=71 DIVQ=4). Hosts ONLY the SPI Mode 3
|
||
bit engine inside `ExiCapture`. 54 MHz = 2× the **real 27 MHz** EXI clock —
|
||
the minimum oversampling for clean Mode 3. The isolated bit engine closes
|
||
~91 MHz; integrated with the byte-FIFO read path the capture domain closes
|
||
~62 MHz, so 54 passes with margin.
|
||
- `exi` — 24 MHz (HFOSC ÷2). BBA register file / transaction FSM.
|
||
- `sync` — 24 MHz (same HFOSC net as `exi`). SPRAM arbiter, RX/TX engines,
|
||
W5500 SPI master.
|
||
- **Why split:** only the tiny SPI bit engine needs a fast clock to sample
|
||
27 MHz EXI. The bulky register-file/SPRAM/W5500 logic is routing-bound at
|
||
~33–44 MHz on the UP5K and only needs the byte rate (27 MHz ÷ 8 ≈ 3.4 MHz).
|
||
`ExiCapture` bridges capture↔exi with rx/tx byte AsyncFIFOs.
|
||
- **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).
|
||
- MOSI sampled on **falling** CLK edge. MISO driven on **rising** CLK edge.
|
||
- Getting this wrong means the GC never enumerates the device.
|
||
- CS is active **low**, delineates each transaction.
|
||
|
||
### EXI Transaction Header
|
||
|
||
⚠️ **THE IMPLEMENTED FORMAT IS WRONG AND MUST BE REPLACED (found 2026-10-03).**
|
||
What `exi_bba/bba_register_file.py` implements today is:
|
||
```
|
||
Byte 0: [7]=write_flag [6:0]=addr[12:6]
|
||
Byte 1: [7:2]=addr[5:0] [1:0]=xfer_len-1 (0=1B … 3=4B)
|
||
```
|
||
That was designed from the spec's own guess. The design doc hedged on it
|
||
(§"ID query": *"Alternatively, read the Dolphin source for the exact byte
|
||
sequence…"*) and that verification never happened.
|
||
|
||
**The REAL protocol**, read from the two drivers that actually run — libogc
|
||
(`libdb/uIP/bba.c`) and Swiss (`cube/patches/bba/bba.c`) — has TWO address
|
||
spaces, selected by bit 7 of the first byte, and **no length field at all**:
|
||
|
||
```
|
||
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, which is the single case where the two formats coincide — which
|
||
is exactly why enumeration appeared to work and nothing else would have.
|
||
|
||
**The mismatch is destructive, not merely non-functional.** A `NWAYS` read
|
||
arrives as `80 00 31 00`; the current decoder reads that as *write 0x31 to
|
||
NCRA*, and bit 0 of 0x31 is RESET. Every register read Swiss performs would
|
||
reset the device.
|
||
|
||
**The trailing `0x00` byte is the device's prefetch window**, and that is the
|
||
root of the latency problem below: in the real protocol 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 format above spends those bits on the length field,
|
||
so the address is not known until the final header bit and the device gets
|
||
**zero** slack — which is why the first on-console run returned the device ID
|
||
22 bit-times late.
|
||
|
||
**Data-phase length is whatever the GC clocks** (it streams until CS), so the
|
||
register path must stream like the SPRAM path already does — the 4-byte cap
|
||
imposed by the 2-bit length field has to go. `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 — this is the one path that works with the current (wrong)
|
||
header format, because `00 00` decodes to address 0 either way.
|
||
|
||
---
|
||
|
||
## 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` | capture (+exi FIFOs) | `exi_bba/exi_capture.py` |
|
||
| `SPIMode3Slave` | capture (param `domain`) | `exi_bba/spi_mode3_slave.py` |
|
||
| `BBARegisterFile` | exi (+FIFO to 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` | exi | `exi_bba/eeprom_model.py` |
|
||
|
||
`ExiCapture` wraps `SPIMode3Slave` (in the fast `capture` domain) plus the
|
||
capture↔exi rx/tx byte AsyncFIFOs. `BBARegisterFile` consumes the rx byte
|
||
stream and proactively pushes read responses into the tx byte FIFO — it no
|
||
longer sees the per-bit SPI cadence (that lives entirely in `capture`).
|
||
|
||
---
|
||
|
||
## CDC Signal Inventory
|
||
|
||
| Signal | Direction | Primitive |
|
||
|---|---|---|
|
||
| EXI CLK / MOSI / CS pins | async → capture | `FFSynchronizer` (stages=2) |
|
||
| RX byte stream (capture→core) | capture → exi | `AsyncFIFO` 8-bit, depth=4 |
|
||
| TX byte stream (core→capture) | exi → capture | `AsyncFIFO` 8-bit, depth=2 |
|
||
| cs_active (transaction in progress) | capture → exi | `FFSynchronizer` (DMA read length) |
|
||
| SPRAM read request (addr) | exi → sync | `AsyncFIFO` 16-bit, depth=4 |
|
||
| SPRAM read result (data) | sync → exi | `AsyncFIFO` 8-bit, depth=4 |
|
||
| TX packet bytes | exi → sync | `AsyncFIFO` 8-bit, depth=16 |
|
||
| TX frame length | exi → sync | `AsyncFIFO` 16-bit, depth=4 |
|
||
| RX frame bytes | sync → SPRAM | `RXFrameAssembler` → `SPRAMArbiter` (not a byte FIFO; the GC reads frames back out of SPRAM via the SPRAM read req/rsp FIFOs) |
|
||
| RWP update | sync → exi | `AsyncFIFO` 8-bit, depth=4 |
|
||
| RRP update | exi → sync | `AsyncFIFO` 8-bit, depth=4 |
|
||
| RX ready (IR[RI]) | sync → exi | `PulseSynchronizer` |
|
||
| TX done (IR[TI]) | sync → exi | `PulseSynchronizer` |
|
||
| NCRA reset pulse | exi → sync | `PulseSynchronizer` |
|
||
|
||
---
|
||
|
||
## 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.
|
||
- **PLL target 54 MHz**: verify with `icepll -i 12 -o 54` (DIVR=0 DIVF=71 DIVQ=4)
|
||
before coding PLL parameters; the capture-domain bit engine oversamples the
|
||
27 MHz EXI clock 2×.
|
||
- **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.
|