Clock the EXI bit engine from the EXI pin: timing closes on all 8 seeds

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:10:11 +02:00
parent 02239466fb
commit 91c3bf0ece
7 changed files with 611 additions and 408 deletions
+215 -212
View File
@@ -50,8 +50,9 @@ 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.
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:**
@@ -68,89 +69,54 @@ 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.)
**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. 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.
All modules elaborate without errors and pass their unit tests, including
`bba_top` at the real 27 MHz EXI rate (`EXI_HALF=1`).
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`:
**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` (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) |
| `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 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`.
Best seed **3**: `clk` 32.18 MHz, EXI 55.86 MHz (worst relative margin 1.34×).
Flash `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`.
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.
**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.)
⚠️ **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.
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.
⚠️ **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; synth 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` | ✅ 4 tests (live-drive TX) |
| `BBARegisterFile` | `exi_bba/bba_register_file.py` | ✅ 7 tests (proactive push + DMA stream) |
| `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 |
@@ -194,6 +160,8 @@ 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)
@@ -228,66 +196,53 @@ 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**.
### ⚠️ The simulation clocks EXI 8× slower than hardware
### ⚠️ Simulation rate — always re-run at the hardware rate
`bba_top`'s testbench derives the EXI clock from `HALF` (capture ticks per SPI
half-period): **EXI = 54 MHz / (2 × HALF)**. The default `HALF=8` gives
**3.4 MHz** — eight times slower than the real GC's ~27 MHz, so the suite
passes green on designs that fail outright on a console. `HALF` is
`EXI_HALF`-configurable; **run `EXI_HALF=1 python -m exi_bba.bba_top` before
trusting any timing-related change.**
`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`.
Worse, **every testbench drives MOSI many capture ticks before toggling the
clock**, so MOSI is stable at BOTH edges and the sampling-edge convention is
structurally undetectable. That is how a Mode 2/Mode 3 mix-up survived to
silicon with a fully green suite. `bba_top`'s master now models a true Mode 3
master (drive on falling, sample on rising).
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.
### ✅ EXI edge convention — FIXED 2026-10-03 (was Mode 2, must be Mode 3)
### ✅ FIXED 2026-10-03 — edge convention, and MISO latency
The notes below 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 of each cycle — the
**rising** one — and that is what the GameCube does: it DRIVES MOSI on the
falling edge, so sampling there latched the previous bit.
Two bugs, both now resolved by the pin-clocked rewrite:
Symptom on console: 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 and the
speed sweep failed at every index. Raw 8-slot dump, first transaction after an
FPGA reset: `00 00 02 01 01 00 00 00` = `04 02 02 00 >> 1`.
**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`.
`SPIMode3Slave(mode2=False)` (the default) now samples MOSI on RISING.
`mode2=True` restores the old behaviour, which is what the Pi/J4 probe scripts
were written against.
**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.
### ⚠️ OPEN: MISO output is ~2 bits late at 27 MHz
**Lowering the EXI clock was never an option:** Swiss hardcodes
`EXI_SPEED_32MHZ` in `exi_select()` (`cube/patches/bba/bba.c`).
With MOSI fixed, register reads are **correct up to 4 MHz** and fail from
8 MHz (hardware speed sweep 2026-10-03: `ok=[1,1,1,0,0,0]`, highest good
index 2). At index 5 a NWAYS read returns `05 C0` = `0x17 >> 2`.
Cause: the MISO output path is delayed by roughly **3 capture ticks ≈ 55 ns** —
2 ticks of input `FFSynchronizer` on EXI CLK plus 1 tick to register the shift.
At 27 MHz one bit is 37 ns, so the master samples ~2 bits behind. The failure
threshold (pass at 250 ns/bit, fail at 125 ns/bit) brackets the delay at
60–125 ns, consistent with that structure.
**Lowering the EXI clock is NOT an option:** Swiss hardcodes
`EXI_SPEED_32MHZ` in `exi_select()` (`cube/patches/bba/bba.c`) — there is no
setting to turn down.
Options, none yet done:
1. **Clock the bit engine from the EXI clock itself** (route the pin to a
global buffer and make it a real clock domain) — the textbook SPI-slave
architecture, removes oversampling and the synchroniser entirely. Biggest
change, but the only one that is structurally correct rather than tuned.
2. **Output-bit lookahead** — drive the bit the master will sample ~2 bit-times
from now. Deterministic compensation for a deterministic delay, but it needs
lookahead into the next byte at byte boundaries, and the constant may not
hold across parts/temperature.
3. **Raise the capture clock** — 81 MHz would give 3× oversampling, but the
design's best measured fmax is 67 MHz, so it will not close.
**The fix was structural, not compensatory:** clock the bit engine from the EXI
clock pin itself (pin 44 sits at die (6,0) = global buffer input net 6, so it
drives a real clock network with no board change). This removes oversampling
and the synchroniser entirely, making MISO clock-to-Q. The two alternatives
were rejected: output-bit lookahead would have *tuned* a constant that may not
hold across parts and temperature, and 3× oversampling needs 81 MHz, which this
part cannot close. `SPIMode3Slave` has no `mode2` parameter any more — there is
only one correct convention and it is enforced by mutation tests.
### EXI on the J4 debug header (`EXI_J4=1`) — bench host without SP1
@@ -316,9 +271,17 @@ 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.
`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
@@ -388,23 +351,29 @@ over EXI to TXDATA and triggered via the NCRA ST bits was captured on a PC:
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
✅ **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:
@@ -436,6 +405,12 @@ Two bring-up gotchas, both cost real debugging time:
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
@@ -452,10 +427,9 @@ 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.
- **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
@@ -656,19 +630,30 @@ The full design specification lives in `docs/gc_bba_fpga_design.md`.
- **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.
- **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
@@ -690,25 +675,24 @@ The full design specification lives in `docs/gc_bba_fpga_design.md`.
### 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.
- 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
⚠️ **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**:
**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
@@ -720,32 +704,38 @@ REGISTER space (16-bit reg) 4-byte header, then a SEPARATE data phase
```
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.
short header — the single case where the two formats coincide.
**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.
**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**, 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.
**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 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**;
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 — this is the one path that works with the current (wrong)
header format, because `00 00` decodes to address 0 either way.
real console.
---
@@ -781,41 +771,54 @@ header format, because `00 00` decodes to address 0 either way.
| 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` |
| `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` | exi | `exi_bba/eeprom_model.py` |
| `EEPROMModel` | sync | `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`).
`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 |
|---|---|---|
| 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` |
| 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).
---
@@ -893,9 +896,9 @@ parallel master is the default — see "W5100 vs W5500".)
- **`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×.
- **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