synth: document the build flow, and fix comments that had gone stale

589 -> 602 lines but 42% -> 13% comment-only: the prose shrank by two thirds
while the structure and the docstrings grew.  The module docstring now carries
the usage, both timing targets, the per-seed build layout and every environment
variable in one place.

SIX of the comments here were not merely verbose, they were WRONG, and three of
them described hardware behaviour that had been replaced:

  * MISO was described in three places as leaving on the FALLING edge with the
    path having to fit a HALF period (18.5 ns).  It has not worked that way
    since the full-cycle-early launch: NEG_TRIGGER=0 keeps the IO register on
    the RISING edge and the bit is launched one edge before the host samples
    it, so the budget is a FULL period.  The >12 ns warning text said the same
    wrong thing to anyone who tripped it.
  * The MISO clock-to-pad parse had no note saying it is EXPECTED to find
    nothing.  "IOB (none)" is the good case -- a number there means the output
    has regressed into the fabric -- and only the summary printer said so.
  * Two comments sat adjacent at the ranking site giving DIFFERENT rules: one
    said rank by worst relative fmax margin, the next said rank by smallest
    MISO delay.  The code does MISO first and falls back to margin; the
    docstring now says that once.
  * "~22% LC utilisation" -- it is ~81%, which inverts the point being made
    (routing dominates because the part is FULL, not because it is empty).
  * "the final artefact in build/top.bin is the result of the last (or best)
    seed" was contradicted 150 lines later by "There is no top-level
    build/top.bin".  The latter is true.
  * A comment described `results` as 4-tuples; the code appends 5.

Structure: the 240-line __main__ is now main() over _udp_config,
_make_dut_factory, _build_seed, _parse_timing, _score, _run_sweep and
_print_summary.  elaborate() delegates to _drive_miso and
_drive_status_panel.  Each keeps the reasoning that belongs to it -- why a
fresh platform and a separate build dir per seed (both were real mis-scoring
bugs), why the post-route number is the last occurrence, why an unparseable
seed must never be ranked.

Cut: the pin-35/PLL history, which ends in a conclusion that is all that
matters now (ETH_INT is on pin 13 because the board is built that way); the
reverted router-options experiment, down to one line; and a duplicated
paragraph about the clock constraint, plus a duplicate `import os`.

VERIFIED EQUIVALENT, not just tested:
  * Generated RTLIL is identical across all 30659 lines after stripping the
    \src source-location attributes -- those 44 lines are the only diff, so
    the hardware is provably unchanged.
  * The ranking logic was differential-tested against the old algorithm
    transcribed verbatim: 4000 randomised sweeps including unparseable seeds
    and the best_miso/best_score interaction, 0 mismatches.
  * A real build of seed 1 reports clk 27.62 / exi 59.47 MHz, matching the
    pre-refactor sweep's seed 1 exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 19:48:49 +02:00
