"""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"), # and this design needs the PLL for the 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. # # 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. The binding constraint is the isolated 54 MHz capture # domain (the SPI Mode-3 bit engine); the 24 MHz sync domain has wide margin. # # TRIED (2026-08 shell build) and REVERTED — prioritising routing toward the # critical capture paths did NOT help: `--router router2 --tmg-ripup # --placer-heap-timingweight 30 --placer-heap-critexp 4` left capture at # 41-49 MHz (best 49.07, still < 54.02) across seeds AND eroded the slow-clock # margin (24.4-25.9 MHz vs the 28-33 that plain --opt-timing gives). The # capture shortfall is congestion + inherent path delay at this LC level, not a # routing-priority tuning problem, so weighting P&R toward it only robs the # sync domain. 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= 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") 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")), Attrs(IO_STANDARD="SB_LVCMOS")), # 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 ───────────────────────────────── class BBATopSynth(BBATop): """BBATop with platform pin connections added in elaborate().""" def elaborate(self, platform): m = super().elaborate(platform) if platform is not None: exi = platform.request("exi", 0) w5100 = platform.request("w5100", 0) 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.miso.o .eq(self.exi_miso), 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")), ) def make_dut(): if use_shell: return BBATopSynth(status_panel=True, uart_shell=True, **udp_kw) return BBATopSynth(status_panel=True, uart_console=True) 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 results = [] # (seed, fmax_clk, fmax_capture, 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' (exi/sync, 24 MHz target) and 'capture_clk' # (54 MHz target, the tighter constraint) — NOT 'exi', which never # matched any real log line (this regex silently reported 0.0 MHz # for every seed until fixed). # 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 = {} 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+'(\w+)':\s*([\d.]+)\s*MHz", line) if m_: fmax[m_.group(1)] = float(m_.group(2)) except OSError as exc: print(f" [seed {seed}] could not read {tf}: {exc}") if "clk" not in fmax or "capture_clk" not in fmax: 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_capture = fmax["clk"], fmax["capture_clk"] verdict = ("PASS" if fmax_capture >= 54.02 and fmax_clk >= 24.0 else "FAIL") results.append((seed, fmax_clk, fmax_capture, verdict)) print(f" [seed {seed}] clk fmax: {fmax_clk:.2f} MHz (target 24) " f"capture_clk fmax: {fmax_capture:.2f} MHz (target 54.02) " f"{verdict}" f"{'' if build_ok else ' [build reported an error]'}") # capture_clk is the binding constraint (tighter target, historically # the one that swings ±20% with seed) — rank seeds by it. if fmax_capture > best_fmax: best_fmax = fmax_capture best_seed = seed # Per-seed summary. capture_clk swings hard with seed on this design, so # 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} {'capture_clk (≥54.02)':>21} verdict") for seed, fc, fcap, verdict in results: print(f" {seed:>4} {fc:>10.2f} {fcap:>21.2f} {verdict}") n_pass = sum(1 for *_, v in results if v == "PASS") if results: print(f"\n passing seeds: {n_pass}/{len(results)} " f"({[s for s, *_, v in results if v == 'PASS']})") overall = "PASS" if best_fmax >= 54.02 else "FAIL" print(f"\nBest seed: {best_seed} capture_clk fmax: {best_fmax:.2f} MHz " f"(target 54.02) — {overall}") if overall == "PASS" and n_pass * 2 < len(results): print(" NOTE: a MINORITY of seeds close timing. The bitstream from " "the best seed is usable, but this design has little margin — " "treat any logic addition as likely to break timing.") # build/ now holds one subdirectory per seed; the flashable bitstream for # the best seed is build/seed/top.bin. There is no top-level build/top.bin. if results: print(f"\nBitstream for best seed: build/seed{best_seed}/top.bin") if do_flash and overall == "FAIL": print("\nREFUSING TO FLASH: no seed met the capture-domain timing " "constraint. The EXI front-end samples a 27 MHz clock and will " "miss bits. 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.")