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