Files
rebbarb/exi_bba/synth.py
T
Roflin 2f081a3e69 Drive MISO from an SB_IO output register
Removing ~2 ns from the fabric MISO path (EXI_J4 merge + the CS mux) did NOT
move the hardware result: the GC still returned a byte-identical 0B 80 at
27 MHz while reading correctly at 16 MHz and below.  Identical rather than
intermittent means the path is well outside the window, not marginally inside
it, so trimming fabric delay further was not going to get there.

MISO is source-synchronous -- driven on the falling edge, sampled by the GC on
the next rising edge -- so the whole path must fit a HALF period, 18.5 ns at
27 MHz, minus global-buffer insertion, pad, connector and the GC's setup.
Driving from fabric spent 8.4-11.5 ns of that on routing alone.

An SB_IO output register sits in the IO tile: clock-to-pad is the IO flop's
own delay with no fabric routing.  nextpnr confirms the path left the fabric
entirely -- miso_io.D_OUT_0 packs at die (9,0), the pad itself, SB_IO count is
unchanged at 24, and no negedge -> <async> path is reported any more.  What
replaces it is a setup path INTO the IOB register (10.07 ns against a
half-period), which is internal and has nothing stacked on top of it.

PIN_TYPE was decoded from yosys's own SB_IO model rather than from memory,
because wrong bits there produce a silently dead or mistimed output:
  [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=1 moves the IO registers to the FALLING edge.

To keep simulation and silicon agreeing about MISO, the slave exposes
miso_next -- the value the shift register WILL hold after the falling edge --
and spi_mode3_slave T6 proves it predicts spi_miso bit for bit.  Without that
the IOB register would be an untested sim/hardware divergence, which is the
exact class of bug that has cost this design the most time.

BEHAVIOUR CHANGE: MISO is now TRI-STATED when CS is high, held high by the
pad's PULLUP, instead of actively driven high.  EXI MISO is a SHARED bus --
SP1 is on channel 0 alongside memory-card slot A -- so driving it while
deselected was wrong and could fight a card.

--seeds 8: clk 28.5-30.7 MHz, EXI 41.6-49.7 MHz, 8/8 PASS.  The EXI numbers
fall (62 -> 49) because the posedge->negedge setup path into the IOB register
is now charged to that domain at a half-period constraint; 41.6 MHz worst case
is still 1.5x the 27 MHz target.  Best seed 3 (clk 30.68, EXI 49.14), flashed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:26:08 +02:00

588 lines
31 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 pad attributes. No PULLUP: the GameCube drives all three inputs
# through the SP1 edge connector.
_EXI_ATTRS = 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),
# 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:
# 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)
# 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)
# ── 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=1 moves the IO registers to the FALLING edge.
#
# 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 = 1,
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,
)
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),
]
# ── 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")),
)
# 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:
print("*** PHASE_METER build: MISO is a constant 0x80 pattern. "
"This bitstream is a measuring instrument, NOT a working BBA. ***")
def make_dut():
if use_shell:
return BBATopSynth(status_panel=True, uart_shell=True,
phase_meter=_PHASE_METER, **udp_kw)
return BBATopSynth(status_panel=True, uart_console=True,
phase_meter=_PHASE_METER)
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
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")
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.")