"""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/`, 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 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= 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 → ` 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"), 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)