diff --git a/gc_bba_test/Makefile b/gc_bba_test/Makefile index cd7f538..7b9e139 100644 --- a/gc_bba_test/Makefile +++ b/gc_bba_test/Makefile @@ -24,7 +24,7 @@ CFLAGS := -g -O2 -Wall -Wextra $(MACHDEP) $(INCLUDE) CXXFLAGS:= $(CFLAGS) LDFLAGS := -g $(MACHDEP) -Wl,-Map,$(notdir $@).map -LIBS := -logc -lm +LIBS := -lfat -logc -lm LIBDIRS := #--------------------------------------------------------------------------------- diff --git a/gc_bba_test/README.md b/gc_bba_test/README.md index bc98f30..07185e3 100644 --- a/gc_bba_test/README.md +++ b/gc_bba_test/README.md @@ -75,44 +75,53 @@ Builds clean (no warnings) with devkitPPC 16.1.0 / GCC 16 in the devcontainer: ## 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: +Press **Z**, then on the host: ```bash -sudo ./scripts/send-dol.py enp3s0 gc_bba_test/gc_bba_test.dol +sudo ./scripts/send-dol.py 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. +The flow is deliberately **receive → write to SD → boot Swiss**: -**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. +1. The new build arrives over the adapter into a staging buffer. +2. It is written to the SD card (default `/gc_bba_test.dol`, override with + `--dest`) and **read back and compared**. +3. The app then boots **`/boot.dol`** — your known-good Swiss. -**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. +**The received image is never launched.** The only thing this ever jumps into +is Swiss, which was already known good. So a broken test build costs nothing: +Swiss comes up, and you load whatever you like from there, including the +previous build. That is what makes the update recoverable without a power +cycle or digging the SD card out. -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. +If the SD write or the verify fails, nothing is launched at all — you stay in +the menu with a working app. -### Send Swiss's `boot.dol` first +`sudo` is needed for `CAP_NET_RAW`. The GC's MAC is discovered from its first +ACK, so neither side needs an address configured. -The launcher and the transfer are separate things that can each be broken, and -a hang looks the same either way. Send a **known-good** image the first time — -Swiss's `boot.dol` is ideal, because it is unmistakable when it boots. If -Swiss comes up, both halves work and you can then trust an untested build. +### Why it is not IP + +Frames carry a private ethertype (`0x88B5`, IEEE 802 local experimental 1) and +a 12-byte header: 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 — so each update doubles as a few hundred round trips of soak +testing through that path. + +### Stop-and-wait 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. + +### SD interfaces + +`netboot_sd_write()` auto-detects, trying SD2SP2, then SD Gecko slot B, then +slot A. Note the SD Gecko in **slot A shares EXI channel 0 with the BBA** +(channel 0, device 2); libogc arbitrates, but SD2SP2 sits on its own channel +and avoids the question. ### Why the copier lives in assembly at the top of MEM1 @@ -121,18 +130,22 @@ A DOL's sections load from 0x80003100 upward — exactly where this program's ow own instructions partway through the copy** and hang. So: - `netboot_reserve()` carves the top of MEM1 and hands it to - `SYS_SetArena1Hi()`, so neither libogc nor `malloc` will touch it. The image - is staged there, never in heap memory. -- `netboot_stub.S` is copied into that region and executed *there*, clear of - everything the image writes. It must stay strictly position independent: no - globals, no literal pool, no TOC references, no calls — otherwise it reads - from the address it was *linked* at instead of the one it is *running* at. + `SYS_SetArena1Hi()`, so neither libogc nor `malloc` will touch it. +- `netboot_stub.S` is copied into that region and executed *there*. It must + stay strictly position independent: no globals, no literal pool, no TOC + references, no calls — otherwise it reads from the address it was *linked* + at instead of the one it is *running* at. - `netboot_launch()` checks every section (and BSS) against the reserved - region and **refuses** if any would land on it, rather than corrupting the - image or the copier mid-flight. + region and **refuses** if any would land on it. -The remaining 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 and -overlap checks are what stand between a truncated transfer and that — they -cannot validate a *plausible* but broken image, which is why the first send -should be Swiss. +### Failure behaviour + +- 30 s with no frames, 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_receive()` re-asserts the NCRA SR bit first: an earlier test may + have left receive disabled, and it would otherwise wait forever for frames + the gateware is not storing. +- A failed SD write or verify launches nothing. +- The residual risk is confined to booting `/boot.dol`. If that is a healthy + Swiss, this is the safe part; if it is missing or corrupt, you get a message + and stay in the menu — the new build is already on the card either way. diff --git a/gc_bba_test/source/main.c b/gc_bba_test/source/main.c index 4552812..0c238c6 100644 --- a/gc_bba_test/source/main.c +++ b/gc_bba_test/source/main.c @@ -131,10 +131,10 @@ static void do_netboot(char *status, size_t nstatus) printf("\n" C_HEAD " Self-update over the BBA" C_RESET "\n\n"); printf(" On the host: sudo ./scripts/send-dol.py \n\n"); - printf(C_DIM " Send a KNOWN-GOOD image the first time -- Swiss's boot.dol\n" - " is ideal: it is unmistakable when it boots, so it tells you the\n" - " transfer AND the launcher both work before you trust either\n" - " with an untested build.\n" C_RESET "\n"); + printf(C_DIM " The received build is WRITTEN TO THE SD CARD and verified;\n" + " it is never launched directly. This then boots /boot.dol --\n" + " your known-good Swiss -- so a broken test build costs nothing:\n" + " Swiss comes up and can load whatever you like.\n" C_RESET "\n"); printf(" Blocks until done or it times out (30 s idle, 5 s mid-xfer).\n\n"); if (!s_stage) { @@ -148,19 +148,26 @@ static void do_netboot(char *status, size_t nstatus) return; } - printf("\n launching %d bytes...\n", len); - /* Give the console a frame to actually show that before we disable - * interrupts and overwrite ourselves. */ + rc = netboot_sd_write(netboot_dest_path(), s_stage, (u32)len, nb_status); + if (rc < 0) { + /* Nothing has been launched and nothing is half-written that we + * know of, so staying in the menu is the safe outcome. */ + snprintf(status, nstatus, "SD write failed (%d) - nothing launched", rc); + return; + } + + printf("\n booting /boot.dol ...\n"); VIDEO_WaitVSync(); VIDEO_WaitVSync(); - rc = netboot_launch(s_stage); - /* Only reached on refusal. -2 = header implausible (truncated or not a - * DOL); -3 = a section would land on the reserved staging region, which - * would corrupt the image or the copier mid-flight. */ + /* Reuse the staging region: the new build is already on the card and + * verified, so the copy in RAM is finished with. */ + rc = netboot_boot_file("/boot.dol", s_stage, s_stage_max, nb_status); + + /* Only reached on refusal. The new build IS on the card either way, + * so this is recoverable by rebooting into Swiss by hand. */ snprintf(status, nstatus, - rc == -3 ? "refused: image overlaps staging region" - : "refused: not a plausible DOL"); + "update saved, but /boot.dol would not boot (%d)", rc); } int main(void) diff --git a/gc_bba_test/source/netboot.c b/gc_bba_test/source/netboot.c index 1e435cc..329bad8 100644 --- a/gc_bba_test/source/netboot.c +++ b/gc_bba_test/source/netboot.c @@ -5,6 +5,8 @@ #include #include #include +#include +#include /* ── frame layout ──────────────────────────────────────────────────────── * 0 dst MAC (6) @@ -85,6 +87,12 @@ static int send_ack(u16 seq) return bba_tx_bytes(f, sizeof(f)); } +/* Destination path carried in the DONE frame, so the host decides where the + * build lands rather than it being baked in here. */ +static char s_dest[64] = "/gc_bba_test.dol"; + +const char *netboot_dest_path(void) { return s_dest; } + int netboot_receive(void *dst, u32 maxlen, void (*status)(const char *msg)) { static u8 frame[1536]; @@ -136,6 +144,12 @@ int netboot_receive(void *dst, u32 maxlen, void (*status)(const char *msg)) t0 = gettime(); if (frame[18] == NB_OP_DONE) { + /* DONE may carry the destination path as its payload. */ + u16 plen = rd16(frame + 24); + if (plen && plen < sizeof(s_dest) && NB_HDR + plen <= (u32)n) { + memcpy(s_dest, frame + NB_HDR, plen); + s_dest[plen] = 0; + } if (status) { snprintf(line, sizeof(line), "received %u bytes in %u blocks", (unsigned)got, (unsigned)nblocks); @@ -291,3 +305,139 @@ int netboot_launch(const void *dol) stub(desc); return 0; /* not reached */ } + +/* ── SD card: save the new build, then boot a known-good image ─────────── + * The update deliberately does NOT launch what it just received. It writes + * the image to the card and then boots /boot.dol (Swiss), so a broken test + * build is recoverable: Swiss comes up regardless and can load the previous + * one. The only image we ever jump into is one that was already known good. */ + +static int s_sd_mounted; + +static int sd_mount(void (*status)(const char *msg)) +{ + /* Try every interface a GameCube might have. Auto-detecting beats + * asking: SD Gecko can be in either memory-card slot, and SD2SP2 is on + * its own EXI channel. */ + static const struct { const char *name; const DISC_INTERFACE *io; } ifs[] = { + { "SD2SP2", &__io_gcsd2 }, + { "SD Gecko slot B", &__io_gcsdb }, + { "SD Gecko slot A", &__io_gcsda }, + }; + unsigned i; + char line[64]; + + if (s_sd_mounted) return 0; + for (i = 0; i < sizeof(ifs) / sizeof(ifs[0]); i++) { + if (fatMountSimple("sd", ifs[i].io)) { + if (status) { + snprintf(line, sizeof(line), "SD mounted (%s)", ifs[i].name); + status(line); + } + s_sd_mounted = 1; + return 0; + } + } + if (status) status("no SD card found (SD2SP2 or SD Gecko)"); + return -1; +} + +int netboot_sd_write(const char *path, const void *buf, u32 len, + void (*status)(const char *msg)) +{ + char full[96], line[128]; + FILE *f; + u32 done = 0; + int rc = -1; + + if (sd_mount(status) < 0) return -1; + snprintf(full, sizeof(full), "sd:%s", path); + + if (status) { snprintf(line, sizeof(line), "writing %s", full); status(line); } + f = fopen(full, "wb"); + if (!f) { + if (status) status("open for write failed"); + return -2; + } + done = (u32)fwrite(buf, 1, len, f); + fclose(f); + if (done != len) { + if (status) status("short write"); + return -3; + } + + /* Read it back. A half-written .dol that still parses is exactly the + * failure this whole design exists to avoid, and the card is the one + * component here with no other integrity check. */ + if (status) status("verifying..."); + f = fopen(full, "rb"); + if (!f) { + if (status) status("reopen for verify failed"); + return -4; + } + { + static u8 chunk[4096]; + const u8 *src = (const u8 *)buf; + u32 off = 0; + rc = 0; + while (off < len) { + u32 want = len - off; + if (want > sizeof(chunk)) want = sizeof(chunk); + if (fread(chunk, 1, want, f) != want) { rc = -5; break; } + if (memcmp(chunk, src + off, want) != 0) { rc = -6; break; } + off += want; + } + } + fclose(f); + if (rc < 0) { + if (status) status("VERIFY FAILED - card image is not what we sent"); + return rc; + } + if (status) { snprintf(line, sizeof(line), "wrote and verified %u bytes", + (unsigned)len); status(line); } + return 0; +} + +int netboot_boot_file(const char *path, void *dst, u32 maxlen, + void (*status)(const char *msg)) +{ + char full[96], line[128]; + FILE *f; + long sz; + u32 got; + + if (sd_mount(status) < 0) return -1; + snprintf(full, sizeof(full), "sd:%s", path); + + f = fopen(full, "rb"); + if (!f) { + if (status) { snprintf(line, sizeof(line), "%s not found", full); + status(line); } + return -2; + } + fseek(f, 0, SEEK_END); + sz = ftell(f); + fseek(f, 0, SEEK_SET); + if (sz <= 0 || (u32)sz > maxlen) { + fclose(f); + if (status) status("boot image size implausible"); + return -3; + } + got = (u32)fread(dst, 1, (size_t)sz, f); + fclose(f); + if (got != (u32)sz) { + if (status) status("short read of boot image"); + return -4; + } + + if (status) { snprintf(line, sizeof(line), "booting %s (%u bytes)", + full, (unsigned)got); status(line); } + + /* Unmount before we disable interrupts and jump: leaving the card + * mounted hands the next image a filesystem that libfat still thinks it + * owns, on an EXI channel it is about to reinitialise. */ + fatUnmount("sd"); + s_sd_mounted = 0; + + return netboot_launch(dst); +} diff --git a/gc_bba_test/source/netboot.h b/gc_bba_test/source/netboot.h index edc7d1f..6572c3b 100644 --- a/gc_bba_test/source/netboot.h +++ b/gc_bba_test/source/netboot.h @@ -45,6 +45,30 @@ void *netboot_reserve(u32 *out_max); int netboot_receive(void *dst, u32 maxlen, void (*status)(const char *msg)); +/* Mount whichever SD interface is present (SD Gecko slot A or B, or SD2SP2) + * and write `len` bytes to `path` (e.g. "/gc_bba_test.dol"), verifying the + * result by reading it back. Returns 0 on success, <0 on error. + * + * NOTE the SD Gecko in memory-card slot A shares EXI channel 0 with the BBA + * (which is channel 0, device 2). libogc arbitrates, but SD2SP2 -- on its own + * channel -- avoids the question entirely. */ +int netboot_sd_write(const char *path, const void *buf, u32 len, + void (*status)(const char *msg)); + +/* Read `path` off the SD card into `dst` and launch it. Does not return on + * success. Returns <0 if it cannot be read or is not a plausible DOL. + * + * This is how the update completes: we NEVER jump into the image we just + * received, only into a known-good /boot.dol (Swiss). A broken test build + * therefore costs nothing -- Swiss still comes up and can load whatever you + * want, including the previous build. */ +int netboot_boot_file(const char *path, void *dst, u32 maxlen, + void (*status)(const char *msg)); + +/* Destination path the host asked for in its DONE frame (default + * "/gc_bba_test.dol"). */ +const char *netboot_dest_path(void); + /* 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); diff --git a/scripts/send-dol.py b/scripts/send-dol.py index f672ef7..2c28ddd 100755 --- a/scripts/send-dol.py +++ b/scripts/send-dol.py @@ -51,6 +51,9 @@ def main(): 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)") + ap.add_argument("--dest", default="/gc_bba_test.dol", + help="where to write it on the SD card " + "(default /gc_bba_test.dol)") args = ap.parse_args() img = open(args.dol, "rb").read() @@ -96,15 +99,21 @@ def main(): 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. + # DONE carries the destination path and is not ACKed: the GC goes + # straight on to writing the card and booting Swiss, so there is nothing + # left running to answer. Send it a few times in case one is lost. + dest = args.dest.encode() + if len(dest) >= 64: + sys.exit("--dest too long (max 63 bytes)") for _ in range(5): - s.send(frame(dst, src, OP_DONE, 0, len(blocks))) + s.send(frame(dst, src, OP_DONE, 0, len(blocks), dest)) 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") + f"({len(img) * 8 / dt / 1e6:.2f} Mbit/s)") + print(f"GC writes it to {args.dest} on the SD card, verifies it, " + f"then boots /boot.dol") if __name__ == "__main__":