Files
rebbarb/exi_bba/synth.py
T
Roflin b5961d0731 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>
2026-10-10 19:48:49 +02:00

603 lines
26 KiB
Python

"""Synthesis and seed sweep for `BBATop` on the re-bba-rb board (iCE40UP5K SG48).
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
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
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
# ────────────────────────── 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 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
#
# Five things here are not guessable from the signal names:
#
# * 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 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 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")
# 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")
# 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):
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")),
# 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 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 console/shell, 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])
class BBATopSynth(BBATop):
"""`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
# 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 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)
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,
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 → <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
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"),
udp_gateway = os.environ.get("UDP_GATEWAY", "192.168.1.1"),
udp_dst_ip = os.environ.get("UDP_DST_IP", "192.168.1.100"),
udp_src_port = int(os.environ.get("UDP_SRC_PORT", "40000")),
udp_dst_port = int(os.environ.get("UDP_DST_PORT", "6464")),
)
def _make_dut_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)
return BBATopSynth(status_panel=True, uart_console=True,
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} "
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']}")
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. "
"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"
# 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}")
print("Done.")
if __name__ == "__main__":
main(sys.argv)