diff --git a/exi_bba/exi_capture.py b/exi_bba/exi_capture.py index 6ad21f1..524df77 100644 --- a/exi_bba/exi_capture.py +++ b/exi_bba/exi_capture.py @@ -1,8 +1,9 @@ -"""ExiCapture — EXI byte-capture front-end, clocked by the GC's EXI clock. +"""ExiCapture — the EXI front end, clocked by the GameCube's own EXI clock. -Creates the pin-driven clock domains (so the byte FIFOs can name them), wraps -the SPIMode3Slave bit engine, and bridges it to the 24 MHz `sync` domain -through two AsyncFIFOs: +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) @@ -10,36 +11,43 @@ through two AsyncFIFOs: │ (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 -- 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 + 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. -The FIFO domains must NOT see CS: resetting one side of an AsyncFIFO -desynchronises its gray pointers against the other side. They are also both -posedge, because AsyncFIFO instantiates a posedge-only reset CDC internally and -cannot host a negedge side. +All three are the same clock and the same edge; they differ only in reset, +and each difference is forced: -Why pin-clocked: oversampling the EXI clock from a faster domain put BIT-level -timing across a CDC boundary (~55 ns of synchroniser latency against a 37 ns -bit), so MISO came out ~2 bits late and the GC read 0x17 as 05 C0. Clocking -from the pin makes MISO clock-to-Q and leaves these byte FIFOs as the only -crossing, each with a full byte-time (~296 ns) of slack. + * 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, so pops during the header drain whatever the -previous transaction left in the FIFO (this replaces the old CS-fall flush), -and the response the register file pushes during the protocol's turnaround -byte lands in data slot 0. +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: -⚠️ Command space has only ONE drain slot (2-byte header), so a command-space -responder must push exactly its bytes and stop -- see the DEVID note in -bba_register_file.py. + * 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 * @@ -52,97 +60,110 @@ __all__ = ["ExiCapture"] class ExiCapture(Elaboratable): - """EXI front-end: SPI bit engine (capture domain) + byte FIFOs to core. + """The EXI bit engine plus the byte FIFOs that connect it to `sync`. - Physical SPI pins (capture domain) - ---------------------------------- - spi_clk / spi_mosi / spi_cs_n : raw async inputs from the GC - spi_miso : output to the GC + 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. - Core-facing RX byte stream (core domain, FWFT read side of rx_fifo) - ------------------------------------------------------------------ - rx_data : current received byte - rx_rdy : a received byte is available - rx_en : pop (assert for one core cycle to consume rx_data) + 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` - Core-facing TX byte stream (core domain, write side of tx_fifo) - -------------------------------------------------------------- - tx_data : response byte to enqueue - tx_en : write strobe - tx_rdy : tx_fifo has room + 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._phase_meter = phase_meter # diagnostic; see SPIMode3Slave - # Physical SPI (capture domain, wired to pins by BBATop) 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) # for an SB_IO output register - # Core-facing RX read side + self.miso_next = Signal(init=1) + self.rx_data = Signal(8) self.rx_rdy = Signal() self.rx_en = Signal() - # Core-facing TX write side self.tx_data = Signal(8) self.tx_en = Signal() self.tx_rdy = Signal() - # Core-facing: high (exi domain) while a transaction is in progress. - # The register file uses it to stream variable-length (DMA) reads until - # CS deasserts. 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 - # ── Clock domains driven by the EXI clock pin ───────────────────── - # The bit engine is clocked by EXI CLK itself (pin 44 = die (6,0) = - # global buffer net 6 on the 5k-sg48, so it can drive a real clock - # network). 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 -- the bit/byte counters, which must - # restart at every transaction - # (plain) no reset -- the byte FIFOs. Resetting only ONE side of an - # AsyncFIFO desynchronises its gray pointers and corrupts it, - # so the FIFOs must NOT see CS. - m.domains.exiclk = cd_p = ClockDomain(async_reset=True) - m.domains.exififo_w = cd_fw = ClockDomain(reset_less=True) - # NOTE: both FIFO domains are POSEDGE. AsyncFIFO instantiates a - # posedge-only reset CDC internally, so a negedge domain cannot host a - # FIFO side. The engine pops in `exififo_r` (posedge) and hands the - # byte to its negedge output stage through a register. - m.domains.exififo_r = cd_fr = ClockDomain(reset_less=True) + 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) - # The device-ID fast path is ARMED PERMANENTLY. It used to be gated - # by the register file's `id_phase`, a one-shot cleared on the GC's - # first NCRA reset and crossed in here with an FFSynchronizer -- so - # after init the device stopped identifying itself and Swiss's - # EXI_GetID returned 0x00000000, i.e. "no adapter". Real hardware - # answers EXI_GetID whenever asked. `id_arm` stays a port because - # the PHASE_METER diagnostic build needs to turn the fast path off - # (it would otherwise send ID bytes instead of the constant 0x80). - m.d.comb += spi.id_arm.eq(0 if self._phase_meter else 1) m.submodules.spi = spi - # RX bytes cross exiclk -> sync; TX bytes cross sync -> exififo_r. - # These are the ONLY crossings left, and each has a whole byte-time - # (~296 ns at 27 MHz) of slack -- unlike the old design, where - # bit-level MISO timing crossed a domain with a 37 ns budget. + # 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, @@ -150,16 +171,7 @@ class ExiCapture(Elaboratable): m.submodules.rx_fifo = rx_fifo m.submodules.tx_fifo = tx_fifo - # ── Physical pins ↔ bit engine ─────────────────────────────────── - 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), - ] - - # ── RX: every received byte → rx_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), @@ -168,10 +180,8 @@ class ExiCapture(Elaboratable): rx_fifo.r_en .eq(self.rx_en), ] - # ── TX: core pushes responses; the engine pops one per byte-time ── - # Pops are UNCONDITIONAL (one per byte boundary). Header pops drain - # whatever the previous transaction left behind, so no CS-fall flush - # or slot-threshold logic is needed here any more. + # 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), @@ -180,16 +190,21 @@ class ExiCapture(Elaboratable): tx_fifo.r_en .eq(spi.tx_load), ] - # ── Transaction framing, in the slow domain ────────────────────── - # CS is slow relative to `exi`, so a plain synchroniser is correct and - # there is no longer any reason to detect it in a fast domain. + 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) - return m - # ── Testbench ───────────────────────────────────────────────────────────── @@ -200,13 +215,13 @@ if __name__ == "__main__": dut = ExiCapture() errors = [] - # SPI half-period in capture ticks. At 54 MHz capture / 27 MHz EXI the real + # 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 - # No capture domain any more: the bit engine is clocked by the EXI pin, - # so drive the SPI clock with real delays instead of counting ticks. - HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n + # 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."""