From 2f081a3e694739ec232d5e82db0fd3090f8eb06a Mon Sep 17 00:00:00 2001 From: Dennis Brentjes Date: Sat, 3 Oct 2026 19:26:08 +0200 Subject: [PATCH] Drive MISO from an SB_IO output register Removing ~2 ns from the fabric MISO path (EXI_J4 merge + the CS mux) did NOT move the hardware result: the GC still returned a byte-identical 0B 80 at 27 MHz while reading correctly at 16 MHz and below. Identical rather than intermittent means the path is well outside the window, not marginally inside it, so trimming fabric delay further was not going to get there. 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. Driving from fabric spent 8.4-11.5 ns of that on routing alone. An SB_IO output register sits in the IO tile: clock-to-pad is the IO flop's own delay with no fabric routing. nextpnr confirms the path left the fabric entirely -- miso_io.D_OUT_0 packs at die (9,0), the pad itself, SB_IO count is unchanged at 24, and no negedge -> path is reported any more. What replaces it is a setup path INTO the IOB register (10.07 ns against a half-period), which is internal and has nothing stacked on top of it. PIN_TYPE was decoded from yosys's own SB_IO model rather than from memory, because wrong bits there produce a silently dead or mistimed output: [5:4]=10 tri-state, combinational OUTPUT_ENABLE [3]=0,[2]=1 dout = dout_q_0, single-rate REGISTERED output [1:0]=01 simple input (unused) NEG_TRIGGER=1 moves the IO registers to the FALLING edge. To keep simulation and silicon agreeing about MISO, the slave exposes miso_next -- the value the shift register WILL hold after the falling edge -- and spi_mode3_slave T6 proves it predicts spi_miso bit for bit. Without that the IOB register would be an untested sim/hardware divergence, which is the exact class of bug that has cost this design the most time. BEHAVIOUR CHANGE: MISO is now TRI-STATED when CS is high, held high by the pad's PULLUP, instead of actively driven high. EXI MISO is a SHARED bus -- SP1 is on channel 0 alongside memory-card slot A -- so driving it while deselected was wrong and could fight a card. --seeds 8: clk 28.5-30.7 MHz, EXI 41.6-49.7 MHz, 8/8 PASS. The EXI numbers fall (62 -> 49) because the posedge->negedge setup path into the IOB register is now charged to that domain at a half-period constraint; 41.6 MHz worst case is still 1.5x the 27 MHz target. Best seed 3 (clk 30.68, EXI 49.14), flashed. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 44 ++++++++++++++++++------------ exi_bba/bba_top.py | 2 ++ exi_bba/exi_capture.py | 2 ++ exi_bba/spi_mode3_slave.py | 31 +++++++++++++++++++++ exi_bba/synth.py | 56 +++++++++++++++++++++++++++++++++++--- 5 files changed, 113 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cdf363c..4a52fca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,28 +83,36 @@ 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).** `python -m exi_bba.synth ---seeds 8`, 68% LC: +--seeds 8`: -| domain / path | target | result | +| domain | target | result | |---|---|---| -| `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** | +| `clk` (sync, HFOSC) | 24 MHz | 28.5–30.7 MHz — **PASS on all 8 seeds** | +| EXI pin domain | 27 MHz | 41.6–49.7 MHz — **PASS on all 8 seeds** | -Best seed **8**: `clk` 30.35 MHz, EXI 49.56 MHz, MISO 8.38 ns. +Best seed **3**: `clk` 30.68 MHz, EXI 49.14 MHz. Flash `build/seed/top.bin`. -⚠️ **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). +⚠️ **fmax is NOT the only constraint — MISO clock-to-pad matters more.** MISO +is source-synchronous: it leaves on the FALLING edge and the GameCube samples +it on the next RISING edge, so the path must fit a **half period (18.5 ns at +27 MHz)** minus global-buffer insertion, pad, connector and the GC's own +setup. A build measuring ~10.5 ns of that *internally* read every byte one bit +late at 27 MHz while reading correctly at 16 MHz — with every seed reporting +≥1.29× fmax margin. `synth.py` therefore parses this delay and ranks seeds by +it when it exists. + +**MISO is now driven from an `SB_IO` output register** (`synth.py`), so +clock-to-pad is the IO tile's own delay and never enters the fabric — the +sweep prints `IOB (none)` because no `negedge → ` path exists any more. +Getting there also meant removing the EXI_J4 pad merge and the +`Mux(cs_n, 1, …)` LUT from the data path (see those notes). The EXI fmax +numbers dropped (62 → 49 MHz) because the posedge→negedge setup path into the +IOB register is now counted against that domain at a half-period constraint; +41.6 MHz worst case is still 1.5× over the 27 MHz target. + +⚠️ **MISO is TRI-STATED when CS is high** (held high by the pad's PULLUP), +not actively driven. EXI MISO is a SHARED bus — SP1 is on channel 0 with +memory-card slot A — so driving it while deselected was wrong. 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 diff --git a/exi_bba/bba_top.py b/exi_bba/bba_top.py index 73a29d5..d5165ed 100644 --- a/exi_bba/bba_top.py +++ b/exi_bba/bba_top.py @@ -86,6 +86,7 @@ class BBATop(Elaboratable): dst_port=udp_dst_port) # EXI (GC side) + self.exi_miso_next = Signal(init=1) # for an SB_IO output register self.exi_clk = Signal(init=1) self.exi_mosi = Signal() self.exi_cs_n = Signal(init=1) @@ -188,6 +189,7 @@ class BBATop(Elaboratable): cap.spi_cs_n.eq(self.exi_cs_n), # EXI outputs self.exi_miso.eq(cap.spi_miso), + self.exi_miso_next.eq(cap.miso_next), self.int_n .eq(reg.exi_int_n), ] diff --git a/exi_bba/exi_capture.py b/exi_bba/exi_capture.py index 5a9f978..c8aeeba 100644 --- a/exi_bba/exi_capture.py +++ b/exi_bba/exi_capture.py @@ -82,6 +82,7 @@ class ExiCapture(Elaboratable): self.spi_mosi = Signal() self.spi_cs_n = Signal(init=1) self.spi_miso = Signal() + self.miso_next = Signal(init=1) # for an SB_IO output register # Core-facing RX read side self.rx_data = Signal(8) @@ -150,6 +151,7 @@ class ExiCapture(Elaboratable): spi.spi_mosi.eq(self.spi_mosi), spi.spi_cs_n.eq(self.spi_cs_n), self.spi_miso.eq(spi.spi_miso), + self.miso_next.eq(spi.miso_next), ] # ── RX: every received byte → rx_fifo ──────────────────────────── diff --git a/exi_bba/spi_mode3_slave.py b/exi_bba/spi_mode3_slave.py index 4be0e89..e10d53b 100644 --- a/exi_bba/spi_mode3_slave.py +++ b/exi_bba/spi_mode3_slave.py @@ -73,6 +73,12 @@ class SPIMode3Slave(Elaboratable): self.spi_cs_n = Signal(init=1) # active LOW self.spi_miso = Signal(init=1) + # The SAME bit as spi_miso, but combinational from the PRE-edge state, + # so it can feed an SB_IO output register clocked on the falling edge. + # Driving MISO from a flop inside the IO block removes the fabric + # routing between the shift register and the pad, which is what the + # 27 MHz GameCube read fails on -- see the note in synth.py. + self.miso_next = Signal(init=1) self.rx_byte = Signal(8) self.rx_valid = Signal() @@ -158,6 +164,9 @@ class SPIMode3Slave(Elaboratable): m.d.exiclk_n += tx_shift.eq(Cat(0, tx_shift[:-1])) m.d.comb += self.spi_miso.eq(tx_shift[7]) + # What tx_shift[7] WILL be after the next falling edge. Mirrors the + # load/shift choice above exactly; T6 proves the two agree. + m.d.comb += self.miso_next.eq(Mux(rx_bit == 0, tx_hold[7], tx_shift[6])) return m @@ -306,6 +315,28 @@ if __name__ == "__main__": check("T5 rising-edge start, slot1", hex(s1), hex(0xA5)) check("T5 rising-edge start, slot2", hex(s2), hex(0x3C)) + # T6 — miso_next must equal what spi_miso BECOMES on the next falling + # edge. The synthesised build drives the pad from an SB_IO register + # clocked on that edge (synth.py), so if this ever diverges the + # hardware and the simulation stop agreeing about MISO -- which is + # precisely the class of bug that has cost this design the most time. + ctx.set(dut.spi_clk, 1) + ctx.set(dut.spi_cs_n, 0) + await ctx.delay(HP) + ctx.set(dut.tx_byte, 0x6B) + mismatch = 0 + for _ in range(3): + for _bit in range(8): + predicted = ctx.get(dut.miso_next) # before the falling edge + ctx.set(dut.spi_clk, 0) + await ctx.delay(HP) + if ctx.get(dut.spi_miso) != predicted: + mismatch += 1 + ctx.set(dut.spi_clk, 1) + await ctx.delay(HP) + await cs_high(ctx) + check("T6 miso_next predicts spi_miso", mismatch, 0) + sim = Simulator(top) sim.add_testbench(tb) with sim.write_vcd("SPIMode3Slave.vcd"): diff --git a/exi_bba/synth.py b/exi_bba/synth.py index 8ec98b3..0ce81e1 100644 --- a/exi_bba/synth.py +++ b/exi_bba/synth.py @@ -190,7 +190,9 @@ class BBATopSynth(BBATop): m = super().elaborate(platform) if platform is not None: - exi = platform.request("exi", 0) + # MISO is requested RAW so it can be driven by an SB_IO + # output register instead of a plain buffer -- see below. + exi = platform.request("exi", 0, dir={"miso": "-"}) w5100 = platform.request("w5100", 0) # The EXI clock pin now clocks the bit engine directly (see @@ -206,11 +208,52 @@ class BBATopSynth(BBATop): # that would miss bits on a console. platform.add_clock_constraint(exi.clk.i, _EXI_HZ) + # ── MISO driven from a register INSIDE the IO block ─────────── + # MISO is source-synchronous: the device drives it on the FALLING + # edge and the GameCube samples it on the next RISING edge, so the + # entire 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. Driving it from fabric spends most of that budget on + # routing from the shift register to the pad (measured 8.4-11.5 ns + # across builds), and the console read every byte ONE BIT LATE at + # 27 MHz while reading correctly at 16 MHz and below -- the + # textbook signature of exactly this. + # + # An SB_IO output register sits in the IO tile, so clock-to-pad is + # the IO flop's own delay with no fabric routing at all. It + # captures `exi_miso_next` -- the value the shift register WILL + # hold after the falling edge -- so the bit presented is identical + # to the fabric version (spi_mode3_slave T6 proves the two agree + # bit for bit). + # + # PIN_TYPE decoded from yosys's own SB_IO model, not from memory: + # [5:4]=10 tri-state, combinational OUTPUT_ENABLE + # [3]=0,[2]=1 dout = dout_q_0, single-rate REGISTERED output + # [1:0]=01 simple input (unused) + # NEG_TRIGGER=1 moves the IO registers to the FALLING edge. + # + # NOTE this also changes MISO's electrical behaviour: it is now + # TRI-STATED when CS is high (held high by the pad's own PULLUP) + # instead of being actively driven high. EXI MISO is a SHARED bus + # -- SP1 sits on channel 0 alongside memory-card slot A -- so + # driving it while deselected was wrong and could fight a card. + m.submodules.miso_io = Instance( + "SB_IO", + p_PIN_TYPE = 0b100101, + p_PULLUP = 1, + p_NEG_TRIGGER = 1, + p_IO_STANDARD = "SB_LVCMOS", + io_PACKAGE_PIN = exi.miso.io, + i_CLOCK_ENABLE = Const(1, 1), + i_OUTPUT_CLK = exi.clk.i, + i_OUTPUT_ENABLE = ~exi.cs_n.i, + i_D_OUT_0 = self.exi_miso_next, + ) + m.d.comb += [ self.exi_clk .eq(exi.clk.i), self.exi_mosi .eq(exi.mosi.i), self.exi_cs_n .eq(exi.cs_n.i), - exi.miso.o .eq(self.exi_miso), exi.int_n.o .eq(self.int_n), # W5100 parallel bus (DATA[7:0] bidirectional via SB_IO) @@ -490,7 +533,10 @@ if __name__ == "__main__": 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" + # No negedge->async path means MISO is driven from the SB_IO + # output register, so clock-to-pad never enters the fabric at all -- + # that is the GOOD case, not a parse failure. + ms = f"{miso:.2f} ns" if miso is not None else "IOB (none)" 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: @@ -500,7 +546,9 @@ if __name__ == "__main__": 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 "") + if best_detail[2] is not None + else ", MISO driven from the SB_IO output register " + "(no fabric clock-to-pad path)") print(f"\nBest seed: {best_seed} clk {best_detail[0]:.2f} MHz, " f"exi {best_detail[1]:.2f} MHz{ms} — {overall}") if best_detail[2] is not None and best_detail[2] > 12.0: