Files
rebbarb/exi_bba/synth.py
T
Roflin c0abd45518 synth: optional EXI mirror on the J4 debug header (EXI_J4=1)
Lets a bench host drive EXI without the SP1 edge connector, for bring-up.

Merges a second set of EXI pads onto the existing ones rather than repinning,
so the SP1 connector stays live and the default build is untouched.  The three
inputs are AND-merged, which is correct because every EXI input is inactive
HIGH (CPOL=1 clk, active-low cs_n, mosi only matters while cs_n is low): the
unplugged side idles at 1 through its SB_IO PULLUP and passes the other side
through, so no select signal is needed.  Outputs drive both pads.

All five J4 pads are on the same bottom die edge as the EXI pads (x=13..18 vs
x=5..9), so the capture domain keeps its anchor.  It still adds 3 LUTs ahead of
the input synchronisers, so EXI_J4 builds must sweep seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 17:54:24 +00:00

504 lines
26 KiB
Python

"""Synthesis script for BBATop → re-bba-rb interposer board (iCE40UP5K SG48).
Run from workspace root:
python -m exi_bba.synth # synthesize only
python -m exi_bba.synth --flash # synthesize and flash
This file re-declares IceBreakerPlatform inline so that importing
rebbarb/rebbarb.py (which has a module-level platform.build() call) is avoided.
"""
import glob
import os
import re
import subprocess
import sys
from amaranth import *
from amaranth.build import *
from amaranth.vendor import LatticeICE40Platform
from exi_bba.bba_top import BBATop
# ── Platform definition ───────────────────────────────────────────────────
# Real re-bba-rb board pin map, pulled directly from the schematic netlist
# (U9 = iCE40UP5K-SG48ITR) — not iCEbreaker PMOD placeholders.
#
# EXI (GC side) : CLK=44(G6) MOSI=4(IOB_8a) MISO=3(IOB_9b) CS=45 INT=46
# W5100 bus (IOT bank, indirect parallel):
# A0=42 A1=38 D0=34 D1=32 D2=31 D3=28 D4=27 D5=26 D6=25 D7=23
# CS_N=43 RD_N=37 WR_N=36 RST_N=2 (net /ethernet/ETH_RST, NOT on the IOT
# bank — separate pin)
# Board ties the W5100's upper address lines A[14:2] to 0 (only A[1:0]
# wired); DATA[7:0] is bidirectional (SB_IO tristate, shared output-enable).
#
# 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
# 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
# exact SG48 package/board combo: the 5k-sg48 package has 39 usable I/O pins
# total (per icestorm's icebox.py pin database), and every other one is
# already assigned to a real signal on this board — pin 13 shows up in the
# schematic netlist as an unrouted U9 pad, nothing else was available.
# DONE on the board: the schematic + PCB now carry /ethernet/ETH_INT on U9
# 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.
#
# 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
# FPGA's RX input, and vice versa for UART_RXD — FPGA RX=18(UART_TXD net),
# FPGA TX=19(UART_RXD net). Do not swap by "TXD means transmit" instinct.
# Status LEDs: D6(green,heartbeat)=LED_G=pin47, D7(red,EXI-activity)=LED_R=
# pin48 — both wired ACTIVE-HIGH (LED anode toward the FPGA pin via its
# series resistor, cathode to GND), unlike the iCEbreaker's own onboard
# LEDs which are active-low. No physical button exists on this board (the
# iCEbreaker's BTN_N was dev-board-only); panel_btn is tied idle instead.
# RGB status LED (D11=red/rx, D12=green/tx, D13=yellow/ready) is on the
# iCE40UP5K's dedicated SB_RGBA_DRV pads 39/40/41. It IS declared as a
# "rgb" resource and requested RAW (dir="-") so SB_RGBA_DRV drives the pads
# 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.
#
# 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.
_PNR_TIMING_OPTS = "--opt-timing"
# UART pin override for bring-up. The re-bba-rb V1 lab unit had its UART_RXD
# trace (FPGA pin 19 → FT2232H pin 39) severed by a via-repair drill hole, and
# the 0.5 mm FT2232H pitch is not hand-reworkable. Set env UART_J4=1 to relocate
# the UART onto the accessible J4 debug header — DBG0 = J4 pin 2 = FPGA pin 9
# (FPGA TX), DBG1 = J4 pin 3 = FPGA pin 10 (FPGA RX), GND = J4 pin 1 — so an
# external USB-UART dongle can drive the shell, bypassing the FT2232H channel B
# completely. Unset (default) keeps the normal FT2232H channel-B pins
# (tx = 19 = UART_RXD net, rx = 18 = UART_TXD net).
_UART_TX, _UART_RX = ("9", "10") if os.environ.get("UART_J4") else ("19", "18")
# ETH_D3 relocation for the damaged V1 lab unit: the W5100 data bit-3 trace
# (FPGA pin 28 → W5100 U11 pin 40, top copper) was nicked by a drill hole, and
# neither the 0.5 mm pin nor the 0.1 mm trace is hand-reworkable. Set
# ETH_D3_PIN=<n> to drive W5100 D3 from a coarse J4 header pad instead, and bodge
# that pad to the W5100 D3 net: DBG0=9 (J4 pin2), DBG1=10 (J4 pin3),
# DBG2=11 (J4 pin4), DBG3=12 (J4 pin5), DBG4=6 (J4 pin6). NOTE pin 9 collides
# with UART_J4 TX — fine for the W5100/eth test builds (no UART), not for the
# 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")
# 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):
device = "iCE40UP5K"
package = "SG48"
default_clk = "clk12"
resources = [
Resource("clk12", 0,
Pins("20", dir="i"),
Clock(12e6),
Attrs(GLOBAL=True, IO_STANDARD="SB_LVCMOS")),
# EXI interface (GC side, SPI Mode 3)
Resource("exi", 0,
Subsignal("clk", Pins("44", dir="i")),
Subsignal("mosi", Pins("4", dir="i")),
Subsignal("miso", Pins("3", dir="o")),
Subsignal("cs_n", Pins("45", dir="i")),
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")),
Subsignal("data", Pins(f"34 32 31 {_ETH_D3} 27 26 25 23", dir="io")),
Subsignal("cs_n", Pins("43", dir="o")),
Subsignal("rd_n", Pins("37", dir="o")),
Subsignal("wr_n", Pins("36", dir="o")),
Subsignal("int_n", Pins("13", dir="i")),
Subsignal("rst_n", Pins("2", dir="o")),
Attrs(IO_STANDARD="SB_LVCMOS")),
# Bring-up status panel: D6/D7 discrete LEDs (active-high on this
# board). RGB (pins 39/40/41) is driven via SB_RGBA_DRV — not
# declared here as a platform resource (see BBATopSynth.elaborate).
# No onboard button on this board (see note above).
Resource("ledr", 0, Pins("48", dir="o"), Attrs(IO_STANDARD="SB_LVCMOS")),
Resource("ledg", 0, Pins("47", dir="o"), Attrs(IO_STANDARD="SB_LVCMOS")),
# RGB status LED on the iCE40UP5K's dedicated SB_RGBA_DRV pads
# (39=D11 red, 40=D12 green, 41=D13 yellow). Requested RAW (dir="-") in
# elaborate and wired straight from the SB_RGBA_DRV hard block — a normal
# buffered (dir="o") output fails nextpnr packing:
# "SB_RGB_DRV/SB_RGBA_DRV port connected to more than just package pin!"
# (Bug found at bring-up 2026-08-23: these were previously left dangling,
# so the rx/tx/ready RGB indicators never lit. See BRINGUP.md item 5.)
Resource("rgb", 0,
Subsignal("r", Pins("39", dir="o")),
Subsignal("g", Pins("40", dir="o")),
Subsignal("b", Pins("41", dir="o"))),
# UART debug console → FT2232H Channel B (or J4 header if UART_J4=1).
# On the PC: open the serial port at 115200 8N1.
Resource("uart", 0,
Subsignal("tx", Pins(_UART_TX, dir="o")),
Subsignal("rx", Pins(_UART_RX, dir="i")),
Attrs(IO_STANDARD="SB_LVCMOS")),
]
connectors = []
def toolchain_program(self, products, name):
iceprog = os.environ.get("ICEPROG", "iceprog")
with products.extract(f"{name}.bin") as bitstream_filename:
subprocess.check_call([iceprog, bitstream_filename])
# ── BBATop with platform resource wiring ─────────────────────────────────
class BBATopSynth(BBATop):
"""BBATop with platform pin connections added in elaborate()."""
def elaborate(self, platform):
m = super().elaborate(platform)
if platform is not None:
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
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),
exi.miso.o .eq(self.exi_miso),
exi.int_n.o .eq(self.int_n),
# W5100 parallel bus (DATA[7:0] bidirectional via SB_IO)
w5100.addr.o .eq(self.w5100_addr),
w5100.data.o .eq(self.w5100_data_o),
w5100.data.oe .eq(self.w5100_data_oe),
self.w5100_data_i.eq(w5100.data.i),
w5100.cs_n.o .eq(self.w5100_cs_n),
w5100.rd_n.o .eq(self.w5100_rd_n),
w5100.wr_n.o .eq(self.w5100_wr_n),
self.w5100_int_n .eq(w5100.int_n.i),
w5100.rst_n.o .eq(self.w5100_rst_n),
]
# ── Bring-up status panel → onboard LEDs ────────────────────────
# All 5 panel LEDs mapped:
# LEDG (pin 47) = led[0] heartbeat
# LEDR (pin 48) = led[1] EXI activity
# RGB (pins 39/40/41) = led[2] rx / led[3] tx / led[4] ready
# No physical button on this board — panel_btn tied idle/released.
if self._status_panel:
ledr = platform.request("ledr", 0)
ledg = platform.request("ledg", 0)
rgb = platform.request("rgb", 0, dir="-") # raw pads (no buffer)
led = self.panel_led
# Green-LED PWM dimming. Both green emitters are over-bright on
# the re-bba-rb V1 (bring-up 2026-08-23): the discrete LED_G/D6
# heartbeat has a wrong 49.9 Ohm series resistor (R37, should be
# ~330 Ohm), and the RGB green D12 die is very efficient even at
# the SB_RGBA_DRV minimum current code. The current code is
# already the minimum, so time-average both greens down with a
# low-duty PWM. Red (D11/LED_R) and yellow (D13) are fine — left
# solid. Tune _GRN_DUTY (0..15, /16 duty) at bring-up; see
# BRINGUP.md items 1 & 5. PWM freq = 24 MHz/16 = 1.5 MHz (no
# visible flicker).
_GRN_DUTY = 2 # ~1/8 duty
pwm_cnt = Signal(4)
grn_pwm = Signal()
m.d.sync += pwm_cnt.eq(pwm_cnt + 1)
m.d.comb += grn_pwm.eq(pwm_cnt < _GRN_DUTY)
m.d.comb += [
ledg.o.eq(led[0] & grn_pwm), # heartbeat (green) — PWM-dimmed
ledr.o.eq(led[1]), # EXI activity (red) — fine
# all 3 bits idle/released (active-low idle = 1) — no
# physical button exists on this board to read
self.panel_btn.eq(C(0b111, 3)),
]
# RGB LED has no series resistors — must use SB_RGBA_DRV (raw pad
# driver with built-in current source). o_RGB* MUST connect
# directly to the pads (rgb.*.io) — a buffered output fails
# packing. RGB0=red→rx RGB1=green→tx RGB2=yellow→ready.
m.submodules.rgb_drv = Instance("SB_RGBA_DRV",
p_CURRENT_MODE="0b1",
p_RGB0_CURRENT="0b000001",
p_RGB1_CURRENT="0b000001",
p_RGB2_CURRENT="0b000001",
i_CURREN=Const(1, 1),
i_RGBLEDEN=Const(1, 1),
i_RGB0PWM=led[2], # rx → red D11 (solid)
i_RGB1PWM=led[3] & grn_pwm, # tx → green D12 (PWM-dimmed)
i_RGB2PWM=led[4], # ready → yellow D13 (solid)
o_RGB0=rgb.r.io,
o_RGB1=rgb.g.io,
o_RGB2=rgb.b.io,
)
# ── UART debug console/shell → FT2232H Channel B ───────────────
# Both the event-log console and the interactive command shell use
# the same two UART pins (only one may be enabled at a time).
if self._uart_console or self._uart_shell:
uart = platform.request("uart", 0)
m.d.comb += [
uart.tx.o .eq(self.uart_tx),
self.uart_rx .eq(uart.rx.i),
]
return m
# ── Entry point ───────────────────────────────────────────────────────────
#
# Seed sweep: nextpnr placement is stochastic. With ~22% LC utilisation
# routing dominates timing, so different seeds can vary fmax by ±20%.
# Pass --seeds N to try N seeds (default 1, i.e. seed 1 only).
# The build directory is reused across seeds; the final artefact in
# build/top.bin is the result of the last (or best) seed tried.
if __name__ == "__main__":
import os
do_flash = "--flash" in sys.argv
# The interactive UART shell + UDP bring-up test is the default build;
# pass --console to flash the old event-log console instead (they share the
# UART pins, so only one can be built).
use_shell = "--console" not in sys.argv
n_seeds = next((int(sys.argv[i+1]) for i, a in enumerate(sys.argv)
if a == "--seeds"), 1)
# UDP bring-up test config (only used with --shell). Set these env vars to
# match your LAN — src_ip/subnet/gateway are the board's own identity for
# the W5100 IP stack; the unicast destination is typed at runtime.
udp_kw = dict(
udp_socket = int(os.environ.get("UDP_SOCKET", "3")),
udp_src_ip = os.environ.get("UDP_SRC_IP", "192.168.1.123"),
udp_subnet = os.environ.get("UDP_SUBNET", "255.255.255.0"),
udp_gateway = os.environ.get("UDP_GATEWAY", "192.168.1.1"),
udp_dst_ip = os.environ.get("UDP_DST_IP", "192.168.1.100"),
udp_src_port = int(os.environ.get("UDP_SRC_PORT", "40000")),
udp_dst_port = int(os.environ.get("UDP_DST_PORT", "6464")),
)
def make_dut():
if use_shell:
return BBATopSynth(status_panel=True, uart_shell=True, **udp_kw)
return BBATopSynth(status_panel=True, uart_console=True)
print(f"Synthesizing BBATop for {IceBreakerPlatform.device}-"
f"{IceBreakerPlatform.package} "
f"(do_program={do_flash}, seeds=1..{n_seeds}, "
f"{'shell+UDP' if use_shell else 'console'})")
if use_shell:
print(f" UDP: socket {udp_kw['udp_socket']}, "
f"src {udp_kw['udp_src_ip']}:{udp_kw['udp_src_port']} "
f"gw {udp_kw['udp_gateway']} mask {udp_kw['udp_subnet']} "
f"dst-port {udp_kw['udp_dst_port']}")
best_seed = 1
best_fmax = 0.0
results = [] # (seed, fmax_clk, fmax_capture, verdict), scored only
for seed in range(1, n_seeds + 1):
print(f"\n{'='*60}")
print(f" Seed {seed}/{n_seeds}")
print(f"{'='*60}")
opts = f"{_PNR_TIMING_OPTS} --seed {seed} --timing-allow-fail"
# A Platform instance can only be built ONCE — amaranth's
# TemplatedPlatform.prepare() does `assert not self._prepared`. Reusing
# one platform across the sweep made seeds 2..N raise a bare
# AssertionError (empty message, so the handler below printed nothing),
# after which the fmax parser re-read the PREVIOUS seed's build/top.tim
# and reported identical numbers for every seed — a sweep that looked
# like it ran but never did. Build a fresh platform per seed.
platform = IceBreakerPlatform()
# Each seed gets its OWN build directory. Sharing one `build/top.tim`
# across the sweep is not safe on this workspace: /workspace is a WSL2
# drvfs (Windows drive) mount, and a re-read of a file just rewritten
# by a child process can return stale or partially-flushed content.
# That silently mis-scored the sweep — seeds were credited with other
# seeds' numbers, including nextpnr's PRE-routing placement estimates
# (which run ~8 MHz optimistic), so a 53.08 MHz FAIL got reported as a
# 61.94 MHz PASS. Deleting the stale file first did NOT fix it; only
# not sharing the path does. Do not "simplify" this back to build/.
build_dir = f"build/seed{seed}"
build_ok = True
try:
platform.build(make_dut(), do_program=False,
verbose=True, nextpnr_opts=opts, build_dir=build_dir)
except Exception as exc:
# nextpnr exits non-zero even with --timing-allow-fail on some
# versions; treat as non-fatal timing failure.
build_ok = False
print(f" [seed {seed}] build exception (timing?): "
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).
# 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.
#
# A seed is only scored if BOTH domains were actually found. Defaulting
# a missing value to 0.0 and scoring it anyway is how this script once
# declared "Best seed: 8 ... PASS" while seed 8 in fact failed at
# 49.66 MHz: unparsed seeds silently mixed with real ones. An
# unparseable seed must be reported and skipped, never ranked.
fmax = {}
for tf in sorted(set(glob.glob(f"{build_dir}/*.tim"))):
try:
# errors="replace": a decode hiccup must not abort the parse
# and leave the seed looking like a legitimate 0.0 MHz result.
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)
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:
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
else "FAIL")
results.append((seed, fmax_clk, fmax_capture, 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"{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
best_seed = seed
# 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.
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}")
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}")
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 — "
"treat any logic addition as likely to break timing.")
# build/ now holds one subdirectory per seed; the flashable bitstream for
# the best seed is build/seed<N>/top.bin. There is no top-level build/top.bin.
if results:
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.")
elif do_flash:
print(f"\nFlashing with seed {best_seed}...")
opts = f"{_PNR_TIMING_OPTS} --seed {best_seed} --timing-allow-fail"
# Fresh platform again — the sweep above already consumed one per seed.
# Reuse the best seed's own build dir so the flashed bitstream is the
# one that was actually measured.
IceBreakerPlatform().build(
make_dut(), do_program=True,
verbose=True, nextpnr_opts=opts, build_dir=f"build/seed{best_seed}")
print("Done.")