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: