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:
+230
-67
@@ -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)
|
||||
m.d.exi += eff_addr.eq(eff_addr + 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)
|
||||
m.d.exi += eff_addr.eq(eff_addr + 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):
|
||||
m.next = "HEADER0"
|
||||
with m.Else():
|
||||
m.d.exi += drain_ctr.eq(drain_ctr + 1)
|
||||
with m.If(~self.cs_active):
|
||||
m.next = "HEADER0"
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user