Cut the MISO output path: drop EXI_J4 and the CS mux; rank seeds by it

Remaining after the bit-phase fix was a SPEED-DEPENDENT failure -- the GC read
correctly at 1/2/4/8/16 MHz and failed only at index 5 (27 MHz), returning
0B 80 = 0x17 late by exactly ONE bit.  Opposite signature to the phase slip it
was hiding under, and this one really is propagation delay.

MISO is source-synchronous: driven on the falling edge, sampled by the GC on
the next rising edge, so the whole path must fit a HALF period -- 18.5 ns at
27 MHz -- minus global-buffer insertion, pad, connector and the GC's setup.
The failing build measured ~10.5 ns of that internally.  Critical path:

  clk-to-q  1.39  tx_shift            (3,12)
  routing   3.60  tx_shift[7]  (3,12) -> (5,5)     to the CS mux
  logic     1.21  Mux(cs_n, 1, ...) LUT
  routing   4.79  miso  (5,5) -> (17,0)            to the J4 MISO pad
            10.99 ns   (2.60 logic, 8.39 routing)

Two removals, both of things that were pure cost here:

1. EXI_J4 is GONE entirely (exi_j4.py, the resource, the AND merge, the docs
   section, scripts/pi-exi-wirecheck.py) -- recoverable from git history.  The
   AND put a LUT in the CLOCK path, denying the EXI clock pad its dedicated
   global-buffer route, and MISO fanned out to the J4 pad at die (17,0) which
   was the far end of the critical output path: 4.79 ns of routing to a header
   with nothing plugged into it.  10.2-11.5 ns -> 9.3-9.6 ns.

2. The Mux(cs_n, 1, ...) LUT is gone from the MISO data path.  It only parked
   MISO high when deselected, which the flop's own reset value does for free:
   tx_shift now has init=0xFF and CS is that domain's async reset, so MISO is
   tx_shift[7] with nothing in between.  Removes 1.21 ns of logic plus the
   3.60 ns route feeding it and lets the placer sit the flop by the pad.

synth.py now PARSES AND RANKS BY the MISO clock-to-pad delay.  Ranking by fmax
was how a board that failed at 27 MHz passed every seed at >=1.29x margin --
fmax was never the binding constraint for this design.

