Bring-up: W5100 bus self-test, eFuse OVLO bug, Pi SPI EXI host

From the physical V2 bring-up session (2026-09-20):

- exi_bba/w5100_selftest.py (NEW): standalone FPGA<->W5100 bus diagnostic.
  Clock-independent data-bus walk (direct-mode GAR0 read-back, all 8 lines) +
  MR software-reset self-clear (needs the 25 MHz crystal). Reports 4 verdicts on
  D6/D7 LEDs AND over UART (115200): GOOD / data-line-N / clock-dead / no-response.
  Boot wait is 85 ms (> the W5100S 60.3 ms init). On this board: GOOD -- bus +
  all 8 data lines + 25 MHz clock all verified (unlike V1's broken D3).

- REVIEW.md / TODO.md: eFuse U13 OVLO is tied to GND, which on the TPS25961
  selects the internal FIXED ~6 V overvoltage cutoff -- so it blocks the 12 V
  rail entirely (bench-confirmed: 12V_EXI=0, GC_ON=0, W5100 unpowered). FAB-
  BLOCKER for V2.1: fix with an OVLO divider (~1.1M/100k -> trip ~15 V). Bench
  workaround: lift U13 pin 2 (OVLO) off GND. Also closed the eFuse-placement,
  GND-via/3V3-zone, and CC-ESD TODO items.

- hardware/re-bba-rb/exi_devid_spi.py (NEW): Pi hardware-SPI (~25 MHz) EXI
  device-ID host, companion to the bit-bang exi_devid_rpi.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 14:33:49 +00:00
parent 162dbf7d5f
commit 59948812f3
5 changed files with 549 additions and 12 deletions
+1
View File
@@ -32,3 +32,4 @@ hardware/re-bba-rb/re-bba-rb-eeprom.bin
.DS_Store .DS_Store
*.swp *.swp
.history/ .history/
build_w5100test/
+451
View File
@@ -0,0 +1,451 @@
"""Standalone W5100 <-> FPGA bus connection self-test (bring-up diagnostic).
Flashed on its own (NOT the BBA design), this exercises ONLY the parallel bus
between the iCE40 and the WIZnet W5100 and reports the result on the two
discrete LEDs — no GameCube, no PC, no UART needed, just look at the board:
The test runs in two stages so it can tell a bad bus apart from a dead clock:
1. a data-bus walk (write/read-back patterns to the scratch register) — this
is a purely ASYNCHRONOUS host-register access, so it works even if the
25 MHz crystal is dead; it validates D0..D7 + the CS/RD/WR/address path.
2. an MR software-reset self-clear poll — this DOES need the 25 MHz clock.
Four distinct LED verdicts (GREEN=D6, RED=D7):
GREEN solid, red dark = ALL GOOD (bus + all 8 data lines +
the W5100 clock all verified).
green dark, RED blinks N times = data line D(N-1) is stuck/broken
(e.g. 4 blinks = D3). Reports the
lowest bad line; fix & re-run.
green dark, RED fast continuous = the W5100 does not respond on the bus
at all (read-back never tracked the
write) -> dead/unpowered chip, or the
CS/RD/WR/address path is broken.
GREEN+RED blink together (~2 Hz) = the bus is FINE (data walk passed) but
the MR reset never self-cleared -> the
25 MHz clock isn't running: suspect the
Y2 crystal / its load caps / solder.
Why this and not the BBA build: the BBA W5100 init is write-only with a fixed
settle wait, so it "completes" even on a dead bus. A real connection test must
READ BACK; the MR self-clear poll is the canonical W5100 liveness read.
Reuses the exact indirect-bus (IDM) access engine timing from
`w5100_parallel_master.py`. Single `sync` domain (24 MHz HFOSC) — no PLL/
capture domain, so it place-and-routes with huge timing margin on every seed.
Run: python -m exi_bba.w5100_selftest # self-test in simulation
Build/flash via synth.py (W5100_SELFTEST=1), see that file.
"""
from amaranth import *
# W5100 register addresses (indirect-mode common block).
_MR = 0x0000 # Mode Register (reachable directly at A=00)
_MR_RST = 0x80 # MR reset bit (self-clears when reset completes)
_MR_IND = 0x01 # MR indirect-bus-mode enable
_SCRATCH = 0x0001 # GAR0 (gateway addr byte 0): plain R/W, harmless scratch
# A[1:0] indirect-mode selects.
_A_MR = 0b00 # Mode Register (direct)
_A_AR0 = 0b01 # IDM address high
_A_AR1 = 0b10 # IDM address low
_A_DR = 0b11 # IDM data (auto-inc only if MR.AI=1; here it is NOT)
# Walking-1s then 0xAA/0x55 — catches stuck-high, stuck-low, and (partly)
# shorted-adjacent data lines on read-back of the scratch register.
_PATTERNS = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0xAA, 0x55]
class W5100SelfTest(Elaboratable):
"""Drives the W5100 indirect bus and reports on led_g / led_r.
Timing params default to real-hardware values (24 MHz sync); the testbench
passes tiny ones so the FSM runs in a few hundred ticks.
"""
def __init__(self, *, strobe_cycles=3, rst_cycles=48_000, boot_cycles=2_040_000,
poll_timeout=48_000, blink_cycles=3_000_000):
self._strobe = strobe_cycles
self._rst = rst_cycles # RST_N low pulse (~2 ms @24MHz)
self._boot = boot_cycles # post-reset boot (~85 ms > 60.3 ms W5100S spec)
self._poll = poll_timeout # MR self-clear wait (~2 ms)
self._blink = blink_cycles # LED blink tick (~0.125 s)
# Physical W5100 bus (wired to the platform's `w5100` resource).
self.bus_addr = Signal(2)
self.bus_data_o = Signal(8)
self.bus_data_oe = Signal()
self.bus_data_i = Signal(8)
self.cs_n = Signal(init=1)
self.rd_n = Signal(init=1)
self.wr_n = Signal(init=1)
self.rst_n = Signal(init=1)
# Status LEDs.
self.led_g = Signal()
self.led_r = Signal()
# UART status report (115200 8N1), so a host can read the verdict too.
self.uart_tx = Signal(init=1)
# Observability (simulation checks these instead of the LED blink).
self.done = Signal() # high once the test has a verdict
self.mr_ok = Signal() # MR reset self-cleared (chip alive)
self.bad_bits = Signal(8) # data lines that failed read-back
def elaborate(self, platform):
m = Module()
STROBE = self._strobe
# ── one indirect-bus read/write cycle (same engine as the master) ────
bus_go = Signal()
bus_rw = Signal() # 1 = write, 0 = read
bus_a = Signal(2)
bus_wd = Signal(8)
bus_rd = Signal(8)
bus_done = Signal()
bus_ctr = Signal(range(STROBE + 2))
rw_r = Signal()
a_o = Signal(2)
d_o = Signal(8)
d_oe = Signal()
cs_r = Signal(init=1)
rd_r = Signal(init=1)
wr_r = Signal(init=1)
rstn = Signal(init=1)
m.d.comb += [
self.bus_addr.eq(a_o), self.bus_data_o.eq(d_o), self.bus_data_oe.eq(d_oe),
self.cs_n.eq(cs_r), self.rd_n.eq(rd_r), self.wr_n.eq(wr_r), self.rst_n.eq(rstn),
]
m.d.sync += bus_done.eq(0)
with m.FSM(domain="sync", name="bus_fsm"):
with m.State("IDLE"):
m.d.sync += [cs_r.eq(1), rd_r.eq(1), wr_r.eq(1), d_oe.eq(0)]
with m.If(bus_go):
m.d.sync += [a_o.eq(bus_a), rw_r.eq(bus_rw), cs_r.eq(0), bus_ctr.eq(0)]
with m.If(bus_rw):
m.d.sync += [d_o.eq(bus_wd), d_oe.eq(1), wr_r.eq(0)]
with m.Else():
m.d.sync += rd_r.eq(0)
m.next = "STROBE"
with m.State("STROBE"):
m.d.sync += bus_ctr.eq(bus_ctr + 1)
with m.If(bus_ctr == STROBE - 1):
with m.If(~rw_r):
m.d.sync += bus_rd.eq(self.bus_data_i)
m.d.sync += [rd_r.eq(1), wr_r.eq(1)]
m.next = "FINISH"
with m.State("FINISH"):
m.d.sync += [cs_r.eq(1), d_oe.eq(0), bus_done.eq(1)]
m.next = "IDLE"
# ── test sequencer ───────────────────────────────────────────────────
m.d.comb += [bus_go.eq(0), bus_rw.eq(0), bus_a.eq(0), bus_wd.eq(0)]
def bus_write(a, data):
m.d.comb += [bus_go.eq(1), bus_rw.eq(1), bus_a.eq(a), bus_wd.eq(data)]
def bus_read(a):
m.d.comb += [bus_go.eq(1), bus_rw.eq(0), bus_a.eq(a)]
ctr = Signal(range(max(self._rst, self._boot, self._poll) + 2))
pat_idx = Signal(range(len(_PATTERNS)))
patterns = Array([Const(p, 8) for p in _PATTERNS])
mr_ok = self.mr_ok
bad = self.bad_bits
# The scratch register (GAR0 = 0x0001) is reachable in DIRECT mode at
# A[1:0]=01, because the board ties A[14:2]=0 and wires only A[1:0]. So
# the whole data-bus walk runs WITHOUT setting indirect mode and WITHOUT
# the 25 MHz clock — the host register interface is asynchronous. That
# is exactly what lets us tell a broken bus/data-line apart from a dead
# crystal: the walk works in both, only the MR self-clear needs the clock.
_A_SCRATCH = _SCRATCH & 0b11 # 0x0001 -> A=01 (GAR0, direct)
with m.FSM(domain="sync", name="test_fsm"):
# Hardware-reset the W5100: RST_N low, then release and let it boot.
with m.State("RST_LOW"):
m.d.sync += [rstn.eq(0), ctr.eq(ctr + 1)]
with m.If(ctr == self._rst - 1):
m.d.sync += ctr.eq(0)
m.next = "BOOT"
with m.State("BOOT"):
m.d.sync += [rstn.eq(1), ctr.eq(ctr + 1)]
with m.If(ctr == self._boot - 1):
m.d.sync += [ctr.eq(0), pat_idx.eq(0)]
m.next = "WR_ISSUE"
# ── clock-INDEPENDENT data-bus walk (direct access to GAR0) ──────
# For each pattern: write it to the scratch reg, read it back, OR any
# mismatched bits into `bad`. Runs even with a dead crystal.
with m.State("WR_ISSUE"):
bus_write(_A_SCRATCH, patterns[pat_idx])
m.next = "WR_WAIT"
with m.State("WR_WAIT"):
with m.If(bus_done):
m.next = "RD_ISSUE"
with m.State("RD_ISSUE"):
bus_read(_A_SCRATCH)
m.next = "RD_WAIT"
with m.State("RD_WAIT"):
with m.If(bus_done):
m.d.sync += bad.eq(bad | (bus_rd ^ patterns[pat_idx]))
with m.If(pat_idx == len(_PATTERNS) - 1):
m.d.sync += ctr.eq(0)
m.next = "MR_RST_ISSUE"
with m.Else():
m.d.sync += pat_idx.eq(pat_idx + 1)
m.next = "WR_ISSUE"
# ── clock-DEPENDENT liveness: MR software-reset self-clear ────────
# Software reset: write MR=0x80, then poll MR until the reset bit
# self-clears. That only happens if the 25 MHz clock is running.
with m.State("MR_RST_ISSUE"):
bus_write(_A_MR, _MR_RST)
m.next = "MR_RST_WAIT"
with m.State("MR_RST_WAIT"):
with m.If(bus_done):
m.d.sync += ctr.eq(0)
m.next = "MR_POLL_ISSUE"
with m.State("MR_POLL_ISSUE"):
bus_read(_A_MR)
m.next = "MR_POLL_WAIT"
with m.State("MR_POLL_WAIT"):
with m.If(bus_done):
with m.If(~bus_rd[7]):
m.d.sync += mr_ok.eq(1)
m.next = "REPORT"
with m.Else():
m.d.sync += ctr.eq(ctr + 1)
with m.If(ctr == self._poll - 1):
m.d.sync += mr_ok.eq(0) # never cleared
m.next = "REPORT"
with m.Else():
m.next = "MR_POLL_ISSUE"
with m.State("REPORT"):
m.d.comb += self.done.eq(1)
# ── LED reporting (free-running, only meaningful once done) ───────────
blink_ctr = Signal(range(self._blink + 1))
tick = Signal()
m.d.comb += tick.eq(blink_ctr == self._blink - 1)
m.d.sync += blink_ctr.eq(Mux(tick, 0, blink_ctr + 1))
# lowest failing data-line index (0..7); N blinks = index+1.
low = Signal(range(8))
for i in reversed(range(8)): # last write wins -> lowest set bit
with m.If(bad[i]):
m.d.comb += low.eq(i)
# N-blink pattern: phase 0..(2N-1) toggles the LED (N pulses), then a
# PAUSE of dark phases, repeating. Phase advances one step per tick.
PAUSE = 4
nblink = Signal(range(9))
m.d.comb += nblink.eq(low + 1)
span = Signal(range(2 * 8 + PAUSE + 1))
m.d.comb += span.eq((nblink << 1) + PAUSE)
phase = Signal(range(2 * 8 + PAUSE + 1))
with m.If(self.done & tick):
m.d.sync += phase.eq(Mux(phase >= span - 1, 0, phase + 1))
red_code = Signal() # blinks index+1 times, then pauses
m.d.comb += red_code.eq((phase < (nblink << 1)) & ~phase[0])
red_fast = Signal() # fast continuous blink (~8 Hz)
m.d.comb += red_fast.eq(blink_ctr < (self._blink >> 1))
# slow square (~2 Hz) for the "clock dead" code — green AND red together
slow_ctr = Signal(2)
with m.If(self.done & tick):
m.d.sync += slow_ctr.eq(slow_ctr + 1)
sq_slow = Signal()
m.d.comb += sq_slow.eq(slow_ctr[1])
with m.If(self.done):
with m.If(bad == 0xFF): # reads never tracked writes: nothing
m.d.comb += [self.led_g.eq(0), self.led_r.eq(red_fast)] # on the bus
with m.Elif(bad != 0): # bus works, one/some data lines bad
m.d.comb += [self.led_g.eq(0), self.led_r.eq(red_code)] # -> D(N-1)
with m.Elif(~mr_ok): # bus OK but MR reset never self-cleared
m.d.comb += [self.led_g.eq(sq_slow), self.led_r.eq(sq_slow)] # clock
with m.Else(): # everything good
m.d.comb += [self.led_g.eq(1), self.led_r.eq(0)]
# ── UART status report (115200 8N1) ──────────────────────────────────
# Continuously transmits "W5100 MROK=X BAD=YY <verdict>\r\n" once `done`.
DIV = round(24_000_000 / 115_200) # 208 cycles / bit
tmpl = list(b"W5100 MROK=0 BAD=00 ....\r\n") # X@11, hi@17, lo@18, verdict@20..23
MSGLEN = len(tmpl)
rom = Array([Const(b, 8) for b in tmpl])
def hexch(nib):
return Mux(nib < 10, 0x30 + nib, 0x37 + nib) # 0-9 / A-F
# four-char verdict word at positions 20..23
verd = Signal(32) # 4 bytes, [20],[21],[22],[23] low->high
with m.If(self.done & (bad == 0xFF)):
m.d.comb += verd.eq(int.from_bytes(b"NORE", "little"))
with m.Elif(self.done & (bad != 0)):
m.d.comb += verd.eq(int.from_bytes(b"DBUS", "little"))
with m.Elif(self.done & ~mr_ok):
m.d.comb += verd.eq(int.from_bytes(b"CLK!", "little"))
with m.Else():
m.d.comb += verd.eq(int.from_bytes(b"GOOD", "little"))
uidx = Signal(range(MSGLEN + 1))
cur = Signal(8)
m.d.comb += cur.eq(rom[uidx])
with m.Switch(uidx):
with m.Case(11): m.d.comb += cur.eq(0x30 + mr_ok)
with m.Case(17): m.d.comb += cur.eq(hexch(bad[4:8]))
with m.Case(18): m.d.comb += cur.eq(hexch(bad[0:4]))
for k in range(4):
with m.Case(20 + k): m.d.comb += cur.eq(verd[8*k:8*k+8])
shift = Signal(10, init=0x3FF)
nbits = Signal(range(11))
baud = Signal(range(DIV))
gap = Signal(range(DIV * 30 + 1))
m.d.comb += self.uart_tx.eq(shift[0])
with m.FSM(domain="sync", name="uart_fsm"):
with m.State("IDLE"):
with m.If(self.done):
m.d.sync += uidx.eq(0)
m.next = "LOAD"
with m.State("LOAD"):
with m.If(uidx == MSGLEN):
m.d.sync += gap.eq(DIV * 30)
m.next = "GAP"
with m.Else():
m.d.sync += [shift.eq(Cat(C(0, 1), cur, C(1, 1))),
nbits.eq(10), baud.eq(DIV - 1)]
m.next = "SHIFT"
with m.State("SHIFT"):
with m.If(baud == 0):
m.d.sync += baud.eq(DIV - 1)
with m.If(nbits == 1):
m.d.sync += uidx.eq(uidx + 1)
m.next = "LOAD"
with m.Else():
m.d.sync += [nbits.eq(nbits - 1),
shift.eq(Cat(shift[1:], C(1, 1)))]
with m.Else():
m.d.sync += baud.eq(baud - 1)
with m.State("GAP"):
m.d.sync += gap.eq(gap - 1)
with m.If(gap == 0):
m.d.sync += uidx.eq(0)
m.next = "LOAD"
return m
class W5100SelfTestTop(Elaboratable):
"""Platform build wrapper: 24 MHz HFOSC `sync` clock + W5100 bus + LED pins.
No PLL / capture domain, so it P&Rs with huge timing margin on any seed.
"""
def elaborate(self, platform):
m = Module()
m.domains += ClockDomain("sync")
m.submodules.hfosc = Instance(
"SB_HFOSC",
p_CLKHF_DIV="0b01", # 48 / 2 -> 24 MHz
i_CLKHFEN=Const(1, 1),
i_CLKHFPU=Const(1, 1),
o_CLKHF=ClockSignal("sync"),
)
m.submodules.dut = dut = W5100SelfTest()
w5100 = platform.request("w5100", 0)
ledr = platform.request("ledr", 0)
ledg = platform.request("ledg", 0)
uart = platform.request("uart", 0)
m.d.comb += [
w5100.addr.o.eq(dut.bus_addr),
w5100.data.o.eq(dut.bus_data_o),
w5100.data.oe.eq(dut.bus_data_oe),
dut.bus_data_i.eq(w5100.data.i),
w5100.cs_n.o.eq(dut.cs_n),
w5100.rd_n.o.eq(dut.rd_n),
w5100.wr_n.o.eq(dut.wr_n),
w5100.rst_n.o.eq(dut.rst_n),
ledr.o.eq(dut.led_r), # LEDs are ACTIVE-HIGH on this board
ledg.o.eq(dut.led_g),
uart.tx.o.eq(dut.uart_tx), # 115200 8N1 status report (FT2232H ch B)
]
return m
# ── simulation self-test: a small W5100 indirect-bus model ───────────────────
if __name__ == "__main__":
import sys
if "--build" in sys.argv or "--flash" in sys.argv:
from exi_bba.synth import IceBreakerPlatform
flash = "--flash" in sys.argv
IceBreakerPlatform().build(W5100SelfTestTop(), do_program=flash,
name="w5100_selftest", build_dir="build_w5100test")
print("[built] build_w5100test/w5100_selftest.bin" + (" + flashed" if flash else ""))
raise SystemExit(0)
from amaranth.sim import Simulator, Period
def run(clock_dead=False, bus_dead=False, break_bit=None):
dut = W5100SelfTest(strobe_cycles=3, rst_cycles=3, boot_cycles=3,
poll_timeout=8, blink_cycles=4)
sim = Simulator(dut)
sim.add_clock(Period(MHz=24), domain="sync")
async def w5100_model(ctx):
mem = {} # direct-mode regs by A[1:0]
for _ in range(8000):
await ctx.tick("sync")
cs = ctx.get(dut.cs_n); rd = ctx.get(dut.rd_n); wr = ctx.get(dut.wr_n)
a = ctx.get(dut.bus_addr); dko = ctx.get(dut.bus_data_o)
if cs == 0 and wr == 0: # write strobe (idempotent)
mem[a] = dko
if cs == 0 and rd == 0: # drive read data
if bus_dead:
ctx.set(dut.bus_data_i, 0xFF) # nothing drives -> float hi
elif a == _A_MR:
mr = mem.get(_A_MR, 0) # reset self-clears only if
ctx.set(dut.bus_data_i, # the clock is alive
mr if clock_dead else (mr & 0x7F))
else: # GAR0.. scratch R/W
val = mem.get(a, 0)
if break_bit is not None: # stuck-low data line
val &= ~(1 << break_bit) & 0xFF
ctx.set(dut.bus_data_i, val)
async def checker(ctx):
for _ in range(4000):
await ctx.tick("sync")
if ctx.get(dut.done):
return (ctx.get(dut.mr_ok), ctx.get(dut.bad_bits))
return (None, None)
result = {}
async def tb(ctx):
result["v"] = await checker(ctx)
sim.add_testbench(w5100_model, background=True)
sim.add_testbench(tb)
sim.run()
return result["v"]
ok = True
mr, bad = run()
print(f"[healthy] mr_ok={mr} bad={bad:#04x} -> GREEN solid")
ok &= (mr == 1 and bad == 0)
mr, bad = run(break_bit=3)
print(f"[D3 broken] mr_ok={mr} bad={bad:#04x} -> green dark, RED 4 blinks")
ok &= ((bad & 0x08) and bad != 0xFF)
mr, bad = run(clock_dead=True)
print(f"[clock/Y2 dead] mr_ok={mr} bad={bad:#04x} -> GREEN+RED slow together")
ok &= (mr == 0 and bad == 0)
mr, bad = run(bus_dead=True)
print(f"[unpowered/dead] mr_ok={mr} bad={bad:#04x} -> green dark, RED fast")
ok &= (bad == 0xFF)
print("PASS" if ok else "FAIL")
import sys; sys.exit(0 if ok else 1)
+13
View File
@@ -308,6 +308,19 @@ datasheet extraction, WebSearch/WebFetch for parts and reference designs.
EFUSE_EN : R46, R47, U13.5 EN/UVLO EFUSE_EN : R46, R47, U13.5 EN/UVLO
EFUSE_ILIM: R45, U13.3 ILIM EFUSE_ILIM: R45, U13.3 ILIM
GND : U13.2 OVLO, U13.4 GND, R45.2, R47.2, C66.2 GND : U13.2 OVLO, U13.4 GND, R45.2, R47.2, C66.2
⚠️ **BUG FOUND AT BRING-UP 2026-09-16 — this OVLO=GND is WRONG for a 12 V
rail and the eFuse passes NOTHING.** Per the TPS25961 datasheet, tying OVLO to
GND selects the *internal fixed* overvoltage threshold **VOVP(R) = 5.98 V typ
(5.55–6.5 V)** — NOT "OVLO disabled". A 12 V input is far above that, so the
device sits in overvoltage lockout with the FET off (bench-confirmed: 12V_EXI =
0 V, GC_ON = 0 V, W5100 unpowered). The earlier review note ("OVLO tied to GND
for the internal fixed threshold, abs max 6.5 V fine") caught the pin abs-max
but MISSED that the fixed VIN cutoff is ~6 V. **Fix (V2.1):** drive OVLO from a
divider off 12V_RAW so the trip sits ~14–16 V (above 12 V, below the 19 V max /
the D3 clamp): e.g. **R_top ≈ 1.1 MΩ (12V_RAW→OVLO) + R_bottom ≈ 100 kΩ
(OVLO→GND)** → OVLO pin = 1.24 V at ~15 V VIN, and ~1.0 V at 12 V (stays on).
BENCH WORKAROUND to keep bring-up moving: inject 12 V straight onto 12V_EXI
(C65+ pad or U3.3 VIN), bypassing the mis-set eFuse.
**D3 (SMAJ12A TVS) stays on the INPUT side on purpose** — it clamps **D3 (SMAJ12A TVS) stays on the INPUT side on purpose** — it clamps
GC-side surges before they reach the eFuse's 21 V absolute maximum. **C65 GC-side surges before they reach the eFuse's 21 V absolute maximum. **C65
is on the OUTPUT side on purpose** — it is the bulk cap whose inrush we are is on the OUTPUT side on purpose** — it is the bulk cap whose inrush we are
+19 -12
View File
@@ -241,6 +241,14 @@ schematic changes.
at (148.49, 61.7225) near U11 pin 48. Verified 2026-07-22: nets at (148.49, 61.7225) near U11 pin 48. Verified 2026-07-22: nets
`/ethernet/ETH_RST` + `/ethernet/ETH_3V3` both present on the footprint, `/ethernet/ETH_RST` + `/ethernet/ETH_3V3` both present on the footprint,
0 unconnected items. 0 unconnected items.
- [ ] 🔴 **eFuse U13 OVLO MISCONFIGURED — BLOCKS 12 V (found at bring-up
2026-09-16, FAB-BLOCKER for V2.1).** OVLO (pin 2) is tied to GND, which on
the TPS25961 selects the internal *fixed* overvoltage cutoff ~5.98 V — so a
12 V rail is permanently in overvoltage lockout and the eFuse passes
nothing (bench-confirmed: 12V_EXI=0, GC_ON=0, W5100 unpowered). **Fix:**
OVLO divider off 12V_RAW, R_top ≈ 1.1 M / R_bottom ≈ 100 k → trip ~15 V.
See REVIEW.md eFuse entry. Bench workaround: feed 12 V onto 12V_EXI (C65+ /
U3 VIN) to bypass it. This must be fixed before any V2.1 fab order.
- [ ] **12 V inrush limiter (U13 TPS25961 eFuse) — SCHEMATIC DONE 2026-07-31**, - [ ] **12 V inrush limiter (U13 TPS25961 eFuse) — SCHEMATIC DONE 2026-07-31**,
full design rationale in REVIEW.md §6. J3.5 → 12V_RAW → U13 → 12V_EXI; full design rationale in REVIEW.md §6. J3.5 → 12V_RAW → U13 → 12V_EXI;
D3 (TVS) stays upstream, C65 downstream. R45 100 k sets I_LIM ≈ 500 mA D3 (TVS) stays upstream, C65 downstream. R45 100 k sets I_LIM ≈ 500 mA
@@ -273,16 +281,14 @@ schematic changes.
board PCF. board PCF.
- [ ] **Gateware: EXI INT drive style** — prefer open-drain emulation (drive - [ ] **Gateware: EXI INT drive style** — prefer open-drain emulation (drive
low / release) on J3.3 rather than push-pull high. low / release) on J3.3 rather than push-pull high.
- [ ] **New 2026-07-22: 2 GND vias short the `/Power/3V3` zone on In2.Cu** — - [x] **2 GND vias vs `/Power/3V3` zone on In2.Cu — RESOLVED 2026-08-28.**
found during the last DRC pass, 4 error-severity violations (2× Was 4 error-severity DRC violations (2× `clearance` + 2× `hole_clearance`,
`clearance` + 2× `hole_clearance`, both 0 mm actual). Two GND vias at 0 mm) from GND stitching vias sitting on the 3V3 pour. Cleared by refilling
(190.78, 58.58) and (190.34, 58.58) — just south of J3's courtyard, the zones (the pour now voids around the vias); `kicad-cli pcb drc
likely stitching vias added during the J3 reroute — sit directly on --severity-error` = 0 on the filled board. The `flash`/production skill
top of the 3V3 pour on In2.Cu. This is a real short risk, not a (kicad-manufacturing-export) now refills zones before export, so this
manufacturability nit: move or delete these two vias (or void the 3V3 can't silently regress. (Any leftover `track_dangling` stub is
zone locally) before ordering. Also 1 harmless `track_dangling`: a warning-level debris, delete when convenient.)
0.06 mm stub on `/exi/EXI_MOSI_RAW` near (188.39, 57.52) — routing
debris, delete when convenient.
## Broader items (beyond the wiring review — block fab on the first two) ## Broader items (beyond the wiring review — block fab on the first two)
@@ -374,8 +380,9 @@ schematic changes.
exactly what stock iceprog opens by default, which now matches the exactly what stock iceprog opens by default, which now matches the
rewired pins. Optionally program serial/product string later with rewired pins. Optionally program serial/product string later with
FT_PROG (do NOT change VID/PID, or iceprog needs -d vid:pid). FT_PROG (do NOT change VID/PID, or iceprog needs -d vid:pid).
- [ ] CC1/CC2 have no ESD protection (USBLC6 covers D+/D− only) — optional, - [x] CC1/CC2 ESD protection — **WON'T FIX** (Dennis, 2026-08-28). USBLC6
most hobby designs skip; note spare-channel budget if respinning D2 area. covers D+/D− only; CC lines left unprotected by choice, as most hobby
designs do. Closed, not a pending item.
- [x] `.history/` (embedded `.git`) — VERIFIED ignored via root - [x] `.history/` (embedded `.git`) — VERIFIED ignored via root
`.gitignore:28`, nothing tracked. `.gitignore:28`, nothing tracked.
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""EXI device-ID query over the Pi's HARDWARE SPI (spidev) — a near-full-speed
(~25 MHz, close to the GameCube's real ~27 MHz EXI clock) companion to the
bit-bang exi_devid_rpi.py.
Use bit-bang (exi_devid_rpi.py) for a slow logic smoke test; use THIS to prove
the FPGA's SPI Mode-3 capture front-end also samples cleanly *at rate* without a
GameCube. The Pi 4 SPI SCK is core_clk/even-divisor, so a 27 MHz request lands
on ~25 MHz — comfortably inside the design's 54 MHz 2x oversampling (and a hair
more margin than the real 27 MHz). Do NOT push past ~27 MHz: the capture domain
only closes ~49-58 MHz across seeds, so >~27 MHz input under-samples.
The FPGA prefetches the reply during a clock-IDLE gap between the header and the
data bytes (the GC pauses the clock there), so CS must stay LOW across that gap.
spidev toggles CS per call, so we park SPI0's hardware CS on BCM7 (unused) and
drive the real CS manually on BCM8. Pi config (then reboot):
/boot/firmware/config.txt: dtoverlay=spi0-1cs,cs0_pin=7
(That frees BCM8 and puts the unused hw CE0 on BCM7. To go back to the bit-bang
script, remove that line + reboot so BCM9/10/11 return to plain GPIO.)
Wiring (Pi -> FPGA EXI, all 3.3 V, shared GND) — identical to the bit-bang one:
SCLK BCM11 (pin 23) -> EXI CLK (FPGA 44)
MOSI BCM10 (pin 19) -> EXI MOSI (FPGA 4)
MISO BCM9 (pin 21) <- EXI MISO (FPGA 3)
CS BCM8 (pin 24) -> EXI CS (FPGA 45) [held low manually across the txn]
GND (pin 25) <-> GND
Run: sudo python3 exi_devid_spi.py [speed_hz] [gap_us]
"""
import sys, time
import spidev
try:
import RPi.GPIO as GPIO
except ImportError:
raise SystemExit("needs RPi.GPIO -> sudo apt install python3-rpi.gpio")
SPEED = int(sys.argv[1]) if len(sys.argv) > 1 else 27_000_000 # ~25 MHz actual (core/10)
GAP_US = int(sys.argv[2]) if len(sys.argv) > 2 else 50 # header->data prefetch gap
CS = 8 # BCM8 manual chip-select (hardware CE0 parked on BCM7 by the overlay)
spi = spidev.SpiDev()
spi.open(0, 0)
spi.mode = 0b11 # SPI Mode 3: CPOL=1, CPHA=1 (EXI CLK idles high)
spi.max_speed_hz = SPEED
GPIO.setmode(GPIO.BCM)
GPIO.setup(CS, GPIO.OUT, initial=1) # CS idle high
GPIO.output(CS, 0) # assert CS (active low) for the whole transaction
spi.xfer2([0x00, 0x03]) # header: read, addr[12:0]=0, len-1=3 (4 bytes)
time.sleep(GAP_US / 1e6) # clock-idle gap -> FPGA prefetches the reply
resp = spi.xfer2([0x00] * 6) # clock read bytes; MISO carries the response
GPIO.output(CS, 1) # deassert CS
spi.close(); GPIO.cleanup()
print(f"SPI mode 3, {SPEED/1e6:.1f} MHz requested (actual ~= core/even-divisor, ~25 MHz), "
f"header->data gap {GAP_US} us")
print("read bytes:", " ".join("%02x" % b for b in resp))
want = [0x04, 0x02, 0x02, 0x00]
if any(resp[i:i + 4] == want for i in range(len(resp) - 3)):
print("EXI device-ID 04 02 02 00 -> FOUND. Capture path works at ~full EXI rate!")
else:
print("device-ID not found. If the slow bit-bang test (exi_devid_rpi.py) PASSES "
"but this fails, the front-end is marginal at rate on the flashed seed -> "
"reflash the BEST --seeds bitstream, and/or raise the gap (arg2) or lower "
"the speed (arg1). Otherwise check wiring/GND and that the FPGA is running.")