Files
rebbarb/exi_bba/exi_j4.py
T
Roflin 91c3bf0ece Clock the EXI bit engine from the EXI pin: timing closes on all 8 seeds
Replaces the PLL-fed 54 MHz `capture` domain, which oversampled the GC's
27 MHz EXI clock 2x and so put BIT-level timing across a CDC boundary: the
engine's view of the clock lagged the pin by 2 FFSynchronizer stages plus a
register (~55 ns) against a 37 ns bit time, so MISO came out ~2 bits late and
the GC read 0x17 as 05 C0.  No amount of oversampling fixes that -- 54 MHz
gives only 2 ticks per bit and the part cannot close the 81 MHz that 3x needs.

The engine is now clocked by the pin itself (pin 44 = global buffer input net
6, no board change), making MISO clock-to-Q.  ExiCapture creates four domains:
exiclk (posedge, samples MOSI), exiclk_n (negedge, drives MISO), both
async-reset by CS, plus reset-less exififo_w/exififo_r for the byte FIFO sides
-- reset-less because resetting one side of an AsyncFIFO desynchronises its
gray pointers, and posedge because AsyncFIFO has a posedge-only reset CDC
internally and cannot host a negedge side.

Also removes the SPRAM response FIFO.  The DMA read was one byte late because
the round trip did not fit the protocol's turnaround byte; the arbiter already
exposed valid/ready, so the FIFO bought a cycle of latency and nothing else.
The response is handled outside the FSM (the reply lands in RHDR_PAD, not
SPRAM_STREAM) with a one-entry hold for when tx_fifo is momentarily full.

The PLL was left instantiated but unused after the rewrite; removing it frees
the hard macro and pin 35 (which stays unconnected only because the board is
already built that way).  This is what lifted the pass rate:

  domain            target    before        after
  clk (sync)        24 MHz    28.1-30.2     26.4-32.2   8/8
  capture_clk       54 MHz    2 of 8 PASS   (gone)
  EXI pin domain    27 MHz    --            45.6-64.4   8/8

Best seed 3: clk 32.18 MHz, EXI 55.86 MHz.  The binding constraint has FLIPPED
-- `clk` (1.10-1.34x) is now tighter than the EXI domain (1.69-2.39x) -- so
synth.py ranks seeds by worst RELATIVE margin, not by the EXI number.

synth.py could not have scored this design at all: it required the vanished
capture_clk, and its regex '(\w+)' cannot match nextpnr's exi_clk_$glb_clk
(no $ in \w).  add_clock_constraint was also landing on the wrong net -- under
EXI_J4 the clock is the AND of two pads, not either pad -- so nextpnr silently
fell back to a 12 MHz default and under-optimised the whole EXI domain.

Testbenches:
- spi_mode3_slave had lost its testbench entirely in the rewrite.  Restored
  with a strict Mode 3 master (changes MOSI on the falling edge, samples MISO
  in the clock-low phase) and MUTATION-TESTED: flipping either clock edge in
  the RTL makes it fail.  Both of those bugs previously reached silicon
  against a lax master that parked MOSI well before the edge.
- exi_j4 T2/T3 returned [0,4,2,2].  Real bug: the engine latches the FIFO head
  at each byte boundary, so a 2-byte command header leaves only ONE drain
  slot.  DEVID streamed zeros until CS and left two stale bytes -- one too
  many -- so the NEXT enumeration came back a slot late.  DEVID now pushes
  exactly four bytes and stops.  Register-space reads may still over-push:
  their 4-byte header gives three drain slots.
- Dead ports removed from SPIMode3Slave (hdr_bytes, frame_start, cs_active,
  byte_idx) and the mode2 parameter is gone -- there is one correct edge
  convention and it is now enforced by tests.

All 14 module suites pass, plus bba_top at EXI_HALF=1/2/8.

CLAUDE.md: rewrote the clocking, CDC inventory and module tables; marked the
2026-09-30 capture_clk measurements historical; and corrected the line in
"Critical Protocol Notes" that still stated the INVERTED edge convention
("MOSI sampled on falling, MISO driven on rising" is Mode 2) -- the original
implementation followed that doc error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 18:36:51 +02:00

197 lines
7.7 KiB
Python

"""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.")