diff --git a/exi_bba/synth.py b/exi_bba/synth.py index e1f7250..1b39475 100644 --- a/exi_bba/synth.py +++ b/exi_bba/synth.py @@ -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/`, and the flashable bitstream is +`build/seed/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= bodges for a damaged lab unit, + see hardware/re-bba-rb/BRINGUP.md + ICEPROG= 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= 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= 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,191 +162,362 @@ 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. - exi = platform.request("exi", 0, dir={"miso": "-"}) - w5100 = platform.request("w5100", 0) + # 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. - platform.add_clock_constraint(exi.clk.i, _EXI_HZ) + # 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. - m.submodules.miso_io = Instance( - "SB_IO", - p_PIN_TYPE = 0b100101, - p_PULLUP = 1, - p_NEG_TRIGGER = 0, - p_IO_STANDARD = "SB_LVCMOS", - io_PACKAGE_PIN = exi.miso.io, - i_CLOCK_ENABLE = Const(1, 1), - i_OUTPUT_CLK = exi.clk.i, - i_OUTPUT_ENABLE = ~exi.cs_n.i, - i_D_OUT_0 = self.exi_miso_next, - ) + 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), + 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 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), - ] + 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 + if self._status_panel: + self._drive_status_panel(m, platform) - # 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), - ] + # 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. -# ── 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. + 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. -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) + 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. - # 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( + 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, + p_PULLUP = 1, + p_NEG_TRIGGER = 0, + p_IO_STANDARD = "SB_LVCMOS", + io_PACKAGE_PIN = exi.miso.io, + i_CLOCK_ENABLE = Const(1, 1), + i_OUTPUT_CLK = exi.clk.i, + i_OUTPUT_ENABLE = ~exi.cs_n.i, + i_D_OUT_0 = self.exi_miso_next, + ) + + def _drive_status_panel(self, m, platform): + """Wire the bring-up panel to the board's LEDs. + + 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 + + ledr = platform.request("ledr", 0) + ledg = platform.request("ledg", 0) + rgb = platform.request("rgb", 0, dir="-") # raw pads, no buffer + led = self.panel_led + + 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), + 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)), + ] + + 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, + ) + + +# ──────────────────────────── 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 → ` 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*\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 + + +def _score(fmax, seed, build_ok): + """Turn a parsed report into `(clk, exi, verdict)`, or None if unscorable. + + 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 + + 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*\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/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)