Rework the EXI decoder to the real GC<->BBA protocol

Replaces the invented 2-byte header (addr split across both bytes, 2-bit
length field) with the protocol libogc and Swiss actually speak:

  command  (bit7=0, 6-bit reg)   [reg, 00, 00, data]  one 4-byte transfer
  register (bit7=1, 16-bit reg)  [0x80|w<<6, ah, al, 00] + data until CS

This was not an optimisation: the old decoder read Swiss's NWAYS read
(80 00 31 00) as 'write 0x31 to NCRA', and bit 0 of 0x31 is RESET, so every
register read would have reset the device.

Changes:
  * 16-bit address; no length field -- register reads and writes now stream
    until CS, like the SPRAM path already did
  * RHDR_LO starts the SPRAM fetch as soon as the address completes, using
    the trailing header byte as the prefetch window the protocol provides
  * command space serves EXI regs 0x02/0x03 (interrupt mask/status), the only
    two Swiss uses
  * EXI_GetID gets its own path: it offers just ONE header byte of warning, so
    the first ID byte is pushed the instant byte 0 identifies the query

Two timing subtleties, both found by simulation and commented in place:
  * MISO is driven LIVE from the tx FIFO head, so a byte pushed while the pad
    byte is on the wire appears in the pad slot.  A filler byte is parked
    there (MISO is don't-care through the header) so the first real response
    lands in data slot 0.
  * the prefetch must be one-shot; re-firing while waiting for the pad byte
    consumed the register stream early and shifted reads by a byte.

All testbenches updated to the real format and passing: bba_register_file
(7), bba_top (8 incl. the full W5100->SPRAM->GC loop and TX), exi_j4,
exi_capture, spi_mode3_slave, tx_frame_drain, spram_arbiter,
rx_frame_assembler, w5100_parallel_master.  gc_bba_test rebuilt against the
new protocol.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:58:52 +02:00
parent f2762f613d
commit 2458884160
6 changed files with 319 additions and 131 deletions
+227 -64
View File
@@ -202,11 +202,24 @@ class BBARegisterFile(Elaboratable):
m.d.comb += self.ncra_sr.eq(r_ncra[3]) # start-receive bit
# ── Transaction state ────────────────────────────────────────────
hdr0 = Signal(8)
addr = Signal(13)
# Real GC<->BBA header (see docs/exi_protocol_real.md), verified
# against libogc and Swiss. Byte 0 bit7 selects the address space and
# bit6 is the write flag; there is NO length field -- the data phase
# runs until CS.
# command (bit7=0): [reg&0x3F, 00, 00, data] one 4-byte transfer
# register (bit7=1): [0x80|w<<6, ah, al, 00] + data phase until CS
addr = Signal(16)
is_write = Signal()
xfer_len = Signal(2) # 0=1B … 3=4B
byte_ctr = Signal(2)
cmd_space = Signal() # this transaction is a command access
cmd_reg = Signal(6)
byte_ctr = Signal(3) # response-byte index (device-ID sequence)
cmd_wr = Signal() # command-space write pending
prefetched = Signal() # first response byte already pushed
cmd_wdata = Signal(8)
# EXI-side command registers. Swiss uses exactly two: 0x02 is the EXI
# interrupt mask and 0x03 the EXI interrupt status (write-1-to-clear).
r_cmd_imr = Signal(8)
r_cmd_ir = Signal(8)
tx_frame_len = Signal(16)
# TX trigger bookkeeping. `twd_armed` fires one cycle AFTER TWD_HI is
# written, so tx_frame_len has both halves by then; `tx_queued` records
@@ -226,7 +239,7 @@ class BBARegisterFile(Elaboratable):
# sp_addr — next SPRAM byte address to request (auto-increments)
# outstanding — SPRAM requests issued but whose responses are not yet
# popped (bounds prefetch and is drained at end)
sp_addr = Signal(13)
sp_addr = Signal(16)
outstanding = Signal(4)
SP_LIMIT = 4 # max prefetch depth in flight
@@ -234,7 +247,7 @@ class BBARegisterFile(Elaboratable):
# pointer (set to the base in HEADER1, incremented per byte). Keeping
# it registered keeps the 13-bit adder off the combinational path that
# feeds the read-response mux → tx_fifo write data.
eff_addr = Signal(13)
eff_addr = Signal(16)
rd_sel = eff_addr[0:8]
# ── Combinational read-response value (non-SPRAM) ────────────────
@@ -259,11 +272,17 @@ class BBARegisterFile(Elaboratable):
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.Case(3): m.d.comb += devid.eq(0x00)
with m.Default(): m.d.comb += devid.eq(0x00)
cmd_rdval = Signal(8)
with m.Switch(cmd_reg):
with m.Case(0x02): m.d.comb += cmd_rdval.eq(r_cmd_imr)
with m.Case(0x03): m.d.comb += cmd_rdval.eq(r_cmd_ir)
with m.Default(): m.d.comb += cmd_rdval.eq(0)
rd_val = Signal(8) # response for the current non-SPRAM read byte
with m.If((addr == 0) & id_phase):
m.d.comb += rd_val.eq(devid)
with m.If(cmd_space):
m.d.comb += rd_val.eq(cmd_rdval)
with m.Else():
m.d.comb += rd_val.eq(reg_rdval)
@@ -292,35 +311,132 @@ class BBARegisterFile(Elaboratable):
with m.FSM(domain="exi", name="exi_fsm"):
with m.State("HEADER0"):
# Byte 0 selects the space and direction. The REAL protocol
# (docs/exi_protocol_real.md) has no length field: the GC
# clocks as many data bytes as it wants and ends with CS.
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
m.d.exi += hdr0.eq(self.rx_data)
m.next = "HEADER1"
m.d.exi += [is_write.eq(self.rx_data[6]), prefetched.eq(0),
byte_ctr.eq(0), sp_req.eq(0), outstanding.eq(0)]
with m.If(self.rx_data[7]): # register space
m.d.exi += cmd_space.eq(0)
m.next = "RHDR_HI"
with m.Else(): # command space
m.d.exi += [cmd_space.eq(1),
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.exi += byte_ctr.eq(1)
m.next = "CHDR_1"
with m.State("HEADER1"):
# ── register space: [0x80|w<<6, ah, al, 0x00] ──────────────────
with m.State("RHDR_HI"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
new_addr = Cat(self.rx_data[2:8], hdr0[0:7]) # 13-bit addr
new_len = self.rx_data[0:2]
new_write = hdr0[7]
m.d.exi += addr[8:16].eq(self.rx_data)
m.next = "RHDR_LO"
m.d.exi += addr.eq(new_addr)
m.d.exi += eff_addr.eq(new_addr) # running pointer init
m.d.exi += xfer_len.eq(new_len)
m.d.exi += is_write.eq(new_write)
m.d.exi += byte_ctr.eq(0)
m.d.exi += sp_req.eq(0)
m.d.exi += drain_ctr.eq(0)
with m.State("RHDR_LO"):
# The address completes HERE, one whole byte before the data
# phase. That trailing 0x00 byte is the device's prefetch
# window -- the old 2-byte-plus-length format spent those bits
# on the length field and left zero slack, which is why the
# first on-console read came back 22 bit-times late. Kick the
# fetch off now so the answer is ready for the first data slot.
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
full = Cat(self.rx_data, addr[8:16]) # 16-bit address
m.d.exi += [addr.eq(full), eff_addr.eq(full),
sp_addr.eq(full)]
with m.If(~is_write & (full >= 0x100) & spram_req.w_rdy):
m.d.exi += [spram_req.w_data.eq(full),
spram_req.w_en.eq(1),
sp_addr.eq(full + 1),
outstanding.eq(1)]
# MISO is driven LIVE from the tx FIFO head, so a byte
# pushed now would show up during the pad slot rather than
# the first data slot. Park a filler byte there -- MISO is
# don't-care for the whole header -- so the real first
# response byte lands one slot later, where it belongs.
with m.If(~is_write & self.tx_rdy):
m.d.comb += [self.tx_data.eq(0x00), self.tx_en.eq(1)]
m.next = "RHDR_PAD"
with m.If(new_write):
with m.State("RHDR_PAD"):
# Trailing header byte. For a register read, push the first
# response byte during this byte-time so it has crossed the
# tx CDC before the GC clocks data slot 0.
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(is_write):
m.next = "WRITE"
with m.Elif(new_addr >= 0x100):
# SPRAM region: stream until CS deasserts (DMA-capable).
m.d.exi += sp_addr.eq(new_addr)
m.d.exi += outstanding.eq(0)
with m.Elif(addr >= 0x100):
m.next = "SPRAM_STREAM"
with m.Else():
m.next = "REG_READ"
with m.Elif(~is_write & (addr < 0x100) & self.tx_rdy & ~prefetched):
# Prefetch the FIRST response byte (once!) while the pad
# byte is still on the wire, so it has crossed the tx CDC
# before the GC clocks data slot 0. Must be one-shot: this
# branch is re-evaluated every cycle the pad byte has not
# arrived, and repeating it would consume the register
# stream early (symptom: reads shifted by a byte).
m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)]
m.d.exi += [eff_addr.eq(eff_addr + 1), prefetched.eq(1)]
# ── command space: [reg, 00, 00, data] in ONE 4-byte transfer ──
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.
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):
# 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.exi += 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)]
m.d.exi += prefetched.eq(1)
with m.State("CHDR_2"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
m.next = "CHDR_3"
with m.State("CHDR_3"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(is_write):
m.d.exi += cmd_wr.eq(1) # applied below
m.d.exi += cmd_wdata.eq(self.rx_data)
m.next = "TRAILER"
with m.State("TRAILER"):
# Soak up anything else the GC clocks, then wait for CS to rise.
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(~self.cs_active):
m.next = "HEADER0"
with m.State("WRITE"):
# Consume xfer_len+1 data bytes, writing the register file.
@@ -374,32 +490,32 @@ class BBARegisterFile(Elaboratable):
m.d.exi += tx_bytes.w_en.eq(1)
# All other addresses silently ignored
with m.If(byte_ctr == xfer_len):
m.next = "HEADER0"
with m.Else():
m.d.exi += byte_ctr.eq(byte_ctr + 1)
# No length field in the real protocol: consume data
# bytes until CS rises.
m.d.exi += eff_addr.eq(eff_addr + 1)
with m.If(~self.cs_active):
m.next = "HEADER0"
with m.State("REG_READ"):
# Register / device-ID read (addr < 0x100): value available
# immediately, bounded by the header's xfer_len (≤4 bytes).
with m.If(self.tx_rdy):
m.d.comb += self.tx_data.eq(rd_val)
m.d.comb += self.tx_en.eq(1)
with m.If(byte_ctr == xfer_len):
m.next = "READ_DRAIN"
with m.Else():
m.d.exi += byte_ctr.eq(byte_ctr + 1)
# Register read (addr < 0x100): stream until CS, like the SPRAM
# path. The real protocol has no length field.
with m.If(self.tx_rdy & self.cs_active):
m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)]
m.d.exi += eff_addr.eq(eff_addr + 1)
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1) # drain dummies
with m.If(~self.cs_active):
m.next = "HEADER0"
with m.State("READ_DRAIN"):
# Discard the xfer_len+1 dummy bytes the GC clocks while reading.
with m.State("DEVID"):
# Stream 04 02 02 00 until CS rises.
with m.If(self.tx_rdy & self.cs_active):
m.d.comb += [self.tx_data.eq(devid), self.tx_en.eq(1)]
m.d.exi += byte_ctr.eq(byte_ctr + 1)
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
with m.If(drain_ctr == xfer_len):
with m.If(~self.cs_active):
m.next = "HEADER0"
with m.Else():
m.d.exi += drain_ctr.eq(drain_ctr + 1)
with m.State("SPRAM_STREAM"):
# Stream SPRAM bytes until CS deasserts — handles both ≤4-byte
@@ -441,6 +557,13 @@ class BBARegisterFile(Elaboratable):
with m.If((outstanding == 0) & ~self.rx_rdy & ~spram_rsp.r_rdy):
m.next = "HEADER0"
# ── Command-space write, applied the cycle after CHDR_3 ──────────
with m.If(cmd_wr):
m.d.exi += cmd_wr.eq(0)
with m.Switch(cmd_reg):
with m.Case(0x02): m.d.exi += r_cmd_imr.eq(cmd_wdata)
with m.Case(0x03): m.d.exi += r_cmd_ir.eq(r_cmd_ir & ~cmd_wdata)
# ── Interrupt output ─────────────────────────────────────────────
m.d.exi += self.exi_int_n.eq(~(r_ir & r_imr).any())
@@ -481,45 +604,81 @@ if __name__ == "__main__":
# Drive the byte-stream interface directly (the SPI bit cadence and FIFOs
# live in ExiCapture; here we model the byte producer/consumer).
# Responses can be pushed WHILE the header is still being clocked -- the
# device prefetches during the header's turnaround byte, and the device-ID
# reply starts from header byte 0. So every helper that advances time has
# to record tx pushes, or the early ones are silently lost and reads look
# shifted. tx_log is that shared record; collect_tx drains it first.
tx_log = []
def sample_tx(ctx):
if ctx.get(reg.tx_en):
tx_log.append(ctx.get(reg.tx_data))
async def push_rx(ctx, b):
"""Present one received byte and wait for the register file to pop it."""
ctx.set(reg.rx_data, b)
ctx.set(reg.rx_rdy, 1)
while True:
en = ctx.get(reg.rx_en)
sample_tx(ctx)
await ctx.tick("exi")
if en:
break
ctx.set(reg.rx_rdy, 0)
async def collect_tx(ctx, n):
"""Collect n response bytes pushed by the register file (bounded)."""
"""Collect n response bytes, including any pushed during the header."""
out = []
while tx_log and len(out) < n:
out.append(tx_log.pop(0))
for _ in range(3000):
if ctx.get(reg.tx_en):
out.append(ctx.get(reg.tx_data))
if len(out) >= n:
break
if ctx.get(reg.tx_en):
out.append(ctx.get(reg.tx_data))
await ctx.tick("exi")
return out
# Real protocol (docs/exi_protocol_real.md): register space is a 4-byte
# header [0x80|w<<6, ah, al, 0x00] followed by a data phase that runs
# until CS rises -- there is no length field.
async def exi_read(ctx, addr, length=1):
hdr0 = (addr >> 6) & 0x7F
hdr1 = ((addr & 0x3F) << 2) | (length - 1)
await push_rx(ctx, hdr0)
await push_rx(ctx, hdr1)
result = await collect_tx(ctx, length) # READ pushes `length` bytes
for _ in range(length): # READ_DRAIN dummies
await push_rx(ctx, 0x00)
tx_log.clear()
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0x80)
await push_rx(ctx, (addr >> 8) & 0xFF)
await push_rx(ctx, addr & 0xFF)
await push_rx(ctx, 0x00) # turnaround / prefetch window
# The device parks a filler byte in the pad slot (MISO is don't-care
# for the whole header) so the first REAL response byte lands in data
# slot 0. This bench counts pushes rather than wire slots, so drop it.
result = (await collect_tx(ctx, length + 1))[1:]
ctx.set(reg.cs_active, 0)
await ctx.tick("exi").repeat(6)
return result
async def exi_write(ctx, addr, data):
hdr0 = 0x80 | ((addr >> 6) & 0x7F)
hdr1 = ((addr & 0x3F) << 2) | (len(data) - 1)
await push_rx(ctx, hdr0)
await push_rx(ctx, hdr1)
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0xC0)
await push_rx(ctx, (addr >> 8) & 0xFF)
await push_rx(ctx, addr & 0xFF)
await push_rx(ctx, 0x00)
for b in data:
await push_rx(ctx, b)
ctx.set(reg.cs_active, 0)
await ctx.tick("exi").repeat(6)
async def exi_devid(ctx):
"""EXI_GetID: write 0x0000 (2 bytes), then clock 4 read bytes."""
tx_log.clear()
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0x00) # command space, reg 0, read
await push_rx(ctx, 0x00)
result = await collect_tx(ctx, 4)
ctx.set(reg.cs_active, 0)
await ctx.tick("exi").repeat(6)
return result
# SPRAM contents the streaming-read test reads back (byte i = 0xA0+i).
spram_mem = {0x100 + i: (0xA0 + i) & 0xFF for i in range(64)}
@@ -555,7 +714,7 @@ if __name__ == "__main__":
await ctx.tick("exi").repeat(8)
# T1: Device ID (addr=0, 4-byte read)
result = await exi_read(ctx, 0x0000, length=4)
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]}")
@@ -602,12 +761,16 @@ if __name__ == "__main__":
errors.append(f"T5 INT_N after clear: expected 1, got {int_n}")
print(f"T5 INT_N after IR clear: {int_n} (want 1)")
# T6: streaming SPRAM read (DMA) — read N>4 bytes from 0x100 by holding
# cs_active and clocking past the header's 4-byte length field.
# T6: streaming SPRAM read (DMA) — the real protocol has no length
# field, so the data phase simply runs for as long as CS is held.
# Swiss reads the packet buffer exactly this way (bba_ins -> DMA).
N = 12
tx_log.clear()
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0x04) # hdr0 → addr[12:6]; addr 0x100, read
await push_rx(ctx, 0x00) # hdr1 → addr[5:0]=0, len field ignored
await push_rx(ctx, 0x80) # register space, read
await push_rx(ctx, 0x01) # addr[15:8] -> 0x0100
await push_rx(ctx, 0x00) # addr[7:0]
await push_rx(ctx, 0x00) # turnaround byte
got = []
for _ in range(5000):
if ctx.get(reg.tx_en):
+26 -14
View File
@@ -420,17 +420,16 @@ if __name__ == "__main__":
return miso
async def exi_read(ctx, addr, length):
"""EXI immediate read: 2-byte header, clock-idle gap, then `length` bytes."""
hdr0 = (addr >> 6) & 0x7F
# The header length field is only 2 bits ([1:0]); mask it so a long
# (DMA) read doesn't overflow length-1 into the addr[5:0] bits. For
# SPRAM reads the field is ignored anyway — the stream runs until CS.
hdr1 = ((addr & 0x3F) << 2) | ((length - 1) & 0x3)
"""Register-space read, REAL protocol (docs/exi_protocol_real.md):
4-byte header [0x80, ah, al, 0x00] then a data phase that runs until
CS. The trailing 0x00 is the device's prefetch window."""
ctx.set(dut.exi_cs_n, 0)
ctx.set(dut.exi_clk, 1)
await ctx.tick("capture").repeat(HALF)
await spi_byte(ctx, hdr0)
await spi_byte(ctx, hdr1)
await spi_byte(ctx, 0x80)
await spi_byte(ctx, (addr >> 8) & 0xFF)
await spi_byte(ctx, addr & 0xFF)
await spi_byte(ctx, 0x00)
# EXI_Imm clock-idle gap: the core decodes the header and prefetches
# responses into the tx FIFO before the GC clocks the data phase.
await ctx.tick("capture").repeat(HALF * 12)
@@ -440,19 +439,32 @@ if __name__ == "__main__":
return result
async def exi_write(ctx, addr, data):
"""EXI immediate write: 2-byte header then the data bytes."""
hdr0 = 0x80 | ((addr >> 6) & 0x7F)
hdr1 = ((addr & 0x3F) << 2) | (len(data) - 1)
"""Register-space write: 4-byte header [0xC0, ah, al, 0x00] + data."""
ctx.set(dut.exi_cs_n, 0)
ctx.set(dut.exi_clk, 1)
await ctx.tick("capture").repeat(HALF)
await spi_byte(ctx, hdr0)
await spi_byte(ctx, hdr1)
await spi_byte(ctx, 0xC0)
await spi_byte(ctx, (addr >> 8) & 0xFF)
await spi_byte(ctx, addr & 0xFF)
await spi_byte(ctx, 0x00)
for b in data:
await spi_byte(ctx, b)
ctx.set(dut.exi_cs_n, 1)
await ctx.tick("capture").repeat(HALF)
async def exi_devid(ctx):
"""EXI_GetID: write 0x0000 (two bytes) then clock four read bytes."""
ctx.set(dut.exi_cs_n, 0)
ctx.set(dut.exi_clk, 1)
await ctx.tick("capture").repeat(HALF)
await spi_byte(ctx, 0x00)
await spi_byte(ctx, 0x00)
await ctx.tick("capture").repeat(HALF * 12)
out = [await spi_byte(ctx, 0x00) for _ in range(4)]
ctx.set(dut.exi_cs_n, 1)
await ctx.tick("capture").repeat(HALF)
return out
# ── W5100 indirect-bus slave model (drives w5100_data_i) ─────────────
# Pre-loads a known MACRAW packet in the RX buffer so we can verify the full
# ethernet→SPRAM→GC path. Same protocol as the W5100ParallelMaster bench.
@@ -522,7 +534,7 @@ if __name__ == "__main__":
await ctx.tick("capture").repeat(20)
# T1: device ID — read 4 bytes from addr 0 → 0x04 0x02 0x02 0x00
dev = await exi_read(ctx, 0x0000, 4)
dev = await exi_devid(ctx)
print(f"T1 device ID: {[f'0x{b:02X}' for b in dev]}")
if dev != [0x04, 0x02, 0x02, 0x00]:
errors.append(f"T1 device ID: got {dev}")
+21 -9
View File
@@ -89,27 +89,39 @@ if __name__ == "__main__":
return miso
async def exi_read(ctx, pads, addr, length):
"""EXI immediate read on `pads`: header, clock-idle gap, data phase."""
"""Register-space read, REAL protocol: 4-byte header then data."""
clk, mosi, cs_n = pads
hdr0 = (addr >> 6) & 0x7F
hdr1 = ((addr & 0x3F) << 2) | ((length - 1) & 0x3)
ctx.set(cs_n, 0)
ctx.set(clk, 1)
await ctx.tick("capture").repeat(HALF)
await spi_byte(ctx, pads, hdr0)
await spi_byte(ctx, pads, hdr1)
await ctx.tick("capture").repeat(HALF * 12) # header-decode gap
await spi_byte(ctx, pads, 0x80)
await spi_byte(ctx, pads, (addr >> 8) & 0xFF)
await spi_byte(ctx, pads, addr & 0xFF)
await spi_byte(ctx, pads, 0x00) # turnaround byte
result = [await spi_byte(ctx, pads, 0x00) for _ in range(length)]
ctx.set(cs_n, 1)
await ctx.tick("capture").repeat(HALF)
return result
async def exi_devid(ctx, pads):
"""EXI_GetID: write 0x0000 (2 bytes), clock 4 read bytes."""
clk, mosi, cs_n = pads
ctx.set(cs_n, 0)
ctx.set(clk, 1)
await ctx.tick("capture").repeat(HALF)
await spi_byte(ctx, pads, 0x00)
await spi_byte(ctx, pads, 0x00)
out = [await spi_byte(ctx, pads, 0x00) for _ in range(4)]
ctx.set(cs_n, 1)
await ctx.tick("capture").repeat(HALF)
return out
async def testbench(ctx):
await ctx.tick("capture").repeat(20)
# T1: enumerate over the SP1 pads while J4 is unplugged (idle high).
unplug(ctx, top.j4)
dev = await exi_read(ctx, top.sp1, 0x0000, 4)
dev = await exi_devid(ctx, top.sp1)
print(f"T1 enumerate via SP1 (J4 idle): {[f'0x{b:02X}' for b in dev]}")
if dev != DEVICE_ID:
errors.append(f"T1 SP1 device ID: got {dev}")
@@ -117,7 +129,7 @@ if __name__ == "__main__":
# T2: enumerate over the J4 pads while SP1 is unplugged (idle high).
unplug(ctx, top.sp1)
dev = await exi_read(ctx, top.j4, 0x0000, 4)
dev = await exi_devid(ctx, top.j4)
print(f"T2 enumerate via J4 (SP1 idle): {[f'0x{b:02X}' for b in dev]}")
if dev != DEVICE_ID:
errors.append(f"T2 J4 device ID: got {dev}")
@@ -125,7 +137,7 @@ if __name__ == "__main__":
# T3: back to SP1, to prove using J4 left no sticky state behind.
unplug(ctx, top.j4)
dev = await exi_read(ctx, top.sp1, 0x0000, 4)
dev = await exi_devid(ctx, top.sp1)
print(f"T3 enumerate via SP1 again: {[f'0x{b:02X}' for b in dev]}")
if dev != DEVICE_ID:
errors.append(f"T3 SP1 re-enumerate: got {dev}")
+32 -35
View File
@@ -16,18 +16,13 @@ static void gap(void)
while (ticks_to_microsecs(diff_ticks(t0, gettime())) < bba_gap_us) ;
}
/* Build the 2-byte EXI transaction header.
* byte0: [7]=write [6:0]=addr[12:6]
* byte1: [7:2]=addr[5:0] [1:0]=len-1
* Returned packed big-endian in a u16 so EXI_Imm(...,2,...) clocks byte0 first
* (the GameCube is big-endian, so the high byte goes out first). */
static u16 hdr(u16 addr, u32 len, int write)
{
u8 b0 = (write ? 0x80 : 0x00) | ((addr >> 6) & 0x7F);
u8 b1 = (u8)(((addr & 0x3F) << 2) | ((len - 1) & 0x03));
return ((u16)b0 << 8) | b1;
}
/* Real GC<->BBA protocol (docs/exi_protocol_real.md), verified against libogc
* and Swiss. Register space is a 4-byte header followed by a data phase that
* runs until CS -- there is no length field.
* read [0x80, addr>>8, addr&0xFF, 0x00] + N bytes
* write [0xC0, addr>>8, addr&0xFF, 0x00] + N bytes
* The trailing 0x00 is the device's prefetch window: the address completes a
* whole byte before the data phase so the register file can fetch in time. */
static int begin(void)
{
int tries;
@@ -53,10 +48,10 @@ static void end(void)
EXI_Unlock(BBA_EXI_CHAN);
}
static int put_hdr(u16 addr, u32 len, int write)
static int put_hdr(u16 addr, int write)
{
u16 h = hdr(addr, len, write);
if (EXI_Imm(BBA_EXI_CHAN, &h, 2, EXI_WRITE, NULL) <= 0) return -3;
u32 h = ((u32)(write ? 0xC0 : 0x80) << 24) | ((u32)addr << 8);
if (EXI_Imm(BBA_EXI_CHAN, &h, 4, EXI_WRITE, NULL) <= 0) return -3;
if (EXI_Sync(BBA_EXI_CHAN) <= 0) return -4;
return 0;
}
@@ -64,12 +59,9 @@ static int put_hdr(u16 addr, u32 len, int write)
int bba_read(u16 addr, void *buf, u32 len)
{
int rc;
if (len < 1 || len > 4) return -10;
if (len < 1) return -10;
if ((rc = begin()) < 0) return rc;
if ((rc = put_hdr(addr, len, 0)) < 0) { end(); return rc; }
/* Hold the clock idle while the device decodes the header and prefetches
* the response into its TX FIFO. libogc's inter-call overhead alone is
* NOT enough at full EXI speed -- see bba_gap_us. */
if ((rc = put_hdr(addr, 0)) < 0) { end(); return rc; }
gap();
memset(buf, 0, len);
if (EXI_ImmEx(BBA_EXI_CHAN, buf, len, EXI_READ) <= 0) { end(); return -5; }
@@ -80,16 +72,30 @@ int bba_read(u16 addr, void *buf, u32 len)
int bba_write(u16 addr, const void *buf, u32 len)
{
int rc;
u8 tmp[4];
if (len < 1 || len > 4) return -10;
u8 tmp[64];
if (len < 1 || len > sizeof(tmp)) return -10;
memcpy(tmp, buf, len);
if ((rc = begin()) < 0) return rc;
if ((rc = put_hdr(addr, len, 1)) < 0) { end(); return rc; }
if ((rc = put_hdr(addr, 1)) < 0) { end(); return rc; }
if (EXI_ImmEx(BBA_EXI_CHAN, tmp, len, EXI_WRITE) <= 0) { end(); return -6; }
end();
return 0;
}
int bba_devid(u8 id[4])
{
int rc;
u16 h = 0x0000; /* command space, reg 0, read */
if ((rc = begin()) < 0) return rc;
if (EXI_Imm(BBA_EXI_CHAN, &h, 2, EXI_WRITE, NULL) <= 0) { end(); return -3; }
if (EXI_Sync(BBA_EXI_CHAN) <= 0) { end(); return -4; }
gap();
memset(id, 0, 4);
if (EXI_ImmEx(BBA_EXI_CHAN, id, 4, EXI_READ) <= 0) { end(); return -5; }
end();
return 0;
}
int bba_r8(u16 addr)
{
u8 v;
@@ -104,18 +110,9 @@ int bba_w8(u16 addr, u8 val)
int bba_stream(u16 addr, void *buf, u32 len)
{
int rc;
if (len < 1) return -10;
if ((rc = begin()) < 0) return rc;
/* Length field is capped at 2 bits; the device ignores it for addresses
* >= 0x0100 and streams until CS rises, so ask for 4 and clock as many
* bytes as we actually want. */
if ((rc = put_hdr(addr, 4, 0)) < 0) { end(); return rc; }
gap();
memset(buf, 0, len);
if (EXI_ImmEx(BBA_EXI_CHAN, buf, len, EXI_READ) <= 0) { end(); return -5; }
end();
return 0;
/* Identical to bba_read now: the real protocol always streams until CS,
* so there is no separate "DMA" header form. */
return bba_read(addr, buf, len);
}
int bba_tx_bytes(const u8 *buf, u32 len)
+9 -5
View File
@@ -59,15 +59,19 @@ extern u32 bba_gap_us;
/* All return 0 on success, negative on an EXI-layer failure. */
/* Immediate read of `len` (1..4) bytes from `addr`.
* Clocks EXACTLY `len` bytes: over-clocking a register read leaves stale
* prefetched bytes queued in the gateware and desynchronises the NEXT
* transaction (hardware-confirmed 2026-09-30). Never pad this. */
/* REGISTER-space read: 4-byte header [0x80, ah, al, 0x00] then `len` bytes.
* The real protocol has NO length field -- the data phase runs until CS, so
* `len` is unbounded. The trailing 0x00 header byte is the device's prefetch
* window. See docs/exi_protocol_real.md. */
int bba_read(u16 addr, void *buf, u32 len);
/* Immediate write of `len` (1..4) bytes to `addr`. */
/* REGISTER-space write: 4-byte header [0xC0, ah, al, 0x00] then `len` bytes. */
int bba_write(u16 addr, const void *buf, u32 len);
/* EXI_GetID: write 0x0000 (two bytes) then read 4. Returns the device ID,
* which is a ONE-SHOT -- the first NCRA reset clears it until power cycle. */
int bba_devid(u8 id[4]);
/* Convenience single-byte helpers. bba_r8 returns the byte, or <0 on error. */
int bba_r8(u16 addr);
int bba_w8(u16 addr, u8 val);
+1 -1
View File
@@ -19,7 +19,7 @@ static int t_devid(char *m, size_t n)
snprintf(m, n, "skipped: one-shot, needs a console power cycle");
return 2;
}
if ((rc = bba_read(0x0000, b, 4)) < 0) { snprintf(m, n, "EXI error %d", rc); return 1; }
if ((rc = bba_devid(b)) < 0) { snprintf(m, n, "EXI error %d", rc); return 1; }
snprintf(m, n, "%02X %02X %02X %02X (want 04 02 02 00)", b[0], b[1], b[2], b[3]);
return (b[0]==0x04 && b[1]==0x02 && b[2]==0x02 && b[3]==0x00) ? 0 : 1;
}