Answer the device-ID query from the bit engine, not the register file

bba_devid fails on a console with 00 04 02 02 -- the ID correct but one byte
late -- and it fails in its REAL shape, with the 16 us gap and exactly four
bytes clocked, so this is not the t_raw_id probe artefact.  The register suite
passes, so register-space reads are fine; only command space is affected.

Cause is latency, not phase.  A command-space header is two bytes, so the
reply must be in the tx FIFO by the LAST RISING EDGE of the header transfer:
one byte time, 296 ns at 27 MHz.  The round trip is rx AsyncFIFO (~125 ns) +
a sync cycle + tx AsyncFIFO (~110 ns) = 280-320 ns, marginal by construction.
HEADER0 already pushed in the same cycle it consumed byte 0, so the FSM side
was already as early as it can be.

The inter-transfer gap cannot rescue it: the load happens on that last rising
edge and the clock is PAUSED afterwards, so no edge exists during the gap for
anything to update.  That is why bba_devid's 16 us gap makes no difference.

Pre-pushing the ID at CS-fall does not work either -- the engine's
unconditional per-byte pops would eat it, and the header length is not known
until byte 0 has been decoded, which is the latency in question.

So take the round trip out of the path.  The device ID is a hardwired constant
and belongs to the physical interface rather than to the register file, and
byte 0 == 0x00 identifies the query exactly (bit 7 clear = command space, bit
6 clear = read, reg 0).  SPIMode3Slave now recognises it and answers
04 02 02 00 from the EXI clock domain, with no CDC at all.  The only thing it
needs from sync is id_phase, a single bit that changes once, crossed with an
FFSynchronizer.

The register file's ID path is REMOVED rather than kept as a fallback: left
in, it would push bytes that a 2-byte header has too few pops to drain, which
is exactly how an earlier version made the NEXT enumeration come back a slot
late.

Tests follow the responsibility:
  - spi_mode3_slave T7: the query returns 04 02 02 00 with the tx FIFO
    deliberately feeding 0x00, so only the fast path can be answering.
  - spi_mode3_slave T8: and it is GATED -- with id_arm low, 0x5A comes
    through from the FIFO.  Without this the fast path would hijack address 0
    forever and the GC could never read NCRA back after its own reset.
  - bba_register_file T1: retargeted to assert the FSM pushes NOTHING for an
    ID query, which is the property that stops stale bytes corrupting the
    following transaction.

Note what these tests do NOT establish: bba_top already passed the device ID
at EXI_HALF=1 before this change, because simulation cannot reproduce a
296 ns window miss -- the sync domain's latency relative to the EXI clock in
sim does not match silicon closely enough.  T7/T8 prove the fast path works;
the evidence that it is NECESSARY is the hardware result plus the paused-clock
argument above.

