diff --git a/gc_bba_test/README.md b/gc_bba_test/README.md index 4fb9280..cb15067 100644 --- a/gc_bba_test/README.md +++ b/gc_bba_test/README.md @@ -72,3 +72,41 @@ Builds clean (no warnings) with devkitPPC 16.1.0 / GCC 16 in the devcontainer: `gc_bba_test.dol`, ~172 KB, entry `0x80003100`. `.gitignore` excludes the build products — run `make` to produce the `.dol`. + +## Self-update over the BBA (Z button) + +Press **Z** in the test app to pull a new `.dol` over the adapter and relaunch +into it, instead of copying to the SD card and rebooting. + +On the host, with the interface facing the GameCube: + +```bash +sudo ./scripts/send-dol.py enp3s0 gc_bba_test/gc_bba_test.dol +``` + +It needs `CAP_NET_RAW`, hence `sudo`. The GC's MAC is discovered from its first +ACK, so nothing needs configuring on either side — no IP address, no DHCP. + +**It is deliberately not IP.** Frames carry a private ethertype (`0x88B5`, +IEEE 802 local experimental 1) and a 12-byte header, so there is no ARP, no IP +stack, no checksums and no libogc network driver competing for the EXI +channel. It reuses exactly the two paths the test suite already proves on +hardware: `bba_tx_bytes()` for TX, and the SPRAM ring at RRP for RX. That also +makes it a useful soak test of the data path — a few hundred round trips per +update. + +**The transfer is stop-and-wait, and that is load-bearing.** The gateware does +not consume RRP, so `RXFrameAssembler` can overwrite ring pages the GC has not +read yet — there is no overflow protection. One block in flight means the ring +never holds more than one unread frame. Do **not** turn this into a sliding +window without adding RBFI-style protection first. + +Failure behaviour, so a bad update never costs a power cycle: +- 30 s with no frames at all, or 5 s mid-transfer, aborts back to the menu. +- A duplicate block is re-ACKed rather than ignored, so a lost ACK recovers. +- `netboot_launch()` refuses an image whose entry point or first text section + is not in MEM1, rather than jumping into a mis-parsed header. + +The one unavoidable risk is the jump itself: once sections are copied and +interrupts are off, a bad image hangs the console and needs a power cycle. The +header check above is what stands between a truncated transfer and that. diff --git a/gc_bba_test/source/main.c b/gc_bba_test/source/main.c index 3e437c6..d708d13 100644 --- a/gc_bba_test/source/main.c +++ b/gc_bba_test/source/main.c @@ -8,14 +8,17 @@ * START run everything * Y clear all results * B back + * Z self-update over the BBA, then relaunch (see netboot.c) */ #include #include #include +#include #include #include #include "tests.h" #include "bba.h" +#include "netboot.h" static void *xfb = NULL; static GXRModeObj *rmode = NULL; @@ -87,7 +90,7 @@ static void draw_main(int sel, const char *status) } printf("\n"); } - printf("\n" C_DIM " A open X run suite START run all Y clear\n" C_RESET); + printf("\n" C_DIM " A open X run suite START run all Y clear Z net-update\n" C_RESET); if (status && *status) printf("\n %s\n", status); } @@ -112,6 +115,53 @@ static void draw_suite(int si, int sel, const char *status) if (status && *status) printf("\n %s\n", status); } +/* Progress callback: netboot runs with the console as the only output, so + * print straight to it rather than buffering -- a stalled transfer should show + * where it stalled. */ +static void nb_status(const char *msg) +{ + printf(" %s\n", msg); +} + +static void do_netboot(char *status, size_t nstatus) +{ + void *buf; + int len; + + printf("\n" C_HEAD " Self-update over the BBA" C_RESET "\n\n"); + printf(" On the host: sudo ./scripts/send-dol.py \n"); + printf(" B on the next screen is NOT available -- this blocks until the\n" + " transfer finishes or times out (30 s idle, 5 s mid-transfer).\n\n"); + + /* 8 MB ceiling: far above any .dol, and it keeps a corrupt length field + * from trying to allocate the whole heap. */ + buf = memalign(32, 8u << 20); + if (!buf) { + snprintf(status, nstatus, "net-update: out of memory"); + return; + } + + len = netboot_receive(buf, 8u << 20, nb_status); + if (len <= 0) { + free(buf); + snprintf(status, nstatus, "net-update failed (%d)", len); + return; + } + + printf("\n launching %d bytes...\n", len); + /* Give the console a frame to actually show that before we disable + * interrupts and overwrite ourselves. */ + VIDEO_WaitVSync(); + VIDEO_WaitVSync(); + + if (netboot_launch(buf) < 0) { + free(buf); + snprintf(status, nstatus, "not a valid DOL - not launching"); + return; + } + /* netboot_launch() does not return on success. */ +} + int main(void) { int screen = 0; /* 0 = main, 1 = suite detail */ @@ -141,6 +191,17 @@ int main(void) down = PAD_ButtonsDown(0); if (!down) continue; + if (down & PAD_TRIGGER_Z) { + /* Self-update: pull a new .dol over the BBA and launch it. + * Staged in a malloc'd buffer rather than written in place -- + * netboot_launch() copies sections to their load addresses, + * which overlap the memory this program is running from. */ + do_netboot(status, sizeof(status)); + screen = 0; + draw_main(sel, status); + continue; + } + if (down & PAD_BUTTON_START) { snprintf(status, sizeof(status), "running all suites..."); if (screen) draw_suite(cur, ssel, status); else draw_main(sel, status); diff --git a/gc_bba_test/source/netboot.c b/gc_bba_test/source/netboot.c new file mode 100644 index 0000000..782746b --- /dev/null +++ b/gc_bba_test/source/netboot.c @@ -0,0 +1,229 @@ +#include "netboot.h" +#include "bba.h" +#include +#include +#include +#include +#include + +/* ── frame layout ──────────────────────────────────────────────────────── + * 0 dst MAC (6) + * 6 src MAC (6) + * 12 ethertype (2) = NB_ETHERTYPE + * 14 magic (4) = "GCUP" + * 18 opcode (1) + * 19 reserved (1) + * 20 seq (2, big endian) + * 22 nblocks (2, big endian) + * 24 len (2, big endian) + * 26 data (len) + */ +#define NB_HDR 26u +#define NB_MIN 60u /* ethernet minimum, padded */ + +static u8 s_our_mac[6]; +static u8 s_peer_mac[6]; + +static u16 rd16(const u8 *p) { return ((u16)p[0] << 8) | p[1]; } +static void wr16(u8 *p, u16 v) { p[0] = (u8)(v >> 8); p[1] = (u8)v; } + +/* ── RX: pull one frame out of the SPRAM ring ──────────────────────────── + * The ring is pages 1-15 with a 4-byte descriptor at each page start whose + * length field INCLUDES the descriptor. RRP is ours to advance; the + * gateware does not act on it. */ +static int ring_next(u8 *buf, u32 cap) +{ + u8 rp[2], wp[2], hdr[4]; + u32 rpage, wpage, dlen, flen, addr; + + if (bba_read(REG_RRP_LO, rp, 2) < 0) return -1; + if (bba_read(REG_RWP_LO, wp, 2) < 0) return -1; + rpage = ((u32)rp[1] << 8) | rp[0]; + wpage = ((u32)wp[1] << 8) | wp[0]; + if (rpage == wpage) return 0; /* empty */ + if (rpage < 1 || rpage > 15) return -2; /* pointer corrupt */ + + addr = rpage << 8; + if (bba_stream((u16)addr, hdr, 4) < 0) return -1; + dlen = ((u32)hdr[2] << 8) | hdr[3]; + if (dlen <= 4 || dlen > 1518 + 4) return -3; /* descriptor implausible */ + flen = dlen - 4; + if (flen > cap) flen = cap; + + /* Descriptor + frame are contiguous from the page start. Read both and + * drop the descriptor rather than streaming from a mid-page address, so + * one DMA read covers it. */ + { + static u8 tmp[1536 + 4]; + u32 want = dlen; + if (want > sizeof(tmp)) want = sizeof(tmp); + if (bba_stream((u16)addr, tmp, want) < 0) return -1; + memcpy(buf, tmp + 4, flen); + } + + /* Advance RRP by the pages this frame occupied, wrapping 1..15. */ + { + u32 pages = (dlen + 255) >> 8; + u32 next = ((rpage - 1 + pages) % 15) + 1; + if (bba_w8(REG_RRP_LO, (u8)next) < 0) return -1; + } + return (int)flen; +} + +static int send_ack(u16 seq) +{ + u8 f[NB_MIN]; + memset(f, 0, sizeof(f)); + memcpy(f + 0, s_peer_mac, 6); + memcpy(f + 6, s_our_mac, 6); + wr16(f + 12, NB_ETHERTYPE); + f[14] = 'G'; f[15] = 'C'; f[16] = 'U'; f[17] = 'P'; + f[18] = NB_OP_ACK; + wr16(f + 20, seq); + wr16(f + 22, 0); + wr16(f + 24, 0); + return bba_tx_bytes(f, sizeof(f)); +} + +int netboot_receive(void *dst, u32 maxlen, void (*status)(const char *msg)) +{ + static u8 frame[1536]; + u8 *out = (u8 *)dst; + u32 got = 0, nblocks = 0; + u16 expect = 0; + char line[80]; + u64 t0; + int have_peer = 0; + + if (bba_read(REG_PAR0, s_our_mac, 6) < 0) return -1; + + /* Make sure RX is actually enabled -- a prior test may have left the + * NCRA SR bit clear, and then this would wait forever for frames the + * gateware is not storing. */ + { + int ncra = bba_r8(REG_NCRA); + if (ncra >= 0 && !(ncra & NCRA_SR)) + bba_w8(REG_NCRA, (u8)(ncra | NCRA_SR)); + } + + if (status) status("waiting for host (send-dol.py)..."); + t0 = gettime(); + + for (;;) { + int n = ring_next(frame, sizeof(frame)); + if (n < 0) { + if (status) { snprintf(line, sizeof(line), "ring error %d", n); + status(line); } + return -2; + } + if (n == 0) { + /* 30 s with nothing at all, or 5 s mid-transfer, is a bail-out + * rather than a hang -- a wedged update must not require a + * console power cycle. */ + u32 idle = (u32)ticks_to_millisecs(diff_ticks(t0, gettime())); + if (idle > (got ? 5000u : 30000u)) { + if (status) status(got ? "timeout mid-transfer" : "timeout: no frames"); + return -3; + } + continue; + } + if ((u32)n < NB_HDR) continue; + if (rd16(frame + 12) != NB_ETHERTYPE) continue; + if (frame[14] != 'G' || frame[15] != 'C' || + frame[16] != 'U' || frame[17] != 'P') continue; + + if (!have_peer) { memcpy(s_peer_mac, frame + 6, 6); have_peer = 1; } + t0 = gettime(); + + if (frame[18] == NB_OP_DONE) { + if (status) { snprintf(line, sizeof(line), + "received %u bytes in %u blocks", + (unsigned)got, (unsigned)nblocks); + status(line); } + return (int)got; + } + if (frame[18] != NB_OP_DATA) continue; + + { + u16 seq = rd16(frame + 20); + u16 len = rd16(frame + 24); + nblocks = rd16(frame + 22); + if (len > NB_BLOCK_MAX || NB_HDR + len > (u32)n) continue; + + if (seq == expect) { + if (got + len > maxlen) { + if (status) status("image larger than buffer"); + return -4; + } + memcpy(out + got, frame + NB_HDR, len); + got += len; + expect++; + if ((expect & 0x1F) == 0 && status) { + snprintf(line, sizeof(line), "block %u/%u (%u KB)", + (unsigned)expect, (unsigned)nblocks, + (unsigned)(got >> 10)); + status(line); + } + } + /* ACK even a duplicate: it means our previous ACK was lost, and + * re-ACKing is what lets the host move on. */ + send_ack(seq); + } + } +} + +/* ── DOL loader ────────────────────────────────────────────────────────── + * 7 text + 11 data sections, each with a file offset, load address and size, + * then BSS and the entry point. All big endian. */ +typedef struct { + u32 text_off[7]; + u32 data_off[11]; + u32 text_addr[7]; + u32 data_addr[11]; + u32 text_len[7]; + u32 data_len[11]; + u32 bss_addr; + u32 bss_len; + u32 entry; + u32 pad[7]; +} dolhdr_t; + +int netboot_launch(const void *dol) +{ + const dolhdr_t *h = (const dolhdr_t *)dol; + const u8 *base = (const u8 *)dol; + void (*entry)(void); + int i; + + /* Sanity: the entry point and at least one text section must live in + * MEM1. Jumping to a mis-parsed header hangs the console hard enough + * to need a power cycle, so refuse rather than guess. */ + if ((h->entry >> 24) != 0x80 || h->text_len[0] == 0 || + (h->text_addr[0] >> 24) != 0x80) + return -1; + + /* No interrupts from here on: we are about to overwrite the memory this + * program is running from, and with interrupts off the scheduler cannot + * preempt us into code that no longer exists. */ + IRQ_Disable(); + + for (i = 0; i < 7; i++) { + if (!h->text_len[i]) continue; + memmove((void *)h->text_addr[i], base + h->text_off[i], h->text_len[i]); + DCFlushRange((void *)h->text_addr[i], h->text_len[i]); + ICInvalidateRange((void *)h->text_addr[i], h->text_len[i]); + } + for (i = 0; i < 11; i++) { + if (!h->data_len[i]) continue; + memmove((void *)h->data_addr[i], base + h->data_off[i], h->data_len[i]); + DCFlushRange((void *)h->data_addr[i], h->data_len[i]); + } + if (h->bss_len) { + memset((void *)h->bss_addr, 0, h->bss_len); + DCFlushRange((void *)h->bss_addr, h->bss_len); + } + + entry = (void (*)(void))h->entry; + entry(); + return 0; /* not reached */ +} diff --git a/gc_bba_test/source/netboot.h b/gc_bba_test/source/netboot.h new file mode 100644 index 0000000..efca8ad --- /dev/null +++ b/gc_bba_test/source/netboot.h @@ -0,0 +1,41 @@ +#ifndef NETBOOT_H +#define NETBOOT_H + +#include +#include + +/* Self-update over the BBA: receive a .dol via raw ethernet and launch it. + * + * Deliberately NOT built on an IP stack. Frames carry a private ethertype + * and a 12-byte header, so there is no ARP, no IP, no checksums and no + * libogc network driver competing with us for the EXI channel -- it reuses + * exactly the two paths the test suite already proves on hardware: + * bba_tx_bytes() for TX and the SPRAM ring at RRP for RX. + * + * The transfer is STOP-AND-WAIT (one block in flight, ACKed before the next). + * That is not just simplicity: the gateware does not consume RRP, so + * RXFrameAssembler can overwrite ring pages the GC has not read yet. One + * block in flight means the ring never has more than one frame outstanding, + * so the missing overflow protection cannot bite. Do not "optimise" this + * into a sliding window without adding RBFI-style protection first. + */ + +#define NB_ETHERTYPE 0x88B5u /* IEEE 802 local experimental ethertype 1 */ +#define NB_MAGIC 0x47435550u /* "GCUP" */ + +#define NB_OP_DATA 0x01u /* host -> GC: one block */ +#define NB_OP_ACK 0x02u /* GC -> host: block received */ +#define NB_OP_DONE 0x03u /* host -> GC: transfer complete, launch */ + +#define NB_BLOCK_MAX 1024u /* payload bytes per frame */ + +/* Receive a .dol into `dst`. Returns its length, or <0 on error. + * `status` (optional) is called with a human-readable progress line. */ +int netboot_receive(void *dst, u32 maxlen, + void (*status)(const char *msg)); + +/* Launch a .dol image already in memory. Does not return on success. + * Returns <0 if the image is not a plausible DOL. */ +int netboot_launch(const void *dol); + +#endif diff --git a/scripts/send-dol.py b/scripts/send-dol.py new file mode 100755 index 0000000..f672ef7 --- /dev/null +++ b/scripts/send-dol.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Send a .dol to the running gc_bba_test over raw ethernet, for self-update. + +Pairs with gc_bba_test/source/netboot.c. Deliberately NOT IP: frames carry a +private ethertype and a 12-byte header, so there is no ARP, no IP stack and no +DHCP to go wrong -- it exercises exactly the BBA TX/RX paths the test suite +already proves on hardware. + +Transfer is STOP-AND-WAIT: one block in flight, ACKed before the next. That +is load-bearing, not laziness -- the gateware does not consume RRP, so the +SPRAM ring has no overflow protection, and one outstanding frame means it +cannot overrun. + +Needs CAP_NET_RAW, so run with sudo. + + sudo ./scripts/send-dol.py enp3s0 gc_bba_test/gc_bba_test.dol +""" +import argparse +import os +import socket +import struct +import sys +import time + +ETHERTYPE = 0x88B5 +MAGIC = b"GCUP" +OP_DATA, OP_ACK, OP_DONE = 1, 2, 3 +BLOCK = 1024 +HDR = 26 +# The GC's MAC comes from PAR0-5, which the gateware pre-populates with a +# Nintendo OUI. Discovered from the first ACK, so this is only the initial +# destination for block 0. +BROADCAST = b"\xff" * 6 + + +def frame(dst, src, op, seq, nblocks, payload=b""): + f = bytearray() + f += dst + src + struct.pack("!H", ETHERTYPE) + f += MAGIC + bytes([op, 0]) + struct.pack("!HHH", seq, nblocks, len(payload)) + f += payload + if len(f) < 60: # ethernet minimum + f += b"\x00" * (60 - len(f)) + return bytes(f) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("iface", help="network interface facing the GameCube") + ap.add_argument("dol", help=".dol to send") + ap.add_argument("--timeout", type=float, default=0.5, + help="per-block ACK timeout in seconds (default 0.5)") + ap.add_argument("--retries", type=int, default=20, + help="per-block retransmits before giving up (default 20)") + args = ap.parse_args() + + img = open(args.dol, "rb").read() + blocks = [img[i:i + BLOCK] for i in range(0, len(img), BLOCK)] + if not blocks: + sys.exit("empty image") + if len(blocks) > 0xFFFF: + sys.exit("image too large for a 16-bit block count") + + s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETHERTYPE)) + s.bind((args.iface, 0)) + src = s.getsockname()[4] + s.settimeout(args.timeout) + + print(f"{args.dol}: {len(img)} bytes, {len(blocks)} blocks of {BLOCK}") + print(f"sending from {src.hex(':')} on {args.iface}") + + dst = BROADCAST + t0 = time.time() + for seq, payload in enumerate(blocks): + for attempt in range(args.retries): + s.send(frame(dst, src, OP_DATA, seq, len(blocks), payload)) + try: + while True: + pkt = s.recv(2048) + if len(pkt) < HDR or pkt[14:18] != MAGIC: + continue + if pkt[18] != OP_ACK: + continue + if struct.unpack("!H", pkt[20:22])[0] != seq: + continue # stale ACK for an earlier block + if dst == BROADCAST: + dst = pkt[6:12] + print(f"GameCube is {dst.hex(':')}") + break + break + except socket.timeout: + if attempt == 0: + print(f" block {seq}: no ACK, retrying", file=sys.stderr) + else: + sys.exit(f"block {seq}: no ACK after {args.retries} tries") + if seq % 32 == 0 or seq == len(blocks) - 1: + pct = 100 * (seq + 1) // len(blocks) + print(f"\r {pct:3d}% block {seq + 1}/{len(blocks)}", end="", flush=True) + + # DONE is not ACKed: the GC launches the new image immediately, so there is + # nothing left running to answer. Send it a few times in case one is lost. + for _ in range(5): + s.send(frame(dst, src, OP_DONE, 0, len(blocks))) + time.sleep(0.02) + + dt = time.time() - t0 + print(f"\nsent {len(img)} bytes in {dt:.1f}s " + f"({len(img) * 8 / dt / 1e6:.2f} Mbit/s); GC should relaunch now") + + +if __name__ == "__main__": + if os.geteuid() != 0: + print("warning: AF_PACKET needs root; try sudo", file=sys.stderr) + main()