b5961d0731
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>
603 lines
26 KiB
Python
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)
|