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
+85 -38
View File
@@ -36,7 +36,7 @@ from exi_bba.bba_top import BBATop
# INT_N=13 — MOVED off the schematic's original pin 35: pin 35 physically
# conflicts with the iCE40UP5K's PLL hard macro (nextpnr: "PLL bel
# 'X12/Y31/pll_3' cannot be used... conflicts with input... on pin 35"),
# and this design needs the PLL for the 54 MHz capture-domain clock. Pin 35
# that design needed the PLL for its 54 MHz capture-domain clock. Pin 35
# cannot be used as regular I/O at all while the PLL is instantiated,
# regardless of what drives it — this is a fixed silicon constraint, not a
# routing choice. Pin 13 was picked because it's the ONLY spare GPIO on this
@@ -48,6 +48,10 @@ from exi_bba.bba_top import BBATop
# pin 13, and pin 35 is left unconnected (net
# "unconnected-(U9A-IOT_46b_G0-Pad35)") so the PLL hard macro can claim it.
# Verified against the schematic netlist 2026-07-31 — do not "restore" pin 35.
# NOTE 2026-10-03: the PLL is GONE (the bit engine is clocked by the EXI pin),
# so the silicon conflict no longer applies and pin 35 is electrically usable
# again. It stays unconnected anyway because the BOARD is already built with
# ETH_INT on pin 13 — this is now a hardware fact, not a tool constraint.
#
# Debug (J4 header, unpopulated): DBG0=9 DBG1=10 DBG2=11 DBG3=12 DBG4=6
# UART (FT2232H channel B): net UART_TXD is the FTDI's OUTPUT, so it is the
@@ -64,17 +68,18 @@ from exi_bba.bba_top import BBATop
# directly — leaving o_RGB* dangling (the old code) never bonds them and the
# LEDs stay dark. See the "rgb" Resource + elaborate() below, and BRINGUP.md.
# nextpnr P&R options. The binding constraint is the isolated 54 MHz capture
# domain (the SPI Mode-3 bit engine); the 24 MHz sync domain has wide margin.
# nextpnr P&R options. Since the PLL-fed 54 MHz capture domain was removed
# (the bit engine is now clocked by the EXI pin itself), the EXI domain has
# ~2.4x margin against its 27 MHz target and the 24 MHz `sync` domain is the
# TIGHTER of the two. The sweep therefore ranks seeds by worst RELATIVE
# margin, not by the EXI number.
#
# TRIED (2026-08 shell build) and REVERTED — prioritising routing toward the
# critical capture paths did NOT help: `--router router2 --tmg-ripup
# --placer-heap-timingweight 30 --placer-heap-critexp 4` left capture at
# 41-49 MHz (best 49.07, still < 54.02) across seeds AND eroded the slow-clock
# margin (24.4-25.9 MHz vs the 28-33 that plain --opt-timing gives). The
# capture shortfall is congestion + inherent path delay at this LC level, not a
# routing-priority tuning problem, so weighting P&R toward it only robs the
# sync domain. Plain --opt-timing is the better baseline.
# TRIED (2026-08, against the old capture domain) and REVERTED — prioritising
# routing toward the critical paths did NOT help: `--router router2
# --tmg-ripup --placer-heap-timingweight 30 --placer-heap-critexp 4` left
# capture at 41-49 MHz across seeds AND eroded the slow-clock margin
# (24.4-25.9 MHz vs the 28-33 that plain --opt-timing gives). Plain
# --opt-timing is the better baseline.
_PNR_TIMING_OPTS = "--opt-timing"
# UART pin override for bring-up. The re-bba-rb V1 lab unit had its UART_RXD
@@ -212,6 +217,12 @@ class IceBreakerPlatform(LatticeICE40Platform):
# ── BBATop with platform resource wiring ─────────────────────────────────
# The GameCube's EXI clock ceiling. libogc's EXI_SPEED32MHZ is a nominal
# name; the bus actually tops out here, and the bit engine is clocked by it.
_EXI_HZ = 27e6
_EXI_TARGET = 27.0 # MHz, as the sweep scores it
class BBATopSynth(BBATop):
"""BBATop with platform pin connections added in elaborate()."""
@@ -238,6 +249,26 @@ class BBATopSynth(BBATop):
else:
exi_clk_i, exi_mosi_i, exi_cs_n_i = exi.clk.i, exi.mosi.i, exi.cs_n.i
# The EXI clock pin now clocks the bit engine directly (see
# BBATop's clock-domain comment), so it is a REAL clock net and
# must be constrained. Without this nextpnr defaults it to
# 12 MHz and happily reports PASS on a build that would miss bits
# on a console. The GC tops out at ~27 MHz ("EXI_SPEED32MHZ" is a
# nominal name, not the actual rate).
# Constrain the net that actually BECOMES the clock, which
# differs between the two build variants:
# plain -- self.exi_clk is a pure alias of the pad and yosys
# absorbs it, leaving the pad net (nextpnr calls it
# 'exi_0__clk__i_$glb_clk').
# EXI_J4 -- the clock is the AND of the two pads, a distinct net
# ('exi_clk_$glb_clk'); constraining either pad then
# misses, and nextpnr silently falls back to its 12 MHz
# default and under-optimises the whole EXI domain.
if _EXI_J4:
platform.add_clock_constraint(self.exi_clk, _EXI_HZ)
else:
platform.add_clock_constraint(exi.clk.i, _EXI_HZ)
m.d.comb += [
self.exi_clk .eq(exi_clk_i),
self.exi_mosi .eq(exi_mosi_i),
@@ -373,7 +404,8 @@ if __name__ == "__main__":
best_seed = 1
best_fmax = 0.0
results = [] # (seed, fmax_clk, fmax_capture, verdict), scored only
best_detail = None
results = [] # (seed, fmax_clk, fmax_exi, verdict), scored only
for seed in range(1, n_seeds + 1):
print(f"\n{'='*60}")
print(f" Seed {seed}/{n_seeds}")
@@ -412,10 +444,10 @@ if __name__ == "__main__":
f"{type(exc).__name__}: {exc}")
# Parse fmax from nextpnr log in build/top.tim (if present). Domain
# names are 'clk' (exi/sync, 24 MHz target) and 'capture_clk'
# (54 MHz target, the tighter constraint) — NOT 'exi', which never
# matched any real log line (this regex silently reported 0.0 MHz
# for every seed until fixed).
# names are 'clk' (sync, 24 MHz target) and the EXI pin's promoted
# global-clock net (27 MHz target), whose name nextpnr derives from
# the net and which CONTAINS a '$' — so the capture group must not be
# \w+ (that silently matched nothing and left seeds unscored).
# nextpnr reports each domain TWICE: once post-placement (an estimate)
# and once post-routing. Only the post-route number is real, so take
# the LAST occurrence of each domain — never the first.
@@ -433,49 +465,63 @@ if __name__ == "__main__":
with open(tf, errors="replace") as f:
for line in f:
m_ = re.search(
r"Max frequency for clock\s+'(\w+)':\s*([\d.]+)\s*MHz", line)
r"Max frequency for clock\s+'([^']+)':\s*([\d.]+)\s*MHz", line)
if m_:
fmax[m_.group(1)] = float(m_.group(2))
except OSError as exc:
print(f" [seed {seed}] could not read {tf}: {exc}")
if "clk" not in fmax or "capture_clk" not in fmax:
# The EXI domain is clocked by the GC's clock PIN, so nextpnr names it
# after the net it was promoted from (e.g. "exi_0__clk__i_$glb_clk")
# rather than a domain name we chose. Match on substring, and require
# EXACTLY ONE match so a renamed or duplicated net can never be scored
# silently against the wrong clock.
exi_keys = [k for k in fmax if "exi" in k]
if "clk" not in fmax or len(exi_keys) != 1:
print(f" [seed {seed}] NO USABLE TIMING REPORT "
f"(found {sorted(fmax) or 'nothing'}) — NOT SCORED"
f"{'' if build_ok else '; build also reported an error'}")
continue
fmax_clk, fmax_capture = fmax["clk"], fmax["capture_clk"]
verdict = ("PASS" if fmax_capture >= 54.02 and fmax_clk >= 24.0
fmax_clk, fmax_exi = fmax["clk"], fmax[exi_keys[0]]
verdict = ("PASS" if fmax_exi >= _EXI_TARGET and fmax_clk >= 24.0
else "FAIL")
results.append((seed, fmax_clk, fmax_capture, verdict))
results.append((seed, fmax_clk, fmax_exi, verdict))
print(f" [seed {seed}] clk fmax: {fmax_clk:.2f} MHz (target 24) "
f"capture_clk fmax: {fmax_capture:.2f} MHz (target 54.02) "
f"exi fmax: {fmax_exi:.2f} MHz (target {_EXI_TARGET}) "
f"{verdict}"
f"{'' if build_ok else ' [build reported an error]'}")
# capture_clk is the binding constraint (tighter target, historically
# the one that swings ±20% with seed) — rank seeds by it.
if fmax_capture > best_fmax:
best_fmax = fmax_capture
# Rank by whichever domain has the least RELATIVE margin. The old
# script ranked by capture_clk because it was always the binding one;
# with the PLL gone the EXI domain has ~2.3x margin and `clk` can
# easily be the tighter of the two, so ranking by EXI alone would pick
# a seed that is worse overall.
score = min(fmax_exi / _EXI_TARGET, fmax_clk / 24.0)
if score > best_fmax:
best_fmax = score
best_seed = seed
best_detail = (fmax_clk, fmax_exi)
# Per-seed summary. capture_clk swings hard with seed on this design, so
# the pass RATE matters as much as the best number — a design that only
# closes on a minority of seeds has no real margin.
# Per-seed summary. The pass RATE matters as much as the best number — a
# design that only closes on a minority of seeds has no real margin.
print(f"\n{'='*60}")
print(f" Seed sweep summary ({len(results)}/{n_seeds} seeds scored)")
print(f"{'='*60}")
print(f" {'seed':>4} {'clk (≥24)':>10} {'capture_clk (≥54.02)':>21} verdict")
for seed, fc, fcap, verdict in results:
print(f" {seed:>4} {fc:>10.2f} {fcap:>21.2f} {verdict}")
print(f" {'seed':>4} {'clk (≥24)':>10} {f'exi (≥{_EXI_TARGET})':>16} verdict")
for seed, fc, fexi, verdict in results:
print(f" {seed:>4} {fc:>10.2f} {fexi:>16.2f} {verdict}")
n_pass = sum(1 for *_, v in results if v == "PASS")
if results:
print(f"\n passing seeds: {n_pass}/{len(results)} "
f"({[s for s, *_, v in results if v == 'PASS']})")
overall = "PASS" if best_fmax >= 54.02 else "FAIL"
print(f"\nBest seed: {best_seed} capture_clk fmax: {best_fmax:.2f} MHz "
f"(target 54.02) — {overall}")
overall = "PASS" if best_fmax >= 1.0 else "FAIL"
if best_detail:
print(f"\nBest seed: {best_seed} clk {best_detail[0]:.2f} MHz, "
f"exi {best_detail[1]:.2f} MHz "
f"(worst margin {best_fmax:.2f}x) — {overall}")
else:
print(f"\nBest seed: {best_seed} — {overall}")
if overall == "PASS" and n_pass * 2 < len(results):
print(" NOTE: a MINORITY of seeds close timing. The bitstream from "
"the best seed is usable, but this design has little margin — "
@@ -487,9 +533,10 @@ if __name__ == "__main__":
print(f"\nBitstream for best seed: build/seed{best_seed}/top.bin")
if do_flash and overall == "FAIL":
print("\nREFUSING TO FLASH: no seed met the capture-domain timing "
"constraint. The EXI front-end samples a 27 MHz clock and will "
"miss bits. Re-run with more seeds (--seeds 16) or reduce logic.")
print("\nREFUSING TO FLASH: no seed met both timing constraints. "
"The EXI front-end is clocked BY the GC's 27 MHz clock, so a "
"failing seed will miss bits on a console. Re-run with more "
"seeds (--seeds 16) or reduce logic.")
elif do_flash:
print(f"\nFlashing with seed {best_seed}...")
opts = f"{_PNR_TIMING_OPTS} --seed {best_seed} --timing-allow-fail"