Implement the COMMAND-level interrupt: without it no driver sees a frame

The BBA has two interrupt levels and we implemented only one.  Both drivers'
handlers are identical in shape -- libogc2's bba_event_handler and Swiss's
exi_coroutine:

  status = bba_cmd_in8(0x03);            /* COMMAND level */
  bba_cmd_out8(0x02, BBA_CMD_IRMASKALL);
  if (status & 0x80) bba_interrupt();    /* ONLY then the MAC level */

and bba_interrupt() is what reads register-space IR/IMR and calls
bba_start_rx().  Bit 7 of command register 0x03 means "a MAC interrupt is
pending".  r_cmd_ir was write-1-clear with nothing ever setting it, so a
command read of 0x03 returned 0x00, every branch of both handlers fell through,
and NEITHER DRIVER WOULD EVER HAVE PROCESSED A RECEIVED FRAME.

Our own tooling could not have caught it: gc_bba_test and netboot poll RWP/IR
directly and never use the interrupt path, so the whole command-level mechanism
was untested and unused.  Green suite, dead interface.

cmd_ir[7] is now a live level equal to (IR & IMR) != 0, and INT_N is
(cmd_ir & cmd_imr) != 0.  A level rather than a latch because both drivers
clear the MAC IR inside bba_interrupt() and only then write-1-clear command
0x03: a latched bit would be cleared by that write even if a new frame had
arrived in between, while a level stays asserted as long as the condition does.
INT_N honouring the command-level mask is what stops the line re-asserting
while a handler runs (IRMASKALL on entry, IRMASKNONE on exit).  r_cmd_imr
resets to 0xF8 since the real power-on value is unknown and both drivers set it
explicitly first thing.

T13 and T13b cover it, both mutation-tested: reading the latched bits only
leaves 0x00 after an RI, and ignoring the command mask leaves INT_N asserted
through IRMASKALL.

