spi_mode3_slave: document the engine, and fix three stale comments
418 -> 439 lines, 31% -> 14% comment-only. The reasoning that matters here is
kept in full, because the edge conventions and the TX launch phase are the two
things in this design that cost silicon bugs; what went was the history around
them and the console measurement logs, which CLAUDE.md already holds.
Three comments described mechanisms that no longer exist:
* `miso_next` was documented as feeding "an SB_IO output register clocked on
the FALLING edge". It is clocked on the RISING edge (NEG_TRIGGER=0) --
that is the whole point of the full-cycle-early launch. The file
contradicted itself, since the comment on the assignment 100 lines later
said "after the next RISING edge" correctly.
* `id_arm` was documented as "the register file's id_phase, synchronised
into this domain by the parent". The one-shot was removed; id_arm is tied
high and the register file has no id_phase. It is now driven low only by
the PHASE_METER build.
* T8's rationale said the gate exists so that "once the GC's first NCRA
reset clears id_phase, address 0 reads back NCRA". That justification was
not merely stale, it was WRONG -- NCRA is register space and the fast path
requires command space, so they were never ambiguous. T8's real job is to
prove a disarmed build passes the FIFO byte through, which is what
PHASE_METER depends on.
Structure: elaborate() now delegates to _rx_bytes, _device_id and _tx_bits,
each carrying the reasoning for its own block -- why rx_byte/rx_valid must be
combinational (a registered pulse loses the last byte of every transaction),
why the device ID is answered here rather than through the FIFOs, and the
output-path budget that forces the early launch.
VERIFIED EQUIVALENT: generated RTLIL is identical for BOTH build variants
(phase_meter False and True), 395 lines each, after stripping \src attributes.
Executable testbench code is unchanged apart from one trailing comment. The
suite still has teeth: mutating the TX load back to rx_bit == 0 (a cycle late)
fails 8 of the 12 checks. Full module suite passes and bba_top T9 is 6/6 at
EXI_HALF=1, 2 and 8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+184
-163
@@ -1,38 +1,30 @@
|
||||
"""SPI Mode 3 byte-oriented slave for the EXI bus — CLOCKED BY EXI CLK.
|
||||
"""SPI Mode 3 byte-oriented slave for the EXI bus, clocked by the EXI clock.
|
||||
|
||||
CPOL=1, CPHA=1 (real Mode 3): CLK idles HIGH, the master CHANGES MOSI on the
|
||||
falling edge and both ends SAMPLE on the rising edge.
|
||||
Mode 3 is CPOL=1, CPHA=1: CLK idles HIGH, the master changes MOSI on the
|
||||
FALLING edge, and both ends sample on the RISING edge.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
The engine is clocked by the EXI clock itself rather than oversampling it from
|
||||
a faster domain:
|
||||
The engine is clocked by the EXI clock pin itself rather than oversampled from
|
||||
a faster domain, so there is exactly one clock edge and one phase reference in
|
||||
the whole module:
|
||||
|
||||
exiclk posedge of EXI CLK — sample MOSI, assemble RX bytes, AND
|
||||
launch MISO one bit early (see the TX
|
||||
note in elaborate)
|
||||
CS_N asynchronous reset
|
||||
exiclk posedge of EXI CLK. Samples MOSI, assembles RX bytes, and
|
||||
launches MISO -- see `_tx_bits` for why MISO goes out on this
|
||||
edge rather than the falling one.
|
||||
spi_cs_n asynchronous reset of that domain.
|
||||
|
||||
Why, and what it fixes (hardware 2026-10-03)
|
||||
--------------------------------------------
|
||||
The previous version oversampled EXI CLK from a 54 MHz `capture` domain, which
|
||||
put BIT-level timing across a clock-domain boundary: the engine's view of the
|
||||
clock lagged the pin by 2 stages of `FFSynchronizer` plus a register, ~3 ticks
|
||||
≈ 55 ns. One EXI bit at 27 MHz is 37 ns, so MISO came out ~2 bits late and the
|
||||
master read `0x17` as `05 C0` (= `0x17 >> 2`). Register reads were correct up
|
||||
to 4 MHz and failed from 8 MHz — the sweep read `ok=[1,1,1,0,0,0]`.
|
||||
Oversampling cannot work here, which is why it is not an option to fall back
|
||||
to: it puts BIT-level timing across a clock-domain boundary, and the engine's
|
||||
view of the clock then lags the pin by two `FFSynchronizer` stages plus a
|
||||
register -- about 55 ns against a 37 ns bit time at 27 MHz. Clocking from the
|
||||
pin makes MISO clock-to-Q and leaves the byte-level `AsyncFIFO`s as the only
|
||||
crossing, where a whole byte time (~296 ns) of slack is available instead of a
|
||||
fraction of a bit.
|
||||
|
||||
No amount of oversampling fixes that: at 54 MHz there are only 2 ticks per bit,
|
||||
and the design cannot close the 81 MHz that 3x would need. Clocking the engine
|
||||
from the EXI pin makes MISO clock-to-Q instead, and moves the ONLY remaining
|
||||
crossing to the byte-level AsyncFIFOs, which have a whole byte-time (~296 ns at
|
||||
27 MHz) of slack instead of a fraction of a bit.
|
||||
EXI CLK is on pin 44, which sits at die (6,0) on the 5k-sg48 = global buffer
|
||||
input net 6, so it drives a real clock network with no board change.
|
||||
|
||||
EXI CLK (pin 44) sits at die (6,0) on the 5k-sg48, which is global buffer input
|
||||
net 6 — so it can drive a real clock network with no board change.
|
||||
|
||||
The domains are created by the PARENT (`ExiCapture`) so the byte FIFOs can name
|
||||
them; this module only uses `m.d.exiclk`.
|
||||
The clock domain is created by the PARENT (`ExiCapture`) so the byte FIFOs can
|
||||
name it; this module only uses `m.d.exiclk`.
|
||||
"""
|
||||
|
||||
from amaranth import *
|
||||
@@ -45,27 +37,40 @@ class SPIMode3Slave(Elaboratable):
|
||||
|
||||
Ports
|
||||
-----
|
||||
spi_clk / spi_mosi / spi_cs_n : raw inputs from the GC. spi_clk drives the
|
||||
exiclk domain and spi_cs_n its async reset (both wired by
|
||||
the parent); they are still listed here so the module is self-describing
|
||||
and so spi_cs_n can gate MISO combinationally.
|
||||
spi_miso : output to the GC; idles HIGH while CS is deasserted.
|
||||
rx_byte : last complete received byte (valid while rx_valid is high)
|
||||
rx_valid : 1-cycle pulse in `exiclk` when rx_byte holds a new byte
|
||||
tx_byte : byte to transmit; sampled at each byte boundary
|
||||
tx_load : 1-cycle pulse asking upstream for the next byte, one per byte
|
||||
boundary. No header-length or slot-threshold logic: see the TX comment
|
||||
in elaborate() for why unconditional pops are both sufficient and
|
||||
necessary.
|
||||
spi_clk, spi_mosi, spi_cs_n
|
||||
Raw inputs from the GC. `spi_clk` drives the `exiclk` domain and
|
||||
`spi_cs_n` is its async reset, both wired by the parent; they are
|
||||
listed here so the module is self-describing.
|
||||
spi_miso
|
||||
Serial output to the GC. Idles HIGH while CS is deasserted.
|
||||
miso_next
|
||||
The same bit, but combinational from the pre-edge state, so it can
|
||||
feed an SB_IO output register clocked on the RISING edge. The
|
||||
synthesised build drives the pad from that register; see `synth.py`.
|
||||
id_arm
|
||||
Enables the device-ID fast path. Normally tied high -- real hardware
|
||||
answers EXI_GetID whenever it is asked -- and driven low only by the
|
||||
PHASE_METER diagnostic build, so the constant test pattern is not
|
||||
replaced by ID bytes.
|
||||
rx_byte
|
||||
Last complete received byte, valid while `rx_valid` is high.
|
||||
rx_valid
|
||||
One-cycle pulse in `exiclk` when `rx_byte` holds a new byte.
|
||||
tx_byte
|
||||
Byte to transmit, sampled at each byte boundary.
|
||||
tx_load
|
||||
One-cycle pulse per byte boundary asking upstream for the next byte.
|
||||
Unconditional: there is no header-length or slot-threshold logic, and
|
||||
`_tx_bits` explains why that is both sufficient and necessary.
|
||||
"""
|
||||
|
||||
def __init__(self, phase_meter=False):
|
||||
# DIAGNOSTIC: when set, every byte the engine transmits is the constant
|
||||
# 0x80 instead of the FIFO head. The load/shift/mux/output path is
|
||||
# otherwise untouched, so the register file and both byte FIFOs drop
|
||||
# out of the picture and what the host reads back is purely the
|
||||
# engine's byte PHASE: 0x80 = aligned, 0x40 = 1 bit late, 0x20 = 2 bits
|
||||
# late, and so on. Not a functional build.
|
||||
# DIAGNOSTIC build: every transmitted byte becomes the constant 0x80
|
||||
# instead of the FIFO head, leaving the load/shift/output path
|
||||
# untouched. The register file and both byte FIFOs therefore drop out
|
||||
# of the MISO path, and whatever the host reads back is purely the
|
||||
# engine's byte PHASE -- 0x80 aligned, 0x40 one bit late, 0x20 two
|
||||
# bits late. Not a functional bitstream.
|
||||
self._phase_meter = phase_meter
|
||||
|
||||
self.spi_clk = Signal(init=1) # idles HIGH
|
||||
@@ -73,15 +78,8 @@ class SPIMode3Slave(Elaboratable):
|
||||
self.spi_cs_n = Signal(init=1) # active LOW
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
@@ -91,40 +89,59 @@ class SPIMode3Slave(Elaboratable):
|
||||
|
||||
def elaborate(self, platform):
|
||||
m = Module()
|
||||
rx_bit, nxt = self._rx_bytes(m)
|
||||
id_sel, id_byte = self._device_id(m, rx_bit, nxt)
|
||||
self._tx_bits(m, rx_bit, id_sel, id_byte)
|
||||
return m
|
||||
|
||||
# ── RX: sample MOSI on the RISING edge (Mode 3 samples on edge 2) ──
|
||||
def _rx_bytes(self, m):
|
||||
"""Sample MOSI on the rising edge and assemble bytes, MSB first.
|
||||
|
||||
Returns `(rx_bit, nxt)`, where `nxt` is the shift register's next
|
||||
value -- i.e. the complete byte during the final bit.
|
||||
|
||||
`rx_byte`/`rx_valid` are COMBINATIONAL rather than registered. The
|
||||
clock stops and CS rises immediately after a transaction's final bit,
|
||||
so a registered pulse would need one more clock edge that never
|
||||
arrives, and the LAST byte of every transaction would be silently
|
||||
lost: a 4-byte write landed as DE AD BE 00, and a 34-byte TX stream as
|
||||
15 bytes. Driving them combinationally makes the parent's FIFO
|
||||
capture the byte on the very edge that completes it.
|
||||
"""
|
||||
rx_shift = Signal(8)
|
||||
rx_bit = Signal(3)
|
||||
|
||||
nxt = Cat(self.spi_mosi, rx_shift[:-1]) # MSB-first: shift left, new bit at LSB
|
||||
m.d.exiclk += rx_shift.eq(nxt)
|
||||
m.d.exiclk += rx_bit.eq(rx_bit + 1)
|
||||
nxt = Cat(self.spi_mosi, rx_shift[:-1]) # shift left, new bit at LSB
|
||||
m.d.exiclk += [rx_shift.eq(nxt),
|
||||
rx_bit.eq(rx_bit + 1)]
|
||||
|
||||
# rx_byte/rx_valid are COMBINATIONAL, not registered. The clock stops
|
||||
# and CS rises immediately after a transaction's final bit, so a
|
||||
# registered pulse would need one more clock edge that never arrives
|
||||
# and the LAST byte of every transaction would be silently lost
|
||||
# (symptom: a 4-byte write landed as DE AD BE 00, and a 34-byte TX
|
||||
# stream as 15 bytes). Driving them combinationally makes the FIFO
|
||||
# capture the byte on the very edge that completes it.
|
||||
m.d.comb += self.rx_byte .eq(nxt)
|
||||
m.d.comb += self.rx_valid.eq(rx_bit == 7)
|
||||
m.d.comb += [self.rx_byte .eq(nxt),
|
||||
self.rx_valid.eq(rx_bit == 7)]
|
||||
return rx_bit, nxt
|
||||
|
||||
# ── 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)
|
||||
def _device_id(self, m, rx_bit, nxt):
|
||||
"""Answer the device-ID query from this domain, not the register file.
|
||||
|
||||
Returns `(id_sel, id_byte)` for the TX mux.
|
||||
|
||||
A command-space header is only TWO bytes, so a reply that travels
|
||||
rx `AsyncFIFO` → `sync` FSM → tx `AsyncFIFO` has one byte time to make
|
||||
it back -- 296 ns at 27 MHz -- against a round trip costing roughly
|
||||
280-320 ns. That is marginal by construction, and on a console it
|
||||
lost: the ID came back one byte late.
|
||||
|
||||
The inter-transfer gap cannot rescue it, which is the non-obvious
|
||||
part. The reply must be in hand by the LAST rising edge of the header
|
||||
transfer, and the clock is paused after that edge, so no edge exists
|
||||
during the gap for anything to update.
|
||||
|
||||
The device ID is a constant and belongs to the physical interface
|
||||
rather than to the register file, so answering it here skips both
|
||||
crossings. `byte 0 == 0x00` identifies the query exactly: bit 7 clear
|
||||
selects command space, bit 6 clear selects read, and the register
|
||||
number is 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):
|
||||
@@ -140,56 +157,54 @@ class SPIMode3Slave(Elaboratable):
|
||||
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))
|
||||
return id_sel, id_byte
|
||||
|
||||
# ── 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
|
||||
# a HALF period -- 18.5 ns at 27 MHz. That is not enough on this
|
||||
# part, and the shortfall is irreducible:
|
||||
#
|
||||
# MISO changes at (falling edge AT THE CONNECTOR)
|
||||
# + input pad delay
|
||||
# + GLOBAL CLOCK NETWORK INSERTION <-- the big one
|
||||
# + IOB clock-to-Q
|
||||
# + output pad delay
|
||||
# ... and the host still needs its own setup time on top.
|
||||
#
|
||||
# Measured on a GameCube 2026-10-03 with PHASE_METER=1 (the engine
|
||||
# drives a constant 0x80 per byte, so the host reads the engine's byte
|
||||
# phase directly, with the register file and both byte FIFOs out of the
|
||||
# path). Per-speed dump, first two slots per EXI speed index:
|
||||
#
|
||||
# 0:8080 1:8080 2:8080 3:8080 4:8080 5:C040
|
||||
#
|
||||
# Perfect to 16 MHz, one bit late at 27 MHz. An 8-slot dump at 27 MHz
|
||||
# read C0 40 40 40 C0 40 40 40 -- a UNIFORM one-bit lag (ones at bit
|
||||
# 1, 9, 17, 25, 33, ...), with extra highs at bits 0 and 32 where
|
||||
# libogc splits the read into two 4-byte transfers and MISO tri-states
|
||||
# to its pull-up in the gap.
|
||||
#
|
||||
# Crucially, ~9 ns was removed from the fabric output path first (the
|
||||
# EXI_J4 pad merge, then the CS mux, then the whole fabric route by
|
||||
# moving the output into an SB_IO register) and the result stayed BYTE
|
||||
# IDENTICAL. Nine nanoseconds out of an 18.5 ns budget cannot change
|
||||
# nothing if propagation delay were the whole story, so the remaining
|
||||
# cost is the part no RTL change can touch: getting the clock in and
|
||||
# the data back out of the chip.
|
||||
#
|
||||
# So stop shrinking the delay and DOUBLE THE BUDGET. Present the bit
|
||||
# the host will sample at rising edge N on rising edge N-1, giving the
|
||||
# whole path a FULL period (37 ns at 27 MHz) instead of half. The
|
||||
# output then changes shortly AFTER the edge on which the host samples
|
||||
# the previous bit, which is safe in both directions: the insertion
|
||||
# delay that caused the problem now covers the host's hold time, and
|
||||
# a full period covers its setup.
|
||||
#
|
||||
# `tx_out` is therefore clocked on the RISING edge and loads at
|
||||
# rx_bit == 7 -- one bit-time earlier than a falling-edge shifter
|
||||
# loading at rx_bit == 0. init=0xFF parks MISO high under CS reset.
|
||||
# This also removes `tx_hold` and the whole exiclk_n domain: the
|
||||
# engine now has ONE clock edge and ONE phase reference.
|
||||
def _tx_bits(self, m, rx_bit, id_sel, id_byte):
|
||||
"""Shift MISO out, launching each bit a FULL CYCLE EARLY.
|
||||
|
||||
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 a
|
||||
half period -- 18.5 ns at 27 MHz. That is not enough on this part,
|
||||
and the shortfall cannot be removed by any RTL change, because of what
|
||||
the path is made of:
|
||||
|
||||
MISO changes at (falling edge AT THE CONNECTOR)
|
||||
+ input pad delay
|
||||
+ GLOBAL CLOCK NETWORK INSERTION <- the big one
|
||||
+ IOB clock-to-Q
|
||||
+ output pad delay
|
||||
... and the host still needs its own setup time on top.
|
||||
|
||||
About 9 ns was removed from the fabric output path in three steps and
|
||||
the console result stayed BYTE IDENTICAL each time. Nine nanoseconds
|
||||
out of 18.5 cannot change nothing if propagation were the whole story,
|
||||
so the rest is the irreducible part: getting the clock into the chip
|
||||
and the data back out.
|
||||
|
||||
So the fix doubles the budget instead of shrinking the delay: present
|
||||
the bit the host samples at rising edge N on rising edge N-1, giving
|
||||
the whole path a FULL period (37 ns at 27 MHz). The output then
|
||||
changes shortly AFTER the edge on which the host sampled the previous
|
||||
bit, which is safe at both ends -- the insertion delay that caused the
|
||||
problem now covers the host's hold time, and a full period covers its
|
||||
setup.
|
||||
|
||||
If a LOW speed index ever regresses while 27 MHz still works, that is
|
||||
the HOLD edge of this window (the launch being too early), not a setup
|
||||
problem.
|
||||
|
||||
`tx_out` is therefore clocked on the rising edge and loads at
|
||||
`rx_bit == 7`, one bit time earlier than a falling-edge shifter
|
||||
loading at `rx_bit == 0`. `init=0xFF` parks MISO high under CS reset.
|
||||
|
||||
`tx_load` pops once per byte boundary unconditionally. That is
|
||||
sufficient because the register file pushes its response during the
|
||||
header's trailing pad byte, and necessary because the pops are also
|
||||
what drains whatever a previous transaction left in the FIFO.
|
||||
"""
|
||||
tx_out = Signal(8, init=0xFF)
|
||||
tx_src = Signal(8) # the byte loaded at the next byte boundary
|
||||
m.d.comb += tx_src.eq(
|
||||
@@ -202,13 +217,11 @@ class SPIMode3Slave(Elaboratable):
|
||||
m.d.exiclk += tx_out.eq(Cat(0, tx_out[:-1]))
|
||||
|
||||
m.d.comb += self.spi_miso.eq(tx_out[7])
|
||||
# What tx_out[7] WILL be after the next RISING edge -- this is what the
|
||||
# What tx_out[7] WILL be after the next rising edge, which 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, tx_src[7], tx_out[6]))
|
||||
|
||||
return m
|
||||
|
||||
|
||||
# ── Testbench ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -248,11 +261,16 @@ if __name__ == "__main__":
|
||||
|
||||
# A STRICT Mode 3 master. Unlike a convenience model that parks MOSI long
|
||||
# before the edge, this one changes MOSI exactly ON the falling edge and
|
||||
# samples MISO in the CLK-low phase. That is what makes the edge
|
||||
# convention observable: a slave that sampled MOSI on the falling edge
|
||||
# would capture the previous bit, and one that drove MISO on the rising
|
||||
# edge would come out a bit late. Both bugs shipped to hardware against a
|
||||
# lax master that could not see them.
|
||||
# samples MISO in the CLK-LOW phase. Both details are what make the edge
|
||||
# conventions observable at all:
|
||||
#
|
||||
# * A slave that sampled MOSI on the falling edge would capture the
|
||||
# PREVIOUS bit. A master that parks MOSI early cannot see that, and
|
||||
# a Mode 2/Mode 3 mix-up reached silicon against one that did.
|
||||
# * MISO must be read in the clock-low phase, because the engine drives
|
||||
# it ON the rising edge. Sampling just after raising the clock sees
|
||||
# the NEXT bit in a zero-delay simulation, which would report
|
||||
# everything one bit early while the hardware was correct.
|
||||
async def spi_byte(ctx, mosi_val, probe_rx=False):
|
||||
miso = 0
|
||||
rx = None
|
||||
@@ -284,9 +302,9 @@ if __name__ == "__main__":
|
||||
ctx.set(dut.tx_byte, 0)
|
||||
await ctx.delay(HP * 4)
|
||||
|
||||
# T1 — RX samples MOSI on the RISING edge, MSB first.
|
||||
# rx_byte/rx_valid are combinational and peak during the final bit,
|
||||
# which is the instant the parent's FIFO captures them.
|
||||
# T1 — RX samples MOSI on the RISING edge, MSB first. rx_byte and
|
||||
# rx_valid are combinational and peak during the final bit, which is
|
||||
# the instant the parent's FIFO captures them.
|
||||
await cs_low(ctx)
|
||||
seen = []
|
||||
for b in (0x80, 0x00, 0x31, 0x00):
|
||||
@@ -297,7 +315,10 @@ if __name__ == "__main__":
|
||||
check("T1 rx_valid", [v for v, _ in seen], [1, 1, 1, 1])
|
||||
|
||||
# T2 — TX: the byte latched at the end of slot N appears in slot N+1,
|
||||
# MSB first. Slot 0 is the reset value, before any load.
|
||||
# MSB first. Slot 0 is the header byte: the host ignores MISO there
|
||||
# and nothing has been loaded yet, so it reads the CS-reset parking
|
||||
# value (tx_out init=0xFF, which is what holds MISO high while
|
||||
# deselected). The bits the host actually uses start at slot 1.
|
||||
await cs_low(ctx)
|
||||
ctx.set(dut.tx_byte, 0xA5)
|
||||
s0 = await spi_byte(ctx, 0x00)
|
||||
@@ -306,10 +327,6 @@ if __name__ == "__main__":
|
||||
ctx.set(dut.tx_byte, 0x00)
|
||||
s2 = await spi_byte(ctx, 0x00)
|
||||
await cs_high(ctx)
|
||||
# Slot 0 is the header byte: the host ignores MISO there and the
|
||||
# engine has not loaded anything yet, so it reads the CS-reset parking
|
||||
# value (tx_out init=0xFF, which is what keeps MISO high while
|
||||
# deselected). The bits the host actually uses start at slot 1.
|
||||
check("T2 miso slot0 (pre-load)", hex(s0), hex(0xFF))
|
||||
check("T2 miso slot1", hex(s1), hex(0xA5))
|
||||
check("T2 miso slot2", hex(s2), hex(0x3C))
|
||||
@@ -333,16 +350,18 @@ if __name__ == "__main__":
|
||||
|
||||
# T5 — a transaction that BEGINS ON A RISING EDGE. The host asserts
|
||||
# CS while CLK is already low, so the first edge the device sees is a
|
||||
# rise and the transaction carries one fewer FALLING edge than rising.
|
||||
# rise and the transaction carries one fewer falling edge than rising.
|
||||
# A TX phase counted on falling edges slips one bit here and never
|
||||
# recovers; a phase taken from rx_bit cannot.
|
||||
#
|
||||
# THIS IS THE HARDWARE BUG. On a GameCube the phase-meter build read
|
||||
# 0x20 (= 0x80 >> 2) in every slot, identically at 1 MHz and 27 MHz.
|
||||
# Nothing in the old testbench started a transaction this way -- every
|
||||
# case asserted CS with CLK high -- so the falling and rising counts
|
||||
# always matched and the slip was structurally invisible, exactly as
|
||||
# the Mode 2/3 mix-up was invisible to a master that parked MOSI early.
|
||||
# THIS IS A HARDWARE BUG THIS TEST EXISTS TO CATCH. With a
|
||||
# free-running falling-edge counter the console's phase-meter build
|
||||
# read 0x20 (= 0x80 >> 2) in every slot, identically at 1 MHz and
|
||||
# 27 MHz -- speed independence being the tell that it was not a
|
||||
# propagation problem. Every earlier test asserted CS with CLK high,
|
||||
# so falls and rises always matched and the slip was structurally
|
||||
# invisible. When adding SPI tests, vary the edge the transaction
|
||||
# starts on.
|
||||
ctx.set(dut.spi_clk, 0) # CLK already LOW ...
|
||||
ctx.set(dut.spi_cs_n, 0) # ... when CS asserts
|
||||
await ctx.delay(HP)
|
||||
@@ -355,11 +374,11 @@ if __name__ == "__main__":
|
||||
check("T5 rising-edge start, slot1", hex(s1), hex(0xA5))
|
||||
check("T5 rising-edge start, slot2", hex(s2), hex(0x3C))
|
||||
|
||||
# T6 — miso_next must equal what spi_miso BECOMES on the next RISING
|
||||
# T6 — miso_next must equal what spi_miso BECOMES on the next rising
|
||||
# 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.
|
||||
# clocked on that edge (synth.py), so if these ever diverge then the
|
||||
# hardware and the simulation stop agreeing about MISO -- 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)
|
||||
@@ -369,7 +388,7 @@ if __name__ == "__main__":
|
||||
for _bit in range(8):
|
||||
ctx.set(dut.spi_clk, 0)
|
||||
await ctx.delay(HP)
|
||||
predicted = ctx.get(dut.miso_next) # before the RISING edge
|
||||
predicted = ctx.get(dut.miso_next) # before the rising edge
|
||||
ctx.set(dut.spi_clk, 1)
|
||||
await ctx.delay(HP)
|
||||
if ctx.get(dut.spi_miso) != predicted:
|
||||
@@ -379,9 +398,10 @@ if __name__ == "__main__":
|
||||
|
||||
# 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).
|
||||
# domain, with the tx FIFO deliberately feeding 0x00 so that only the
|
||||
# fast path can be the source. The register file cannot make the
|
||||
# round trip inside a 2-byte header's one byte time; on a console it
|
||||
# returned the ID one byte late.
|
||||
ctx.set(dut.id_arm, 1)
|
||||
ctx.set(dut.tx_byte, 0x00) # FIFO deliberately contributes nothing
|
||||
await cs_low(ctx)
|
||||
@@ -392,9 +412,10 @@ if __name__ == "__main__":
|
||||
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.
|
||||
# T8 — and the fast path must be GATED by id_arm, so that a build
|
||||
# which disarms it gets the FIFO byte through untouched. That is what
|
||||
# the PHASE_METER bitstream relies on: its constant 0x80 pattern would
|
||||
# otherwise be replaced by ID bytes in the slots it measures.
|
||||
ctx.set(dut.id_arm, 0)
|
||||
ctx.set(dut.tx_byte, 0x5A)
|
||||
await cs_low(ctx)
|
||||
|
||||
Reference in New Issue
Block a user