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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user