From 0d100eb84fee92ebf0a517c452e7a79f28b75789 Mon Sep 17 00:00:00 2001 From: Dennis Brentjes Date: Sat, 10 Oct 2026 11:11:55 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 77 ++++++++++++++++++++++++++++++++ exi_bba/bba_register_file.py | 85 ++++++++++++++++++++++++++++++++++-- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 83da879..6d5bb35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1307,6 +1307,83 @@ parallel master is the default — see "W5100 vs W5500".) --- +## ⚠️ Which driver actually talks to us — and the TWO interrupt levels + +**Swiss has several ethernet back-ends, and `cube/patches/bba/` holds one file +per chip** — `bba.c` (real DOL-015 / MX98730EC), `enc28j60.c`, `w5500.c`, +`w6100.c`, `w6300.c`. Only `bba.c` is our chain; the others are different +adapters (Swiss also ships `eth2gc` and `gcnet` artwork). When reading Swiss, +check which file you are in. + +**Swiss uses TWO different drivers, and we meet both:** + +| context | driver | command-space form | +|---|---|---| +| Swiss's own menu/network (`if_configex`) | **libogc2** `lwip/arch/gc/netif/gcif.c` | 2-byte header + data | +| patches injected into a GAME | **Swiss** `cube/patches/bba/bba.c` | one 4-byte transfer | + +`cube/swiss/source/bba.c` is only the app-side glue: it calls `if_configex()` +and then picks a `GetExiSpeed` by the interface name libogc2 registered +(`'e'` → MX98730EC, `'E'` → ENC28J60, `'W'` → W5500, `'w'` → W6X00). +Detection is by **EXI device ID**: + +```c +if ((location & LOC_SERIAL_PORT_1) && EXI_GetID(EXI_CHANNEL_0, EXI_DEVICE_2, &id)) { + switch (id) { case EXI_W5500_ID: case EXI_MX98730EC_ID: case EXI_ENC28J60_ID: +``` + +so returning `04 02 02 00` is what makes Swiss treat us as a real BBA and hand +us to the libogc2 path. + +### ✅ FIXED 2026-10-10 — the COMMAND-level interrupt (showstopper) + +**The BBA has two interrupt levels and we implemented only one.** Both drivers' +handlers are identical in shape: + +```c +/* libogc2 bba_event_handler, and Swiss's exi_coroutine */ +status = bba_cmd_in8(0x03); /* COMMAND level */ +bba_cmd_out8(0x02, BBA_CMD_IRMASKALL); /* mask while working */ +if (status & 0x80) bba_interrupt(); /* ONLY then the MAC level */ +... +bba_cmd_out8(0x03, status); /* write-1-clear */ +bba_cmd_out8(0x02, BBA_CMD_IRMASKNONE); +``` + +and `bba_interrupt()` is the function that 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 and nothing ever set 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 this.** `gc_bba_test` and `netboot` +poll `RWP`/`IR` directly and never use the interrupt path, so the entire +command-level mechanism was untested and unused — green suite, dead interface. + +Now: `cmd_ir[7]` is a **live level** equal to `(IR & IMR) != 0`, and +`INT_N = (cmd_ir & cmd_imr) != 0`. +- A level, not 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; + a level stays asserted exactly as long as the MAC condition does. +- `INT_N` honours the **command-level** mask, which is how both drivers stop + the line re-asserting while their handler runs (`IRMASKALL` = `0x00` on + entry, `IRMASKNONE` = `0xF8` on exit). Ignoring it is an interrupt storm. +- `r_cmd_imr` resets to `0xF8` (unmasked). The real power-on value is unknown; + both drivers set it explicitly first thing, so a permissive default costs + them nothing and keeps `INT_N` usable for software that never writes it. + +Tests: `bba_register_file` **T13** (bit 7 clear when idle, set after an RI with +`IMR` enabling it, `INT_N` asserted) and **T13b** (`IRMASKALL` drops `INT_N` +but must NOT clear the pending status). Both mutation-tested. + +Other command registers both drivers touch, none of them decoded: `0x01` +(revision ID — libogc2 stores it as `revid`), `0x04`/`0x05` (libogc2 writes its +`devid` 2 bytes + `acstart`), `0x08`/`0x09`/`0x0B` (challenge/response — see +the note saying not to implement it). Reads return `0x00`. + +--- + ## TX path — libogc writes NO TWD (found 2026-10-10) `__bba_link_tx` is **four transactions**: diff --git a/exi_bba/bba_register_file.py b/exi_bba/bba_register_file.py index 3020dc5..506a015 100644 --- a/exi_bba/bba_register_file.py +++ b/exi_bba/bba_register_file.py @@ -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