Files
rebbarb/docs/bba_compat_todo.md
T
Roflin d8ddea13ba Measure the throughput limit: window x RTT, not the adapter
Swiss dumping a disc over the adapter measures 850 KB/s (6.97 Mbit/s, 25.8% of
the 27 Mbit/s EXI ceiling).  The capture decomposes it completely:

  18395 data frames / 9200 ACKs = 2.0 frames per ACK
  18.84 MB / 9200             = 2048 B per round trip
  22.78 s / 9200              = 2.48 ms per round trip
  2048 B / 2.48 ms            = 826 KB/s  ~= the measured 850

So the flow is window x RTT bound and nothing else.  Payload per frame is
exactly 1024 B (wire 1078 minus 54 of headers) against a 2920 B window, so two
segments fit in flight.  Zero retransmissions means the 15-page ring never
overflowed; 0.2% stall time means nothing waits on the disc; our W5100 bus
engine sits at ~17% utilisation.  The adapter is not the constraint.

Levers, and the first two are not ours: a larger lwIP TCP_SND_BUF (scales
almost linearly while window-bound), and full-MSS segments -- Swiss writes
1024 B at a time, so the same window and RTT at 1460 B would give ~1150 KB/s,
a 1.43x gain for free.  Third is a shorter round trip, which is GC-side
interrupt dispatch plus lwIP, plus EXI lock contention: libogc2's
bba_event_handler defers when EXI_Lock fails, so RX queues behind TX on the
single channel.

⚠️ This REVERSES an earlier recommendation in the TODO.  RX interrupt
coalescing (NCRB.RXINTC, RXINTT) was listed as a throughput lever; for a
window-bound flow it makes things worse, because delaying the ACK's interrupt
lengthens the round trip and throughput is bytes-per-round-trip / RTT.  The
measurement showed the guess was backwards.  Noted in item 3 as "implement for
fidelity, not for speed".

analyse-pcap.py now derives frames-per-ACK, bytes-per-round-trip and the
implied RTT itself, identifies a window-bound flow, quantifies the full-MSS
gain, and warns against coalescing -- so the next person gets the conclusion
rather than the raw numbers.  Self-test G replays this exact shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 15:26:39 +02:00

15 KiB
Raw Blame History

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. 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. 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

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.

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.