02239466fb
bba_top wired ClockSignal("sync") to ClockSignal("exi"), so the two "domains"
were always the same physical clock net. Everything between them -- six
AsyncFIFOs and three PulseSynchronizers -- was pure latency and logic for a
crossing that did not exist.
* the six AsyncFIFOs become plain SyncFIFOs
* the three PulseSynchronizers become wires
* BBARegisterFile and ExiCapture move from m.d.exi to m.d.sync
* the exi ClockDomain is gone; HFOSC drives sync directly
The SPRAM request write port is also combinational now. A registered w_en
costs a 24 MHz cycle, and the protocol's turnaround byte (~296 ns at 27 MHz)
has no cycles to spare.
Passing: bba_register_file, spram_arbiter, rx_frame_assembler, tx_frame_drain,
w5100_parallel_master, uart_shell, status_panel. bba_top at EXI_HALF=1
(27 MHz, the hardware rate) passes device ID, PAR read/write, NWAYS, RWP and
the 34-byte TX frame.
STILL OPEN: the SPRAM/DMA read is one byte late. Removing the CDC was not
enough -- the remaining round trip is the rx crossing (exiclk->sync, a REAL
CDC, ~125 ns) plus FSM decode plus the spram_req FIFO, arbiter, SPRAM access
and spram_rsp FIFO, which still exceeds the turnaround byte. Next step: now
that it is all one domain, drop the two SPRAM FIFOs entirely and give the
register file a direct request/grant port on the arbiter -- that should take
the round trip to ~2-3 cycles and fit.
exi_capture and exi_j4 testbenches still assert removed behaviour (the CS-fall
flush and the old MISO slot timing) and need rewriting for the clocked engine.
Still WIP -- do not flash.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
7.7 KiB
Python
197 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)
|
|
|
|
|
|
# No capture domain any more: the bit engine is clocked by the EXI
|
|
|
|
|
|
# pin, so drive the SPI clock with real delays instead of ticks.
|
|
|
|
|
|
HP = Period(MHz=54) * HALF
|
|
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="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.")
|