Clock the EXI bit engine from the EXI pin: timing closes on all 8 seeds

Replaces the PLL-fed 54 MHz `capture` domain, which oversampled the GC's
27 MHz EXI clock 2x and so put BIT-level timing across a CDC boundary: the
engine's view of the clock lagged the pin by 2 FFSynchronizer stages plus a
register (~55 ns) against a 37 ns bit time, so MISO came out ~2 bits late and
the GC read 0x17 as 05 C0.  No amount of oversampling fixes that -- 54 MHz
gives only 2 ticks per bit and the part cannot close the 81 MHz that 3x needs.

The engine is now clocked by the pin itself (pin 44 = global buffer input net
6, no board change), making MISO clock-to-Q.  ExiCapture creates four domains:
exiclk (posedge, samples MOSI), exiclk_n (negedge, drives MISO), both
async-reset by CS, plus reset-less exififo_w/exififo_r for the byte FIFO sides
-- reset-less because resetting one side of an AsyncFIFO desynchronises its
gray pointers, and posedge because AsyncFIFO has a posedge-only reset CDC
internally and cannot host a negedge side.

Also removes the SPRAM response FIFO.  The DMA read was one byte late because
the round trip did not fit the protocol's turnaround byte; the arbiter already
exposed valid/ready, so the FIFO bought a cycle of latency and nothing else.
The response is handled outside the FSM (the reply lands in RHDR_PAD, not
SPRAM_STREAM) with a one-entry hold for when tx_fifo is momentarily full.

The PLL was left instantiated but unused after the rewrite; removing it frees
the hard macro and pin 35 (which stays unconnected only because the board is
already built that way).  This is what lifted the pass rate:

  domain            target    before        after
  clk (sync)        24 MHz    28.1-30.2     26.4-32.2   8/8
  capture_clk       54 MHz    2 of 8 PASS   (gone)
  EXI pin domain    27 MHz    --            45.6-64.4   8/8

Best seed 3: clk 32.18 MHz, EXI 55.86 MHz.  The binding constraint has FLIPPED
-- `clk` (1.10-1.34x) is now tighter than the EXI domain (1.69-2.39x) -- so
synth.py ranks seeds by worst RELATIVE margin, not by the EXI number.

synth.py could not have scored this design at all: it required the vanished
capture_clk, and its regex '(\w+)' cannot match nextpnr's exi_clk_$glb_clk
(no $ in \w).  add_clock_constraint was also landing on the wrong net -- under
EXI_J4 the clock is the AND of two pads, not either pad -- so nextpnr silently
fell back to a 12 MHz default and under-optimised the whole EXI domain.

Testbenches:
- spi_mode3_slave had lost its testbench entirely in the rewrite.  Restored
  with a strict Mode 3 master (changes MOSI on the falling edge, samples MISO
  in the clock-low phase) and MUTATION-TESTED: flipping either clock edge in
  the RTL makes it fail.  Both of those bugs previously reached silicon
  against a lax master that parked MOSI well before the edge.
- exi_j4 T2/T3 returned [0,4,2,2].  Real bug: the engine latches the FIFO head
  at each byte boundary, so a 2-byte command header leaves only ONE drain
  slot.  DEVID streamed zeros until CS and left two stale bytes -- one too
  many -- so the NEXT enumeration came back a slot late.  DEVID now pushes
  exactly four bytes and stops.  Register-space reads may still over-push:
  their 4-byte header gives three drain slots.
- Dead ports removed from SPIMode3Slave (hdr_bytes, frame_start, cs_active,
  byte_idx) and the mode2 parameter is gone -- there is one correct edge
  convention and it is now enforced by tests.

All 14 module suites pass, plus bba_top at EXI_HALF=1/2/8.