parent f4e7e2c661
commit b5961d0731
+434 -421
View File
@@ -1,11 +1,30 @@
"""Synthesis script for BBATop → re-bba-rb interposer board (iCE40UP5K SG48).
"""Synthesis and seed sweep for `BBATop` on the re-bba-rb board (iCE40UP5K SG48).
Run from workspace root:
python -m exi_bba.synth # synthesize only
python -m exi_bba.synth --flash # synthesize and flash
python -m exi_bba.synth # build seed 1
python -m exi_bba.synth --seeds 8 # sweep 8 seeds, rank them, report the best
python -m exi_bba.synth --flash # build, then flash the best seed
python -m exi_bba.synth --console # event-log console instead of the UART shell
This file re-declares IceBreakerPlatform inline so that importing
rebbarb/rebbarb.py (which has a module-level platform.build() call) is avoided.
Two timing targets must BOTH be met: `clk` (the `sync` domain, HFOSC/2) at
24 MHz, and the EXI pin domain at 27 MHz. nextpnr placement is stochastic and
this design sits around 81% LC, so fmax varies by seed. `--seeds N` builds N
of them and ranks by worst RELATIVE margin, because either domain can be the
tighter one.
Each seed builds into its own `build/seed<N>/`, and the flashable bitstream is
`build/seed<N>/top.bin`. There is no top-level `build/top.bin`.
Build-time configuration, all through the environment:
UDP_SOCKET, UDP_SRC_IP, UDP_SUBNET, UDP_GATEWAY,
UDP_DST_IP, UDP_SRC_PORT, UDP_DST_PORT the UART shell's UDP test identity
PHASE_METER=1 diagnostic build, see `_make_dut`
UART_J4=1, ETH_D3_PIN=<n> bodges for a damaged lab unit,
see hardware/re-bba-rb/BRINGUP.md
ICEPROG=<path> iceprog binary used by --flash
`IceBreakerPlatform` is re-declared here rather than imported from
`rebbarb/rebbarb.py`, which runs `platform.build()` at module level.
"""
import glob
@@ -21,92 +40,67 @@ 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.
# ────────────────────────── board pin map ─────────────────────────────────
# U9 = iCE40UP5K-SG48ITR, taken from the re-bba-rb schematic netlist (these
# are 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).
# EXI (GC side) CLK 44 MOSI 4 MISO 3 CS_N 45 INT_N 46
# W5100 bus A0 42 A1 38 D0..D7 = 34 32 31 28 27 26 25 23
# CS_N 43 RD_N 37 WR_N 36 INT_N 13 RST_N 2
# UART FPGA TX 19 FPGA RX 18
# LEDs LED_G 47 (D6 green) LED_R 48 (D7 red)
# RGB 39/40/41 = D11 red, D12 green, D13 yellow
# J4 debug header DBG0 9 DBG1 10 DBG2 11 DBG3 12 DBG4 6
#
# 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.
# Five things here are not guessable from the signal names:
#
# 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.
# * The UART names are CROSSED OVER. The net called UART_TXD is the
# FT2232H's output, so it is the FPGA's RX input, and UART_RXD is the
# FPGA's TX. Do not wire these by "TXD means transmit" instinct.
# * The LEDs are ACTIVE-HIGH on this board -- anode toward the FPGA pin
# through its series resistor -- unlike the iCEbreaker dev board's
# active-low onboard LEDs.
# * The W5100's INT_N is on pin 13, not the schematic's original pin 35.
# Pin 35 cannot serve as ordinary I/O while the PLL hard macro is
# instantiated, and 13 was the only spare GPIO left on this package. The
# PLL is gone now, but the board is already built this way, so pin 13 is
# a hardware fact rather than a tool constraint. Do not "restore" 35.
# * There is no button on this board, so `panel_btn` is tied idle. The
# iCEbreaker's BTN_N was a dev-board feature.
# * The RGB LED has no series resistors and must be driven by the
# SB_RGBA_DRV hard block, whose outputs bond straight to the pads. The
# "rgb" resource is therefore requested RAW (dir="-"); see `elaborate`.
#
# The board ties the W5100's A[14:2] low, so only A[1:0] are wired, and
# D[7:0] is bidirectional through one shared output enable.
# 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.
# nextpnr place-and-route options. Plain --opt-timing is the best baseline
# found: steering the router toward critical paths (`--router router2
# --tmg-ripup --placer-heap-timingweight 30 --placer-heap-critexp 4`) was
# measured to erode the `sync` margin without helping the EXI domain.
_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 relocation for a damaged lab unit. UART_J4=1 moves the console onto
# the J4 debug header -- FPGA TX on pin 9 (J4 pin 2), RX on pin 10 (J4 pin 3)
# -- so an external USB-UART dongle can drive the shell without the FT2232H.
# Default is the FT2232H channel-B pins. See BRINGUP.md.
_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.
# W5100 D3 relocation for the same unit, whose D3 trace is cut and not
# hand-reworkable. ETH_D3_PIN=<n> drives D3 from a coarse J4 pad instead,
# to be bodged to the W5100 D3 net. Note pin 9 collides with UART_J4's TX --
# fine for ethernet-only test builds, not for the full UART_J4 design.
_ETH_D3 = os.environ.get("ETH_D3_PIN", "28")
# EXI pad attributes. No PULLUP: the GameCube drives all three inputs
# through the SP1 edge connector.
# No PULLUP on the EXI inputs: the GameCube drives all three through the SP1
# edge connector.
_EXI_ATTRS = Attrs(IO_STANDARD="SB_LVCMOS")
# 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
_CLK_TARGET = 24.0 # MHz, the `sync` domain (HFOSC/2)
class IceBreakerPlatform(LatticeICE40Platform):
@@ -140,27 +134,20 @@ class IceBreakerPlatform(LatticeICE40Platform):
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).
# Discrete status LEDs (active-high, see the pin map note).
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.)
# RGB status LED on the dedicated SB_RGBA_DRV pads. Requested raw in
# `elaborate`, because a buffered (dir="o") output fails nextpnr
# packing: "SB_RGB_DRV/SB_RGBA_DRV port connected to more than just
# package pin!"
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.
# UART console/shell, 115200 8N1.
Resource("uart", 0,
Subsignal("tx", Pins(_UART_TX, dir="o")),
Subsignal("rx", Pins(_UART_RX, dir="i")),
@@ -175,70 +162,87 @@ class IceBreakerPlatform(LatticeICE40Platform):
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()."""
"""`BBATop` with the board's pins and hard blocks wired up."""
def elaborate(self, platform):
m = super().elaborate(platform)
if platform is None:
return m
if platform is not None:
# MISO is requested RAW so it can be driven by an SB_IO
# output register instead of a plain buffer -- see below.
# MISO is requested RAW so it can be driven from an SB_IO output
# register rather than a plain output buffer -- see `_drive_miso`.
exi = platform.request("exi", 0, dir={"miso": "-"})
w5100 = platform.request("w5100", 0)
# 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 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.
# The EXI clock pin clocks the bit engine directly, so it is a real
# clock net and must be constrained. Without this, nextpnr assumes
# 12 MHz and reports PASS on a build that would miss bits on a
# console. Constrain the PAD: `self.exi_clk` is a pure alias that
# yosys absorbs, so the surviving net is the pad's.
platform.add_clock_constraint(exi.clk.i, _EXI_HZ)
# ── MISO driven from a register INSIDE the IO block ───────────
# MISO is source-synchronous: the device drives it on the FALLING
# edge and the GameCube samples it on the next RISING edge, so the
# entire path must fit a HALF period -- 18.5 ns at 27 MHz -- minus
# global-buffer insertion, the pad, the connector and the GC's own
# setup. Driving it from fabric spends most of that budget on
# routing from the shift register to the pad (measured 8.4-11.5 ns
# across builds), and the console read every byte ONE BIT LATE at
# 27 MHz while reading correctly at 16 MHz and below -- the
# textbook signature of exactly this.
#
# An SB_IO output register sits in the IO tile, so clock-to-pad is
# the IO flop's own delay with no fabric routing at all. It
# captures `exi_miso_next` -- the value the shift register WILL
# hold after the falling edge -- so the bit presented is identical
# to the fabric version (spi_mode3_slave T6 proves the two agree
# bit for bit).
#
# PIN_TYPE decoded from yosys's own SB_IO model, not from memory:
# [5:4]=10 tri-state, combinational OUTPUT_ENABLE
# [3]=0,[2]=1 dout = dout_q_0, single-rate REGISTERED output
# [1:0]=01 simple input (unused)
# NEG_TRIGGER=0 keeps the IO registers on the RISING edge: the
# engine launches MISO one bit EARLY so the path gets a FULL
# period instead of half -- see the TX note in spi_mode3_slave.
#
# NOTE this also changes MISO's electrical behaviour: it is now
# TRI-STATED when CS is high (held high by the pad's own PULLUP)
# instead of being actively driven high. EXI MISO is a SHARED bus
# -- SP1 sits on channel 0 alongside memory-card slot A -- so
# driving it while deselected was wrong and could fight a card.
self._drive_miso(m, exi)
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.int_n.o .eq(self.int_n),
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),
]
if self._status_panel:
self._drive_status_panel(m, platform)
# The event-log console and the interactive shell share these two
# pins, so only one of them may be built.
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
def _drive_miso(self, m, exi):
"""Drive MISO from a register inside the IO block.
MISO is source-synchronous, and the budget is set by when the host
samples it. The engine launches each bit on the RISING edge BEFORE
the rising edge the host samples it on, so the output path gets a
full period -- 37 ns at 27 MHz -- instead of the half period a
falling-edge launch would allow. `NEG_TRIGGER=0` is what keeps the
IO register on that rising edge.
Putting the register in the IO tile makes clock-to-pad the tile's own
delay, with no fabric routing in the path at all. Driving MISO from
fabric spent 8.4-11.5 ns of the budget on routing to the pad.
It captures `exi_miso_next`, the value the shift register will hold
after the edge, so the bit presented is identical to the fabric
version (`spi_mode3_slave` T6 proves the two agree bit for bit).
PIN_TYPE, decoded from yosys's own SB_IO model:
[5:4]=10 tri-state, combinational OUTPUT_ENABLE
[3]=0,[2]=1 dout = dout_q_0, single-rate REGISTERED output
[1:0]=01 simple input (unused)
Note the electrical consequence: MISO is TRI-STATED when CS is high,
held there by the pad's own pull-up, rather than actively driven.
EXI MISO is a SHARED bus -- SP1 sits on channel 0 alongside
memory-card slot A -- so driving it while deselected could fight a
card.
"""
m.submodules.miso_io = Instance(
"SB_IO",
p_PIN_TYPE = 0b100101,
@@ -252,114 +256,268 @@ class BBATopSynth(BBATop):
i_D_OUT_0 = self.exi_miso_next,
)
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.int_n.o .eq(self.int_n),
def _drive_status_panel(self, m, platform):
"""Wire the bring-up panel to the board's LEDs.
# 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),
]
LEDG pin 47 led[0] heartbeat
LEDR pin 48 led[1] EXI activity
RGB pins 39/40/41 led[2] rx (red), led[3] tx (green),
led[4] ready (yellow)
Both green emitters are over-bright on this board: the discrete
LED_G has a 49.9 ohm series resistor where it wants ~330, and the RGB
green die is efficient even at the SB_RGBA_DRV minimum current code
(which is already what is set below). So both greens are time-
averaged down with a low-duty PWM at 24 MHz/16 = 1.5 MHz, well above
visible flicker. Red and yellow are left solid. See BRINGUP.md.
"""
_GRN_DUTY = 2 # of 16
# ── 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)
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
ledg.o.eq(led[0] & grn_pwm),
ledr.o.eq(led[1]),
# No physical button exists to read; all three bits idle
# (active-low, so released = 1).
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,
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
i_RGB1PWM = led[3] & grn_pwm, # tx → green D12
i_RGB2PWM = led[4], # ready → yellow D13
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
# ──────────────────────────── timing reports ──────────────────────────────
def _parse_timing(build_dir, seed):
"""Read nextpnr's timing report for one seed.
Returns `(fmax, miso_ns)`: a domain → MHz mapping, and the MISO
clock-to-pad delay if such a path exists.
Two parsing rules are load-bearing:
* nextpnr reports each domain TWICE, once post-placement (an estimate,
running ~8 MHz optimistic) and once post-route. Only the post-route
number is real, so the last occurrence wins.
* The EXI domain's name is derived from the net its clock was promoted
from (`exi_0__clk__i_$glb_clk`) and contains a `$`, so the capture
group cannot be `\\w+`.
`miso_ns` is normally None. MISO is driven from an SB_IO register, so no
`negedge → <async>` path exists; a number appearing here means the output
has regressed into the fabric, which is what failed on hardware at
27 MHz while reading correctly at 16.
"""
fmax = {}
miso_ns = None
for path 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(path, errors="replace") as fh:
for line in fh:
hit = re.search(
r"Max frequency for clock\s+'([^']+)':\s*([\d.]+)\s*MHz",
line)
if hit:
fmax[hit.group(1)] = float(hit.group(2))
hit = re.search(
r"Max delay\s+negedge\s+\S+\s*->\s*<async>\s*:"
r"\s*([\d.]+)\s*ns", line)
if hit:
miso_ns = float(hit.group(1))
except OSError as exc:
print(f" [seed {seed}] could not read {path}: {exc}")
return fmax, miso_ns
# ── 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.
def _score(fmax, seed, build_ok):
"""Turn a parsed report into `(clk, exi, verdict)`, or None if unscorable.
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)
A seed is scored only when BOTH domains were found, and when exactly one
domain name matches "exi" so a renamed or duplicated net cannot be scored
silently against the wrong clock. Defaulting a missing value to 0.0 and
ranking it anyway is how this script once reported "Best seed: 8 ... PASS"
for a seed that had in fact failed at 49.66 MHz.
"""
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'}")
return None
# 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(
clk, exi = fmax["clk"], fmax[exi_keys[0]]
verdict = ("PASS" if exi >= _EXI_TARGET and clk >= _CLK_TARGET else "FAIL")
return clk, exi, verdict
# ──────────────────────────────── sweep ───────────────────────────────────
def _build_seed(make_dut, seed, build_dir):
"""Build one seed. Returns False if the build reported an error.
A fresh platform per seed is required: amaranth's
`TemplatedPlatform.prepare()` asserts `not self._prepared`, so reusing one
raised a bare AssertionError for seeds 2..N -- after which the parser
re-read the PREVIOUS seed's report and gave every seed identical numbers,
a sweep that looked like it ran but never did.
A separate build directory per seed is required too. /workspace is a WSL2
drvfs mount, where re-reading a file a child process just rewrote can
return stale or partially-flushed content; sharing one `build/top.tim`
credited seeds with each other's numbers, including pre-route placement
estimates, so a 53.08 MHz FAIL was once reported as a 61.94 MHz PASS.
Deleting the stale file first did NOT fix it -- only separate paths do.
"""
opts = f"{_PNR_TIMING_OPTS} --seed {seed} --timing-allow-fail"
try:
IceBreakerPlatform().build(make_dut(), do_program=False, verbose=True,
nextpnr_opts=opts, build_dir=build_dir)
return True
except Exception as exc:
# nextpnr exits non-zero even with --timing-allow-fail on some
# versions, so treat this as a (non-fatal) timing failure.
print(f" [seed {seed}] build exception (timing?): "
f"{type(exc).__name__}: {exc}")
return False
def _run_sweep(make_dut, n_seeds):
"""Build every seed and rank them.
Returns `(results, best_seed, best_detail, best_score)`, where `results`
holds one `(seed, clk, exi, verdict, miso_ns)` per scored seed.
Ranking prefers the smallest MISO clock-to-pad delay among passing seeds,
because that -- not fmax -- is the constraint that actually failed on
hardware. With MISO in an SB_IO register no such delay exists, so in
practice ranking falls through to worst RELATIVE fmax margin. Relative,
because either domain can be the tighter one: `clk` runs at about 1.1-1.3x
its target while the EXI domain has roughly 2x, so ranking on the EXI
number alone would pick a seed that is worse overall.
"""
results = []
best_seed = 1
best_score = 0.0
best_detail = None
best_miso = None
for seed in range(1, n_seeds + 1):
print(f"\n{'='*60}\n Seed {seed}/{n_seeds}\n{'='*60}")
build_dir = f"build/seed{seed}"
build_ok = _build_seed(make_dut, seed, build_dir)
fmax, miso = _parse_timing(build_dir, seed)
scored = _score(fmax, seed, build_ok)
if scored is None:
continue
clk, exi, verdict = scored
results.append((seed, clk, exi, verdict, miso))
print(f" [seed {seed}] clk fmax: {clk:.2f} MHz (target {_CLK_TARGET:g}) "
f"exi fmax: {exi:.2f} MHz (target {_EXI_TARGET}) {verdict}"
f"{'' if build_ok else ' [build reported an error]'}")
if verdict == "PASS" and miso is not None:
if best_miso is None or miso < best_miso:
best_miso, best_seed = miso, seed
best_detail = (clk, exi, miso)
score = min(exi / _EXI_TARGET, clk / _CLK_TARGET)
if score > best_score:
best_score = score
if best_miso is None:
best_seed = seed
best_detail = (clk, exi, miso)
return results, best_seed, best_detail, best_score
def _print_summary(results, n_seeds, best_seed, best_detail, best_score):
"""Print the per-seed table and the winner. Returns the overall verdict.
The pass RATE is reported alongside the best number because a design that
closes on only a minority of seeds has no real margin, however good its
best seed looks.
"""
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}"
f" {'MISO clk→pad':>13} verdict")
for seed, clk, exi, verdict, miso in results:
# No negedge->async path means MISO comes from the SB_IO output
# register and clock-to-pad never enters the fabric. That is the
# GOOD case, not a parse failure.
shown = f"{miso:.2f} ns" if miso is not None else "IOB (none)"
print(f" {seed:>4} {clk:>10.2f} {exi:>16.2f} {shown:>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"({[r[0] for r in results if r[3] == 'PASS']})")
overall = "PASS" if best_score >= 1.0 else "FAIL"
if best_detail:
clk, exi, miso = best_detail
shown = (f", MISO clk→pad {miso:.2f} ns" if miso is not None
else ", MISO driven from the SB_IO output register "
"(no fabric clock-to-pad path)")
print(f"\nBest seed: {best_seed} clk {clk:.2f} MHz, "
f"exi {exi:.2f} MHz{shown} — {overall}")
if miso is not None and miso > 12.0:
print(" ⚠️ MISO clk→pad > 12 ns. The output path has to fit "
"between the edge the bit is launched on and the edge the "
"GameCube samples it on, minus global-buffer insertion, the "
"pad, the connector and the GC's own setup. Reads failed at "
"speed 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):
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.")
if results:
print(f"\nBitstream for best seed: build/seed{best_seed}/top.bin")
return overall
# ───────────────────────────── entry point ────────────────────────────────
def _udp_config():
"""The UART shell's UDP test identity, from the environment.
src_ip/subnet/gateway are the board's own identity for the W5100's IP
stack and must match the LAN; the unicast destination is typed at runtime.
"""
return 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"),
@@ -369,24 +527,44 @@ if __name__ == "__main__":
udp_dst_port = int(os.environ.get("UDP_DST_PORT", "6464")),
)
# PHASE_METER=1 builds a DIAGNOSTIC, NON-FUNCTIONAL bitstream: the bit
# engine transmits the constant 0x80 for every byte instead of the FIFO
# head, taking the register file and both byte FIFOs out of the MISO path.
# Whatever the host reads back is then purely the engine's byte PHASE
# relative to the host's -- 0x80 aligned, 0x40 one bit late, 0x20 two bits
# late. Used to measure the 2-bit MISO offset seen on console 2026-10-03,
# which simulation does not reproduce. Re-flash a normal build after.
_PHASE_METER = os.environ.get("PHASE_METER") == "1"
if _PHASE_METER:
def _make_dut_factory(use_shell):
"""Return a zero-argument factory for the design under build.
PHASE_METER=1 builds a DIAGNOSTIC, NON-FUNCTIONAL bitstream: the bit
engine transmits a constant 0x80 for every byte instead of the TX FIFO
head, taking the register file and both byte FIFOs out of the MISO path.
Whatever the host reads back is then purely the engine's byte PHASE
relative to the host's -- 0x80 aligned, 0x40 one bit late, 0x20 two bits
late. It measures what simulation cannot reproduce. Re-flash a normal
build afterwards.
"""
phase_meter = os.environ.get("PHASE_METER") == "1"
if phase_meter:
print("*** PHASE_METER build: MISO is a constant 0x80 pattern. "
"This bitstream is a measuring instrument, NOT a working BBA. ***")
udp_kw = _udp_config()
def make_dut():
if use_shell:
return BBATopSynth(status_panel=True, uart_shell=True,
phase_meter=_PHASE_METER, **udp_kw)
phase_meter=phase_meter, **udp_kw)
return BBATopSynth(status_panel=True, uart_console=True,
phase_meter=_PHASE_METER)
phase_meter=phase_meter)
return make_dut, udp_kw
def main(argv):
do_flash = "--flash" in argv
# The interactive shell + UDP bring-up test is the default build; --console
# selects the event-log console instead. They share the UART pins.
use_shell = "--console" not in argv
n_seeds = next((int(argv[i + 1]) for i, a in enumerate(argv)
if a == "--seeds"), 1)
make_dut, udp_kw = _make_dut_factory(use_shell)
print(f"Synthesizing BBATop for {IceBreakerPlatform.device}-"
f"{IceBreakerPlatform.package} "
@@ -398,178 +576,9 @@ if __name__ == "__main__":
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
best_miso = 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 = {}
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
# 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))
# 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}")
# 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, 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}"
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.
# 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
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}"
f" {'MISO clk→pad':>13} verdict")
for seed, fc, fexi, verdict, miso in results:
# No negedge->async path means MISO is driven from the SB_IO
# output register, so clock-to-pad never enters the fabric at all --
# that is the GOOD case, not a parse failure.
ms = f"{miso:.2f} ns" if miso is not None else "IOB (none)"
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"({[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 ", MISO driven from the SB_IO output register "
"(no fabric clock-to-pad path)")
print(f"\nBest seed: {best_seed} clk {best_detail[0]:.2f} MHz, "
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):
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")
results, best_seed, best_detail, best_score = _run_sweep(make_dut, n_seeds)
overall = _print_summary(results, n_seeds, best_seed, best_detail,
best_score)
if do_flash and overall == "FAIL":
print("\nREFUSING TO FLASH: no seed met both timing constraints. "
@@ -579,11 +588,15 @@ if __name__ == "__main__":
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.
# Reuse the best seed's own build directory so the bitstream flashed is
# the one that was measured. A fresh platform again: the sweep has
# already consumed one per seed.
IceBreakerPlatform().build(
make_dut(), do_program=True,
verbose=True, nextpnr_opts=opts, build_dir=f"build/seed{best_seed}")
make_dut(), do_program=True, verbose=True,
nextpnr_opts=opts, build_dir=f"build/seed{best_seed}")
print("Done.")
if __name__ == "__main__":
main(sys.argv)