7ad03a34ac
308 -> 322 lines, 18% -> 13% comment-only, and the clock-domain comment was
flatly wrong. It said:
"Two edges are needed: Mode 3 samples MOSI on the RISING edge and MISO
must change on the FALLING edge, between the master's sampling points.
Each edge gets TWO domains with the same clock: *_rst async-reset by
CS ... (plain) no reset ..."
None of that describes this module. There are THREE domains, not four; they
are all the same edge, not two; there is no `*_rst` naming; and MISO has not
changed on the falling edge since the full-cycle-early launch. The docstring
now states what is actually built and, for each domain, what forces its reset
choice: the engine must see CS because its counters restart per transaction,
the FIFO sides must not (resetting one side desynchronises an AsyncFIFO's gray
pointers), and both FIFO sides must be posedge because AsyncFIFO instantiates
a posedge-only reset CDC internally.
Also corrected: "capture domain" and "core domain" in the class docstring,
which are names from the deleted oversampling design; cs_active described as
living in "the exi domain" when it is produced in sync; and a claim that the
engine "hands the byte to its negedge output stage", which no longer exists.
The id_arm comment kept its rule and lost its history: armed permanently
because real hardware answers EXI_GetID whenever asked, and a device that
stops identifying itself reads to the host as ABSENT rather than broken; the
port survives only for the PHASE_METER build.
Structure: elaborate() is now _clock_domains, _bit_engine, _byte_fifos and
_transaction_framing. _transaction_framing records what is deliberately NOT
synchronised here, since that is the non-obvious part -- CLK is a clock and CS
is an async reset, and synchronising them was the original bug rather than a
safety measure.
VERIFIED EQUIVALENT: generated RTLIL identical for both build variants
(phase_meter False and True, 2120 and 2117 lines) after stripping \src
attributes. 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>
324 lines
13 KiB
Python
324 lines
13 KiB
Python
"""ExiCapture — the EXI front end, clocked by the GameCube's own EXI clock.
|
|
|
|
Owns the pin-driven clock domains (which is why it creates them rather than
|
|
`BBATop`: the byte FIFOs have to name them), wraps the `SPIMode3Slave` bit
|
|
engine, and bridges that engine to the 24 MHz `sync` domain through two
|
|
`AsyncFIFO`s:
|
|
|
|
EXI clock pin (~27 MHz) sync (24 MHz, HFOSC)
|
|
┌─────────────────────┐ rx_fifo ───► received bytes (header + data)
|
|
│ SPIMode3Slave │ (8-bit, exififo_w→sync)
|
|
│ (bit engine) │ tx_fifo ◄─── response bytes to drive on MISO
|
|
└─────────────────────┘ (8-bit, sync→exififo_r)
|
|
|
|
These two FIFOs are the ONLY clock-domain crossings in the EXI path, and each
|
|
has a whole byte time (~296 ns at 27 MHz) of slack. That is the point of
|
|
clocking the engine from the pin: oversampling it from a faster domain put
|
|
BIT-level timing across a crossing instead, with roughly 55 ns of synchroniser
|
|
latency against a 37 ns bit, and MISO came out two bits late.
|
|
|
|
Domains created here
|
|
--------------------
|
|
exiclk EXI clock pin, posedge, ASYNC-RESET BY CS. Hosts the bit
|
|
engine: samples MOSI, and launches MISO one bit early.
|
|
exififo_w EXI clock pin, posedge, RESET-LESS. rx FIFO write side.
|
|
exififo_r EXI clock pin, posedge, RESET-LESS. tx FIFO read side.
|
|
|
|
All three are the same clock and the same edge; they differ only in reset,
|
|
and each difference is forced:
|
|
|
|
* The bit engine MUST be reset by CS, because its bit and byte counters
|
|
have to restart at every transaction.
|
|
* The FIFO sides must NOT see CS. Resetting one side of an `AsyncFIFO`
|
|
desynchronises its gray pointers against the other side and corrupts it.
|
|
* Both FIFO sides must be POSEDGE, because `AsyncFIFO` instantiates a
|
|
posedge-only reset CDC internally and cannot host a negedge side.
|
|
|
|
TX response timing
|
|
------------------
|
|
The engine pops `tx_fifo` once per byte boundary, unconditionally -- there is
|
|
no header counter and no slot threshold. The byte popped at the end of byte N
|
|
is presented during byte N+1, which gives the scheme two useful properties:
|
|
|
|
* pops during a header drain whatever the previous transaction left in the
|
|
FIFO, so no CS-fall flush is needed; and
|
|
* the response the register file pushes during the protocol's turnaround
|
|
byte lands in data slot 0.
|
|
|
|
⚠️ Command space has only ONE drain slot, because its header is two bytes. A
|
|
command-space responder must therefore push exactly its bytes and stop; see
|
|
the device-ID note in `bba_register_file.py`.
|
|
"""
|
|
|
|
from amaranth import *
|
|
from amaranth.lib.cdc import FFSynchronizer
|
|
from amaranth.lib.fifo import AsyncFIFO
|
|
|
|
from exi_bba.spi_mode3_slave import SPIMode3Slave
|
|
|
|
__all__ = ["ExiCapture"]
|
|
|
|
|
|
class ExiCapture(Elaboratable):
|
|
"""The EXI bit engine plus the byte FIFOs that connect it to `sync`.
|
|
|
|
Physical SPI pins, from the GC
|
|
------------------------------
|
|
spi_clk, spi_mosi, spi_cs_n
|
|
Raw inputs. `spi_clk` becomes the clock of all three domains above
|
|
and `spi_cs_n` the async reset of `exiclk`.
|
|
spi_miso
|
|
Serial output, driven from the fabric shift register.
|
|
miso_next
|
|
The same bit one edge early, for an SB_IO output register; this is
|
|
what the synthesised build drives the pad from.
|
|
|
|
RX byte stream, read side in `sync`
|
|
-----------------------------------
|
|
rx_data the received byte at the head of the FIFO
|
|
rx_rdy a byte is available
|
|
rx_en pop; assert for one `sync` cycle to consume `rx_data`
|
|
|
|
TX byte stream, write side in `sync`
|
|
------------------------------------
|
|
tx_data response byte to enqueue
|
|
tx_en write strobe
|
|
tx_rdy the FIFO has room
|
|
|
|
Transaction framing
|
|
-------------------
|
|
cs_active
|
|
High in `sync` while a transaction is in progress. The register file
|
|
uses it to stream a variable-length (DMA) read until CS deasserts,
|
|
since the protocol carries no length field.
|
|
"""
|
|
|
|
def __init__(self, rx_depth=4, tx_depth=2, phase_meter=False):
|
|
self._rx_depth = rx_depth
|
|
self._tx_depth = tx_depth
|
|
self._phase_meter = phase_meter # diagnostic; see SPIMode3Slave
|
|
|
|
self.spi_clk = Signal(init=1)
|
|
self.spi_mosi = Signal()
|
|
self.spi_cs_n = Signal(init=1)
|
|
self.spi_miso = Signal()
|
|
self.miso_next = Signal(init=1)
|
|
|
|
self.rx_data = Signal(8)
|
|
self.rx_rdy = Signal()
|
|
self.rx_en = Signal()
|
|
|
|
self.tx_data = Signal(8)
|
|
self.tx_en = Signal()
|
|
self.tx_rdy = Signal()
|
|
|
|
self.cs_active = Signal()
|
|
|
|
def elaborate(self, platform):
|
|
m = Module()
|
|
self._clock_domains(m)
|
|
spi = self._bit_engine(m)
|
|
self._byte_fifos(m, spi)
|
|
self._transaction_framing(m)
|
|
return m
|
|
|
|
def _clock_domains(self, m):
|
|
"""Create the three pin-clocked domains.
|
|
|
|
EXI CLK is 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. See the module docstring for why each domain's reset differs.
|
|
"""
|
|
m.domains.exiclk = cd_p = ClockDomain(async_reset=True)
|
|
m.domains.exififo_w = cd_fw = ClockDomain(reset_less=True)
|
|
m.domains.exififo_r = cd_fr = ClockDomain(reset_less=True)
|
|
m.d.comb += [
|
|
cd_p .clk.eq(self.spi_clk), cd_p .rst.eq(self.spi_cs_n),
|
|
cd_fw.clk.eq(self.spi_clk),
|
|
cd_fr.clk.eq(self.spi_clk),
|
|
]
|
|
|
|
def _bit_engine(self, m):
|
|
"""Instantiate the bit engine and wire it to the pins."""
|
|
spi = SPIMode3Slave(phase_meter=self._phase_meter)
|
|
m.submodules.spi = spi
|
|
|
|
# The device-ID fast path is ARMED PERMANENTLY, because real hardware
|
|
# answers EXI_GetID whenever it is asked -- that is how EXI enumerates
|
|
# devices, and a device that stops identifying itself after init reads
|
|
# to the host as absent rather than broken.
|
|
#
|
|
# `id_arm` remains a port only for the PHASE_METER diagnostic build,
|
|
# which must turn the fast path OFF so its constant 0x80 pattern is
|
|
# not replaced by ID bytes in the slots it measures.
|
|
m.d.comb += spi.id_arm.eq(0 if self._phase_meter else 1)
|
|
|
|
m.d.comb += [
|
|
spi.spi_clk .eq(self.spi_clk),
|
|
spi.spi_mosi.eq(self.spi_mosi),
|
|
spi.spi_cs_n.eq(self.spi_cs_n),
|
|
self.spi_miso .eq(spi.spi_miso),
|
|
self.miso_next.eq(spi.miso_next),
|
|
]
|
|
return spi
|
|
|
|
def _byte_fifos(self, m, spi):
|
|
"""The two AsyncFIFOs that carry bytes across to and from `sync`."""
|
|
rx_fifo = AsyncFIFO(width=8, depth=self._rx_depth,
|
|
w_domain="exififo_w", r_domain="sync")
|
|
tx_fifo = AsyncFIFO(width=8, depth=self._tx_depth,
|
|
w_domain="sync", r_domain="exififo_r")
|
|
m.submodules.rx_fifo = rx_fifo
|
|
m.submodules.tx_fifo = tx_fifo
|
|
|
|
# RX: every byte the engine completes goes straight in.
|
|
m.d.comb += [
|
|
rx_fifo.w_data.eq(spi.rx_byte),
|
|
rx_fifo.w_en .eq(spi.rx_valid),
|
|
self.rx_data .eq(rx_fifo.r_data),
|
|
self.rx_rdy .eq(rx_fifo.r_rdy),
|
|
rx_fifo.r_en .eq(self.rx_en),
|
|
]
|
|
|
|
# TX: `sync` pushes responses and the engine pops one per byte time.
|
|
# Those pops are unconditional -- see the module docstring.
|
|
m.d.comb += [
|
|
tx_fifo.w_data.eq(self.tx_data),
|
|
tx_fifo.w_en .eq(self.tx_en),
|
|
self.tx_rdy .eq(tx_fifo.w_rdy),
|
|
spi.tx_byte .eq(tx_fifo.r_data),
|
|
tx_fifo.r_en .eq(spi.tx_load),
|
|
]
|
|
|
|
def _transaction_framing(self, m):
|
|
"""Bring CS into `sync` as a level.
|
|
|
|
CS changes once per transaction, which is slow relative to `sync`, so
|
|
a plain two-stage synchroniser is the right primitive. Note what is
|
|
deliberately NOT done here: the EXI clock and MOSI are not
|
|
synchronised anywhere. CLK *is* a clock and CS *is* an async reset --
|
|
synchronising them into a fast domain was the original MISO bug, not
|
|
a safety measure.
|
|
"""
|
|
cs_n_s = Signal(init=1)
|
|
m.submodules.cs_sync = FFSynchronizer(self.spi_cs_n, cs_n_s,
|
|
o_domain="sync", init=1)
|
|
m.d.comb += self.cs_active.eq(~cs_n_s)
|
|
|
|
|
|
# ── Testbench ─────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
from amaranth.sim import Simulator, Period
|
|
|
|
dut = ExiCapture()
|
|
errors = []
|
|
|
|
# SPI half-period, in units of HP below. At 27 MHz EXI the real
|
|
# ratio is ~2; use 4 here for a clean, well-oversampled functional check.
|
|
HALF = 4
|
|
|
|
# The bit engine is clocked by the EXI pin, so there is no faster domain
|
|
# to count ticks against; the SPI clock is driven with real delays.
|
|
HP = Period(MHz=54) # the unit of delay; half-periods are HP * HALF
|
|
|
|
async def spi_byte(ctx, mosi_val):
|
|
"""Clock one SPI Mode 3 byte; return the assembled MISO byte."""
|
|
miso = 0
|
|
for bit in range(7, -1, -1):
|
|
ctx.set(dut.spi_mosi, (mosi_val >> bit) & 1)
|
|
ctx.set(dut.spi_clk, 0)
|
|
await ctx.delay(HP * (HALF))
|
|
miso = (miso << 1) | ctx.get(dut.spi_miso)
|
|
ctx.set(dut.spi_clk, 1)
|
|
await ctx.delay(HP * (HALF))
|
|
return miso
|
|
|
|
async def core_drain_rx(ctx, into):
|
|
"""Pop one byte from the core RX side if available."""
|
|
if ctx.get(dut.rx_rdy):
|
|
into.append(ctx.get(dut.rx_data))
|
|
ctx.set(dut.rx_en, 1)
|
|
await ctx.tick("sync").repeat(1)
|
|
ctx.set(dut.rx_en, 0)
|
|
return True
|
|
return False
|
|
|
|
async def push_tx(ctx, b):
|
|
ctx.set(dut.tx_data, b)
|
|
ctx.set(dut.tx_en, 1)
|
|
await ctx.tick("sync").repeat(1)
|
|
ctx.set(dut.tx_en, 0)
|
|
|
|
async def do_txn(ctx, hdr, responses, n_data, rx_seen):
|
|
"""One EXI transaction, modelling the REAL protocol shape.
|
|
|
|
The engine pops tx_fifo at every byte boundary, so a response must be
|
|
in the FIFO before the LAST header byte ends to land in data slot 0.
|
|
That byte is the protocol's turnaround byte (docs/exi_protocol_real.md)
|
|
and is exactly when the register file pushes. Pushing after the whole
|
|
header -- as this bench used to -- is one slot too late.
|
|
"""
|
|
ctx.set(dut.spi_cs_n, 0)
|
|
ctx.set(dut.spi_clk, 1)
|
|
await ctx.delay(HP * (HALF))
|
|
for h in hdr[:-1]:
|
|
await spi_byte(ctx, h)
|
|
await core_drain_rx(ctx, rx_seen)
|
|
for r in responses: # pushed during the turnaround
|
|
await push_tx(ctx, r)
|
|
await spi_byte(ctx, hdr[-1])
|
|
await core_drain_rx(ctx, rx_seen)
|
|
miso = [await spi_byte(ctx, 0x00) for _ in range(n_data)]
|
|
ctx.set(dut.spi_cs_n, 1)
|
|
await ctx.delay(HP * (HALF))
|
|
for _ in range(20): # drain data-phase dummies
|
|
await core_drain_rx(ctx, rx_seen)
|
|
await ctx.tick("sync").repeat(1)
|
|
return miso
|
|
|
|
async def testbench(ctx):
|
|
rx_seen = []
|
|
await ctx.delay(HP * (2))
|
|
|
|
# Real register-space header: [0x80, ah, al, 0x00] -- the trailing
|
|
# byte is the turnaround slot.
|
|
HDR = [0x80, 0x00, 0x12, 0x00]
|
|
|
|
# ── T1: header + 2 data bytes read back ──────────────────────────
|
|
miso = await do_txn(ctx, HDR, [0xA5, 0x5A], 2, rx_seen)
|
|
print(f"T1 rx={[hex(b) for b in rx_seen[:4]]} MISO={[f'0x{b:02X}' for b in miso]}")
|
|
if rx_seen[:4] != HDR:
|
|
errors.append(f"T1 header rx wrong: {[hex(b) for b in rx_seen[:4]]}")
|
|
if miso != [0xA5, 0x5A]:
|
|
errors.append(f"T1 MISO wrong: {[hex(b) for b in miso]}")
|
|
|
|
# ── T2: over-push must NOT leak into the next transaction ────────
|
|
# Txn A pushes 2 responses but the GC clocks only 1 data byte, leaving
|
|
# a stale byte in tx_fifo. Txn B must read its OWN responses. There
|
|
# is no CS-fall flush any more: the engine's pops during the header
|
|
# drain whatever the previous transaction left behind.
|
|
rx_seen.clear()
|
|
await do_txn(ctx, HDR, [0xA5, 0x5A], 1, rx_seen) # leaves 0x5A
|
|
rx_seen.clear()
|
|
misoB = await do_txn(ctx, HDR, [0x11, 0x22], 2, rx_seen)
|
|
print(f"T2 MISO after over-push: {[f'0x{b:02X}' for b in misoB]} (want 0x11 0x22)")
|
|
if misoB != [0x11, 0x22]:
|
|
errors.append(f"T2 stale byte leaked: {[hex(b) for b in misoB]}")
|
|
|
|
sim = Simulator(dut)
|
|
sim.add_clock(Period(MHz=24), domain="sync")
|
|
sim.add_testbench(testbench)
|
|
|
|
with sim.write_vcd("ExiCapture.vcd"):
|
|
sim.run()
|
|
|
|
if errors:
|
|
print("\nFAILURES:")
|
|
for e in errors:
|
|
print(" ", e)
|
|
sys.exit(1)
|
|
else:
|
|
print("\nAll tests passed.")
|