diff --git a/exi_bba/exi_j4.py b/exi_bba/exi_j4.py new file mode 100644 index 0000000..a0cca41 --- /dev/null +++ b/exi_bba/exi_j4.py @@ -0,0 +1,177 @@ +"""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): + """EXI immediate read on `pads`: header, clock-idle gap, data phase.""" + 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 + 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 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) + 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_read(ctx, top.j4, 0x0000, 4) + 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_read(ctx, top.sp1, 0x0000, 4) + 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.")