2458884160
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>
190 lines
7.7 KiB
Python
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.")
|