--seeds 8: clk 28.4-31.0 MHz, EXI 56.7-64.4 MHz, 8/8 PASS.  Best seed 2
(clk 30.98, EXI 59.47), flashed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 10:51:37 +02:00
parent 5db3b7751a
commit 7587636b48
4 changed files with 116 additions and 51 deletions
+71 -5
View File
@@ -80,6 +80,10 @@ class SPIMode3Slave(Elaboratable):
# 27 MHz GameCube read fails on -- see the note in synth.py.
self.miso_next = Signal(init=1)
# Asserted while the device-ID query is still answerable (the register
# file's id_phase, synchronised into this domain by the parent).
self.id_arm = Signal()
self.rx_byte = Signal(8)
self.rx_valid = Signal()
self.tx_byte = Signal(8)
@@ -106,6 +110,39 @@ class SPIMode3Slave(Elaboratable):
m.d.comb += self.rx_byte .eq(nxt)
m.d.comb += self.rx_valid.eq(rx_bit == 7)
# ── Device-ID fast path, answered HERE and not by the register file ──
# A command-space header is only TWO bytes, so a reply that travels
# rx AsyncFIFO -> sync FSM -> tx AsyncFIFO has one byte time (296 ns
# at 27 MHz) to make it back. That round trip costs ~280-320 ns, so
# it is marginal by construction, and measured on a console it LOST:
# the ID came back one byte late (00 04 02 02). The inter-transfer
# gap cannot rescue it either -- the reply must be in hand by the last
# rising edge of the header transfer, and the clock is paused after
# that edge, so nothing can update during the gap.
#
# The device ID is a CONSTANT and belongs to the physical interface
# rather than to the register file, so answer it from this domain and
# skip both crossings entirely. byte 0 == 0x00 identifies the query
# exactly: bit 7 clear = command space, bit 6 clear = read, reg = 0.
byte_idx = Signal(3) # bytes completed this transaction (saturating)
id_mode = Signal()
with m.If(rx_bit == 7):
with m.If(byte_idx != 7):
m.d.exiclk += byte_idx.eq(byte_idx + 1)
with m.If((byte_idx == 0) & (nxt == 0x00) & self.id_arm):
m.d.exiclk += id_mode.eq(1)
# Byte N of the reply is loaded at the end of header byte N, so the
# four ID bytes are selected by byte_idx 1..4.
id_byte = Signal(8)
with m.Switch(byte_idx):
with m.Case(1): m.d.comb += id_byte.eq(0x04)
with m.Case(2): m.d.comb += id_byte.eq(0x02)
with m.Case(3): m.d.comb += id_byte.eq(0x02)
with m.Default(): m.d.comb += id_byte.eq(0x00)
id_sel = Signal()
m.d.comb += id_sel.eq(id_mode & (byte_idx >= 1) & (byte_idx <= 4))
# ── TX: launch MISO a FULL CYCLE EARLY, on the RISING edge ────────
# The host samples MISO on the rising edge. The obvious thing is to
# drive it on the preceding FALLING edge, which gives the output path
@@ -154,9 +191,13 @@ class SPIMode3Slave(Elaboratable):
# This also removes `tx_hold` and the whole exiclk_n domain: the
# engine now has ONE clock edge and ONE phase reference.
tx_out = Signal(8, init=0xFF)
tx_src = Signal(8) # the byte loaded at the next byte boundary
m.d.comb += tx_src.eq(
Mux(id_sel, id_byte,
0x80 if self._phase_meter else self.tx_byte))
m.d.comb += self.tx_load.eq(rx_bit == 7)
with m.If(rx_bit == 7):
m.d.exiclk += tx_out.eq(0x80 if self._phase_meter else self.tx_byte)
m.d.exiclk += tx_out.eq(tx_src)
with m.Else():
m.d.exiclk += tx_out.eq(Cat(0, tx_out[:-1]))
@@ -164,10 +205,7 @@ class SPIMode3Slave(Elaboratable):
# What tx_out[7] WILL be after the next RISING edge -- this is what the
# SB_IO output register captures. Mirrors the load/shift choice above
# exactly; T6 proves the two agree bit for bit.
m.d.comb += self.miso_next.eq(
Mux(rx_bit == 7,
1 if self._phase_meter else self.tx_byte[7],
tx_out[6]))
m.d.comb += self.miso_next.eq(Mux(rx_bit == 7, tx_src[7], tx_out[6]))
return m
@@ -339,6 +377,34 @@ if __name__ == "__main__":
await cs_high(ctx)
check("T6 miso_next predicts spi_miso", mismatch, 0)
# T7 — the device-ID fast path. A command-space query (byte 0 = 0x00,
# byte 1 = 0x00) must be answered 04 02 02 00 straight from this
# domain, with the tx FIFO empty: the register file cannot make the
# round trip inside a 2-byte header's one byte time, which on a
# console returned the ID one byte late (00 04 02 02).
ctx.set(dut.id_arm, 1)
ctx.set(dut.tx_byte, 0x00) # FIFO deliberately contributes nothing
await cs_low(ctx)
await spi_byte(ctx, 0x00) # header byte 0 identifies the query
await spi_byte(ctx, 0x00) # header byte 1
got = [await spi_byte(ctx, 0x00) for _ in range(4)]
await cs_high(ctx)
check("T7 device ID", [hex(b) for b in got],
[hex(x) for x in (0x04, 0x02, 0x02, 0x00)])
# T8 — and it must be GATED. Once the GC's first NCRA reset clears
# id_phase, address 0 reads back NCRA, so the fast path must stop
# hijacking the reply and let the FIFO through.
ctx.set(dut.id_arm, 0)
ctx.set(dut.tx_byte, 0x5A)
await cs_low(ctx)
await spi_byte(ctx, 0x00)
await spi_byte(ctx, 0x00)
got2 = [await spi_byte(ctx, 0x00) for _ in range(2)]
await cs_high(ctx)
check("T8 fast path gated by id_arm", [hex(b) for b in got2],
[hex(0x5A), hex(0x5A)])
sim = Simulator(top)
sim.add_testbench(tb)
with sim.write_vcd("SPIMode3Slave.vcd"):