--seeds 8: clk 28.7-32.5 MHz, EXI 49.6-62.1 MHz, MISO 8.38-10.22 ns, 8/8 PASS.
Best seed 8 (clk 30.35, EXI 49.56, MISO 8.38 ns), flashed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:16:58 +02:00
parent d35688390e
commit 5f7f20db43
5 changed files with 106 additions and 486 deletions
+61 -86
View File
@@ -102,42 +102,11 @@ _UART_TX, _UART_RX = ("9", "10") if os.environ.get("UART_J4") else ("19", "18")
# full UART_J4 design. Default = 28 (real board). See BRINGUP.md.
_ETH_D3 = os.environ.get("ETH_D3_PIN", "28")
# EXI bring-up on the J4 debug header (set env EXI_J4=1). Rather than REPINNING
# the EXI resource onto J4 -- which moves where the capture domain anchors on the
# die -- this MERGES a second set of EXI pads onto the existing ones, so the SP1
# edge connector stays live and the normal build is one env var away.
#
# J4.1 = GND
# J4.2 = DBG0 = FPGA pin 9 -> EXI CLK (in)
# J4.3 = DBG1 = FPGA pin 10 -> EXI MOSI (in)
# J4.4 = DBG2 = FPGA pin 11 -> EXI MISO (out)
# J4.5 = DBG3 = FPGA pin 12 -> EXI CS_N (in)
# J4.6 = DBG4 = FPGA pin 6 -> EXI INT_N (out)
#
# The three INPUTS are merged with AND. That is correct precisely because every
# EXI input is idle/inactive HIGH: CPOL=1 so CLK idles high, CS_N is active-low,
# and MOSI only matters while CS_N is low. Whichever side is unplugged sits at 1
# through its SB_IO PULLUP and passes the other side through untouched, so no
# select signal is needed. Both pad sets therefore get PULLUP=1 in this build.
# DRIVE ONE SIDE AT A TIME -- a GameCube on SP1 and a host on J4 at the same time
# will fight, and the AND will hand the capture engine the mangled product.
# The two OUTPUTS are simply driven onto both pads (fanout 2, no added logic).
#
# Timing: 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), so the capture domain keeps its bottom-edge
# anchor -- this does not recreate the cross-die span described in CLAUDE.md.
# It still adds 3 LUTs ahead of the input synchronisers and a second MISO load,
# and capture has little margin, so ALWAYS sweep seeds on an EXI_J4 build.
# COLLIDES WITH: UART_J4 (pins 9/10) and ETH_D3_PIN if pointed at a J4 pad.
_EXI_J4 = bool(os.environ.get("EXI_J4"))
if _EXI_J4 and os.environ.get("UART_J4"):
raise SystemExit("EXI_J4 and UART_J4 both claim J4 pins 9/10 - pick one")
if _EXI_J4 and _ETH_D3 in ("9", "10", "11", "12", "6"):
raise SystemExit(f"EXI_J4 conflicts with ETH_D3_PIN={_ETH_D3} on the J4 header")
# EXI pad attributes. No PULLUP: the GameCube drives all three inputs
# through the SP1 edge connector.
_EXI_ATTRS = Attrs(IO_STANDARD="SB_LVCMOS")
# With the merge active the unplugged side must idle high rather than float.
_EXI_ATTRS = (Attrs(IO_STANDARD="SB_LVCMOS", PULLUP=1) if _EXI_J4
else Attrs(IO_STANDARD="SB_LVCMOS"))
class IceBreakerPlatform(LatticeICE40Platform):
@@ -160,15 +129,6 @@ class IceBreakerPlatform(LatticeICE40Platform):
Subsignal("int_n", Pins("46", dir="o")),
_EXI_ATTRS),
# EXI mirror on the J4 debug header (EXI_J4=1 only) -- see note above.
*([Resource("exi_j4", 0,
Subsignal("clk", Pins("9", dir="i")),
Subsignal("mosi", Pins("10", dir="i")),
Subsignal("miso", Pins("11", dir="o")),
Subsignal("cs_n", Pins("12", dir="i")),
Subsignal("int_n", Pins("6", dir="o")),
_EXI_ATTRS)] if _EXI_J4 else []),
# W5100 indirect parallel bus
Resource("w5100", 0,
Subsignal("addr", Pins("42 38", dir="o")),
@@ -233,46 +193,23 @@ class BBATopSynth(BBATop):
exi = platform.request("exi", 0)
w5100 = platform.request("w5100", 0)
# EXI inputs: optionally AND-merged with the J4 header mirror so a
# bench host can drive EXI without the SP1 edge connector. AND is
# valid because all three inputs are inactive-HIGH and the unused
# side is held there by its SB_IO PULLUP -- see the _EXI_J4 note.
if _EXI_J4:
j4 = platform.request("exi_j4", 0)
exi_clk_i = exi.clk.i & j4.clk.i
exi_mosi_i = exi.mosi.i & j4.mosi.i
exi_cs_n_i = exi.cs_n.i & j4.cs_n.i
m.d.comb += [
j4.miso.o .eq(self.exi_miso),
j4.int_n.o .eq(self.int_n),
]
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)
# Constrain the pad that becomes the clock. self.exi_clk is a
# pure alias of it and yosys absorbs the alias, so the surviving
# net is the pad's ('exi_0__clk__i_$glb_clk'). Without this
# nextpnr defaults the clock to 12 MHz and reports PASS on a build
# that would miss bits on a console.
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),
self.exi_cs_n .eq(exi_cs_n_i),
self.exi_clk .eq(exi.clk.i),
self.exi_mosi .eq(exi.mosi.i),
self.exi_cs_n .eq(exi.cs_n.i),
exi.miso.o .eq(self.exi_miso),
exi.int_n.o .eq(self.int_n),
@@ -419,6 +356,7 @@ if __name__ == "__main__":
best_seed = 1
best_fmax = 0.0
best_detail = None
best_miso = None
results = [] # (seed, fmax_clk, fmax_exi, verdict), scored only
for seed in range(1, n_seeds + 1):
print(f"\n{'='*60}")
@@ -472,6 +410,7 @@ if __name__ == "__main__":
# 49.66 MHz: unparsed seeds silently mixed with real ones. An
# unparseable seed must be reported and skipped, never ranked.
fmax = {}
out_ns = [None] # MISO clock-to-pad, post-route
for tf in sorted(set(glob.glob(f"{build_dir}/*.tim"))):
try:
# errors="replace": a decode hiccup must not abort the parse
@@ -482,6 +421,21 @@ if __name__ == "__main__":
r"Max frequency for clock\s+'([^']+)':\s*([\d.]+)\s*MHz", line)
if m_:
fmax[m_.group(1)] = float(m_.group(2))
# MISO clock-to-pad. fmax is NOT the binding
# constraint for this design: every seed passed both
# fmax targets with >=1.29x margin while the BOARD
# still failed at 27 MHz, because MISO is a
# source-synchronous output -- it leaves on the
# falling edge and the GameCube samples it on the
# next rising edge, so the whole path must fit a
# HALF period (18.5 ns at 27 MHz) minus global-buffer
# insertion, pad, connector and the GC's own setup.
# Track it explicitly and rank on it.
m_ = re.search(
r"Max delay\s+negedge\s+\S+\s*->\s*<async>\s*:"
r"\s*([\d.]+)\s*ns", line)
if m_:
out_ns[0] = float(m_.group(1))
except OSError as exc:
print(f" [seed {seed}] could not read {tf}: {exc}")
@@ -500,7 +454,7 @@ if __name__ == "__main__":
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_exi, verdict))
results.append((seed, fmax_clk, fmax_exi, verdict, None))
print(f" [seed {seed}] clk fmax: {fmax_clk:.2f} MHz (target 24) "
f"exi fmax: {fmax_exi:.2f} MHz (target {_EXI_TARGET}) "
f"{verdict}"
@@ -510,30 +464,51 @@ if __name__ == "__main__":
# 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.
# Rank by the SMALLEST MISO clock-to-pad delay among seeds that meet
# both fmax targets -- that is the constraint that actually failed on
# hardware. Fall back to relative fmax margin only if the delay could
# not be parsed.
miso = out_ns[0]
if verdict == "PASS" and miso is not None:
if best_miso is None or miso < best_miso:
best_miso = miso
best_seed = seed
best_detail = (fmax_clk, fmax_exi, miso)
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)
if best_miso is None:
best_seed = seed
best_detail = (fmax_clk, fmax_exi, miso)
results[-1] = (seed, fmax_clk, fmax_exi, verdict, miso)
# 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} {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")
print(f" {'seed':>4} {'clk (≥24)':>10} {f'exi (≥{_EXI_TARGET})':>16}"
f" {'MISO clk→pad':>13} verdict")
for seed, fc, fexi, verdict, miso in results:
ms = f"{miso:.2f} ns" if miso is not None else " n/a"
print(f" {seed:>4} {fc:>10.2f} {fexi:>16.2f} {ms:>13} {verdict}")
n_pass = sum(1 for r in results if r[3] == "PASS")
if results:
print(f"\n passing seeds: {n_pass}/{len(results)} "
f"({[s for s, *_, v in results if v == 'PASS']})")
f"({[r[0] for r in results if r[3] == 'PASS']})")
overall = "PASS" if best_fmax >= 1.0 else "FAIL"
if best_detail:
ms = (f", MISO clk→pad {best_detail[2]:.2f} ns"
if best_detail[2] is not None else "")
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}")
f"exi {best_detail[1]:.2f} MHz{ms} — {overall}")
if best_detail[2] is not None and best_detail[2] > 12.0:
print(" ⚠️ MISO clk→pad > 12 ns. At 27 MHz the GameCube samples "
"MISO a HALF period (18.5 ns) after the falling edge it is "
"driven on, and global-buffer insertion, the pad, the "
"connector and the GC's setup all come out of what is left. "
"Reads failed at index 5 and passed at 16 MHz with 10.99 ns.")
else:
print(f"\nBest seed: {best_seed} — {overall}")
if overall == "PASS" and n_pass * 2 < len(results):