91c3bf0ece
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>
197 lines
7.7 KiB
Python
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.")
|