4e7a2ef429
The 850 KB/s dump measurement does not bound ROM streaming, and the tool could not have measured streaming at all: it only counted TCP payload, so a streaming capture would have reported ~zero throughput. Swiss's ROM streaming runs in the game via the patch driver's own stack (cube/patches/bba/tcpip.c), which contains no TCP whatsoever -- UDP + FSP, plus ICMP and ARP. Reads are sized to the path MTU rather than 1024: _fsp.data_length = MIN(length, env->pmtu - (fsp->data - eth->data)); so ~1446 B of payload per datagram at a 1500 MTU, with QUEUE_SIZE 2 outstanding and udp->checksum = 0x0000 (no payload checksum). None of the menu path's TCP window, lwIP overhead or 1024-byte writes apply. The analyser now counts UDP payload and picks the bulk direction by which way the payload actually flows -- streaming is PC -> GC, the opposite of a disc dump -- with --bulk to_gc to steer the gap analysis. Self-test H replays the streaming shape (1446 B datagrams, 2 per request) and checks the bulk direction is identified and the payload counted. Recorded in the TODO that "can it stream?" is still OPEN, but open on the GC's side: the dump established the adapter has headroom (zero retransmissions, ~17% W5100 bus utilisation, 26% of the EXI ceiling). The hard ceiling is 3375 KB/s and the GC's disc delivers ~2000-3100 KB/s, so full disc rate has no margin -- but games read in bursts, not continuously at peak. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
17 KiB
Markdown
350 lines
17 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.
|
||
|
||
## Throughput: measured 850 KB/s, and it is NOT the adapter
|
||
|
||
**Measured 2026-10-10**, Swiss dumping a disc over the adapter, captured on
|
||
the PC and analysed with `scripts/analyse-pcap.py`:
|
||
|
||
```
|
||
GC -> PC 18395 frames, 19.83 MB, 850 KB/s (6.97 Mbit/s, 25.8% of the EXI ceiling)
|
||
807 frames/s, mean frame 1078 B, retransmissions 0
|
||
PC -> GC 9200 frames, mean 54 B (pure ACKs)
|
||
GC advertised window 2920 B; gaps >5 ms total 56 ms (0.2% of 22.78 s)
|
||
```
|
||
|
||
**The flow is window × RTT bound and nothing else:**
|
||
|
||
| derived | value |
|
||
|---|---|
|
||
| data frames per ACK | 2.0 |
|
||
| payload per round trip | 2048 B |
|
||
| implied RTT | 2.48 ms |
|
||
| 2048 B / 2.48 ms | **826 KB/s** ≈ the measured 850 |
|
||
| payload per frame | **1024 B** (wire 1078 − 54 of headers) |
|
||
| window ÷ segment | 2920 / 1024 → **2 segments in flight** |
|
||
|
||
So the sender can have 2048 bytes outstanding and each round trip costs
|
||
2.48 ms. **Zero retransmissions** means the 15-page ring never overflowed and
|
||
we drop nothing; **0.2% stall time** means nothing is waiting on the disc.
|
||
Our W5100 bus engine runs at roughly 17% utilisation at this rate. The adapter
|
||
is not the constraint.
|
||
|
||
The levers, in order of effect, and **none of the first two are ours**:
|
||
|
||
1. **A larger TCP send buffer on the GC** (lwIP `TCP_SND_BUF`). Throughput
|
||
scales with it almost linearly while the flow is window-bound.
|
||
2. **Full-MSS segments.** Swiss writes 1024 bytes at a time, so a 2920 B
|
||
window carries 2048 B of payload. At 1460 B segments the same window and
|
||
RTT would give ~**1150 KB/s, a 1.43× gain** for free.
|
||
3. **A shorter round trip** — throughput scales 1/RTT. The 2.48 ms is GC-side
|
||
interrupt dispatch and lwIP, plus EXI lock contention: libogc2's
|
||
`bba_event_handler` bails out if `EXI_Lock` fails and defers until the TX
|
||
path unlocks, so RX handling queues behind TX on the single channel.
|
||
|
||
⚠️ **RX interrupt coalescing would make this WORSE, and that reverses an
|
||
earlier recommendation in this file.** `NCRB.RXINTC` asks for 2 packets per
|
||
interrupt and `RXINTT` adds a timer; both were listed as throughput levers.
|
||
For a **window-bound** flow they are not: delaying the ACK's interrupt
|
||
lengthens the round trip, and throughput is bytes-per-round-trip ÷ RTT. The
|
||
measurement is what showed this — the guess was backwards.
|
||
|
||
### ⚠️ The 850 KB/s figure does NOT bound ROM streaming — different stack
|
||
|
||
The dump was Swiss's **menu** path: libogc2 + lwIP + TCP, 1024-byte
|
||
application writes, a 2920-byte window. **ROM streaming uses a completely
|
||
different code path** — Swiss's in-game patch driver and its own stack in
|
||
`cube/patches/bba/tcpip.c`, which has **no TCP in it at all**:
|
||
|
||
- **UDP + FSP** (File Service Protocol), with ICMP and ARP. No TCP, no lwIP,
|
||
no congestion control, no window.
|
||
- Reads are sized to the path MTU, not 1024:
|
||
`_fsp.data_length = MIN(length, env->pmtu - (fsp->data - eth->data))`
|
||
— about 1446 B of payload per datagram at a 1500 MTU.
|
||
- `QUEUE_SIZE 2` requests outstanding.
|
||
- `udp->checksum = 0x0000` — no payload checksum, which is real CPU saved
|
||
versus TCP.
|
||
- It runs in the game's context via the patch driver, which uses the leaner
|
||
4-byte command-space form (see the command-space note in CLAUDE.md).
|
||
|
||
So the TCP window × RTT limit is not the operative one there, and the measured
|
||
850 KB/s says nothing about it. What the dump DID establish, and which carries
|
||
over, is that the adapter has headroom: **zero retransmissions**, ~17% W5100
|
||
bus utilisation, 26% of the EXI ceiling.
|
||
|
||
**Not yet measured.** `analyse-pcap.py` now counts UDP payload and picks the
|
||
bulk direction automatically (streaming flows PC → GC, the opposite of a
|
||
dump), so `--bulk to_gc` on a streaming capture gives the real number. Until
|
||
that is run, "can it stream?" is open — but it is open on the GC's side, not
|
||
on the adapter's.
|
||
|
||
The hard ceiling remains **3375 KB/s** (27 MHz × 1 bit/clock), and the GC's
|
||
own disc delivers roughly 2000–3100 KB/s, so there is no headroom to spare if
|
||
a game wants full disc rate — but games read in bursts, not continuously at
|
||
peak.
|
||
|
||
## 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.
|
||
|
||
⚠️ **Do NOT implement `RXINTC`/`RXINTT` coalescing as a throughput
|
||
optimisation.** Measurement showed the bulk flow is window × RTT bound (see
|
||
"Throughput" above), so delaying an interrupt lengthens the round trip and
|
||
*lowers* throughput. Implement it for fidelity if a driver depends on the
|
||
behaviour, not for speed.
|
||
|
||
`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.
|