Files
rebbarb/exi_bba/synth.py
T
Roflin 91c3bf0ece 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>
2026-10-03 18:36:51 +02:00

551 lines
28 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"),
# 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
# 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.
# 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
# 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. 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, 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
# 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 ─────────────────────────────────
# 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()."""
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
# 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),
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
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}")
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' (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.
#
# 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+'([^']+)':\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}")
# 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_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))
print(f" [seed {seed}] clk fmax: {fmax_clk:.2f} MHz (target 24) "
f"exi fmax: {fmax_exi:.2f} MHz (target {_EXI_TARGET}) "
f"{verdict}"
f"{'' if build_ok else ' [build reported an error]'}")
# 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. 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")
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 >= 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 — "
"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 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"
# 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.")