# The real GC↔BBA EXI protocol (verified 2026-10-03) Read directly from the two drivers that will actually talk to this device: - **libogc** — `libdb/uIP/bba.c` (devkitPro/libogc) - **Swiss** — `cube/patches/bba/bba.c` (emukidid/swiss-gc) Swiss is the one that matters; it pokes the EXI hardware registers directly rather than going through libogc's EXI API. ## Two address spaces, selected by bit 7 of byte 0 ``` COMMAND space — 6-bit register, ONE 4-byte read/write transfer read [ reg&0x3F, 00, 00, xx ] response is byte 3 write [0x40|reg&0x3F, 00, 00, data] REGISTER space — 16-bit register, 4-byte header THEN a separate data phase read [0x80, reg>>8, reg&0xFF, 0x00] + N bytes read write [0xC0, reg>>8, reg&0xFF, 0x00] + N bytes written ``` byte 0 bit 7 = register space, bit 6 = write. Swiss source: ```c uint8_t bba_cmd_in8(uint8_t reg) { return exi_imm_read_write((reg & 0x3F) << 24, 4); } void bba_cmd_out8(uint8_t reg, uint8_t val) { exi_imm_read_write(0x40 << 24 | (reg & 0x3F) << 24 | val, 4); } uint8_t bba_in8(uint16_t reg) { exi_imm_read_write(0x80 << 24 | reg << 8, 4); return exi_imm_read(1); } void bba_out8(uint16_t reg, uint8_t val) { exi_imm_read_write(0xC0 << 24 | reg << 8, 4); exi_imm_write(val << 24, 1); } void bba_ins(uint16_t reg, void *val, uint32_t len) { exi_imm_read_write(0x80 << 24 | reg << 8, 4); exi_dma_read(val, len, false); } ``` All the BBA registers this project cares about — NCRA (0x00), IR (0x09), RWP (0x16), RRP (0x18), PAR (0x20), NWAYS (0x31) — are accessed through `bba_in8`/`bba_out8`/`bba_ins`/`bba_outs`, i.e. the **register space**. ## Three consequences for the gateware **1. There is no length field.** The data phase runs for as long as the GC clocks it, ending at CS. The register path must stream until CS exactly as the SPRAM path (≥0x100) already does. The current 2-bit length field caps register reads at 4 bytes, which is wrong — `bba_ins` reads arbitrary lengths, and with DMA. **2. The trailing `0x00` byte is the prefetch window.** The address is complete after byte 2 of a 4-byte header, leaving one whole byte — ~8 bit-times, ≈296 ns at 27 MHz — before the data phase starts. The command space is even more generous: the register is known after byte 0, giving ~24 bit-times before the response is needed in byte 3. The protocol was designed to give the device time to fetch. **3. EXI DMA reads are used.** `bba_ins` does `exi_dma_read`. DMA *reads* are fine; it is the GC's DMA-*write* engine that has the 1-bit-shift bug Swiss avoids. ## Why the current implementation is not just wrong but dangerous `exi_bba/bba_register_file.py` implements a 2-byte header with the address split across both bytes and a 2-bit length in the low bits of byte 1. Feeding it Swiss's traffic: | Swiss intends | sends | current decoder sees | |---|---|---| | read NWAYS (0x31) | `80 00 31 00` | **write 0x31 to NCRA** — bit 0 = RESET | | read IR (0x09) | `80 00 09 00` | **write 0x09 to NCRA** — bit 0 = RESET | | write NCRA | `C0 00 00 00` | write 0x00 to addr 0x1000 | Every register read Swiss performs would reset the device. The device-ID query is the sole case where the two formats agree (`00 00` decodes to address 0 either way), which is why enumeration worked on hardware while nothing else could have. ## Relationship to the response-latency problem First on-console run (2026-10-03, EXI index 5) returned the device ID **22 bit-times late** — `00 00 00 10` is a bit-exact match for `04 02 02 00` shifted by 22. ~815 ns, which is about what the CDC chain costs (rx AsyncFIFO capture→exi, register-file FSM decode, tx AsyncFIFO exi→capture, SPI engine) at a 24 MHz exi clock. The design doc demands data "ready on MISO from the very first clock edge of the data phase". That looked impossible until the real format turned up: the protocol hands the device a turnaround byte, and the current header format spends it on a length field. Adopting the real format therefore does double duty — it is required for compatibility anyway, and it recovers ~296 ns (register space) or ~593 ns (command space) of the ~815 ns deficit. The remainder still needs latency work: the lightweight ping-pong TX CDC (already documented as the capture-timing lever), FSM trimming before the first push, and possibly serving hot registers from a capture-domain shadow so the response path crosses no clock domain at all.