fix(spi): sample MOSI on the RISING edge -- real Mode 3, not Mode 2

The docs call this 'Mode 3 (CPOL=1, CPHA=1)' and then describe sampling MOSI
on the FALLING edge.  Idle-high plus sample-on-falling is Mode 2.  Real Mode 3
samples on the second edge of each cycle, which with an idle-high clock is the
RISING one, and that is what the GameCube does.

Sampling on the same edge the GC drives MOSI latched the PREVIOUS bit, so
every byte arrived one bit late in both directions, at every clock speed:
the 0x80 register-space header byte was read as 0x00, which decodes as a
command-space device-ID query -- so NWAYS reads returned the device ID and the
speed sweep failed at every index.  Hardware raw dump, first transaction after
an FPGA reset:

    00 00 02 01 01 00 00 00      02 01 01 00 = 04 02 02 00 >> 1

The Pi probe never caught it because that script was written to match this
device, and every simulation testbench drives MOSI many capture ticks before
toggling the clock, so MOSI is stable at BOTH edges and the convention cannot
be distinguished.  The bba_top master now models a true Mode 3 master (drive
on falling, sample on rising) so the suite can tell the difference.

SPIMode3Slave(mode2=True) restores the old behaviour for the Pi/J4 probe.

Known remaining issue: at EXI_HALF=1 (27 MHz, 2x oversampling) reads are still
shifted, because the 2-stage input FFSynchronizer costs 2 capture ticks which
at 27 MHz is a whole bit.  That is a separate fault and only bites at 2x.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 13:00:49 +02:00
parent 6f151cd1ff
commit afc10d7e5c
2 changed files with 27 additions and 5 deletions
+6 -2
View File
@@ -422,13 +422,17 @@ if __name__ == "__main__":
async def spi_byte(ctx, mosi_val): async def spi_byte(ctx, mosi_val):
"""Drive one EXI Mode-3 byte; return the assembled MISO byte.""" """Drive one EXI Mode-3 byte; return the assembled MISO byte."""
# Real Mode 3 master (CPOL=1, CPHA=1), which is what the GameCube is:
# drive MOSI on the FALLING edge, sample MISO on the RISING edge.
# The old version drove MOSI then fell -- a Mode 2 master -- which is
# what the Pi probe did and why it never exposed the mismatch.
miso = 0 miso = 0
for bit in range(7, -1, -1): for bit in range(7, -1, -1):
ctx.set(dut.exi_clk, 0) # falling: master drives
ctx.set(dut.exi_mosi, (mosi_val >> bit) & 1) ctx.set(dut.exi_mosi, (mosi_val >> bit) & 1)
ctx.set(dut.exi_clk, 0) # falling: slave samples MOSI
await ctx.tick("capture").repeat(HALF) await ctx.tick("capture").repeat(HALF)
ctx.set(dut.exi_clk, 1) # rising: both sample
miso = (miso << 1) | ctx.get(dut.exi_miso) miso = (miso << 1) | ctx.get(dut.exi_miso)
ctx.set(dut.exi_clk, 1) # rising
await ctx.tick("capture").repeat(HALF) await ctx.tick("capture").repeat(HALF)
return miso return miso
+21 -3
View File
@@ -27,11 +27,14 @@ class SPIMode3Slave(Elaboratable):
tx_load : 1-cycle pulse requesting the next TX byte from upstream tx_load : 1-cycle pulse requesting the next TX byte from upstream
""" """
def __init__(self, domain="capture"): def __init__(self, domain="capture", mode2=False):
# Clock domain this byte engine runs in. Split-domain design puts the # Clock domain this byte engine runs in. Split-domain design puts the
# bit engine in a fast `capture` domain (54 MHz) so it can oversample # bit engine in a fast `capture` domain (54 MHz) so it can oversample
# a 27 MHz EXI clock ~3×; the register file lives in a slower domain. # a 27 MHz EXI clock ~3×; the register file lives in a slower domain.
self._domain = domain self._domain = domain
# False = real Mode 3 (sample MOSI on RISING) -- what the GameCube
# needs. True = the old Mode-2 behaviour the Pi probe was built on.
self._mode2 = mode2
self.spi_clk = Signal(init=1) # idles HIGH self.spi_clk = Signal(init=1) # idles HIGH
self.spi_mosi = Signal() self.spi_mosi = Signal()
@@ -75,8 +78,23 @@ class SPIMode3Slave(Elaboratable):
rising_clk = Signal() rising_clk = Signal()
cs_fall = Signal() cs_fall = Signal()
cs_rise = Signal() cs_rise = Signal()
m.d.comb += falling_clk.eq(~clk_s & clk_prev) # ⚠️ EDGE CONVENTION. The docs call this "Mode 3 (CPOL=1, CPHA=1)" but
m.d.comb += rising_clk .eq( clk_s & ~clk_prev) # then describe sampling MOSI on the FALLING edge -- that combination
# is Mode 2. Real Mode 3 samples on the SECOND edge of each cycle,
# which with an idle-high clock is the RISING one.
#
# Hardware 2026-10-03: the GameCube drives MOSI on the falling edge, so
# sampling there latches the PREVIOUS bit -- every byte arrived one bit
# late in both directions at every clock speed (header 0x80 was read as
# 0x00, and the reply came back as 04 02 02 00 >> 1 = 02 01 01 00).
# `mode2=True` restores the old behaviour, which is what the Pi probe
# on J4 was written to match.
if self._mode2:
sample_edge, shift_edge = ~clk_s & clk_prev, clk_s & ~clk_prev
else:
sample_edge, shift_edge = clk_s & ~clk_prev, ~clk_s & clk_prev
m.d.comb += falling_clk.eq(sample_edge) # "sample MOSI / advance bit"
m.d.comb += rising_clk .eq(shift_edge) # "shift MISO / re-arm"
m.d.comb += cs_fall .eq(~cs_s & cs_prev) m.d.comb += cs_fall .eq(~cs_s & cs_prev)
m.d.comb += cs_rise .eq( cs_s & ~cs_prev) m.d.comb += cs_rise .eq( cs_s & ~cs_prev)
m.d.comb += self.frame_start.eq(cs_fall) m.d.comb += self.frame_start.eq(cs_fall)