Cut the MISO output path: drop EXI_J4 and the CS mux; rank seeds by it
Remaining after the bit-phase fix was a SPEED-DEPENDENT failure -- the GC read
correctly at 1/2/4/8/16 MHz and failed only at index 5 (27 MHz), returning
0B 80 = 0x17 late by exactly ONE bit. Opposite signature to the phase slip it
was hiding under, and this one really is propagation delay.
MISO is source-synchronous: driven on the falling edge, sampled by the GC on
the next rising edge, so the whole path must fit a HALF period -- 18.5 ns at
27 MHz -- minus global-buffer insertion, pad, connector and the GC's setup.
The failing build measured ~10.5 ns of that internally. Critical path:
clk-to-q 1.39 tx_shift (3,12)
routing 3.60 tx_shift[7] (3,12) -> (5,5) to the CS mux
logic 1.21 Mux(cs_n, 1, ...) LUT
routing 4.79 miso (5,5) -> (17,0) to the J4 MISO pad
10.99 ns (2.60 logic, 8.39 routing)
Two removals, both of things that were pure cost here:
1. EXI_J4 is GONE entirely (exi_j4.py, the resource, the AND merge, the docs
section, scripts/pi-exi-wirecheck.py) -- recoverable from git history. The
AND put a LUT in the CLOCK path, denying the EXI clock pad its dedicated
global-buffer route, and MISO fanned out to the J4 pad at die (17,0) which
was the far end of the critical output path: 4.79 ns of routing to a header
with nothing plugged into it. 10.2-11.5 ns -> 9.3-9.6 ns.
2. The Mux(cs_n, 1, ...) LUT is gone from the MISO data path. It only parked
MISO high when deselected, which the flop's own reset value does for free:
tx_shift now has init=0xFF and CS is that domain's async reset, so MISO is
tx_shift[7] with nothing in between. Removes 1.21 ns of logic plus the
3.60 ns route feeding it and lets the placer sit the flop by the pad.
synth.py now PARSES AND RANKS BY the MISO clock-to-pad delay. Ranking by fmax
was how a board that failed at 27 MHz passed every seed at >=1.29x margin --
fmax was never the binding constraint for this design.
--seeds 8: clk 28.7-32.5 MHz, EXI 49.6-62.1 MHz, MISO 8.38-10.22 ns, 8/8 PASS.
Best seed 8 (clk 30.35, EXI 49.56, MISO 8.38 ns), flashed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,196 +0,0 @@
|
||||
"""EXI pad merging for the J4 debug-header bring-up path (``EXI_J4=1``).
|
||||
|
||||
``BBATopSynth`` can mirror the EXI interface onto the unpopulated J4 debug
|
||||
header so a bench host (e.g. a Raspberry Pi) can drive EXI without the SP1 edge
|
||||
connector. Rather than REPINNING the EXI resource onto J4 -- which would move
|
||||
where the capture domain anchors on the die -- a second set of pads is MERGED
|
||||
onto the existing ones, leaving SP1 live and the default build untouched.
|
||||
|
||||
The merge itself lives here, in one place, so the synthesis path and the
|
||||
testbench below exercise the same code.
|
||||
|
||||
Run the test: ``python -m exi_bba.exi_j4``
|
||||
"""
|
||||
|
||||
__all__ = ["merge_exi_inputs"]
|
||||
|
||||
|
||||
def merge_exi_inputs(primary, mirror):
|
||||
"""AND-merge two sets of EXI input pads into one.
|
||||
|
||||
`primary` and `mirror` are ``(clk, mosi, cs_n)`` triples; returns the merged
|
||||
triple.
|
||||
|
||||
AND is exact here, not an approximation, because every EXI input is
|
||||
inactive-HIGH: CPOL=1 so CLK idles high, CS_N is active-low, and MOSI only
|
||||
matters while CS_N is low. Whichever pad set is unplugged idles at 1
|
||||
through its ``SB_IO`` pull-up (``PULLUP=1`` is set on both sets in an
|
||||
``EXI_J4`` build) and therefore passes the other set through untouched -- no
|
||||
select signal, no register, no build variant of the core.
|
||||
|
||||
DRIVE ONE SIDE AT A TIME. With a GameCube on SP1 and a host on J4
|
||||
simultaneously the capture engine sees the bitwise product of the two,
|
||||
which is garbage.
|
||||
"""
|
||||
return tuple(a & b for a, b in zip(primary, mirror))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from amaranth import Elaboratable, Module, Signal
|
||||
from amaranth.sim import Simulator, Period
|
||||
from exi_bba.bba_top import BBATop
|
||||
|
||||
HALF = 8 # capture ticks per SPI half-period (well oversampled)
|
||||
|
||||
|
||||
# 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) # ONE capture-equivalent tick; delays are HP * n
|
||||
DEVICE_ID = [0x04, 0x02, 0x02, 0x00]
|
||||
|
||||
class _Harness(Elaboratable):
|
||||
"""BBATop behind the real merge, with two independent EXI pad sets."""
|
||||
|
||||
def __init__(self):
|
||||
self.dut = BBATop(eth="w5100", reset_cycles=20)
|
||||
# Both sets idle HIGH, exactly as the SB_IO pull-ups hold them.
|
||||
self.sp1 = [Signal(init=1, name=f"sp1_{n}") for n in ("clk", "mosi", "cs_n")]
|
||||
self.j4 = [Signal(init=1, name=f"j4_{n}") for n in ("clk", "mosi", "cs_n")]
|
||||
|
||||
def elaborate(self, platform):
|
||||
m = Module()
|
||||
m.submodules.dut = d = self.dut
|
||||
clk, mosi, cs_n = merge_exi_inputs(tuple(self.sp1), tuple(self.j4))
|
||||
m.d.comb += [d.exi_clk.eq(clk), d.exi_mosi.eq(mosi), d.exi_cs_n.eq(cs_n)]
|
||||
return m
|
||||
|
||||
top = _Harness()
|
||||
dut = top.dut
|
||||
errors = []
|
||||
|
||||
def unplug(ctx, pads):
|
||||
"""Model the other connector being unplugged: every pad pulled HIGH.
|
||||
|
||||
Essential, and easy to forget: `spi_byte` leaves `mosi` at the last bit
|
||||
it drove, so a side left at 0 will AND the *other* side's MOSI to zero
|
||||
for the rest of the run. On real hardware the SB_IO pull-up does this
|
||||
for us the moment the connector is empty.
|
||||
"""
|
||||
for s in pads:
|
||||
ctx.set(s, 1)
|
||||
|
||||
async def spi_byte(ctx, pads, mosi_val):
|
||||
"""Drive one EXI Mode-3 byte on `pads`; return the assembled MISO byte."""
|
||||
clk, mosi, cs_n = pads
|
||||
miso = 0
|
||||
for bit in range(7, -1, -1):
|
||||
ctx.set(mosi, (mosi_val >> bit) & 1)
|
||||
ctx.set(clk, 0) # falling: slave samples
|
||||
await ctx.delay(HP * (HALF))
|
||||
miso = (miso << 1) | ctx.get(dut.exi_miso)
|
||||
ctx.set(clk, 1) # rising: slave drives
|
||||
await ctx.delay(HP * (HALF))
|
||||
return miso
|
||||
|
||||
async def exi_read(ctx, pads, addr, length):
|
||||
"""Register-space read, REAL protocol: 4-byte header then data."""
|
||||
clk, mosi, cs_n = pads
|
||||
ctx.set(cs_n, 0)
|
||||
ctx.set(clk, 1)
|
||||
await ctx.delay(HP * (HALF))
|
||||
await spi_byte(ctx, pads, 0x80)
|
||||
await spi_byte(ctx, pads, (addr >> 8) & 0xFF)
|
||||
await spi_byte(ctx, pads, addr & 0xFF)
|
||||
await spi_byte(ctx, pads, 0x00) # turnaround byte
|
||||
result = [await spi_byte(ctx, pads, 0x00) for _ in range(length)]
|
||||
ctx.set(cs_n, 1)
|
||||
await ctx.delay(HP * (HALF))
|
||||
return result
|
||||
|
||||
async def exi_devid(ctx, pads):
|
||||
"""EXI_GetID: write 0x0000 (2 bytes), clock 4 read bytes."""
|
||||
clk, mosi, cs_n = pads
|
||||
ctx.set(cs_n, 0)
|
||||
ctx.set(clk, 1)
|
||||
await ctx.delay(HP * (HALF))
|
||||
await spi_byte(ctx, pads, 0x00)
|
||||
await spi_byte(ctx, pads, 0x00)
|
||||
out = [await spi_byte(ctx, pads, 0x00) for _ in range(4)]
|
||||
ctx.set(cs_n, 1)
|
||||
await ctx.delay(HP * (HALF))
|
||||
return out
|
||||
|
||||
async def testbench(ctx):
|
||||
await ctx.delay(HP * (20))
|
||||
|
||||
# T1: enumerate over the SP1 pads while J4 is unplugged (idle high).
|
||||
unplug(ctx, top.j4)
|
||||
dev = await exi_devid(ctx, top.sp1)
|
||||
print(f"T1 enumerate via SP1 (J4 idle): {[f'0x{b:02X}' for b in dev]}")
|
||||
if dev != DEVICE_ID:
|
||||
errors.append(f"T1 SP1 device ID: got {dev}")
|
||||
await ctx.delay(HP * (HALF * 4))
|
||||
|
||||
# T2: enumerate over the J4 pads while SP1 is unplugged (idle high).
|
||||
unplug(ctx, top.sp1)
|
||||
dev = await exi_devid(ctx, top.j4)
|
||||
print(f"T2 enumerate via J4 (SP1 idle): {[f'0x{b:02X}' for b in dev]}")
|
||||
if dev != DEVICE_ID:
|
||||
errors.append(f"T2 J4 device ID: got {dev}")
|
||||
await ctx.delay(HP * (HALF * 4))
|
||||
|
||||
# T3: back to SP1, to prove using J4 left no sticky state behind.
|
||||
unplug(ctx, top.j4)
|
||||
dev = await exi_devid(ctx, top.sp1)
|
||||
print(f"T3 enumerate via SP1 again: {[f'0x{b:02X}' for b in dev]}")
|
||||
if dev != DEVICE_ID:
|
||||
errors.append(f"T3 SP1 re-enumerate: got {dev}")
|
||||
await ctx.delay(HP * (HALF * 4))
|
||||
|
||||
# T4: a register read through the merge, not just the ID ROM path.
|
||||
unplug(ctx, top.sp1)
|
||||
nways = await exi_read(ctx, top.j4, 0x31, 1)
|
||||
print(f"T4 NWAYS via J4: 0x{nways[0]:02X} (want 0x17)")
|
||||
if nways != [0x17]:
|
||||
errors.append(f"T4 NWAYS via J4: got {nways}")
|
||||
|
||||
# T5: prove the merge really is an AND of both pad sets (and is not
|
||||
# optimised down to one side). Pass-through in EITHER direction is the
|
||||
# DESIRED behaviour -- the driven side wins over the idle-high side.
|
||||
# The last row is the documented hazard: two active drivers collide.
|
||||
unplug(ctx, top.sp1)
|
||||
unplug(ctx, top.j4)
|
||||
for sp1_v, j4_v, want, note in (
|
||||
(1, 1, 1, "both unplugged -> idle"),
|
||||
(1, 0, 0, "J4 drives, SP1 unplugged -> J4 passes through"),
|
||||
(0, 1, 0, "SP1 drives, J4 unplugged -> SP1 passes through"),
|
||||
(0, 0, 0, "both drive low"),
|
||||
):
|
||||
ctx.set(top.sp1[2], sp1_v)
|
||||
ctx.set(top.j4[2], j4_v)
|
||||
await ctx.delay(HP)
|
||||
got = ctx.get(dut.exi_cs_n)
|
||||
ok = "ok" if got == want else "MISMATCH"
|
||||
print(f"T5 cs_n sp1={sp1_v} j4={j4_v} -> core {got} (want {want}) {ok} {note}")
|
||||
if got != want:
|
||||
errors.append(f"T5 merge truth table sp1={sp1_v} j4={j4_v}: got {got}")
|
||||
unplug(ctx, top.sp1)
|
||||
unplug(ctx, top.j4)
|
||||
|
||||
sim = Simulator(top)
|
||||
sim.add_clock(Period(MHz=24), domain="sync")
|
||||
sim.add_testbench(testbench)
|
||||
with sim.write_vcd("exi_j4_merge.vcd"):
|
||||
sim.run()
|
||||
|
||||
if errors:
|
||||
print("\nFAILURES:")
|
||||
for e in errors:
|
||||
print(" -", e)
|
||||
sys.exit(1)
|
||||
print("\nAll EXI_J4 merge tests passed.")
|
||||
@@ -144,13 +144,20 @@ class SPIMode3Slave(Elaboratable):
|
||||
#
|
||||
# Slaving the shift register to rx_bit gives the engine ONE phase
|
||||
# reference, the same edge the host counts, so the two cannot drift.
|
||||
tx_shift = Signal(8)
|
||||
# init=0xFF so CS (the domain's async reset) parks MISO HIGH by
|
||||
# itself. MISO is then tx_shift[7] with NOTHING in between -- no
|
||||
# Mux(cs_n, 1, ...) LUT in the data path. That mux cost 1.21 ns of
|
||||
# logic AND 3.60 ns of routing to reach it, on a source-synchronous
|
||||
# output that has only a HALF period (18.5 ns at 27 MHz) to reach the
|
||||
# GameCube's sampling edge. Removing it also frees the placer to put
|
||||
# this flop next to the MISO pad.
|
||||
tx_shift = Signal(8, init=0xFF)
|
||||
with m.If(rx_bit == 0):
|
||||
m.d.exiclk_n += tx_shift.eq(tx_hold) # byte boundary: load MSB
|
||||
with m.Else():
|
||||
m.d.exiclk_n += tx_shift.eq(Cat(0, tx_shift[:-1]))
|
||||
|
||||
m.d.comb += self.spi_miso.eq(Mux(self.spi_cs_n, 1, tx_shift[7]))
|
||||
m.d.comb += self.spi_miso.eq(tx_shift[7])
|
||||
|
||||
return m
|
||||
|
||||
|
||||
+61
-86
@@ -102,42 +102,11 @@ _UART_TX, _UART_RX = ("9", "10") if os.environ.get("UART_J4") else ("19", "18")
|
||||
# full UART_J4 design. Default = 28 (real board). See BRINGUP.md.
|
||||
_ETH_D3 = os.environ.get("ETH_D3_PIN", "28")
|
||||
|
||||
# EXI bring-up on the J4 debug header (set env EXI_J4=1). Rather than REPINNING
|
||||
# the EXI resource onto J4 -- which moves where the capture domain anchors on the
|
||||
# die -- this MERGES a second set of EXI pads onto the existing ones, so the SP1
|
||||
# edge connector stays live and the normal build is one env var away.
|
||||
#
|
||||
# J4.1 = GND
|
||||
# J4.2 = DBG0 = FPGA pin 9 -> EXI CLK (in)
|
||||
# J4.3 = DBG1 = FPGA pin 10 -> EXI MOSI (in)
|
||||
# J4.4 = DBG2 = FPGA pin 11 -> EXI MISO (out)
|
||||
# J4.5 = DBG3 = FPGA pin 12 -> EXI CS_N (in)
|
||||
# J4.6 = DBG4 = FPGA pin 6 -> EXI INT_N (out)
|
||||
#
|
||||
# The three INPUTS are merged with AND. That is correct precisely because every
|
||||
# EXI input is idle/inactive HIGH: CPOL=1 so CLK idles high, CS_N is active-low,
|
||||
# and MOSI only matters while CS_N is low. Whichever side is unplugged sits at 1
|
||||
# through its SB_IO PULLUP and passes the other side through untouched, so no
|
||||
# select signal is needed. Both pad sets therefore get PULLUP=1 in this build.
|
||||
# DRIVE ONE SIDE AT A TIME -- a GameCube on SP1 and a host on J4 at the same time
|
||||
# will fight, and the AND will hand the capture engine the mangled product.
|
||||
# The two OUTPUTS are simply driven onto both pads (fanout 2, no added logic).
|
||||
#
|
||||
# Timing: all five J4 pads are on the SAME (bottom) die edge as the EXI pads
|
||||
# (x=13..18 vs x=5..9, both y=0), so the capture domain keeps its bottom-edge
|
||||
# anchor -- this does not recreate the cross-die span described in CLAUDE.md.
|
||||
# It still adds 3 LUTs ahead of the input synchronisers and a second MISO load,
|
||||
# and capture has little margin, so ALWAYS sweep seeds on an EXI_J4 build.
|
||||
# COLLIDES WITH: UART_J4 (pins 9/10) and ETH_D3_PIN if pointed at a J4 pad.
|
||||
_EXI_J4 = bool(os.environ.get("EXI_J4"))
|
||||
if _EXI_J4 and os.environ.get("UART_J4"):
|
||||
raise SystemExit("EXI_J4 and UART_J4 both claim J4 pins 9/10 - pick one")
|
||||
if _EXI_J4 and _ETH_D3 in ("9", "10", "11", "12", "6"):
|
||||
raise SystemExit(f"EXI_J4 conflicts with ETH_D3_PIN={_ETH_D3} on the J4 header")
|
||||
# EXI pad attributes. No PULLUP: the GameCube drives all three inputs
|
||||
# through the SP1 edge connector.
|
||||
_EXI_ATTRS = Attrs(IO_STANDARD="SB_LVCMOS")
|
||||
|
||||
|
||||
# With the merge active the unplugged side must idle high rather than float.
|
||||
_EXI_ATTRS = (Attrs(IO_STANDARD="SB_LVCMOS", PULLUP=1) if _EXI_J4
|
||||
else Attrs(IO_STANDARD="SB_LVCMOS"))
|
||||
|
||||
|
||||
class IceBreakerPlatform(LatticeICE40Platform):
|
||||
@@ -160,15 +129,6 @@ class IceBreakerPlatform(LatticeICE40Platform):
|
||||
Subsignal("int_n", Pins("46", dir="o")),
|
||||
_EXI_ATTRS),
|
||||
|
||||
# EXI mirror on the J4 debug header (EXI_J4=1 only) -- see note above.
|
||||
*([Resource("exi_j4", 0,
|
||||
Subsignal("clk", Pins("9", dir="i")),
|
||||
Subsignal("mosi", Pins("10", dir="i")),
|
||||
Subsignal("miso", Pins("11", dir="o")),
|
||||
Subsignal("cs_n", Pins("12", dir="i")),
|
||||
Subsignal("int_n", Pins("6", dir="o")),
|
||||
_EXI_ATTRS)] if _EXI_J4 else []),
|
||||
|
||||
# W5100 indirect parallel bus
|
||||
Resource("w5100", 0,
|
||||
Subsignal("addr", Pins("42 38", dir="o")),
|
||||
@@ -233,46 +193,23 @@ class BBATopSynth(BBATop):
|
||||
exi = platform.request("exi", 0)
|
||||
w5100 = platform.request("w5100", 0)
|
||||
|
||||
# EXI inputs: optionally AND-merged with the J4 header mirror so a
|
||||
# bench host can drive EXI without the SP1 edge connector. AND is
|
||||
# valid because all three inputs are inactive-HIGH and the unused
|
||||
# side is held there by its SB_IO PULLUP -- see the _EXI_J4 note.
|
||||
if _EXI_J4:
|
||||
j4 = platform.request("exi_j4", 0)
|
||||
exi_clk_i = exi.clk.i & j4.clk.i
|
||||
exi_mosi_i = exi.mosi.i & j4.mosi.i
|
||||
exi_cs_n_i = exi.cs_n.i & j4.cs_n.i
|
||||
m.d.comb += [
|
||||
j4.miso.o .eq(self.exi_miso),
|
||||
j4.int_n.o .eq(self.int_n),
|
||||
]
|
||||
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)
|
||||
# Constrain the pad that becomes the clock. self.exi_clk is a
|
||||
# pure alias of it and yosys absorbs the alias, so the surviving
|
||||
# net is the pad's ('exi_0__clk__i_$glb_clk'). Without this
|
||||
# nextpnr defaults the clock to 12 MHz and reports PASS on a build
|
||||
# that would miss bits on a console.
|
||||
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),
|
||||
self.exi_cs_n .eq(exi_cs_n_i),
|
||||
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),
|
||||
|
||||
@@ -419,6 +356,7 @@ if __name__ == "__main__":
|
||||
best_seed = 1
|
||||
best_fmax = 0.0
|
||||
best_detail = None
|
||||
best_miso = None
|
||||
results = [] # (seed, fmax_clk, fmax_exi, verdict), scored only
|
||||
for seed in range(1, n_seeds + 1):
|
||||
print(f"\n{'='*60}")
|
||||
@@ -472,6 +410,7 @@ if __name__ == "__main__":
|
||||
# 49.66 MHz: unparsed seeds silently mixed with real ones. An
|
||||
# unparseable seed must be reported and skipped, never ranked.
|
||||
fmax = {}
|
||||
out_ns = [None] # MISO clock-to-pad, post-route
|
||||
for tf in sorted(set(glob.glob(f"{build_dir}/*.tim"))):
|
||||
try:
|
||||
# errors="replace": a decode hiccup must not abort the parse
|
||||
@@ -482,6 +421,21 @@ if __name__ == "__main__":
|
||||
r"Max frequency for clock\s+'([^']+)':\s*([\d.]+)\s*MHz", line)
|
||||
if m_:
|
||||
fmax[m_.group(1)] = float(m_.group(2))
|
||||
# MISO clock-to-pad. fmax is NOT the binding
|
||||
# constraint for this design: every seed passed both
|
||||
# fmax targets with >=1.29x margin while the BOARD
|
||||
# still failed at 27 MHz, because MISO is a
|
||||
# source-synchronous output -- it leaves on the
|
||||
# falling edge and the GameCube samples it on the
|
||||
# next rising edge, so the whole path must fit a
|
||||
# HALF period (18.5 ns at 27 MHz) minus global-buffer
|
||||
# insertion, pad, connector and the GC's own setup.
|
||||
# Track it explicitly and rank on it.
|
||||
m_ = re.search(
|
||||
r"Max delay\s+negedge\s+\S+\s*->\s*<async>\s*:"
|
||||
r"\s*([\d.]+)\s*ns", line)
|
||||
if m_:
|
||||
out_ns[0] = float(m_.group(1))
|
||||
except OSError as exc:
|
||||
print(f" [seed {seed}] could not read {tf}: {exc}")
|
||||
|
||||
@@ -500,7 +454,7 @@ if __name__ == "__main__":
|
||||
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_exi, verdict))
|
||||
results.append((seed, fmax_clk, fmax_exi, verdict, None))
|
||||
print(f" [seed {seed}] clk fmax: {fmax_clk:.2f} MHz (target 24) "
|
||||
f"exi fmax: {fmax_exi:.2f} MHz (target {_EXI_TARGET}) "
|
||||
f"{verdict}"
|
||||
@@ -510,30 +464,51 @@ if __name__ == "__main__":
|
||||
# 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.
|
||||
# Rank by the SMALLEST MISO clock-to-pad delay among seeds that meet
|
||||
# both fmax targets -- that is the constraint that actually failed on
|
||||
# hardware. Fall back to relative fmax margin only if the delay could
|
||||
# not be parsed.
|
||||
miso = out_ns[0]
|
||||
if verdict == "PASS" and miso is not None:
|
||||
if best_miso is None or miso < best_miso:
|
||||
best_miso = miso
|
||||
best_seed = seed
|
||||
best_detail = (fmax_clk, fmax_exi, miso)
|
||||
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)
|
||||
if best_miso is None:
|
||||
best_seed = seed
|
||||
best_detail = (fmax_clk, fmax_exi, miso)
|
||||
results[-1] = (seed, fmax_clk, fmax_exi, verdict, miso)
|
||||
|
||||
# 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} {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")
|
||||
print(f" {'seed':>4} {'clk (≥24)':>10} {f'exi (≥{_EXI_TARGET})':>16}"
|
||||
f" {'MISO clk→pad':>13} verdict")
|
||||
for seed, fc, fexi, verdict, miso in results:
|
||||
ms = f"{miso:.2f} ns" if miso is not None else " n/a"
|
||||
print(f" {seed:>4} {fc:>10.2f} {fexi:>16.2f} {ms:>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"({[s for s, *_, v in results if v == 'PASS']})")
|
||||
f"({[r[0] for r in results if r[3] == 'PASS']})")
|
||||
|
||||
overall = "PASS" if best_fmax >= 1.0 else "FAIL"
|
||||
if best_detail:
|
||||
ms = (f", MISO clk→pad {best_detail[2]:.2f} ns"
|
||||
if best_detail[2] is not None else "")
|
||||
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}")
|
||||
f"exi {best_detail[1]:.2f} MHz{ms} — {overall}")
|
||||
if best_detail[2] is not None and best_detail[2] > 12.0:
|
||||
print(" ⚠️ MISO clk→pad > 12 ns. At 27 MHz the GameCube samples "
|
||||
"MISO a HALF period (18.5 ns) after the falling edge it is "
|
||||
"driven on, and global-buffer insertion, the pad, the "
|
||||
"connector and the GC's setup all come out of what is left. "
|
||||
"Reads failed at 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):
|
||||
|
||||
Reference in New Issue
Block a user