diff --git a/exi_bba/bba_register_file.py b/exi_bba/bba_register_file.py index f68ac42..33f90a2 100644 --- a/exi_bba/bba_register_file.py +++ b/exi_bba/bba_register_file.py @@ -70,6 +70,13 @@ class BBARegisterFile(Elaboratable): # (DMA) bulk reads, not just ≤4-byte immediate transfers. self.cs_active = Signal() + # One-shot: set at reset, cleared by the GC's first NCRA reset. While + # set, a command-space read of register 0 returns the device ID -- but + # the REPLY is produced by SPIMode3Slave's fast path, not here, because + # a 2-byte command header leaves too little time for a round trip + # through both byte FIFOs. This output is what arms that path. + self.id_phase = Signal(init=1) + # ── Interrupt (exi domain) ──────────────────────────────────────── self.exi_int_n = Signal(init=1) @@ -222,7 +229,7 @@ class BBARegisterFile(Elaboratable): tx_queued = Signal() # True until first NCRA reset write: return device ID on addr=0 reads - id_phase = Signal(init=1) + id_phase = self.id_phase # Per-byte SPRAM read handshake (register-read path): sp_req marks a # request in flight; drain_ctr counts the read-phase dummy bytes. @@ -262,13 +269,8 @@ class BBARegisterFile(Elaboratable): with m.Case(_HIPR): m.d.comb += reg_rdval.eq(_HIPR_VAL) with m.Default(): m.d.comb += reg_rdval.eq(0) - # Device-ID bytes (addr 0 read while id_phase): 0x04 0x02 0x02 0x00 - devid = Signal(8) - with m.Switch(byte_ctr): - with m.Case(0): m.d.comb += devid.eq(0x04) - with m.Case(1): m.d.comb += devid.eq(0x02) - with m.Case(2): m.d.comb += devid.eq(0x02) - with m.Default(): m.d.comb += devid.eq(0x00) + # The device-ID constant lives in SPIMode3Slave now (it must be + # answered with zero CDC latency), so there is no copy here. cmd_rdval = Signal(8) with m.Switch(cmd_reg): @@ -325,14 +327,12 @@ class BBARegisterFile(Elaboratable): cmd_reg.eq(self.rx_data[0:6]), addr.eq(self.rx_data[0:6]), eff_addr.eq(self.rx_data[0:6])] - # EXI_GetID (byte 0 == 0x00) gives only ONE header - # byte of warning before the GC starts clocking the - # reply, so push the first ID byte the instant byte 0 - # identifies the query -- the earliest point possible. - with m.If((self.rx_data == 0x00) & id_phase - & self.tx_rdy): - m.d.comb += [self.tx_data.eq(0x04), self.tx_en.eq(1)] - m.d.sync += byte_ctr.eq(1) + # NOTE: the device-ID reply is NOT produced here. + # A 2-byte command header leaves one byte time for a + # round trip through both AsyncFIFOs, which measured + # too slow on a console (the ID came back one byte + # late). SPIMode3Slave answers it from the EXI clock + # domain instead -- see its device-ID fast path. m.next = "CHDR_1" # ── register space: [0x80|w<<6, ah, al, 0x00] ────────────────── @@ -392,25 +392,14 @@ class BBARegisterFile(Elaboratable): with m.State("CHDR_1"): with m.If(self.rx_rdy): m.d.comb += self.rx_en.eq(1) - # EXI_GetID is its own shape: the GC writes 0x0000 (two - # bytes, so it lands here as command reg 0) and then clocks - # FOUR read bytes. Hand it the ID stream rather than the - # single-byte command response. + # The device-ID query (command reg 0 while id_phase) is + # answered by SPIMode3Slave, not here: pushing anything + # for it would leave stale bytes in the tx FIFO that a + # 2-byte header has too few pops to drain. with m.If(~is_write & (cmd_reg == 0) & id_phase): - # byte_ctr is NOT reset here: HEADER0 already pushed - # ID byte 0 and advanced it. Resetting would restart - # the sequence and duplicate the first byte. m.next = "DEVID" with m.Else(): m.next = "CHDR_2" - with m.Elif(~is_write & (cmd_reg == 0) & id_phase - & self.tx_rdy & (byte_ctr < 4)): - # Device-ID query: keep feeding the ID while byte 1 is - # still on the wire so the tx FIFO stays ahead of the GC. - # NOT one-shot -- the FIFO is only 2 deep, and stopping - # after one byte leaves a hole in the next slot. - m.d.comb += [self.tx_data.eq(devid), self.tx_en.eq(1)] - m.d.sync += byte_ctr.eq(byte_ctr + 1) with m.Elif(~is_write & self.tx_rdy & ~prefetched): # Ordinary command read: response lands in byte slot 3. m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)] @@ -506,17 +495,10 @@ class BBARegisterFile(Elaboratable): m.next = "HEADER0" with m.State("DEVID"): - # Push EXACTLY the four ID bytes, then stop. - # The engine latches the tx_fifo head at every byte boundary, - # and a 2-byte command header leaves only ONE drain slot - # before the data phase. Streaming zeros until CS left two - # stale bytes behind, one too many to drain, so the NEXT - # enumeration came back a slot late (04 02 02 00 -> 00 04 02 02). - # Register-space reads may over-push safely: their 4-byte - # header gives three drain slots. - with m.If(self.tx_rdy & self.cs_active & (byte_ctr < 4)): - m.d.comb += [self.tx_data.eq(devid), self.tx_en.eq(1)] - m.d.sync += byte_ctr.eq(byte_ctr + 1) + # The reply comes from SPIMode3Slave's device-ID fast path; + # this state only drains the GC's dummy bytes and waits for + # CS, so NOTHING is pushed into the tx FIFO here. Pushing + # would leave stale bytes a 2-byte header cannot drain. with m.If(self.rx_rdy): m.d.comb += self.rx_en.eq(1) with m.If(~self.cs_active): @@ -727,11 +709,17 @@ if __name__ == "__main__": ctx.set(reg.tx_rdy, 1) # tx_fifo always has room in this model await ctx.tick("sync").repeat(8) - # T1: Device ID (addr=0, 4-byte read) + # T1: a device-ID query must push NOTHING. + # The reply comes from SPIMode3Slave's fast path (a 2-byte command + # header leaves too little time for a round trip through both byte + # FIFOs), so anything pushed here is stale data the header has too few + # pops to drain -- which is exactly how an earlier version made the + # NEXT enumeration come back a slot late. The ID value itself is + # tested in spi_mode3_slave (T7) and end-to-end in bba_top. result = await exi_devid(ctx) - if result != _DEVICE_ID: - errors.append(f"T1 device ID: expected {_DEVICE_ID}, got {result}") - print(f"T1 device ID: {[f'0x{b:02X}' for b in result]}") + if result != []: + errors.append(f"T1 device-ID query must push nothing, got {result}") + print(f"T1 device-ID query pushes nothing: {result == []}") await ctx.tick("sync").repeat(4) # T2: Write and read back PAR0-PAR3 diff --git a/exi_bba/bba_top.py b/exi_bba/bba_top.py index 1413b64..f91a564 100644 --- a/exi_bba/bba_top.py +++ b/exi_bba/bba_top.py @@ -190,6 +190,7 @@ class BBATop(Elaboratable): # EXI outputs self.exi_miso.eq(cap.spi_miso), self.exi_miso_next.eq(cap.miso_next), + cap.id_phase.eq(reg.id_phase), self.int_n .eq(reg.exi_int_n), ] diff --git a/exi_bba/exi_capture.py b/exi_bba/exi_capture.py index 2bf225c..1989bac 100644 --- a/exi_bba/exi_capture.py +++ b/exi_bba/exi_capture.py @@ -83,6 +83,10 @@ class ExiCapture(Elaboratable): self.spi_cs_n = Signal(init=1) self.spi_miso = Signal() self.miso_next = Signal(init=1) # for an SB_IO output register + # Register-file id_phase (sync domain). Crossed into exiclk so the + # bit engine can answer the device-ID query itself -- see the fast + # path in SPIMode3Slave. + self.id_phase = Signal(init=1) # Core-facing RX read side self.rx_data = Signal(8) @@ -129,6 +133,12 @@ class ExiCapture(Elaboratable): ] spi = SPIMode3Slave(phase_meter=self._phase_meter) + # id_phase is a slow single bit: it starts set and clears once, on the + # GC's first NCRA reset, which happens inside a register-space + # transaction with the clock running -- so it propagates well before + # the next transaction needs it. + m.submodules.id_sync = FFSynchronizer(self.id_phase, spi.id_arm, + o_domain="exiclk", init=1) m.submodules.spi = spi # RX bytes cross exiclk -> sync; TX bytes cross sync -> exififo_r. diff --git a/exi_bba/spi_mode3_slave.py b/exi_bba/spi_mode3_slave.py index 9cfb1cb..056c6c3 100644 --- a/exi_bba/spi_mode3_slave.py +++ b/exi_bba/spi_mode3_slave.py @@ -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"):