2893e7f6fb
Compat TODO item 1. S0_MR now sets the MACRAW MF bit, so the W5100 delivers
frames addressed to SHAR or broadcast only -- what the real BBA does, since
libogc2 configures NCRB = AB|CA|2_PACKETS_PER_INT (PR=0, PM=0, AB=1).
NCRB.PR (bit 0) clears MF if the GC asks for promiscuous, sampled when socket 0
is OPENed, so a later PR change applies on the next NCRA reset.
This was not a performance issue, it was a HANG. Swiss sat on "Initialising
Network" forever. libogc2's bba_interrupt() loops
while (status) { clear IR; start_rx; status = IR & IMR; }
which only exits when IR & IMR reaches zero. Unfiltered, every frame on the
segment set RI, re-asserting faster than the loop could clear it, so it never
exited. Unplugging the ethernet cable let Swiss run all the way through and
then fail DHCP for want of a server -- a one-minute experiment that identified
it after a lot of theorising about descriptors and interrupts.
Filtering against SHAR turned out to be safe for this path after all. The
earlier worry was that MF filters on SHAR, which we write only at init, so a
driver WRITING PAR0-5 would leave it stale and MF would drop everything.
libogc2 READS the MAC out of PAR0-5 instead, so SHAR -- written from reg.par in
that same init -- cannot go stale underneath it. Broadcast still passes MF and
a DHCP OFFER unicast to our MAC passes too.
Left open and recorded: refreshing SHAR on a PAR0-5 write plus a socket
re-open, for a driver that does write it (the official SDK driver is unread).
The W5500 back-end is unchanged, being the unused alternate path; it would need
the same Sn_MR treatment if revived.
T1 expects S0_MR = MACRAW|MF in the init sequence, T1b checks promisc high
re-inits with MF clear. Mutation-tested: never setting MF fails both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
13 KiB
Markdown
262 lines
13 KiB
Markdown
# BBA compatibility TODO
|
||
|
||
Gaps between this implementation and what real GC software expects, found by
|
||
reading the three drivers that actually drive a BBA:
|
||
|
||
- **Swiss (in-game patches)** — `cube/patches/bba/bba.c` in
|
||
[emukidid/swiss-gc](https://github.com/emukidid/swiss-gc). Note that
|
||
directory holds one file per adapter (`bba.c` is the real DOL-015;
|
||
`enc28j60.c`, `w5500.c`, `w6100.c`, `w6300.c` are other hardware) — check
|
||
which file you are in.
|
||
- **libogc2** — `lwip/arch/gc/netif/gcif.c` in
|
||
[extremscorner/libogc2](https://github.com/extremscorner/libogc2). This is
|
||
what Swiss's own menu networking uses, via `if_configex()`.
|
||
- **stock devkitPro libogc** — same shapes as libogc2; read out of
|
||
`/opt/devkitpro/libogc/lib/cube/libbba.a` (`gcif.o`) with `objdump`.
|
||
|
||
⚠️ **Games use the official Nintendo SDK driver, which none of us can read.**
|
||
Everything below is derived from the three open drivers plus the register map
|
||
they document. A game may exercise a mechanism no open driver touches, so
|
||
"no open driver does this" is weaker evidence than "the hardware has no such
|
||
register".
|
||
|
||
The register map referenced throughout is the one in libogc2's `gcif.c`
|
||
(`BBA_*` defines), which is the official one.
|
||
|
||
---
|
||
|
||
## 1. No hardware RX filtering — **it hung Swiss**
|
||
|
||
**Status: ✅ DONE 2026-10-10** — `S0_MR` now sets the MACRAW **MF** bit, so the
|
||
W5100 delivers own-MAC (`SHAR`) + broadcast only, matching what the real BBA
|
||
does. `NCRB.PR` (bit 0) clears MF if the GC asks for promiscuous, sampled when
|
||
socket 0 is OPENed (i.e. at init, so a later `PR` change takes effect on the
|
||
next NCRA reset).
|
||
|
||
**How it presented, which is worth knowing because it does not look like a
|
||
filtering problem.** Swiss hung forever on "Initialising Network". libogc2's
|
||
`bba_interrupt()` is
|
||
|
||
```c
|
||
ir = bba_in8(BBA_IR); imr = bba_in8(BBA_IMR); status = ir & imr;
|
||
while (status) { bba_out8(BBA_IR, status); ... start_rx ...; status = ir & imr; }
|
||
```
|
||
|
||
— it only exits when `IR & IMR` reaches zero. Unfiltered, every frame on the
|
||
segment set `RI`, which re-asserted faster than the loop could clear it, so it
|
||
**never exited**. Not slow: livelocked. Unplugging the ethernet cable let Swiss
|
||
get all the way through (then fail DHCP, there being no server), which is what
|
||
identified it — a one-minute experiment after a lot of theorising.
|
||
|
||
**Why filtering against `SHAR` was safe after all.** The earlier worry was that
|
||
MF filters on `SHAR`, which we write only during init, so a driver that WRITES
|
||
PAR0–5 would leave it stale and MF would drop everything. libogc2 **reads** the
|
||
MAC out of PAR0–5 rather than writing it, so for this path `SHAR` (written from
|
||
`reg.par` in the same init) cannot go stale. Broadcast still passes MF, and a
|
||
DHCP OFFER unicast to our MAC passes too.
|
||
|
||
⚠️ **Still to do for a driver that writes PAR0–5 after init:** refresh `SHAR`
|
||
on a PAR write and re-open the socket, otherwise MF filters on the old MAC.
|
||
Nothing we have seen does this, but the official SDK driver is unread.
|
||
|
||
⚠️ **The W5500 back-end was NOT changed** — it is the alternate path and
|
||
unused. If it is ever revived it needs the same treatment (`Sn_MR` MF bit).
|
||
|
||
Tests: `w5100_parallel_master` **T1** (the init sequence expects
|
||
`S0_MR = MACRAW|MF`) and **T1b** (`promisc` high re-inits with MF clear).
|
||
Mutation-tested: never setting MF fails both, with the message naming the
|
||
Swiss hang.
|
||
|
||
### Original analysis, kept for the reasoning
|
||
|
||
**Was: OPEN. Needs a decision, not just code.**
|
||
|
||
The real BBA filters received frames. libogc2 configures
|
||
`NCRB = AB|CA|2_PACKETS_PER_INT` = `0x52`, i.e. `PR=0` (not promiscuous),
|
||
`PM=0` (no multicast), `AB=1` (accept broadcast) — so the device delivers
|
||
**own-MAC + broadcast only**.
|
||
|
||
We run the W5100 in MACRAW with the **MF (MAC filter) bit clear**
|
||
(`_S0_MR_MACRAW = 0x04`, `w5100_parallel_master.py`), so **every frame on the
|
||
segment enters the SPRAM ring.**
|
||
|
||
Observed consequence, already seen on hardware: the test app's ring filled to
|
||
page 15 from what looked like "ambient broadcast traffic" but was in fact all
|
||
unicast traffic on the segment too. Ring pressure makes RBFI drop frames we
|
||
DO want, and a game's lean driver may not expect foreign unicast at all.
|
||
|
||
⚠️ **Setting MF is not a one-line fix.** MF filters against the W5100's
|
||
`SHAR`, and we write `SHAR` only during the init sequence. libogc2 *reads* the
|
||
MAC out of PAR0–5 and never writes it, so SHAR never diverges there — but a
|
||
game that WRITES PAR0–5 would leave SHAR stale and MF would then drop
|
||
everything. Doing it properly needs:
|
||
|
||
1. decode `NCRB` (see item 3) so `PR`/`PM` can steer the filter;
|
||
2. refresh `SHAR` when PAR0–5 changes, not only at init;
|
||
3. re-open socket 0 when the filter mode changes (`Sn_MR` is sampled at OPEN).
|
||
|
||
**The decision:** filter correctly (matches real hardware, fixes the ring
|
||
pressure) at the cost of that machinery, or stay a permissive superset where
|
||
nothing is ever lost but the ring fills with other hosts' traffic.
|
||
|
||
## 2. Ring geometry is hardcoded — silent corruption risk
|
||
|
||
**Status: ✅ DONE 2026-10-10.** `RXFrameAssembler` takes the ring bounds from
|
||
`bp`/`rhbp`, wired from the GC's `BP` (0x0A/0B) and `RHBP` (0x1A/1B) registers
|
||
in `BBATop`. The wrap and the free-space calculation both use them, so a
|
||
driver's smaller ring is respected instead of ignored.
|
||
|
||
**Garbage is rejected, not obeyed.** The bounds are used only when
|
||
`bp >= 1` (page 0 is the TX buffer), `rhbp <= 0x0F` and `rhbp > bp`;
|
||
otherwise the hardcoded pages 1–15 stand and `geom_honoured` goes low.
|
||
Without that check, `BP = 0` or an `RHBP` below `BP` gives a zero or negative
|
||
ring size and the assembler writes outside it.
|
||
|
||
⚠️ **`geom_honoured` is NOT yet observable from outside the FPGA.** It is
|
||
declared on `RXFrameAssembler` and asserted correctly (T7/T7b check it), but
|
||
`BBATop` does not wire it to anything — no status register, no LED, no UART
|
||
line. So a rejected geometry is currently silent to the GC and to the bench.
|
||
Wiring it to the `StatusPanel` or the UART console is a small loose end worth
|
||
closing; until then "rejected rather than obeyed" is the guarantee, not
|
||
"reported".
|
||
|
||
⚠️ **A ring must be LARGER than `_MAX_FRAME_PAGES` (6) or nothing is ever
|
||
stored.** A frame's length is unknown until EOF, so the assembler reserves the
|
||
worst case (1518 + 4 bytes = 6 pages) before accepting one. With a 4-page ring
|
||
`free` can never reach 6, so RBFI drops every frame — correct, conservative,
|
||
and useless. This was found by writing the test with `RHBP = 4` first, where it
|
||
passed **vacuously** because nothing was stored at all. If a real driver ever
|
||
configures a ring that small, the reservation has to become length-aware
|
||
(accept a frame whose actual length fits), which is a bigger change than this
|
||
one.
|
||
|
||
Tests: `rx_frame_assembler` **T7** (an 8-page ring is honoured, frames land
|
||
only in pages 1–8) and **T7b** (all three garbage cases rejected, and the
|
||
fallback layout still stores). Mutation-tested: wrapping at the hardcoded page
|
||
15 writes pages 9–11 and fails T7; removing the sanity check fails all four
|
||
T7b assertions.
|
||
|
||
⚠️ **This change cost a 0/8 timing failure before it was tuned, and the shape
|
||
of that is worth remembering.** The first version computed the sanitised
|
||
bounds combinationally, which put
|
||
|
||
```
|
||
bp/rhbp -> comparators -> Mux -> subtract -> pg_total -> add -> raw
|
||
-> compare -> free -> compare -> have_room
|
||
```
|
||
|
||
in ONE combinational path where it had previously been constant-folded. `clk`
|
||
fmax collapsed **29 → 17 MHz and all eight seeds failed** the 24 MHz target.
|
||
Two things were needed:
|
||
|
||
1. **Register the bounds** (`pg_first`/`pg_last`/`pg_total`, and `rrp_s`).
|
||
They change only when the GC writes `BP`/`RHBP`/`RRP`, so a cycle of
|
||
staleness costs nothing, and `free` already reaches the FSM through the
|
||
registered `have_room`. That alone got it to ~20 MHz — still failing.
|
||
2. **Narrow them to 4 bits.** Ring pages are 1–15. An 8-bit
|
||
variable-vs-variable comparison is a carry chain, where
|
||
variable-vs-CONSTANT folds into a couple of LUTs — which is exactly what
|
||
was lost by making the bounds dynamic. `geom_ok` guarantees
|
||
`bp < rhbp <= 0x0F`, which is what makes the narrow slice safe.
|
||
|
||
Result: 26.9–28.5 MHz on the two sampled seeds, against 28.0–29.2 for the
|
||
build without this change. **Always sweep after touching the assembler's
|
||
ring arithmetic** — and note the register-space decode (item 4) was measured
|
||
separately and cost nothing, so attributing a regression needs the A/B.
|
||
|
||
For the record, libogc2 writes `TLBP = 0x0000`, `BP = 0x0001`,
|
||
`RHBP = 0x000F` — exactly the old hardcoded layout, which is why this never
|
||
showed up in practice.
|
||
|
||
## 3. `NCRB` (0x01) is not decoded at all
|
||
|
||
**Status: ✅ DECODED 2026-10-10** — stored, readable, and exported as
|
||
`BBARegisterFile.ncrb`. Nothing consumes the bits yet; that is item 1.
|
||
Bits, from libogc2:
|
||
|
||
| bit | name | meaning |
|
||
|---|---|---|
|
||
| 0 | `PR` | promiscuous mode |
|
||
| 1 | `CA` | capture effect mode |
|
||
| 2 | `PM` | pass multicast |
|
||
| 3 | `PB` | pass bad frame |
|
||
| 4 | `AB` | accept broadcast |
|
||
| 6–7 | `RXINTC` | RX interrupt counter: 1 / 2 / … packets per interrupt |
|
||
|
||
We interrupt per frame where libogc2 asks for every 2 packets, which is benign
|
||
(more interrupts than requested). `PR`/`PM`/`AB` feed item 1.
|
||
|
||
`bba_register_file` **T15** asserts the export is `0x52` with `PR=0`, `PM=0`,
|
||
`AB=1` — i.e. the real BBA would deliver own-MAC + broadcast only — which is
|
||
the fact item 1 has to act on.
|
||
|
||
## 4. Registers that read `0x00` but have non-zero hardware defaults
|
||
|
||
**Status: ✅ DONE 2026-10-10** for every register libogc2 writes. They now
|
||
store writes and read back, with the documented hardware defaults at reset.
|
||
`bba_register_file` **T15** checks the defaults, replays libogc2's twelve
|
||
init writes and reads every one back; mutation-tested (dropping `NWAYC`'s
|
||
`0x84` default fails it, as does dropping `NCRB` writes).
|
||
|
||
Still reading `0x00`, deliberately: `LTPS` (0x04), `LRPS` (0x05), `TWP`
|
||
(0x0E/0F), `TRP` (0x12/13) and `TXFIFOCNT` (0x3E/3F). These are **status**
|
||
registers, not configuration — a driver reads them to learn what the device
|
||
did, so storing what was written to them would be a lie rather than a default.
|
||
Drivers read `LRPS`/`LTPS` only on error bits, and `0x00` reads as "no error",
|
||
which is the truthful answer for a device that does not detect those errors.
|
||
`TWP`/`TRP`/`TXFIFOCNT` would need the TX engine to actually report its page
|
||
pointers and FIFO depth; worth doing if a driver is ever seen to poll them.
|
||
|
||
The original problem for reference — a driver doing read-modify-write, or
|
||
sanity-checking a default, saw zero. libogc2 *does* read `NWAYC` (0x30) before
|
||
writing it.
|
||
|
||
| reg | name | hardware default |
|
||
|---|---|---|
|
||
| 0x01 | `NCRB` | — |
|
||
| 0x04 | `LTPS` | last transmitted packet status, RO |
|
||
| 0x05 | `LRPS` | last received packet status, RO |
|
||
| 0x0A/0B | `BP` | boundary page pointer |
|
||
| 0x0C/0D | `TLBP` | TX low boundary page |
|
||
| 0x0E/0F | `TWP` | TX buffer write page |
|
||
| 0x12/13 | `TRP` | TX buffer read page |
|
||
| 0x14/15 | `RXINTT` | RX interrupt timer |
|
||
| 0x1A/1B | `RHBP` | RX high boundary page |
|
||
| 0x30 | `NWAYC` | **0x84** |
|
||
| 0x32 | `GCA` | 0x00 |
|
||
| 0x3D | `MISC` | **0x3C** |
|
||
| 0x3E/3F | `TXFIFOCNT` | TX FIFO counter, RO |
|
||
| 0x50 | `MISC2` | 0x00 |
|
||
| 0x58–0x5F | SI regs | libogc2 writes `80 00 03 83 32 FE 1F 1F` |
|
||
| 0x60 | `SI_ACTRL2` | libogc2 writes `0x00` then `0x74` |
|
||
|
||
## 5. Two of our registers do not exist on real hardware
|
||
|
||
**Status: KNOWN, low risk, do not "fix" blindly.**
|
||
|
||
`0x34`/`0x35` (`TWD_LO`/`TWD_HI`, our TX length latch) and `0x3A` (`HIPR`,
|
||
returning `0x01`) are **not in the official register map** — `0x3A` is
|
||
unassigned there and `MISC` is `0x3D`. They are our own inventions, and it is
|
||
now confirmed that **no driver writes TWD** (the TX length is counted at
|
||
TXDATA instead — see CLAUDE.md). They are harmless unless a game writes those
|
||
addresses for some other purpose and trips our TX-length latch.
|
||
|
||
Removing them would break `gc_bba_test`, which does write TWD, so keep them
|
||
but do not add more inventions.
|
||
|
||
## 6. Lower risk / no action
|
||
|
||
- **Challenge/response** (command regs 0x08/0x09/0x0B) is **device-initiated**
|
||
— gated on command status bit `0x10`, which we never raise, so the path
|
||
cannot be entered. Do NOT implement `bba_calc_response` emulation.
|
||
- **No EEPROM**: the MAC is a fixed reset value in PAR0–5. Since libogc2 reads
|
||
the MAC from there rather than writing it, that value is what the console
|
||
uses. Games keying anything off the MAC see ours.
|
||
- **`RXINTT`** (RX interrupt timer) is interrupt coalescing; ignoring it means
|
||
we interrupt earlier than asked, which is safe.
|
||
- **Command register `0x0F`** may be a reset strobe rather than a register:
|
||
`bba_cmd_in8_slow(0x0F)` holds CS for 200 µs between two 10 ms delays and
|
||
discards the result. Implemented as storage; the NCRA reset follows
|
||
immediately in that sequence so ignoring any side effect costs nothing
|
||
today.
|