Also records in CLAUDE.md which driver actually talks to us, since Swiss has
several back-ends and it is easy to read the wrong one: cube/patches/bba/ has
one file per chip (bba.c is ours; enc28j60.c, w5500.c, w6100.c, w6300.c are
other adapters), Swiss's menu networking goes through libogc2's gcif.c, and
cube/patches/bba/bba.c is the in-game patch driver.  Detection is by EXI device
ID, so returning 04 02 02 00 is what gets us handed to the BBA path at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 11:11:55 +02:00
parent 88d05988b5
commit 0d100eb84f
2 changed files with 159 additions and 3 deletions
+82 -3
View File
@@ -265,7 +265,11 @@ class BBARegisterFile(Elaboratable):
cmd_wdata = Signal(8)
# EXI-side command registers. Swiss uses exactly two: 0x02 is the EXI
# interrupt mask and 0x03 the EXI interrupt status (write-1-to-clear).
r_cmd_imr = Signal(8)
r_cmd_imr = Signal(8, init=0xF8)
# Reset UNMASKED: the real device's power-on value is unknown, and
# both drivers set this explicitly (IRMASKALL first thing in init), so
# the permissive default costs them nothing while keeping INT_N usable
# for software that never writes it.
r_cmd_ir = Signal(8)
tx_frame_len = Signal(16)
# TX trigger bookkeeping. `twd_armed` fires one cycle AFTER TWD_HI is
@@ -329,10 +333,37 @@ class BBARegisterFile(Elaboratable):
# The device-ID constant lives in SPIMode3Slave now (it must be
# answered with zero CDC latency), so there is no copy here.
# ── Command-level interrupt status (reg 0x03) ────────────────────
# ⚠️ THE BBA HAS TWO INTERRUPT LEVELS and both drivers depend on it.
# Their handlers are, identically:
#
# status = bba_cmd_in8(0x03); /* COMMAND level */
# if (status & 0x80) bba_interrupt(); /* only then the MAC level */
#
# and bba_interrupt() is what reads register-space IR/IMR and calls
# bba_start_rx(). Bit 7 of command register 0x03 means "a MAC
# interrupt is pending". This file implemented only the MAC level and
# never set that bit, so a command read of 0x03 returned 0x00, every
# branch of both handlers fell through, and NEITHER DRIVER WOULD EVER
# PROCESS A RECEIVED FRAME. Our own tooling never noticed because
# gc_bba_test and netboot poll RWP/IR directly and do not use the
# interrupt path at all.
#
# Bit 7 is a LIVE LEVEL, not a latched bit, deliberately: both drivers
# clear the MAC IR inside bba_interrupt() and only then write-1-clear
# command 0x03. A latched bit would be cleared by that write even if
# a new frame had arrived in between, losing the event; a level stays
# asserted exactly as long as the MAC condition does.
mac_int = Signal()
m.d.comb += mac_int.eq((r_ir & r_imr).any())
cmd_ir_val = Signal(8)
m.d.comb += cmd_ir_val.eq(Cat(r_cmd_ir[0:7], mac_int))
cmd_rdval = Signal(8)
with m.Switch(cmd_reg):
with m.Case(0x02): m.d.comb += cmd_rdval.eq(r_cmd_imr)
with m.Case(0x03): m.d.comb += cmd_rdval.eq(r_cmd_ir)
with m.Case(0x03): m.d.comb += cmd_rdval.eq(cmd_ir_val)
with m.Default(): m.d.comb += cmd_rdval.eq(0)
rd_val = Signal(8) # response for the current non-SPRAM read byte
@@ -735,7 +766,12 @@ class BBARegisterFile(Elaboratable):
# ── Interrupt output ─────────────────────────────────────────────
m.d.comb += self.rrp.eq(r_rrp_lo)
m.d.sync += self.exi_int_n.eq(~(r_ir & r_imr).any())
# INT_N comes from the COMMAND level, gated by the command-level mask
# (reg 0x02). Both drivers write IRMASKALL (0x00) on entry to their
# handler and IRMASKNONE (0xF8) on exit, which is how they stop the
# line re-asserting while they work -- so honouring that mask is what
# prevents an interrupt storm during handling.
m.d.sync += self.exi_int_n.eq(~(cmd_ir_val & r_cmd_imr).any())
# ── Consume RWP updates from sync domain ──────────────────────────
with m.If(rx_wptr.r_rdy):
@@ -1197,6 +1233,49 @@ if __name__ == "__main__":
ctx.set(reg.tx_irq_i, 0)
await ctx.tick("sync").repeat(2)
# ── T13: the COMMAND-level interrupt both drivers gate on ────────
# Both handlers are, identically:
# status = bba_cmd_in8(0x03); if (status & 0x80) bba_interrupt();
# so with bit 7 clear NEITHER driver ever reaches the MAC-level
# handler that reads IR/IMR and starts RX. Our own tooling cannot
# catch this: gc_bba_test and netboot poll RWP/IR and never use the
# interrupt path.
await exi_write(ctx, _IR, [0xFF]) # clear MAC IR
await exi_write(ctx, _IMR, [0x86]) # enable RBFI|TI|RI
await cmd_write(ctx, 0x02, [0xF8]) # IRMASKNONE
before = (await cmd_read(ctx, 0x03, 1))[0]
int_idle = ctx.get(reg.exi_int_n)
ctx.set(reg.rx_irq_i, 1) # a frame arrives
await ctx.tick("sync")
ctx.set(reg.rx_irq_i, 0)
await ctx.tick("sync").repeat(3)
after = (await cmd_read(ctx, 0x03, 1))[0]
int_pend = ctx.get(reg.exi_int_n)
print(f"T13 cmd 0x03: idle=0x{before:02X} after RI=0x{after:02X}; "
f"INT_N {int_idle} -> {int_pend}")
if before & 0x80:
errors.append(f"T13 cmd 0x03 bit7 set while idle: 0x{before:02X}")
if not (after & 0x80):
errors.append(f"T13 cmd 0x03 bit7 NOT set after RI: 0x{after:02X} "
f"- both drivers would skip bba_interrupt()")
if int_pend:
errors.append("T13 INT_N not asserted with an unmasked MAC event")
# Masking at the COMMAND level must drop INT_N -- this is how both
# drivers stop the line re-asserting while their handler runs.
await cmd_write(ctx, 0x02, [0x00]) # IRMASKALL
await ctx.tick("sync").repeat(3)
masked = ctx.get(reg.exi_int_n)
still = (await cmd_read(ctx, 0x03, 1))[0]
print(f"T13b IRMASKALL: INT_N={masked} (want 1), "
f"status still 0x{still:02X}")
if not masked:
errors.append("T13b INT_N still asserted after IRMASKALL")
if not (still & 0x80):
errors.append("T13b masking must not clear the pending status")
await cmd_write(ctx, 0x02, [0xF8])
await exi_write(ctx, _IR, [0xFF]) # tidy up
# ── T12: Swiss's command-space form, which DIFFERS from libogc's ──
# Swiss sends ONE 4-byte transfer with the value as byte 3 and reads
# the response from byte 3; libogc sends three bytes with the value as