a3b3e052bc
Replaces 54 MHz oversampling with a slave clocked by EXI CLK itself:
exiclk posedge sample MOSI (Mode 3 samples on edge 2)
exiclk_n negedge drive MISO (must settle between the master's samples)
CS_N async reset for both
EXI CLK (pin 44) is at die (6,0) = global buffer net 6 on the 5k-sg48, so it
can drive a real clock network with no board change.
This removes the capture domain entirely. Bit-level timing no longer
crosses a clock boundary -- MISO is clock-to-Q instead of 3 ticks of a foreign
clock -- and the only crossings left are the byte FIFOs, which have a whole
byte-time (~296 ns) of slack rather than a fraction of a bit.
AsyncFIFO needs a posedge clock for its internal reset CDC, so the FIFO sides
use reset-less POSEDGE domains on the same pin; the engine pops on the posedge
and hands the byte to the negedge output stage through a register. The FIFOs
must not see CS: resetting one side of an AsyncFIFO desynchronises its gray
pointers.
Two bugs found and fixed along the way:
* one pop per byte boundary is all that is needed -- pops during the header
drain whatever the previous transaction left (replacing the CS-fall flush)
and the turnaround-byte push lands in data slot 0. No slot-threshold or
header-length logic at all.
* rx_byte/rx_valid had to become COMBINATIONAL. Registered, they need one
more clock edge, and after a transaction's final bit the clock stops and
CS rises -- so the last byte of every transaction was lost (a 4-byte write
landed as DE AD BE 00, a 34-byte TX as 15 bytes).
At the HARDWARE rate (EXI_HALF=1, 27 MHz) the device ID, NWAYS, PAR
read/write and the W5100->SPRAM RX path now all pass -- none of which has ever
worked at 27 MHz before. Testbenches now drive the SPI clock with real
delays, since there is no capture domain to count ticks against.
STILL BROKEN: the SPRAM/DMA stream is offset by one byte (its round trip
exceeds the single turnaround byte), and TX lands 15 of 34 bytes. Do not
flash this yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
7.5 KiB
Python
189 lines
7.5 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.delay(HP * (HALF))
|
|
miso = (miso << 1) | ctx.get(dut.exi_miso)
|
|
ctx.set(clk, 1) # rising: slave drives
|
|
await ctx.delay(HP * (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.delay(HP * (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.delay(HP * (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.delay(HP * (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.delay(HP * (HALF))
|
|
return out
|
|
|
|
async def testbench(ctx):
|
|
await ctx.delay(HP * (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.delay(HP * (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.delay(HP * (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.delay(HP * (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.delay(HP)
|
|
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=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.")
|