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 -> <async> 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:26:08 +02:00
parent 5f7f20db43
commit 2f081a3e69
5 changed files with 113 additions and 22 deletions
+26 -18
View File
@@ -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`). `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 **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** | | `clk` (sync, HFOSC) | 24 MHz | 28.5–30.7 MHz — **PASS on all 8 seeds** |
| EXI pin domain | 27 MHz | 49.6–62.1 MHz — **PASS on all 8 seeds** | | EXI pin domain | 27 MHz | 41.6–49.7 MHz — **PASS on all 8 seeds** |
| **MISO clock→pad** | see below | **8.38–10.22 ns** |
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<N>/top.bin`.
⚠️ **fmax is NOT the binding constraint — MISO clock-to-pad is.** MISO is a ⚠️ **fmax is NOT the only constraint — MISO clock-to-pad matters more.** MISO
source-synchronous output: it leaves on the falling edge and the GameCube is source-synchronous: it leaves on the FALLING edge and the GameCube samples
samples it on the next RISING edge, so the whole path must fit a **half it on the next RISING edge, so the path must fit a **half period (18.5 ns at
period — 18.5 ns at 27 MHz** — minus global-buffer insertion, the pad, the 27 MHz)** minus global-buffer insertion, pad, connector and the GC's own
connector and the GC's own setup time. A build measuring ~10.5 ns failed on setup. A build measuring ~10.5 ns of that *internally* read every byte one bit
console at 27 MHz while passing at 16 MHz, with every seed reporting ≥1.29× late at 27 MHz while reading correctly at 16 MHz — with every seed reporting
fmax margin. `synth.py` therefore reports this delay and **ranks seeds by it**, ≥1.29× fmax margin. `synth.py` therefore parses this delay and ranks seeds by
not by fmax. Two changes bought the headroom: removing the EXI_J4 pad merge it when it exists.
(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 is now driven from an `SB_IO` output register** (`synth.py`), so
MISO data path (`tx_shift` has `init=0xFF`, so CS — the domain's async reset — clock-to-pad is the IO tile's own delay and never enters the fabric — the
parks MISO high by itself). sweep prints `IOB (none)` because no `negedge → <async>` 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 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 was entirely an artefact of the PLL-fed 54 MHz `capture` domain. Removing that
+2
View File
@@ -86,6 +86,7 @@ class BBATop(Elaboratable):
dst_port=udp_dst_port) dst_port=udp_dst_port)
# EXI (GC side) # EXI (GC side)
self.exi_miso_next = Signal(init=1) # for an SB_IO output register
self.exi_clk = Signal(init=1) self.exi_clk = Signal(init=1)
self.exi_mosi = Signal() self.exi_mosi = Signal()
self.exi_cs_n = Signal(init=1) self.exi_cs_n = Signal(init=1)
@@ -188,6 +189,7 @@ class BBATop(Elaboratable):
cap.spi_cs_n.eq(self.exi_cs_n), cap.spi_cs_n.eq(self.exi_cs_n),
# EXI outputs # EXI outputs
self.exi_miso.eq(cap.spi_miso), self.exi_miso.eq(cap.spi_miso),
self.exi_miso_next.eq(cap.miso_next),
self.int_n .eq(reg.exi_int_n), self.int_n .eq(reg.exi_int_n),
] ]
+2
View File
@@ -82,6 +82,7 @@ class ExiCapture(Elaboratable):
self.spi_mosi = Signal() self.spi_mosi = Signal()
self.spi_cs_n = Signal(init=1) self.spi_cs_n = Signal(init=1)
self.spi_miso = Signal() self.spi_miso = Signal()
self.miso_next = Signal(init=1) # for an SB_IO output register
# Core-facing RX read side # Core-facing RX read side
self.rx_data = Signal(8) self.rx_data = Signal(8)
@@ -150,6 +151,7 @@ class ExiCapture(Elaboratable):
spi.spi_mosi.eq(self.spi_mosi), spi.spi_mosi.eq(self.spi_mosi),
spi.spi_cs_n.eq(self.spi_cs_n), spi.spi_cs_n.eq(self.spi_cs_n),
self.spi_miso.eq(spi.spi_miso), self.spi_miso.eq(spi.spi_miso),
self.miso_next.eq(spi.miso_next),
] ]
# ── RX: every received byte → rx_fifo ──────────────────────────── # ── RX: every received byte → rx_fifo ────────────────────────────
+31
View File
@@ -73,6 +73,12 @@ class SPIMode3Slave(Elaboratable):
self.spi_cs_n = Signal(init=1) # active LOW self.spi_cs_n = Signal(init=1) # active LOW
self.spi_miso = Signal(init=1) 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_byte = Signal(8)
self.rx_valid = Signal() 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.exiclk_n += tx_shift.eq(Cat(0, tx_shift[:-1]))
m.d.comb += self.spi_miso.eq(tx_shift[7]) 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 return m
@@ -306,6 +315,28 @@ if __name__ == "__main__":
check("T5 rising-edge start, slot1", hex(s1), hex(0xA5)) check("T5 rising-edge start, slot1", hex(s1), hex(0xA5))
check("T5 rising-edge start, slot2", hex(s2), hex(0x3C)) 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 = Simulator(top)
sim.add_testbench(tb) sim.add_testbench(tb)
with sim.write_vcd("SPIMode3Slave.vcd"): with sim.write_vcd("SPIMode3Slave.vcd"):
+52 -4
View File
@@ -190,7 +190,9 @@ class BBATopSynth(BBATop):
m = super().elaborate(platform) m = super().elaborate(platform)
if platform is not None: 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) w5100 = platform.request("w5100", 0)
# The EXI clock pin now clocks the bit engine directly (see # 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. # that would miss bits on a console.
platform.add_clock_constraint(exi.clk.i, _EXI_HZ) 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 += [ m.d.comb += [
self.exi_clk .eq(exi.clk.i), self.exi_clk .eq(exi.clk.i),
self.exi_mosi .eq(exi.mosi.i), self.exi_mosi .eq(exi.mosi.i),
self.exi_cs_n .eq(exi.cs_n.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), exi.int_n.o .eq(self.int_n),
# W5100 parallel bus (DATA[7:0] bidirectional via SB_IO) # 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}" print(f" {'seed':>4} {'clk (≥24)':>10} {f'exi (≥{_EXI_TARGET})':>16}"
f" {'MISO clk→pad':>13} verdict") f" {'MISO clk→pad':>13} verdict")
for seed, fc, fexi, verdict, miso in results: 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}") 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") n_pass = sum(1 for r in results if r[3] == "PASS")
if results: if results:
@@ -500,7 +546,9 @@ if __name__ == "__main__":
overall = "PASS" if best_fmax >= 1.0 else "FAIL" overall = "PASS" if best_fmax >= 1.0 else "FAIL"
if best_detail: if best_detail:
ms = (f", MISO clk→pad {best_detail[2]:.2f} ns" 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, " print(f"\nBest seed: {best_seed} clk {best_detail[0]:.2f} MHz, "
f"exi {best_detail[1]:.2f} MHz{ms} — {overall}") f"exi {best_detail[1]:.2f} MHz{ms} — {overall}")
if best_detail[2] is not None and best_detail[2] > 12.0: if best_detail[2] is not None and best_detail[2] > 12.0: