Files
rebbarb/exi_bba/exi_j4.py
T
Roflin 2458884160 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>
2026-10-03 11:58:52 +02:00

190 lines
7.7 KiB
Python

"""EXI pad merging for the J4 debug-header bring-up path (``EXI_J4=1``).
``BBATopSynth`` can mirror the EXI interface onto the unpopulated J4 debug
header so a bench host (e.g. a Raspberry Pi) can drive EXI without the SP1 edge
connector. Rather than REPINNING the EXI resource onto J4 -- which would move
where the capture domain anchors on the die -- a second set of pads is MERGED
onto the existing ones, leaving SP1 live and the default build untouched.
The merge itself lives here, in one place, so the synthesis path and the
testbench below exercise the same code.
Run the test: ``python -m exi_bba.exi_j4``
"""
__all__ = ["merge_exi_inputs"]
def merge_exi_inputs(primary, mirror):
"""AND-merge two sets of EXI input pads into one.
`primary` and `mirror` are ``(clk, mosi, cs_n)`` triples; returns the merged
triple.
AND is exact here, not an approximation, because every EXI input is
inactive-HIGH: CPOL=1 so CLK idles high, CS_N is active-low, and MOSI only
matters while CS_N is low. Whichever pad set is unplugged idles at 1
through its ``SB_IO`` pull-up (``PULLUP=1`` is set on both sets in an
``EXI_J4`` build) and therefore passes the other set through untouched -- no
select signal, no register, no build variant of the core.
DRIVE ONE SIDE AT A TIME. With a GameCube on SP1 and a host on J4
simultaneously the capture engine sees the bitwise product of the two,
which is garbage.
"""
return tuple(a & b for a, b in zip(primary, mirror))
if __name__ == "__main__":
import sys
from amaranth import Elaboratable, Module, Signal
from amaranth.sim import Simulator, Period
from exi_bba.bba_top import BBATop
HALF = 8 # capture ticks per SPI half-period (well oversampled)
DEVICE_ID = [0x04, 0x02, 0x02, 0x00]
class _Harness(Elaboratable):
"""BBATop behind the real merge, with two independent EXI pad sets."""
def __init__(self):
self.dut = BBATop(eth="w5100", reset_cycles=20)
# Both sets idle HIGH, exactly as the SB_IO pull-ups hold them.
self.sp1 = [Signal(init=1, name=f"sp1_{n}") for n in ("clk", "mosi", "cs_n")]
self.j4 = [Signal(init=1, name=f"j4_{n}") for n in ("clk", "mosi", "cs_n")]
def elaborate(self, platform):
m = Module()
m.submodules.dut = d = self.dut
clk, mosi, cs_n = merge_exi_inputs(tuple(self.sp1), tuple(self.j4))
m.d.comb += [d.exi_clk.eq(clk), d.exi_mosi.eq(mosi), d.exi_cs_n.eq(cs_n)]
return m
top = _Harness()
dut = top.dut
errors = []
def unplug(ctx, pads):
"""Model the other connector being unplugged: every pad pulled HIGH.
Essential, and easy to forget: `spi_byte` leaves `mosi` at the last bit
it drove, so a side left at 0 will AND the *other* side's MOSI to zero
for the rest of the run. On real hardware the SB_IO pull-up does this
for us the moment the connector is empty.
"""
for s in pads:
ctx.set(s, 1)
async def spi_byte(ctx, pads, mosi_val):
"""Drive one EXI Mode-3 byte on `pads`; return the assembled MISO byte."""
clk, mosi, cs_n = pads
miso = 0
for bit in range(7, -1, -1):
ctx.set(mosi, (mosi_val >> bit) & 1)
ctx.set(clk, 0) # falling: slave samples
await ctx.tick("capture").repeat(HALF)
miso = (miso << 1) | ctx.get(dut.exi_miso)
ctx.set(clk, 1) # rising: slave drives
await ctx.tick("capture").repeat(HALF)
return miso
async def exi_read(ctx, pads, addr, length):
"""Register-space read, REAL protocol: 4-byte header then data."""
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, 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_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}")
await ctx.tick("capture").repeat(HALF * 4)
# T2: enumerate over the J4 pads while SP1 is unplugged (idle high).
unplug(ctx, top.sp1)
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}")
await ctx.tick("capture").repeat(HALF * 4)
# T3: back to SP1, to prove using J4 left no sticky state behind.
unplug(ctx, top.j4)
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}")
await ctx.tick("capture").repeat(HALF * 4)
# T4: a register read through the merge, not just the ID ROM path.
unplug(ctx, top.sp1)
nways = await exi_read(ctx, top.j4, 0x31, 1)
print(f"T4 NWAYS via J4: 0x{nways[0]:02X} (want 0x17)")
if nways != [0x17]:
errors.append(f"T4 NWAYS via J4: got {nways}")
# T5: prove the merge really is an AND of both pad sets (and is not
# optimised down to one side). Pass-through in EITHER direction is the
# DESIRED behaviour -- the driven side wins over the idle-high side.
# The last row is the documented hazard: two active drivers collide.
unplug(ctx, top.sp1)
unplug(ctx, top.j4)
for sp1_v, j4_v, want, note in (
(1, 1, 1, "both unplugged -> idle"),
(1, 0, 0, "J4 drives, SP1 unplugged -> J4 passes through"),
(0, 1, 0, "SP1 drives, J4 unplugged -> SP1 passes through"),
(0, 0, 0, "both drive low"),
):
ctx.set(top.sp1[2], sp1_v)
ctx.set(top.j4[2], j4_v)
await ctx.tick("capture")
got = ctx.get(dut.exi_cs_n)
ok = "ok" if got == want else "MISMATCH"
print(f"T5 cs_n sp1={sp1_v} j4={j4_v} -> core {got} (want {want}) {ok} {note}")
if got != want:
errors.append(f"T5 merge truth table sp1={sp1_v} j4={j4_v}: got {got}")
unplug(ctx, top.sp1)
unplug(ctx, top.j4)
sim = Simulator(top)
sim.add_clock(Period(MHz=54), domain="capture")
sim.add_clock(Period(MHz=24), domain="exi")
sim.add_clock(Period(MHz=24), domain="sync")
sim.add_testbench(testbench)
with sim.write_vcd("exi_j4_merge.vcd"):
sim.run()
if errors:
print("\nFAILURES:")
for e in errors:
print(" -", e)
sys.exit(1)
print("\nAll EXI_J4 merge tests passed.")