CLAUDE.md: rewrote the clocking, CDC inventory and module tables; marked the
2026-09-30 capture_clk measurements historical; and corrected the line in
"Critical Protocol Notes" that still stated the INVERTED edge convention
("MOSI sampled on falling, MISO driven on rising" is Mode 2) -- the original
implementation followed that doc error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:10:11 +02:00
parent 02239466fb
commit 91c3bf0ece
7 changed files with 611 additions and 408 deletions
+66 -47
View File
@@ -86,9 +86,13 @@ class BBARegisterFile(Elaboratable):
self.spram_req_r_rdy = Signal()
# SPRAM response sync→exi: sync writes these
self.spram_rsp_w_data = Signal(8)
self.spram_rsp_w_en = Signal()
self.spram_rsp_w_rdy = Signal()
# SPRAM response, DIRECT from the arbiter -- no FIFO. That FIFO cost
# one 24 MHz cycle (42 ns), and the round trip only fits inside the
# protocol's turnaround byte without it (333 ns -> 292 ns against a
# 315 ns deadline). Responses must therefore be consumed the cycle
# they are valid, which is why only ONE request may be outstanding.
self.spram_rsp_data = Signal(8)
self.spram_rsp_valid = Signal()
# TX bytes exi→sync: sync reads these
self.tx_bytes_r_data = Signal(8)
@@ -125,14 +129,12 @@ class BBARegisterFile(Elaboratable):
# On the SPRAM path that latency exceeded the protocol's turnaround
# byte and shifted every DMA read by one byte. One slow domain now.
spram_req = SyncFIFO(width=16, depth=4)
spram_rsp = SyncFIFO(width=8, depth=4)
tx_bytes = SyncFIFO(width=8, depth=16)
tx_ctrl = SyncFIFO(width=16, depth=4)
rx_wptr = SyncFIFO(width=8, depth=4)
rx_rptr = SyncFIFO(width=8, depth=4)
m.submodules.spram_req = spram_req
m.submodules.spram_rsp = spram_rsp
m.submodules.tx_bytes = tx_bytes
m.submodules.tx_ctrl = tx_ctrl
m.submodules.rx_wptr = rx_wptr
@@ -144,10 +146,6 @@ class BBARegisterFile(Elaboratable):
spram_req.r_en .eq(self.spram_req_r_en),
self.spram_req_r_rdy .eq(spram_req.r_rdy),
spram_rsp.w_data .eq(self.spram_rsp_w_data),
spram_rsp.w_en .eq(self.spram_rsp_w_en),
self.spram_rsp_w_rdy .eq(spram_rsp.w_rdy),
self.tx_bytes_r_data .eq(tx_bytes.r_data),
tx_bytes.r_en .eq(self.tx_bytes_r_en),
self.tx_bytes_r_rdy .eq(tx_bytes.r_rdy),
@@ -237,6 +235,8 @@ class BBARegisterFile(Elaboratable):
# popped (bounds prefetch and is drained at end)
sp_addr = Signal(16)
outstanding = Signal(4)
sp_hold = Signal(8) # response held when tx_fifo was full
sp_pending = Signal()
SP_LIMIT = 4 # max prefetch depth in flight
# Effective address of the current data byte — a REGISTERED running
@@ -301,7 +301,6 @@ class BBARegisterFile(Elaboratable):
self.tx_data.eq(0),
# Combinational so the FIFO advances in the SAME cycle as the pop —
# a registered r_en would let `pop` re-fire on the same byte.
spram_rsp.r_en.eq(0),
]
# ── Transaction FSM (proactive push/pull over byte FIFOs) ────────
@@ -404,7 +403,8 @@ class BBARegisterFile(Elaboratable):
m.next = "DEVID"
with m.Else():
m.next = "CHDR_2"
with m.Elif(~is_write & (cmd_reg == 0) & id_phase & self.tx_rdy):
with m.Elif(~is_write & (cmd_reg == 0) & id_phase
& self.tx_rdy & (byte_ctr < 4)):
# Device-ID query: keep feeding the ID while byte 1 is
# still on the wire so the tx FIFO stays ahead of the GC.
# NOT one-shot -- the FIFO is only 2 deep, and stopping
@@ -506,8 +506,15 @@ class BBARegisterFile(Elaboratable):
m.next = "HEADER0"
with m.State("DEVID"):
# Stream 04 02 02 00 until CS rises.
with m.If(self.tx_rdy & self.cs_active):
# Push EXACTLY the four ID bytes, then stop.
# The engine latches the tx_fifo head at every byte boundary,
# and a 2-byte command header leaves only ONE drain slot
# before the data phase. Streaming zeros until CS left two
# stale bytes behind, one too many to drain, so the NEXT
# enumeration came back a slot late (04 02 02 00 -> 00 04 02 02).
# Register-space reads may over-push safely: their 4-byte
# header gives three drain slots.
with m.If(self.tx_rdy & self.cs_active & (byte_ctr < 4)):
m.d.comb += [self.tx_data.eq(devid), self.tx_en.eq(1)]
m.d.sync += byte_ctr.eq(byte_ctr + 1)
with m.If(self.rx_rdy):
@@ -521,21 +528,19 @@ class BBARegisterFile(Elaboratable):
# Issue read requests ahead (prefetch, bounded by SP_LIMIT) and
# push responses into tx_fifo; the capture domain pops them as
# the GC clocks. Drain rx dummies as they arrive.
issue = Signal()
pop = Signal()
m.d.comb += issue.eq(self.cs_active & spram_req.w_rdy
& (outstanding < SP_LIMIT))
m.d.comb += pop.eq(spram_rsp.r_rdy & self.tx_rdy)
with m.If(issue):
# With no response FIFO the reply must be taken the cycle it
# is valid, so keep exactly ONE request in flight and only
# issue when tx_fifo has somewhere to put the answer. The GC
# consumes a byte every ~296 ns and the SPRAM round trip is a
# couple of 24 MHz cycles, so serialising costs nothing.
# Only ONE request in flight (no response FIFO), and only
# when tx_fifo can take the answer.
with m.If(self.cs_active & spram_req.w_rdy & self.tx_rdy
& (outstanding == 0) & ~sp_pending):
m.d.comb += spram_req.w_data.eq(sp_addr)
m.d.comb += spram_req.w_en.eq(1)
m.d.sync += sp_addr.eq(sp_addr + 1)
with m.If(pop):
m.d.comb += self.tx_data.eq(spram_rsp.r_data)
m.d.comb += self.tx_en.eq(1)
m.d.comb += spram_rsp.r_en.eq(1)
m.d.sync += outstanding.eq(outstanding + issue - pop)
m.d.sync += [sp_addr.eq(sp_addr + 1),
outstanding.eq(outstanding + 1)]
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1) # drain dummy bytes
@@ -547,14 +552,28 @@ class BBARegisterFile(Elaboratable):
# CS deasserted: drain in-flight SPRAM responses and rx dummies,
# then idle. Leftover prefetch in tx_fifo is flushed by
# ExiCapture on the next CS assertion.
with m.If(spram_rsp.r_rdy):
m.d.comb += spram_rsp.r_en.eq(1)
m.d.sync += outstanding.eq(outstanding - 1)
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If((outstanding == 0) & ~self.rx_rdy & ~spram_rsp.r_rdy):
with m.If((outstanding == 0) & ~sp_pending & ~self.rx_rdy):
m.next = "HEADER0"
# ── SPRAM response: consumed in ANY state ────────────────────────
# The request is issued in RHDR_LO but the reply lands ~2 cycles later,
# while the FSM is still in RHDR_PAD waiting for the turnaround byte.
# With no response FIFO there is nothing to catch it there, so handle
# it outside the FSM: push straight to tx_fifo if it has room, else
# hold it one cycle. SPRAM reads and register reads never overlap, so
# driving tx here cannot collide with the FSM's own pushes.
with m.If(self.spram_rsp_valid & self.tx_rdy & ~sp_pending):
m.d.comb += [self.tx_data.eq(self.spram_rsp_data), self.tx_en.eq(1)]
m.d.sync += outstanding.eq(outstanding - 1)
with m.Elif(self.spram_rsp_valid):
m.d.sync += [sp_hold.eq(self.spram_rsp_data), sp_pending.eq(1),
outstanding.eq(outstanding - 1)]
with m.Elif(sp_pending & self.tx_rdy):
m.d.comb += [self.tx_data.eq(sp_hold), self.tx_en.eq(1)]
m.d.sync += sp_pending.eq(0)
# ── Command-space write, applied the cycle after CHDR_3 ──────────
with m.If(cmd_wr):
m.d.sync += cmd_wr.eq(0)
@@ -679,28 +698,28 @@ if __name__ == "__main__":
spram_mem = {0x100 + i: (0xA0 + i) & 0xFF for i in range(64)}
async def spram_model(ctx):
"""Model the SPRAM (sync side): answer spram_req with mem[addr].
"""Model the SPRAM + arbiter as a request/grant port.
One request at a time, with cleanly-pulsed r_en/w_en so the FIFO pop
and the response push stay in lock-step (no double-response races).
The response FIFO is gone: the arbiter drives spram_rsp_valid directly
for exactly ONE cycle, one cycle after it takes a request. Mirror that
precisely -- a response held high for two cycles makes the register
file push the same byte twice.
"""
state = "POP"
busy = False
held = 0
async for vals in ctx.tick("sync").sample(
reg.spram_req_r_rdy, reg.spram_req_r_data, reg.spram_rsp_w_rdy):
rdy, addr, rsp_rdy = vals[-3:]
reg.spram_req_r_rdy, reg.spram_req_r_data):
rdy, addr = vals[-2:]
ctx.set(reg.spram_req_r_en, 0)
ctx.set(reg.spram_rsp_w_en, 0)
if state == "POP":
if rdy:
held = spram_mem.get(addr, 0)
ctx.set(reg.spram_req_r_en, 1) # consume the request
state = "RESP"
else: # RESP
if rsp_rdy:
ctx.set(reg.spram_rsp_w_data, held)
ctx.set(reg.spram_rsp_w_en, 1) # deliver the response
state = "POP"
ctx.set(reg.spram_rsp_valid, 0)
if busy: # deliver, one cycle after accept
ctx.set(reg.spram_rsp_data, held)
ctx.set(reg.spram_rsp_valid, 1)
busy = False
elif rdy: # accept one request
held = spram_mem.get(addr, 0)
ctx.set(reg.spram_req_r_en, 1)
busy = True
errors = []
+32 -43
View File
@@ -2,11 +2,11 @@
Clock domains
-------------
capture : 54 MHz, from 12 MHz oscillator via SB_PLL40_CORE (REFERENCECLK fed
from a global-buffer input pin; DIVR=0 DIVF=71 DIVQ=4). CORE (not
PAD) so the reference clock is not tied to the dedicated PLL pad — it
enters on a clock-capable GBIN pin, leaving board routing free.
exi/sync : 24 MHz, from the iCE40UP5K internal SB_HFOSC (÷2, CLKHF_DIV=0b01)
sync : 24 MHz, from the iCE40UP5K internal SB_HFOSC (÷2, CLKHF_DIV=0b01).
Everything except the EXI bit engine. No PLL, no crystal.
exiclk : the GC's EXI clock pin itself (~27 MHz), posedge — created inside
exiclk_n : the same pin, negedge ................ `ExiCapture`.
exififo_* the same pin, posedge, reset-less — the byte-FIFO sides.
Submodule instantiation and signal wiring
-----------------------------------------
@@ -125,40 +125,30 @@ class BBATop(Elaboratable):
m = Module()
# ── Clock domain generation ───────────────────────────────────────
# Three domains, two physical sources (1 PLL + 1 internal HFOSC):
# capture @ 54 MHz (PLL) — SPI bit engine only; oversamples the
# 27 MHz EXI clock 2× (robust Mode-3).
# exi @ 24 MHz (HFOSC) — register file / transaction FSM.
# sync @ 24 MHz (HFOSC) — SPRAM, RX/TX engines, ethernet master.
# exi and sync share the HFOSC net (frequency- and phase-matched); the
# AsyncFIFOs between them are still valid CDC and keep the module
# boundaries clean. Only the tiny capture front-end needs the fast
# clock — which is why 27 MHz-EXI / OG performance is reachable on the
# iCE40UP5K even though the register-file logic tops out ~44 MHz.
# ONE generated domain, one physical source:
# sync @ 24 MHz (internal SB_HFOSC ÷2) — register file, SPRAM,
# RX/TX engines, ethernet master. Everything that is not the EXI
# bit engine lives here.
#
# The EXI front-end is NOT clocked from here: `ExiCapture` builds its
# own exiclk/exiclk_n domains directly from the GC's EXI clock pin.
# That is what removed the old 54 MHz PLL-fed `capture` domain, which
# oversampled the EXI clock 2× and put BIT-level timing across a CDC
# boundary (~55 ns of synchroniser latency against a 37 ns bit) — the
# cause of the `0x17` -> `05 C0` MISO shift on hardware. With the
# engine clocked by the pin, MISO is clock-to-Q and the only crossings
# left are byte-level FIFOs with a full byte-time of slack.
#
# Consequence for timing closure: `capture_clk` is GONE as a
# constraint. It was the binding path for this whole design and the
# reason builds only closed on a minority of seeds.
if platform is not None:
# capture @ 54 MHz: icepll -i 12 -o 54 → DIVR=0 DIVF=71 DIVQ=4.
# 54 MHz = 2× the 27 MHz EXI clock — the minimum oversampling that
# cleanly implements SPI Mode 3. The isolated SPI bit engine closes
# ~91 MHz on this device; the byte-FIFO read path brings the
# integrated capture domain to ~62 MHz, so 54 closes with margin.
m.domains += ClockDomain("capture")
m.submodules.pll = Instance(
"SB_PLL40_CORE",
p_FEEDBACK_PATH = "SIMPLE",
p_DIVR = 0,
p_DIVF = 71,
p_DIVQ = 4,
p_FILTER_RANGE = 1,
i_REFERENCECLK = platform.request("clk12").i,
i_RESETB = Const(1, 1),
i_BYPASS = Const(0, 1),
o_PLLOUTGLOBAL = ClockSignal("capture"),
)
# exi & sync @ 24 MHz: one SB_HFOSC (÷2) drives both slow domains.
# The bulky register-file / SPRAM / W5500 logic is routing-bound at
# sync @ 24 MHz from the internal oscillator. No PLL and no
# external crystal are needed any more, so the 12 MHz input (pin
# 35) and the PLL hard macro are both free.
# The register-file / SPRAM / ethernet logic is routing-bound at
# ~33–44 MHz on the UP5K; 24 MHz closes with large margin. The byte
# rate (27 MHz EXI ÷ 8 ≈ 3.4 MHz) leaves ~7 slow cycles per byte.
# rate (27 MHz EXI ÷ 8 ≈ 3.4 MHz) leaves ~7 sync cycles per byte.
m.domains += ClockDomain("sync")
m.submodules.hfosc = Instance(
"SB_HFOSC",
@@ -167,7 +157,7 @@ class BBATop(Elaboratable):
i_CLKHFPU = Const(1, 1),
o_CLKHF = ClockSignal("sync"),
)
# (simulation: test harness provides capture/exi/sync clocks via add_clock)
# (simulation: the test harness drives the EXI pins and `sync` directly)
# ── Submodules ────────────────────────────────────────────────────
cap = ExiCapture() # SPI bit engine (capture) + byte FIFOs
@@ -191,7 +181,7 @@ class BBATop(Elaboratable):
# ── External pin connections ──────────────────────────────────────
m.d.comb += [
# EXI inputs (to capture-domain front-end)
cap.spi_clk .eq(self.exi_clk),
cap.spi_clk .eq(self.exi_clk), # drives the exiclk domains
cap.spi_mosi.eq(self.exi_mosi),
cap.spi_cs_n.eq(self.exi_cs_n),
# EXI outputs
@@ -244,9 +234,8 @@ class BBATop(Elaboratable):
]
# SPRAM response: arb result → reg sync→exi FIFO write side
m.d.comb += [
reg.spram_rsp_w_data.eq(arb.exi_rsp_data),
reg.spram_rsp_w_en .eq(arb.exi_rsp_valid),
# arb does not need w_rdy feedback (spram_rsp FIFO is deeper than latency)
reg.spram_rsp_data .eq(arb.exi_rsp_data),
reg.spram_rsp_valid.eq(arb.exi_rsp_valid),
]
# ── BBARegisterFile ↔ TXFrameDrain (sync domain FIFO sides) ──────
@@ -421,7 +410,7 @@ if __name__ == "__main__":
# `capture` domain to count ticks against. Drive the SPI clock with real
# delays: one half-period = HALF * (1/54 MHz), preserving the old meaning
# of HALF so the rates in the table above still hold.
HP = Period(MHz=54) * HALF
HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n
# Gap between transactions (CS high). cs_active reaches the exi domain
# through a 2-stage FFSynchronizer at 24 MHz, so CS must stay high for
# ~3 exi cycles or the register file never sees the transaction end and
+75 -50
View File
@@ -1,28 +1,45 @@
"""ExiCapture — fast EXI byte-capture front-end (capture domain, 54 MHz).
"""ExiCapture — EXI byte-capture front-end, clocked by the GC's EXI clock.
Wraps the SPIMode3Slave bit engine and bridges it to the slower `exi` domain
(24 MHz) through two AsyncFIFOs:
Creates the pin-driven clock domains (so the byte FIFOs can name them), wraps
the SPIMode3Slave bit engine, and bridges it to the 24 MHz `sync` domain
through two AsyncFIFOs:
capture (54 MHz) exi (24 MHz)
┌────────────────────┐ rx_fifo ───► received bytes (header + data)
│ SPIMode3Slave │ (8-bit, capture→exi)
│ (bit engine) │ tx_fifo ◄─── response bytes to drive on MISO
└────────────────────┘ (8-bit, exi→capture)
EXI clock pin (~27 MHz) sync (24 MHz, HFOSC)
┌─────────────────────┐ rx_fifo ───► received bytes (header + data)
│ SPIMode3Slave │ (8-bit, exififo_w→sync)
│ (bit engine) │ tx_fifo ◄─── response bytes to drive on MISO
└─────────────────────┘ (8-bit, sync→exififo_r)
Why split: the bit engine must oversample a 27 MHz EXI clock 2×, which needs a
54 MHz clock — far faster than the register-file logic can close (~44 MHz).
Only this small, shallow front-end runs fast; everything else stays at 24 MHz.
Domains created here
--------------------
exiclk EXI clock pin, posedge, async-reset by CS -- samples MOSI
exiclk_n EXI clock pin, negedge, async-reset by CS -- drives MISO
exififo_w EXI clock pin, posedge, RESET-LESS -- rx FIFO write side
exififo_r EXI clock pin, posedge, RESET-LESS -- tx FIFO read side
TX response gating
The FIFO domains must NOT see CS: resetting one side of an AsyncFIFO
desynchronises its gray pointers against the other side. They are also both
posedge, because AsyncFIFO instantiates a posedge-only reset CDC internally and
cannot host a negedge side.
Why pin-clocked: oversampling the EXI clock from a faster domain put BIT-level
timing across a CDC boundary (~55 ns of synchroniser latency against a 37 ns
bit), so MISO came out ~2 bits late and the GC read 0x17 as 05 C0. Clocking
from the pin makes MISO clock-to-Q and leaves these byte FIFOs as the only
crossing, each with a full byte-time (~296 ns) of slack.
TX response timing
------------------
Every EXI transaction begins with 2 header bytes (write_flag/addr/len) during
which the GC ignores MISO. The core cannot have produced a response yet (it
hasn't even decoded the header), so the wrapper must NOT pop tx_fifo for those
2 bytes. A per-transaction counter (`txld_cnt`, reset by frame_start) gates the
pop: header bytes drive a don't-care 0xFF; from the first data byte onward the
wrapper pops tx_fifo (one byte per tx_load). `tx_hold` is registered at tx_load
time — before the FIFO advances — so the bit engine latches the correct byte on
the following SPI rising edge (the classic FWFT-advance off-by-one is avoided).
The engine pops tx_fifo once per byte boundary, unconditionally -- there is no
header counter and no slot threshold. The byte popped at the end of byte N is
presented during byte N+1, so pops during the header drain whatever the
previous transaction left in the FIFO (this replaces the old CS-fall flush),
and the response the register file pushes during the protocol's turnaround
byte lands in data slot 0.
⚠️ Command space has only ONE drain slot (2-byte header), so a command-space
responder must push exactly its bytes and stop -- see the DEVID note in
bba_register_file.py.
"""
from amaranth import *
@@ -96,15 +113,15 @@ class ExiCapture(Elaboratable):
# (plain) no reset -- the byte FIFOs. Resetting only ONE side of an
# AsyncFIFO desynchronises its gray pointers and corrupts it,
# so the FIFOs must NOT see CS.
m.domains.exiclk = cd_p = ClockDomain(local=True, async_reset=True)
m.domains.exiclk_n = cd_n = ClockDomain(local=True, async_reset=True,
m.domains.exiclk = cd_p = ClockDomain(async_reset=True)
m.domains.exiclk_n = cd_n = ClockDomain(async_reset=True,
clk_edge="neg")
m.domains.exififo_w = cd_fw = ClockDomain(local=True, reset_less=True)
m.domains.exififo_w = cd_fw = ClockDomain(reset_less=True)
# NOTE: both FIFO domains are POSEDGE. AsyncFIFO instantiates a
# posedge-only reset CDC internally, so a negedge domain cannot host a
# FIFO side. The engine pops in `exififo_r` (posedge) and hands the
# byte to its negedge output stage through a register.
m.domains.exififo_r = cd_fr = ClockDomain(local=True, reset_less=True)
m.domains.exififo_r = cd_fr = ClockDomain(reset_less=True)
m.d.comb += [
cd_p .clk.eq(self.spi_clk), cd_p .rst.eq(self.spi_cs_n),
cd_n .clk.eq(self.spi_clk), cd_n .rst.eq(self.spi_cs_n),
@@ -144,9 +161,9 @@ class ExiCapture(Elaboratable):
]
# ── TX: core pushes responses; the engine pops one per byte-time ──
# The engine suppresses the pop during the protocol's turnaround byte
# and drains stale bytes during the rest of the header, so no
# CS-fall flush or slot-threshold logic is needed here any more.
# Pops are UNCONDITIONAL (one per byte boundary). Header pops drain
# whatever the previous transaction left behind, so no CS-fall flush
# or slot-threshold logic is needed here any more.
m.d.comb += [
tx_fifo.w_data.eq(self.tx_data),
tx_fifo.w_en .eq(self.tx_en),
@@ -181,11 +198,9 @@ if __name__ == "__main__":
# ratio is ~2; use 4 here for a clean, well-oversampled functional check.
HALF = 4
# No capture domain any more: the bit engine is clocked by the EXI
# pin, so drive the SPI clock with real delays instead of ticks.
HP = Period(MHz=54) * HALF
# No capture domain any more: the bit engine is clocked by the EXI pin,
# so drive the SPI clock with real delays instead of counting ticks.
HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n
async def spi_byte(ctx, mosi_val):
"""Clock one SPI Mode 3 byte; return the assembled MISO byte."""
@@ -216,20 +231,24 @@ if __name__ == "__main__":
ctx.set(dut.tx_en, 0)
async def do_txn(ctx, hdr, responses, n_data, rx_seen):
"""One EXI transaction: clock `hdr` bytes, model the clock-idle gap
(drain rx + prefetch `responses` into tx_fifo), then clock `n_data`
data bytes; return the MISO data bytes read."""
"""One EXI transaction, modelling the REAL protocol shape.
The engine pops tx_fifo at every byte boundary, so a response must be
in the FIFO before the LAST header byte ends to land in data slot 0.
That byte is the protocol's turnaround byte (docs/exi_protocol_real.md)
and is exactly when the register file pushes. Pushing after the whole
header -- as this bench used to -- is one slot too late.
"""
ctx.set(dut.spi_cs_n, 0)
ctx.set(dut.spi_clk, 1)
await ctx.delay(HP * (HALF))
for h in hdr:
for h in hdr[:-1]:
await spi_byte(ctx, h)
for _ in range(20): # clock-idle gap
await core_drain_rx(ctx, rx_seen)
await ctx.tick("sync").repeat(1)
for r in responses:
for r in responses: # pushed during the turnaround
await push_tx(ctx, r)
await ctx.delay(HP * (2))
await spi_byte(ctx, hdr[-1])
await core_drain_rx(ctx, rx_seen)
miso = [await spi_byte(ctx, 0x00) for _ in range(n_data)]
ctx.set(dut.spi_cs_n, 1)
await ctx.delay(HP * (HALF))
@@ -242,24 +261,30 @@ if __name__ == "__main__":
rx_seen = []
await ctx.delay(HP * (2))
# Real register-space header: [0x80, ah, al, 0x00] -- the trailing
# byte is the turnaround slot.
HDR = [0x80, 0x00, 0x12, 0x00]
# ── T1: header + 2 data bytes read back ──────────────────────────
miso = await do_txn(ctx, [0x12, 0x34], [0xA5, 0x5A], 2, rx_seen)
print(f"T1 rx={[hex(b) for b in rx_seen[:2]]} MISO={[f'0x{b:02X}' for b in miso]}")
if rx_seen[:2] != [0x12, 0x34]:
errors.append(f"T1 header rx wrong: {rx_seen[:2]}")
miso = await do_txn(ctx, HDR, [0xA5, 0x5A], 2, rx_seen)
print(f"T1 rx={[hex(b) for b in rx_seen[:4]]} MISO={[f'0x{b:02X}' for b in miso]}")
if rx_seen[:4] != HDR:
errors.append(f"T1 header rx wrong: {[hex(b) for b in rx_seen[:4]]}")
if miso != [0xA5, 0x5A]:
errors.append(f"T1 MISO wrong: {[hex(b) for b in miso]}")
# ── T2: prefetch over-push must NOT leak into the next transaction ─
# ── T2: over-push must NOT leak into the next transaction ────────
# Txn A pushes 2 responses but the GC clocks only 1 data byte, leaving
# one stale byte in tx_fifo. Txn B must read its OWN fresh responses,
# proving the CS-fall flush cleared the stale prefetch.
# a stale byte in tx_fifo. Txn B must read its OWN responses. There
# is no CS-fall flush any more: the engine's pops during the header
# drain whatever the previous transaction left behind.
rx_seen.clear()
await do_txn(ctx, [0x12, 0x34], [0xA5, 0x5A], 1, rx_seen) # leaves 0x5A
misoB = await do_txn(ctx, [0x12, 0x34], [0x11, 0x22], 2, rx_seen)
await do_txn(ctx, HDR, [0xA5, 0x5A], 1, rx_seen) # leaves 0x5A
rx_seen.clear()
misoB = await do_txn(ctx, HDR, [0x11, 0x22], 2, rx_seen)
print(f"T2 MISO after over-push: {[f'0x{b:02X}' for b in misoB]} (want 0x11 0x22)")
if misoB != [0x11, 0x22]:
errors.append(f"T2 flush failed — stale byte leaked: {[hex(b) for b in misoB]}")
errors.append(f"T2 stale byte leaked: {[hex(b) for b in misoB]}")
sim = Simulator(dut)
sim.add_clock(Period(MHz=24), domain="sync")
+1 -1
View File
@@ -50,7 +50,7 @@ if __name__ == "__main__":
# pin, so drive the SPI clock with real delays instead of ticks.
HP = Period(MHz=54) * HALF
HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n
DEVICE_ID = [0x04, 0x02, 0x02, 0x00]
class _Harness(Elaboratable):
+137 -17
View File
@@ -53,10 +53,10 @@ class SPIMode3Slave(Elaboratable):
rx_byte : last complete received byte (valid while rx_valid is high)
rx_valid : 1-cycle pulse in `exiclk` when rx_byte holds a new byte
tx_byte : byte to transmit; sampled at each byte boundary
tx_load : 1-cycle pulse in `exiclk_n` asking upstream for the next byte
hdr_bytes : how many leading bytes are header (MISO don't-care). Pops are
suppressed until past them, so the first response byte the register
file pushes lands in data slot 0 rather than being eaten by the header.
tx_load : 1-cycle pulse asking upstream for the next byte, one per byte
boundary. No header-length or slot-threshold logic: see the TX comment
in elaborate() for why unconditional pops are both sufficient and
necessary.
"""
def __init__(self):
@@ -71,29 +71,16 @@ class SPIMode3Slave(Elaboratable):
self.tx_byte = Signal(8)
self.tx_load = Signal()
# Header length for this transaction, in bytes. The real protocol has
# two forms selected by bit 7 of byte 0 (see docs/exi_protocol_real.md):
# register space = 4-byte header, command space = 2-byte.
self.hdr_bytes = Signal(3, init=4)
# Transaction framing, generated in the slow domain by the parent.
self.frame_start = Signal()
self.cs_active = Signal()
def elaborate(self, platform):
m = Module()
# ── RX: sample MOSI on the RISING edge (Mode 3 samples on edge 2) ──
rx_shift = Signal(8)
rx_bit = Signal(3)
byte_idx = Signal(3, init=0) # bytes seen this transaction (saturating)
nxt = Cat(self.spi_mosi, rx_shift[:-1]) # MSB-first: shift left, new bit at LSB
m.d.exiclk += rx_shift.eq(nxt)
m.d.exiclk += rx_bit.eq(rx_bit + 1)
with m.If(rx_bit == 7):
with m.If(byte_idx != 7):
m.d.exiclk += byte_idx.eq(byte_idx + 1)
# rx_byte/rx_valid are COMBINATIONAL, not registered. The clock stops
# and CS rises immediately after a transaction's final bit, so a
@@ -138,3 +125,136 @@ class SPIMode3Slave(Elaboratable):
m.d.comb += self.spi_miso.eq(Mux(self.spi_cs_n, 1, tx_shift[7]))
return m
# ── Testbench ─────────────────────────────────────────────────────────────
class _Harness(Elaboratable):
"""Wraps the slave with the two pin-driven domains its parent normally
creates, so the engine can be exercised standalone."""
def __init__(self):
self.dut = SPIMode3Slave()
def elaborate(self, platform):
m = Module()
m.domains.exiclk = cd_p = ClockDomain(async_reset=True)
m.domains.exiclk_n = cd_n = ClockDomain(async_reset=True,
clk_edge="neg")
d = self.dut
m.d.comb += [
cd_p.clk.eq(d.spi_clk), cd_p.rst.eq(d.spi_cs_n),
cd_n.clk.eq(d.spi_clk), cd_n.rst.eq(d.spi_cs_n),
]
m.submodules.dut = d
return m
if __name__ == "__main__":
import sys
from amaranth.sim import Simulator, Period
HP = Period(MHz=27) / 2 # one EXI half-period at the hardware rate
top = _Harness()
dut = top.dut
failures = []
def check(name, got, want):
ok = got == want
print(f" {'ok ' if ok else 'FAIL'} {name}: got {got}, want {want}")
if not ok:
failures.append(name)
# A STRICT Mode 3 master. Unlike a convenience model that parks MOSI long
# before the edge, this one changes MOSI exactly ON the falling edge and
# samples MISO in the CLK-low phase. That is what makes the edge
# convention observable: a slave that sampled MOSI on the falling edge
# would capture the previous bit, and one that drove MISO on the rising
# edge would come out a bit late. Both bugs shipped to hardware against a
# lax master that could not see them.
async def spi_byte(ctx, mosi_val, probe_rx=False):
miso = 0
rx = None
for bit in range(7, -1, -1):
ctx.set(dut.spi_clk, 0) # falling edge
ctx.set(dut.spi_mosi, (mosi_val >> bit) & 1)
await ctx.delay(HP)
miso = (miso << 1) | ctx.get(dut.spi_miso) # sample in CLK-low
if probe_rx and bit == 0:
rx = (ctx.get(dut.rx_valid), ctx.get(dut.rx_byte))
ctx.set(dut.spi_clk, 1) # rising edge: slave samples
await ctx.delay(HP)
return (miso, rx) if probe_rx else miso
async def cs_low(ctx):
ctx.set(dut.spi_clk, 1)
ctx.set(dut.spi_cs_n, 0)
await ctx.delay(HP)
async def cs_high(ctx):
ctx.set(dut.spi_clk, 1)
await ctx.delay(HP)
ctx.set(dut.spi_cs_n, 1)
await ctx.delay(HP * 4)
async def tb(ctx):
ctx.set(dut.spi_cs_n, 1)
ctx.set(dut.spi_clk, 1)
ctx.set(dut.tx_byte, 0)
await ctx.delay(HP * 4)
# T1 — RX samples MOSI on the RISING edge, MSB first.
# rx_byte/rx_valid are combinational and peak during the final bit,
# which is the instant the parent's FIFO captures them.
await cs_low(ctx)
seen = []
for b in (0x80, 0x00, 0x31, 0x00):
_, (valid, byte) = await spi_byte(ctx, b, probe_rx=True)
seen.append((valid, byte))
await cs_high(ctx)
check("T1 rx bytes", [b for _, b in seen], [0x80, 0x00, 0x31, 0x00])
check("T1 rx_valid", [v for v, _ in seen], [1, 1, 1, 1])
# T2 — TX: the byte latched at the end of slot N appears in slot N+1,
# MSB first. Slot 0 is the reset value, before any load.
await cs_low(ctx)
ctx.set(dut.tx_byte, 0xA5)
s0 = await spi_byte(ctx, 0x00)
ctx.set(dut.tx_byte, 0x3C)
s1 = await spi_byte(ctx, 0x00)
ctx.set(dut.tx_byte, 0x00)
s2 = await spi_byte(ctx, 0x00)
await cs_high(ctx)
check("T2 miso slot0 (pre-load)", hex(s0), hex(0x00))
check("T2 miso slot1", hex(s1), hex(0xA5))
check("T2 miso slot2", hex(s2), hex(0x3C))
# T3 — tx_load pulses exactly once per byte, during the final bit.
await cs_low(ctx)
loads = 0
for _ in range(3):
for bit in range(8):
ctx.set(dut.spi_clk, 0)
await ctx.delay(HP)
loads += ctx.get(dut.tx_load)
ctx.set(dut.spi_clk, 1)
await ctx.delay(HP)
await cs_high(ctx)
check("T3 tx_load pulses", loads, 3)
# T4 — MISO releases HIGH when CS is deasserted, so it never fights
# another EXI device sharing the bus.
check("T4 miso idles high", ctx.get(dut.spi_miso), 1)
sim = Simulator(top)
sim.add_testbench(tb)
with sim.write_vcd("SPIMode3Slave.vcd"):
sim.run()
if failures:
print("\nFAILURES:")
for f in failures:
print(" -", f)
sys.exit(1)
print("\nAll tests passed.")
+85 -38
View File
@@ -36,7 +36,7 @@ from exi_bba.bba_top import BBATop
# 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
# that design needed the PLL for its 54 MHz capture-domain clock. Pin 35
# cannot be used as regular I/O at all while the PLL is instantiated,
# regardless of what drives it — this is a fixed silicon constraint, not a
# routing choice. Pin 13 was picked because it's the ONLY spare GPIO on this
@@ -48,6 +48,10 @@ from exi_bba.bba_top import BBATop
# pin 13, and pin 35 is left unconnected (net
# "unconnected-(U9A-IOT_46b_G0-Pad35)") so the PLL hard macro can claim it.
# Verified against the schematic netlist 2026-07-31 — do not "restore" pin 35.
# NOTE 2026-10-03: the PLL is GONE (the bit engine is clocked by the EXI pin),
# so the silicon conflict no longer applies and pin 35 is electrically usable
# again. It stays unconnected anyway because the BOARD is already built with
# ETH_INT on pin 13 — this is now a hardware fact, not a tool constraint.
#
# Debug (J4 header, unpopulated): DBG0=9 DBG1=10 DBG2=11 DBG3=12 DBG4=6
# UART (FT2232H channel B): net UART_TXD is the FTDI's OUTPUT, so it is the
@@ -64,17 +68,18 @@ from exi_bba.bba_top import BBATop
# 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.
# nextpnr P&R options. Since the PLL-fed 54 MHz capture domain was removed
# (the bit engine is now clocked by the EXI pin itself), the EXI domain has
# ~2.4x margin against its 27 MHz target and the 24 MHz `sync` domain is the
# TIGHTER of the two. The sweep therefore ranks seeds by worst RELATIVE
# margin, not by the EXI number.
#
# TRIED (2026-08 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.
# TRIED (2026-08, against the old capture domain) and REVERTED — prioritising
# routing toward the critical paths did NOT help: `--router router2
# --tmg-ripup --placer-heap-timingweight 30 --placer-heap-critexp 4` left
# capture at 41-49 MHz across seeds AND eroded the slow-clock margin
# (24.4-25.9 MHz vs the 28-33 that plain --opt-timing gives). Plain
# --opt-timing is the better baseline.
_PNR_TIMING_OPTS = "--opt-timing"
# UART pin override for bring-up. The re-bba-rb V1 lab unit had its UART_RXD
@@ -212,6 +217,12 @@ class IceBreakerPlatform(LatticeICE40Platform):
# ── BBATop with platform resource wiring ─────────────────────────────────
# The GameCube's EXI clock ceiling. libogc's EXI_SPEED32MHZ is a nominal
# name; the bus actually tops out here, and the bit engine is clocked by it.
_EXI_HZ = 27e6
_EXI_TARGET = 27.0 # MHz, as the sweep scores it
class BBATopSynth(BBATop):
"""BBATop with platform pin connections added in elaborate()."""
@@ -238,6 +249,26 @@ class BBATopSynth(BBATop):
else:
exi_clk_i, exi_mosi_i, exi_cs_n_i = exi.clk.i, exi.mosi.i, exi.cs_n.i
# The EXI clock pin now clocks the bit engine directly (see
# BBATop's clock-domain comment), so it is a REAL clock net and
# must be constrained. Without this nextpnr defaults it to
# 12 MHz and happily reports PASS on a build that would miss bits
# on a console. The GC tops out at ~27 MHz ("EXI_SPEED32MHZ" is a
# nominal name, not the actual rate).
# Constrain the net that actually BECOMES the clock, which
# differs between the two build variants:
# plain -- self.exi_clk is a pure alias of the pad and yosys
# absorbs it, leaving the pad net (nextpnr calls it
# 'exi_0__clk__i_$glb_clk').
# EXI_J4 -- the clock is the AND of the two pads, a distinct net
# ('exi_clk_$glb_clk'); constraining either pad then
# misses, and nextpnr silently falls back to its 12 MHz
# default and under-optimises the whole EXI domain.
if _EXI_J4:
platform.add_clock_constraint(self.exi_clk, _EXI_HZ)
else:
platform.add_clock_constraint(exi.clk.i, _EXI_HZ)
m.d.comb += [
self.exi_clk .eq(exi_clk_i),
self.exi_mosi .eq(exi_mosi_i),
@@ -373,7 +404,8 @@ if __name__ == "__main__":
best_seed = 1
best_fmax = 0.0
results = [] # (seed, fmax_clk, fmax_capture, verdict), scored only
best_detail = None
results = [] # (seed, fmax_clk, fmax_exi, verdict), scored only
for seed in range(1, n_seeds + 1):
print(f"\n{'='*60}")
print(f" Seed {seed}/{n_seeds}")
@@ -412,10 +444,10 @@ if __name__ == "__main__":
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).
# names are 'clk' (sync, 24 MHz target) and the EXI pin's promoted
# global-clock net (27 MHz target), whose name nextpnr derives from
# the net and which CONTAINS a '$' — so the capture group must not be
# \w+ (that silently matched nothing and left seeds unscored).
# nextpnr reports each domain TWICE: once post-placement (an estimate)
# and once post-routing. Only the post-route number is real, so take
# the LAST occurrence of each domain — never the first.
@@ -433,49 +465,63 @@ if __name__ == "__main__":
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)
r"Max frequency for clock\s+'([^']+)':\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:
# The EXI domain is clocked by the GC's clock PIN, so nextpnr names it
# after the net it was promoted from (e.g. "exi_0__clk__i_$glb_clk")
# rather than a domain name we chose. Match on substring, and require
# EXACTLY ONE match so a renamed or duplicated net can never be scored
# silently against the wrong clock.
exi_keys = [k for k in fmax if "exi" in k]
if "clk" not in fmax or len(exi_keys) != 1:
print(f" [seed {seed}] NO USABLE TIMING REPORT "
f"(found {sorted(fmax) or 'nothing'}) — NOT SCORED"
f"{'' if build_ok else '; build also reported an error'}")
continue
fmax_clk, fmax_capture = fmax["clk"], fmax["capture_clk"]
verdict = ("PASS" if fmax_capture >= 54.02 and fmax_clk >= 24.0
fmax_clk, fmax_exi = fmax["clk"], fmax[exi_keys[0]]
verdict = ("PASS" if fmax_exi >= _EXI_TARGET and fmax_clk >= 24.0
else "FAIL")
results.append((seed, fmax_clk, fmax_capture, verdict))
results.append((seed, fmax_clk, fmax_exi, 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"exi fmax: {fmax_exi:.2f} MHz (target {_EXI_TARGET}) "
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
# Rank by whichever domain has the least RELATIVE margin. The old
# script ranked by capture_clk because it was always the binding one;
# with the PLL gone the EXI domain has ~2.3x margin and `clk` can
# easily be the tighter of the two, so ranking by EXI alone would pick
# a seed that is worse overall.
score = min(fmax_exi / _EXI_TARGET, fmax_clk / 24.0)
if score > best_fmax:
best_fmax = score
best_seed = seed
best_detail = (fmax_clk, fmax_exi)
# 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.
# Per-seed summary. The pass RATE matters as much as the best number — a
# design that only closes on a minority of seeds has no real margin.
print(f"\n{'='*60}")
print(f" Seed sweep summary ({len(results)}/{n_seeds} seeds scored)")
print(f"{'='*60}")
print(f" {'seed':>4} {'clk (≥24)':>10} {'capture_clk (≥54.02)':>21} verdict")
for seed, fc, fcap, verdict in results:
print(f" {seed:>4} {fc:>10.2f} {fcap:>21.2f} {verdict}")
print(f" {'seed':>4} {'clk (≥24)':>10} {f'exi (≥{_EXI_TARGET})':>16} verdict")
for seed, fc, fexi, verdict in results:
print(f" {seed:>4} {fc:>10.2f} {fexi:>16.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}")
overall = "PASS" if best_fmax >= 1.0 else "FAIL"
if best_detail:
print(f"\nBest seed: {best_seed} clk {best_detail[0]:.2f} MHz, "
f"exi {best_detail[1]:.2f} MHz "
f"(worst margin {best_fmax:.2f}x) — {overall}")
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 — "
@@ -487,9 +533,10 @@ if __name__ == "__main__":
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.")
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"