diff --git a/CLAUDE.md b/CLAUDE.md index be82a67..cdf363c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,16 +82,29 @@ The `exi_bba/` module tree is **fully implemented** with simulation testbenches. All modules elaborate without errors and pass their unit tests, including `bba_top` at the real 27 MHz EXI rate (`EXI_HALF=1`). -**Timing closes on EVERY seed (2026-10-03).** Measured with `EXI_J4=1` + -socket-3 UDP, `python -m exi_bba.synth --seeds 8`, 68% LC: +**Timing closes on EVERY seed (2026-10-03).** `python -m exi_bba.synth +--seeds 8`, 68% LC: -| domain | target | result | +| domain / path | target | result | |---|---|---| -| `clk` (sync, HFOSC) | 24 MHz | 26.4–32.2 MHz — **PASS on all 8 seeds** | -| EXI pin domain | 27 MHz | 45.6–64.4 MHz — **PASS on all 8 seeds** | +| `clk` (sync, HFOSC) | 24 MHz | 28.7–32.5 MHz — **PASS on all 8 seeds** | +| EXI pin domain | 27 MHz | 49.6–62.1 MHz — **PASS on all 8 seeds** | +| **MISO clock→pad** | see below | **8.38–10.22 ns** | -Best seed **3**: `clk` 32.18 MHz, EXI 55.86 MHz (worst relative margin 1.34×). -Flash `build/seed/top.bin`. +Best seed **8**: `clk` 30.35 MHz, EXI 49.56 MHz, MISO 8.38 ns. + +⚠️ **fmax is NOT the binding constraint — MISO clock-to-pad is.** 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, the pad, the +connector and the GC's own setup time. A build measuring ~10.5 ns failed on +console at 27 MHz while passing at 16 MHz, with every seed reporting ≥1.29× +fmax margin. `synth.py` therefore reports this delay and **ranks seeds by it**, +not by fmax. Two changes bought the headroom: removing the EXI_J4 pad merge +(MISO no longer fans out to the far J4 pad, and the clock pad regains its +dedicated global-buffer route) and removing the `Mux(cs_n, 1, …)` LUT from the +MISO data path (`tx_shift` has `init=0xFF`, so CS — the domain's async reset — +parks MISO high by itself). This replaces the long-standing "closes on a minority of seeds" problem, which was entirely an artefact of the PLL-fed 54 MHz `capture` domain. Removing that @@ -161,7 +174,7 @@ python -m exi_bba.uart_shell # 8 tests: shell cmds + reply/timeout python -m exi_bba.eeprom_model python -m exi_bba.bba_top # end-to-end EXI integration test (W5100 RX loop) EXI_HALF=1 python -m exi_bba.bba_top # ...at the REAL 27 MHz rate — always do this -python -m exi_bba.exi_j4 # EXI_J4 pad-merge truth table + enumeration + ``` ### UART shell + UDP bring-up test (default flash build) @@ -285,44 +298,24 @@ slip was *structurally invisible* — the same failure of imagination as the Mode 2/3 mix-up, which was invisible to a master that parked MOSI early. When adding SPI tests, vary the edge the transaction starts on. -### EXI on the J4 debug header (`EXI_J4=1`) — bench host without SP1 +### EXI on the J4 debug header — REMOVED 2026-10-03 -`EXI_J4=1` lets a bench host (the Pi) drive EXI through the unpopulated J4 -header instead of the SP1 edge connector. It does NOT repin the EXI resource — -it **merges** a second set of pads onto the existing ones, so SP1 stays live and -the default build is unchanged: +`EXI_J4=1` used to merge a second set of EXI pads (the unpopulated J4 debug +header, FPGA pins 9/10/11/12/6) onto the SP1 pads with an AND, so a bench host +(a Raspberry Pi) could drive EXI without the edge connector. **The whole path +is gone** — `exi_bba/exi_j4.py`, the `exi_j4` resource, the AND merge and +`scripts/pi-exi-wirecheck.py`. Recover it from git history if a bench host is +ever needed again. -| J4 | DBG | FPGA pin | EXI | -|---|---|---|---| -| 1 | — | — | GND | -| 2 | DBG0 | 9 | CLK (in) | -| 3 | DBG1 | 10 | MOSI (in) | -| 4 | DBG2 | 11 | MISO (out) | -| 5 | DBG3 | 12 | CS_N (in) | -| 6 | DBG4 | 6 | INT_N (out) | +It was removed because it was actively costing MISO timing, and the GameCube +itself is now the test host: +- the AND put a **LUT in the clock path**, so the EXI clock pad could not use + its dedicated global-buffer route; and +- MISO fanned out to both pads, and the J4 pad at die (17,0) was the far end + of the **critical output path** — 4.79 ns of routing to a header with + nothing plugged into it. -The three inputs are **AND-merged** with `PULLUP=1` on both pad sets. AND is -exact, not a hack: every EXI input is inactive-HIGH (CPOL=1 → CLK idles high, -CS_N is active-low, MOSI only matters while CS_N is low), so the unplugged side -idles at 1 through its pull-up and passes the other side through — no select -signal needed. Outputs drive both pads. **Drive one side at a time**; a GC on -SP1 plus a host on J4 will fight and the AND yields the mangled product. -Build refuses to run alongside `UART_J4` (also pins 9/10) or an `ETH_D3_PIN` -aimed at a J4 pad. - -**Why merge rather than repin:** 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 per icestorm's `pinloc_db` -`5k-sg48`), so the EXI clock domain keeps its bottom-edge anchor. Repinning -would have perturbed the one placement variable that the pin-co-location -experiment showed was worth ±5 MHz. (That experiment mattered when the -PLL-fed capture domain was the binding constraint; with ~2.4× margin on the -EXI domain today it is no longer a concern either way.) - -⚠️ **The merge changes which net is the clock.** On a plain build the clock is -the pad itself; with `EXI_J4=1` it is the AND of the two pads, a distinct net. -`synth.py` constrains whichever one applies — constraining the pad on a J4 -build misses, and nextpnr then silently falls back to a 12 MHz default and -under-optimises the whole EXI domain. +Dropping it cut MISO clock-to-pad from ~10.2–11.5 ns to ~9.3–9.6 ns. **HARDWARE BRING-UP 2026-09-30: the EXI capture path is PROVEN on silicon.** A Raspberry Pi 4 bit-banging EXI into J4 (EXI_J4 build, seed 8) enumerated the diff --git a/exi_bba/exi_j4.py b/exi_bba/exi_j4.py deleted file mode 100644 index e98d4c9..0000000 --- a/exi_bba/exi_j4.py +++ /dev/null @@ -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.") diff --git a/exi_bba/spi_mode3_slave.py b/exi_bba/spi_mode3_slave.py index 100a5e4..4be0e89 100644 --- a/exi_bba/spi_mode3_slave.py +++ b/exi_bba/spi_mode3_slave.py @@ -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 diff --git a/exi_bba/synth.py b/exi_bba/synth.py index fa31953..8ec98b3 100644 --- a/exi_bba/synth.py +++ b/exi_bba/synth.py @@ -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*\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): diff --git a/scripts/pi-exi-wirecheck.py b/scripts/pi-exi-wirecheck.py deleted file mode 100755 index 61a9a1e..0000000 --- a/scripts/pi-exi-wirecheck.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -"""Verify the Pi <-> re-bba-rb J4 wiring BEFORE attempting a real EXI transfer. - -Requires the FPGA to be running an EXI_J4=1 bitstream, which mirrors EXI onto -the J4 header and enables SB_IO pull-ups on the three EXI *inputs*. - - J4.1 GND <-> Pi pin 25 GND - J4.2 DBG0 <-- Pi pin 23 BCM11 EXI CLK (FPGA input) - J4.3 DBG1 <-- Pi pin 19 BCM10 EXI MOSI (FPGA input) - J4.4 DBG2 --> Pi pin 21 BCM9 EXI MISO (FPGA OUTPUT - never drive) - J4.5 DBG3 <-- Pi pin 24 BCM8 EXI CS_N (FPGA input) - J4.6 DBG4 --> Pi pin 22 BCM25 EXI INT_N (FPGA OUTPUT - never drive) - -Run: sudo python3 exi_wirecheck.py -""" -import sys, time -import RPi.GPIO as GPIO - -CLK, MOSI, MISO, CS_N, INT_N = 11, 10, 9, 8, 25 -HDR = {11: "hdr23", 10: "hdr19", 9: "hdr21", 8: "hdr24", 25: "hdr22"} -DRIVEN_BY_PI = [(CLK, "CLK", "J4.2"), (MOSI, "MOSI", "J4.3"), (CS_N, "CS_N", "J4.5")] -DRIVEN_BY_FPGA= [(MISO, "MISO", "J4.4"), (INT_N, "INT_N", "J4.6")] - -fails, warns = [], [] - -def rd(pin, pull): - GPIO.setup(pin, GPIO.IN, pull_up_down=pull) - time.sleep(0.002) - return GPIO.input(pin) - -GPIO.setmode(GPIO.BCM) -GPIO.setwarnings(False) -for p, _, _ in DRIVEN_BY_PI + DRIVEN_BY_FPGA: - GPIO.setup(p, GPIO.IN, pull_up_down=GPIO.PUD_OFF) - -print("=" * 66) -print("T0 CONTROL: pins known to be unwired MUST follow the internal pull.") -print(" Without this, a broken pull mechanism would silently turn every") -print(" T1 verdict into a false \'CONNECTED\'.") -print("=" * 66) -for pin, hdr in ((26, "hdr37"), (20, "hdr38"), (16, "hdr36"), (21, "hdr40")): - u, d = rd(pin, GPIO.PUD_UP), rd(pin, GPIO.PUD_DOWN) - ok = (u, d) == (1, 0) - print(f" BCM{pin:<2} {hdr}: pull-up={u} pull-down={d} " - f"{'pull works' if ok else '** PULL NOT WORKING - T1/T2 INVALID **'}") - if not ok: - fails.append(f"control pin BCM{pin} did not follow the pull; results below are meaningless") - GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF) - -print() -print("=" * 66) -print("T1 FPGA-driven pins: value must NOT follow the Pi's internal pull.") -print(" An active driver (~ohms) beats the Pi's ~50k pull; a dead wire") -print(" follows it. This test is decisive.") -print("=" * 66) -for pin, name, j4 in DRIVEN_BY_FPGA: - up, dn = rd(pin, GPIO.PUD_UP), rd(pin, GPIO.PUD_DOWN) - if up == dn: - print(f" {name:<5} {j4} BCM{pin:<2} {HDR[pin]}: pull-up={up} pull-down={dn}" - f" -> CONNECTED, FPGA driving {up}") - else: - print(f" {name:<5} {j4} BCM{pin:<2} {HDR[pin]}: pull-up={up} pull-down={dn}" - f" -> ** NOT CONNECTED ** (follows the Pi's pull)") - fails.append(f"{name} ({j4} -> BCM{pin}/{HDR[pin]}) is open") - GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF) - -print() -print("=" * 66) -print("T2 FPGA-input pins: these carry SB_IO PULLUP=1 in an EXI_J4 build, so") -print(" a correct connection reads HIGH regardless of the Pi's pull.") -print(" Follows-the-pull = open. Hard low = shorted. Decisive either way.") -print("=" * 66) -for pin, name, j4 in DRIVEN_BY_PI: - u, d = rd(pin, GPIO.PUD_UP), rd(pin, GPIO.PUD_DOWN) - if (u, d) == (1, 1): - print(f" {name:<5} {j4} BCM{pin:<2} {HDR[pin]}: up=1 down=1 -> held HIGH: FPGA pull-up seen, CONNECTED") - elif (u, d) == (1, 0): - print(f" {name:<5} {j4} BCM{pin:<2} {HDR[pin]}: up=1 down=0 -> follows the Pi pull: ** OPEN **") - fails.append(f"{name} ({j4} -> BCM{pin}/{HDR[pin]}) is open - no connection") - else: - print(f" {name:<5} {j4} BCM{pin:<2} {HDR[pin]}: up={u} down={d} -> hard LOW: ** SHORTED TO GND **") - fails.append(f"{name} ({j4} -> BCM{pin}/{HDR[pin]}) is shorted low - an FPGA input with " - f"PULLUP=1 cannot read a hard 0") - GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF) - -print() -print("=" * 66) -print("T3 Bridge scan on the Pi-driven lines (catches solder bridges).") -print(" The aggressor drives LOW and victims are pulled UP: a strong") -print(" driver beats any pull, so a victim dragged low IS bridged.") -print(" (Driving HIGH against pull-downs does NOT work here -- the FPGA's") -print(" SB_IO pull-up out-pulls the Pi's internal pull-down, which would") -print(" report every pin as bridged. T2 shows this directly: down=1.)") -print("=" * 66) -for pin, name, j4 in DRIVEN_BY_PI: - victims = [(p2, n) for p2, n, _ in DRIVEN_BY_PI if p2 != pin] - for vp, _ in victims: - GPIO.setup(vp, GPIO.IN, pull_up_down=GPIO.PUD_UP) - GPIO.setup(pin, GPIO.OUT) - GPIO.output(pin, 0) # aggressor drives LOW - time.sleep(0.003) - bad = [n for vp, n in victims if GPIO.input(vp) == 0] - GPIO.output(pin, 1) - GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF) - if bad: - print(f" drive {name:<5} LOW -> ** BRIDGED to {', '.join(bad)} **") - fails.append(f"{name} is bridged to {', '.join(bad)}") - else: - print(f" drive {name:<5} LOW -> no bridge to {', '.join(n for _, n in victims)}") - -print() -print("=" * 66) -print("T4 Liveness: assert CS_N, clock slowly, see if MISO ever moves.") -print(" Proves CLK + CS_N reach the FPGA and MISO comes back, without") -print(" depending on a correct transaction.") -print("=" * 66) -for p in (CLK, CS_N, MOSI): - GPIO.setup(p, GPIO.OUT) -GPIO.output(CLK, 1) # Mode 3: CLK idles HIGH -GPIO.output(MOSI, 0) -GPIO.output(CS_N, 1) -GPIO.setup(MISO, GPIO.IN, pull_up_down=GPIO.PUD_OFF) -time.sleep(0.01) -idle = GPIO.input(MISO) -GPIO.output(CS_N, 0) # assert -time.sleep(0.005) -seen = {GPIO.input(MISO)} -for _ in range(32): - GPIO.output(CLK, 0); time.sleep(0.001) - seen.add(GPIO.input(MISO)) - GPIO.output(CLK, 1); time.sleep(0.001) - seen.add(GPIO.input(MISO)) -GPIO.output(CS_N, 1) -GPIO.output(CLK, 1) -print(f" MISO idle (CS_N high) = {idle}; levels seen while clocking = {sorted(seen)}") -if len(seen) > 1: - print(" -> MISO CHANGES while clocking: the FPGA is responding. Chain alive.") -else: - print(" -> MISO never changed. Either the FPGA isn't seeing CLK/CS_N, or") - print(" it has nothing to send yet. Not conclusive on its own; the") - print(" device-ID read is the real test.") - warns.append("MISO static while clocking") - -GPIO.cleanup() -print() -print("=" * 66) -if fails: - print("WIRING FAULTS:") - for f in fails: - print(" -", f) -if warns: - print("ADVISORY:") - for w in warns: - print(" -", w) -if not fails: - print("No hard wiring faults found." if not warns else - "No hard wiring faults; see advisories above.") - print("Next: sudo python3 ~/exi_devid_rpi.py (expect 04 02 02 00)") -sys.exit(1 if fails else 0)