diff --git a/.github/workflows/3ds-runtime.yml b/.github/workflows/3ds-runtime.yml index 9878e3815..d98991e11 100644 --- a/.github/workflows/3ds-runtime.yml +++ b/.github/workflows/3ds-runtime.yml @@ -17,5 +17,5 @@ jobs: with: bun-version: 1.3.14 - run: bun install --frozen-lockfile - - run: bun test tests/3ds-profile.test.ts tests/3ds-runtime-state.test.ts tests/3ds-runtime-wire.test.ts tests/3ds-soc.test.ts + - run: bun test tests/3ds-profile.test.ts tests/3ds-runtime-state.test.ts tests/3ds-runtime-wire.test.ts tests/3ds-soc.test.ts tests/3ds-dev-worker.test.ts tests/3ds-pairing.test.ts - run: bun test --conditions=browser tests/offload.test.ts tests/offload-images.test.ts tests/offload-meshes.test.ts tests/offload-provider.test.ts tests/tile-viewport.test.ts tests/resource-cache.test.ts tests/resource-view.test.ts tests/resource-pack.test.ts tests/resource-pack-view.test.ts diff --git a/engine/core/src/package.rs b/engine/core/src/package.rs index 456b0db8b..bcade3e29 100644 --- a/engine/core/src/package.rs +++ b/engine/core/src/package.rs @@ -269,6 +269,20 @@ pub fn select_guest<'a>( }) } +/// Compare already hash-verified packages against the embedded native contract. +/// Package identity and the resolved native plan cannot change through JS reload. +pub fn compatible_guest(a: &[u8], b: &[u8], target: &str) -> bool { + fn check(a: &[u8], b: &[u8], target: &str) -> Result { + let a = Package::parse(a, true)?; + let b = Package::parse(b, true)?; + let (Some(a), Some(b)) = (a.find_variant(target)?, b.find_variant(target)?) else { return Ok(false) }; + let (Some(ai), Some(bi)) = (a.identity()?, b.identity()?) else { return Ok(false) }; + Ok(ai.id == bi.id && ai.output == bi.output && a.host_abi == b.host_abi && + a.section(section::PLAN)? == b.section(section::PLAN)?) + } + check(a, b, target).unwrap_or(false) +} + impl<'a> Variant<'a> { /// A section payload by kind (unknown kinds are simply never asked for — /// forward compatible by construction). @@ -374,6 +388,30 @@ mod tests { assert_eq!(widget.host_abi, 3); } + #[test] + fn reload_keeps_app_and_native_plan_but_allows_guest_asset_changes() { + // This comparator runs only AFTER select_guest verifies the footer. + assert!(compatible_guest(FIXTURE, FIXTURE, "psp")); + assert!(!compatible_guest(FIXTURE, FIXTURE, "3ds-dev")); + assert!(!compatible_guest(FIXTURE, &FIXTURE[..100], "psp")); + let pkg = Package::parse(FIXTURE, false).unwrap(); + let variant = pkg.find_variant("psp").unwrap().unwrap(); + for (kind, allowed) in [(section::JS, true), (section::PAK, true), (section::PLAN, false)] { + let section = variant.section(kind).unwrap().unwrap(); + let offset = section.as_ptr() as usize - FIXTURE.as_ptr() as usize; + let mut changed = FIXTURE.to_vec(); + changed[offset] ^= 1; + assert_eq!(compatible_guest(FIXTURE, &changed, "psp"), allowed); + } + let identity = variant.identity().unwrap().unwrap(); + for field in [identity.id, identity.output] { + let offset = field.as_ptr() as usize - FIXTURE.as_ptr() as usize; + let mut changed = FIXTURE.to_vec(); + changed[offset] = b'Z'; + assert!(!compatible_guest(FIXTURE, &changed, "psp")); + } + } + #[test] fn tamper_trips_the_footer_hash() { let mut evil = FIXTURE.to_vec(); diff --git a/hosts/3ds/Makefile b/hosts/3ds/Makefile index ab8a84209..b0424ed82 100644 --- a/hosts/3ds/Makefile +++ b/hosts/3ds/Makefile @@ -106,7 +106,7 @@ LDFLAGS := -specs=3dsx.specs $(ARCH) -Wl,--gc-sections -Wl,-Map,$(BUILD)/pocketj LIBPATHS := -L$(DEVKITPRO)/libctru/lib -L$(DEVKITPRO)/portlibs/3ds/lib LIBS := -lcitro3d -lctru -lz -lm -OBJECTS := $(BUILD)/main.o $(BUILD)/asset_pack.o $(BUILD)/offload.o $(BUILD)/soc.o $(BUILD)/svcwire.o $(BUILD)/runtime.o $(BUILD)/dev_protocol.o $(BUILD)/devserver.o $(BUILD)/devmenu.o $(BUILD)/gfx.o $(BUILD)/qjs.o $(BUILD)/input.o $(BUILD)/vshader_shbin.o +OBJECTS := $(BUILD)/main.o $(BUILD)/asset_pack.o $(BUILD)/offload.o $(BUILD)/soc.o $(BUILD)/svcwire.o $(BUILD)/runtime.o $(BUILD)/dev_protocol.o $(BUILD)/devserver.o $(BUILD)/dev_transport.o $(BUILD)/devmenu.o $(BUILD)/gfx.o $(BUILD)/qjs.o $(BUILD)/input.o $(BUILD)/vshader_shbin.o ELF := $(BUILD)/pocketjs-3ds.elf SMDH := $(BUILD)/pocketjs-3ds.smdh @@ -150,6 +150,10 @@ $(FLAGS_STAMP): $(FLAGS_STAMP).probe ; $(BUILD)/%.o: $(SOURCE)/%.c $(BUILD)/vshader_shbin.h $(FLAGS_STAMP) | $(BUILD) $(CC) $(CFLAGS) -c $< -o $@ +$(BUILD)/devserver.o $(BUILD)/dev_transport.o: CFLAGS += -Werror=frame-larger-than=8192 + +$(BUILD)/devserver.o $(BUILD)/dev_transport.o $(BUILD)/main.o: $(SOURCE)/devserver.h $(SOURCE)/dev_transport.h $(SOURCE)/runtime.h + $(BUILD)/offload.o: $(SOURCE)/offload.h $(SOURCE)/offload_queue.h $(SOURCE)/offload_image.h $(BUILD)/qjs.o: $(SOURCE)/offload.h $(SOURCE)/offload_coverage.h diff --git a/hosts/3ds/README.md b/hosts/3ds/README.md index 0a4b741d1..407ba0ce1 100644 --- a/hosts/3ds/README.md +++ b/hosts/3ds/README.md @@ -28,7 +28,8 @@ core/ pocketjs-3ds-core: the ui_* C ABI over pocketjs-core include/pocket_core.h the C header for the above src/main.c process boot, reusable guest lifecycle, frame loop src/runtime.c .pocket admission, immutable storage, active/rollback state -src/devserver.c discovery, paired TCP pump, uploads, screenshots, receipts +src/devserver.c background worker, bounded UI mailboxes, update transactions +src/dev_transport.c worker-owned discovery, paired TCP, uploads and screenshots src/dev_protocol.c byte-order-safe development wire encoding and admission src/devmenu.c Runtime-owned bottom-screen development menu src/gfx.c the DrawList -> citro3d walker @@ -85,8 +86,11 @@ curl --ftp-create-dirs -T dist/3ds/pocket3ds-demo-main.pocket \ ftp:///pocketjs/runtime/apps//pending.pocket ``` -The runtime verifies the package footer, exact `3ds-dev` target, host ABI, -identity, resolved plan and NUL-terminated JS section before it can boot. A +The worker verifies the package footer, exact `3ds-dev` target, host ABI and +NUL-terminated JS section before it can boot. **Application id, output name and +resolved native plan must match the embedded package.** SD updates are limited +to **8 MiB per package**; changes to native capabilities or build configuration +require installing a new `.3dsx`. A complete pending package is renamed to `sdmc:/pocketjs/runtime/apps//packages/.pocket`; package blobs are immutable. @@ -110,7 +114,8 @@ loads the previous active package, then last-good, then the embedded ROMFS recovery package. Power loss before the generation marker leaves the previous generation active. -`L+R+X` requests the same package check at a GPU-idle frame boundary. The full +`L+R+X` requests the same package check on the background worker. A verified +candidate moves to the UI at a GPU-idle frame boundary. The full chord is removed from the application's button mask. This supports an emulator or direct SD writer; a separate 3DS ftpd cannot run concurrently with Pocket Runtime. @@ -162,9 +167,10 @@ Pair once while ftpd is running, then restart Pocket Runtime: bun run 3ds:dev pair --host --ftp-port 5000 ``` -**The pairing command generates a random 32-byte key, stores the local copy -under `.pocket/3ds/devices/`, uploads the device copy, and verifies the FTP -readback byte for byte.** The Runtime does not open a listener without the key, +**The pairing command adopts an existing device key into +`.pocket/3ds/devices/`.** If the device has no key, it installs a random 32-byte +key and verifies the FTP readback byte for byte. Only `--rotate` replaces an +existing device key, preserving pairings across separate application checkouts. The Runtime does not open a listener without the key, and a client must prove the complete key before any command or package byte is accepted. @@ -214,9 +220,49 @@ active guest. **The connection updates the guest `.pocket`, not the running `.3dsx` or CIA host binary.** A native host or ABI change still requires deploying a new `.3dsx`/CIA and restarting it; the embedded `.pocket` remains its final recovery -guest. Keeping that boundary lets ordinary app, asset and resolved-plan changes -use the in-process loop without letting a guest replace the process that admits -and rolls it back. +guest. App JS and baked assets can change within the embedded native plan; a +different plan requires a native reinstall. + +**Development updates work alongside `io.offload` and SD resource packs.** +A dedicated native worker owns development sockets, pairing-file reads, package +uploads, hashing, immutable-blob preparation and generation-marker writes. The +UI thread copies bounded mailboxes, boots the admitted guest and renders. An +upload or `fsync` cannot make it wait for the development worker. Offload and +asset-pack workers continue independently; deterministic capture builds leave +the development worker disabled. + +**Guest replacement restarts QuickJS and the UI tree.** It does not preserve +component state. Replacement happens after the previous GPU submission retires; +shutdown fences old offload and SD requests before freeing the old JS context +and GPU resources. Debug control records carry a guest generation so queued +commands cannot execute against the next guest. The old accepted package stays +resident until the new guest's first GPU frame retires and the worker commits +its generation. Commit failure restores that resident package without a UI +thread SD read. Later guest failures load last-good asynchronously, with the +embedded guest available during recovery. + +The mailboxes hold **four 16 KiB input records and four 64 KiB output records**, +plus one runtime snapshot and one candidate transaction. A screenshot uses one +pair of UI-owned linear buffers; the worker borrows them until transmission or +disconnect completes. Large screenshot scratch storage stays outside the +worker's **32 KiB stack**; the build rejects individual worker stack frames over +8 KiB. + +To exercise a running console or emulator, use an already paired, compatible +production package. The test temporarily installs diagnostic variants and +restores the supplied package: + +```sh +bun tests/e2e/3ds-hot-update.ts --host \ + --key .pocket/3ds/devices/-8131.key \ + --package dist/3ds/.pocket --out dist/3ds/update-qa +``` + +It checks frame advancement during a deliberately slow upload, native-plan and +hash rejection, eval and first-frame rejection, recovery after a later frame +fails, and an authenticated two-screen screenshot. Host tests additionally +stall admission and commit, force commit failure, and check queue, screenshot +and buffer ownership with ASan/UBSan. Two build-time facts are load-bearing: @@ -235,8 +281,9 @@ Two build-time facts are load-bearing: `src/svcwire.c` implements spec ops 30..32 (`svcOpen`/`svcPoll`/`svcSend`) over the **SVC WIRE (PKNT) protocol** (`contracts/spec/spec.ts`, `engine/core/src/wire.rs`) — the transport the Vita host speaks in -`hosts/vita/src/net.rs`, reduced to the devserver.c shape: non-blocking -sockets pumped once per frame by the main thread, no threads. The device +`hosts/vita/src/net.rs`. This legacy channel uses non-blocking sockets pumped +by the main thread in builds without `io.offload`; the offload capability uses +its own native worker instead. The device listens for the companion's once-a-second UDP beacon on port 8621 (or reads a `sdmc:/pocketjs/host.txt` override, one line `a.b.c.d[:port]`, for broadcast-hostile networks — a failed override alternates back to beacon diff --git a/hosts/3ds/core/src/lib.rs b/hosts/3ds/core/src/lib.rs index ba2ae4f46..c84aec453 100644 --- a/hosts/3ds/core/src/lib.rs +++ b/hosts/3ds/core/src/lib.rs @@ -38,6 +38,13 @@ mod heap; static mut UI: Option = None; +/// Worker-safe: borrowed package metadata only, no UI or allocator access. +#[no_mangle] +pub unsafe extern "C" fn pocket_package_same_app(a: *const u8, a_len: usize, b: *const u8, b_len: usize) -> bool { + if a.is_null() || b.is_null() { return false; } + pocketjs_core::package::compatible_guest(bytes(a, a_len), bytes(b, b_len), "3ds-dev") +} + /// Snapshot of the most recent `ui_draw`. The core's `Vec` reallocates as /// a frame's op count changes, so this is refreshed per build rather than /// cached by the caller across frames. diff --git a/hosts/3ds/include/pocket_core.h b/hosts/3ds/include/pocket_core.h index 2ffb45ddf..647137604 100644 --- a/hosts/3ds/include/pocket_core.h +++ b/hosts/3ds/include/pocket_core.h @@ -13,6 +13,7 @@ */ #include +#include #include /* Verified target variant borrowed from a caller-owned `.pocket` buffer. */ @@ -27,6 +28,9 @@ typedef struct { uint64_t variant_hash; } PocketGuestPackage; +/* No UI state or allocation: compare admitted package identity sections. */ +bool pocket_package_same_app(const uint8_t *a, size_t a_len, const uint8_t *b, size_t b_len); + /* 0 = admitted. The package footer, target, host ABI, identity, plan and * NUL-terminated JS section are all checked before success. */ int32_t pocket_package_open( diff --git a/hosts/3ds/src/dev_transport.c b/hosts/3ds/src/dev_transport.c new file mode 100644 index 000000000..e0f8c7b96 --- /dev/null +++ b/hosts/3ds/src/dev_transport.c @@ -0,0 +1,965 @@ +/* + * Paired in-process development transport for Pocket Runtime. + * + * The development worker owns all sockets, files and mutable transport state. JSON control + * frames feed the existing Pocket DevTools shim; `.pocket` uploads stream to + * SD and screenshots stream from linear memory. Bulk bytes never enter JS. + */ + +#include "dev_transport.h" + +#include <3ds.h> +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "dev_protocol.h" +#include "soc.h" + +#ifndef POCKETJS_HOST_ABI +#error "POCKETJS_HOST_ABI must come from the verified ResolvedBuildPlan" +#endif +#ifndef POCKETJS_TARGET_ID +#error "POCKETJS_TARGET_ID must come from the verified ResolvedBuildPlan" +#endif + +#define RX_BYTES (POCKET_RUNTIME_MAX_FRAME_BYTES + POCKET_RUNTIME_FRAME_HEADER_BYTES) +#define CTRL_IN_BYTES (4u * (POCKET_RUNTIME_MAX_CTRL_BYTES + 1u)) +#define CTRL_OUT_BYTES (POCKET_RUNTIME_MAX_FRAME_BYTES + POCKET_RUNTIME_FRAME_HEADER_BYTES) +#define SCREENSHOT_CHUNK_BYTES (48u * 1024u) + +static int server_fd = -1; +static int discovery_fd = -1; +static int client_fd = -1; +static bool initialized; +static bool authenticated; +static bool handshake_pending; +static bool handshake_accepted; +static uint8_t handshake_ack[POCKET_RUNTIME_ACK_BYTES]; +static size_t handshake_ack_offset; +static uint64_t client_last_rx_ms; +static uint8_t pairing_token[POCKET_RUNTIME_TOKEN_BYTES]; +static uint64_t device_id; + +static uint8_t rx_buffer[RX_BYTES]; +static size_t rx_length; +static uint8_t ctrl_input[CTRL_IN_BYTES]; +static size_t ctrl_input_length; +static uint8_t tx_buffer[CTRL_OUT_BYTES]; +static size_t tx_length; +static size_t tx_offset; +static char install_lines[8][640]; +static unsigned install_read, install_write; +static bool pong_pending; +static uint8_t pong_payload[4]; + +static char hello_cache[1024]; +static size_t hello_cache_length; + +static FILE *upload_file; +static uint32_t upload_expected; +static uint32_t upload_received; +static uint64_t upload_hash; +static bool upload_ready; +static bool upload_busy; +static bool upload_discarding; +static bool screenshot_borrowed; + +static bool screenshot_requested; +static bool screenshot_ready; +static uint8_t *screenshot_top; +static uint8_t *screenshot_auxiliary; +static uint32_t screenshot_top_bytes; +static uint32_t screenshot_auxiliary_bytes; +static uint32_t screenshot_frame; +static uint16_t screenshot_top_width; +static uint16_t screenshot_top_height; +static uint16_t screenshot_auxiliary_width; +static uint16_t screenshot_auxiliary_height; +static uint8_t screenshot_stage; +static uint8_t screenshot_surface; +static uint32_t screenshot_offset; + +static PocketRuntimeState runtime_state; +static uint64_t running_hash; +static uint64_t variant_hash; +static uint32_t runtime_frame; +static char runtime_phase[32] = "starting"; + +static uint64_t rx_bytes; +static uint64_t tx_bytes; +static uint32_t connects; +static uint32_t auth_failures; +static uint32_t uploads; +static uint32_t screenshots; +static uint32_t timeouts; +static uint32_t discoveries; +static uint32_t frame_commands; +static uint32_t frame_vertices; +static uint32_t frame_dropped_vertices; +static char stats_json[768]; + +static void set_error(char *out, size_t length, const char *format, ...) { + if (out == NULL || length == 0) return; + va_list arguments; + va_start(arguments, format); + vsnprintf(out, length, format, arguments); + va_end(arguments); +} + +static bool would_block(void) { + return errno == EAGAIN || errno == EWOULDBLOCK; +} + +static void format_ip(char out[16]) { + uint32_t ip = initialized ? gethostid() : 0; + snprintf( + out, + 16, + "%lu.%lu.%lu.%lu", + (unsigned long)(ip & 0xff), + (unsigned long)((ip >> 8) & 0xff), + (unsigned long)((ip >> 16) & 0xff), + (unsigned long)((ip >> 24) & 0xff) + ); +} + +static void close_upload(void) { + if (upload_file != NULL) fclose(upload_file); + upload_file = NULL; + upload_expected = 0; + upload_received = 0; + upload_hash = 0; +} + +void devtransport_screenshot_cancel(void) { + if (!screenshot_borrowed && screenshot_top != NULL) linearFree(screenshot_top); + if (!screenshot_borrowed && screenshot_auxiliary != NULL) linearFree(screenshot_auxiliary); + screenshot_top = NULL; + screenshot_auxiliary = NULL; + screenshot_top_bytes = 0; + screenshot_auxiliary_bytes = 0; + screenshot_ready = false; + screenshot_stage = 0; + screenshot_surface = 0; + screenshot_offset = 0; + screenshot_borrowed = false; +} + +void devtransport_set_upload_busy(bool busy) { upload_busy = busy; } +void devtransport_reset_guest(void) { + ctrl_input_length = 0; + hello_cache_length = 0; +} +bool devtransport_ctrl_available(size_t length) { + return length <= POCKET_RUNTIME_MAX_FRAME_BYTES && + length + POCKET_RUNTIME_FRAME_HEADER_BYTES <= sizeof tx_buffer - (tx_length - tx_offset); +} +bool devtransport_screenshot_busy(void) { return screenshot_top != NULL; } +void devtransport_adopt_screenshot(uint32_t frame, uint16_t tw, uint16_t th, + uint16_t aw, uint16_t ah, uint8_t *top, uint8_t *aux) { + screenshot_borrowed = true; + screenshot_frame = frame; + screenshot_top_width = tw; screenshot_top_height = th; + screenshot_auxiliary_width = aw; screenshot_auxiliary_height = ah; + screenshot_top_bytes = (uint32_t)tw * th * 3; + screenshot_auxiliary_bytes = (uint32_t)aw * ah * 3; + screenshot_top = top; screenshot_auxiliary = aux; + devtransport_screenshot_ready(); +} + +static void disconnect_client(void) { + if (client_fd >= 0) close(client_fd); + client_fd = -1; + authenticated = false; + handshake_pending = false; + handshake_accepted = false; + handshake_ack_offset = 0; + rx_length = 0; + ctrl_input_length = 0; + tx_length = 0; + tx_offset = 0; + install_read = install_write = 0; + pong_pending = false; + screenshot_requested = false; + devtransport_screenshot_cancel(); + if (upload_file != NULL && !upload_ready) close_upload(); +} + +static bool set_nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +static int hex_digit(int value) { + if (value >= '0' && value <= '9') return value - '0'; + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + if (value >= 'A' && value <= 'F') return value - 'A' + 10; + return -1; +} + +static DevserverInitResult load_key(char *error, size_t error_length) { + FILE *file = fopen(POCKET_RUNTIME_DEV_KEY, "rb"); + if (file == NULL) { + if (errno == ENOENT) return DEVSERVER_DISABLED; + set_error(error, error_length, "open %s failed (%d)", POCKET_RUNTIME_DEV_KEY, errno); + return DEVSERVER_ERROR; + } + char hex[66] = {0}; + size_t length = fread(hex, 1, sizeof hex, file); + int close_result = fclose(file); + if (close_result != 0 || (length != 64 && length != 65) || + (length == 65 && hex[64] != '\n')) { + set_error(error, error_length, "dev.key must contain exactly 64 hexadecimal characters"); + return DEVSERVER_ERROR; + } + for (size_t index = 0; index < POCKET_RUNTIME_TOKEN_BYTES; index += 1) { + int high = hex_digit(hex[index * 2]); + int low = hex_digit(hex[index * 2 + 1]); + if (high < 0 || low < 0) { + set_error(error, error_length, "dev.key contains a non-hexadecimal character"); + return DEVSERVER_ERROR; + } + pairing_token[index] = (uint8_t)((high << 4) | low); + } + device_id = pocket_runtime_device_id(pairing_token); + return DEVSERVER_READY; +} + +DevserverInitResult devtransport_init( + const PocketRuntimeState *state, + char *error, + size_t error_length +) { + if (initialized) return DEVSERVER_READY; + if (state != NULL) runtime_state = *state; + DevserverInitResult key = load_key(error, error_length); + if (key != DEVSERVER_READY) return key; + + if (!soc_ensure(error, error_length)) return DEVSERVER_ERROR; + + server_fd = socket(AF_INET, SOCK_STREAM, 0); + if (server_fd < 0) { + set_error(error, error_length, "Pocket Runtime socket failed (%d)", errno); + devtransport_shutdown(); + return DEVSERVER_ERROR; + } + int reuse = 1; + setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof reuse); + struct sockaddr_in address; + memset(&address, 0, sizeof address); + address.sin_family = AF_INET; + address.sin_addr.s_addr = INADDR_ANY; + address.sin_port = htons(POCKET_RUNTIME_WIRE_PORT); + if (bind(server_fd, (struct sockaddr *)&address, sizeof address) != 0 || + listen(server_fd, 1) != 0 || !set_nonblocking(server_fd)) { + set_error(error, error_length, "Pocket Runtime listen on %u failed (%d)", POCKET_RUNTIME_WIRE_PORT, errno); + devtransport_shutdown(); + return DEVSERVER_ERROR; + } + + discovery_fd = socket(AF_INET, SOCK_DGRAM, 0); + if (discovery_fd >= 0) { + setsockopt(discovery_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof reuse); + if (bind(discovery_fd, (struct sockaddr *)&address, sizeof address) != 0 || + !set_nonblocking(discovery_fd)) { + close(discovery_fd); + discovery_fd = -1; + } + } + initialized = true; + return DEVSERVER_READY; +} + +void devtransport_shutdown(void) { + disconnect_client(); + close_upload(); + if (server_fd >= 0) close(server_fd); + server_fd = -1; + if (discovery_fd >= 0) close(discovery_fd); + discovery_fd = -1; + /* SOC itself is shared with the svc transport; main owns soc_shutdown. */ + initialized = false; +} + +bool devtransport_active(void) { + return initialized; +} + +bool devtransport_connected(void) { + return authenticated && client_fd >= 0; +} + +void devtransport_snapshot(DevserverSnapshot *out) { + if (out == NULL) return; + memset(out, 0, sizeof *out); + out->enabled = initialized; + out->discoverable = discovery_fd >= 0; + out->connected = devtransport_connected(); + format_ip(out->ip); + snprintf(out->phase, sizeof out->phase, "%s", runtime_phase); + out->port = POCKET_RUNTIME_WIRE_PORT; + out->host_abi = POCKETJS_HOST_ABI; + out->generation = runtime_state.generation; + out->running_hash = running_hash; + out->device_id = device_id; + out->connects = connects; + out->auth_failures = auth_failures; + out->timeouts = timeouts; + out->uploads = uploads; + out->screenshots = screenshots; +} + +static bool queue_frame(uint8_t type, uint8_t flags, const uint8_t *payload, size_t length) { + if (length > POCKET_RUNTIME_MAX_FRAME_BYTES) return false; + uint8_t header[POCKET_RUNTIME_FRAME_HEADER_BYTES]; + pocket_runtime_encode_frame_header(header, type, flags, (uint32_t)length); + if (tx_offset > 0) { + if (tx_offset < tx_length) { + memmove(tx_buffer, tx_buffer + tx_offset, tx_length - tx_offset); + tx_length -= tx_offset; + } else { + tx_length = 0; + } + tx_offset = 0; + } + if (sizeof header + length > sizeof tx_buffer - tx_length) return false; + memcpy(tx_buffer + tx_length, header, sizeof header); + tx_length += sizeof header; + if (length > 0) { + memcpy(tx_buffer + tx_length, payload, length); + tx_length += length; + } + return true; +} + +static size_t json_escape(char *out, size_t capacity, const char *text) { + size_t written = 0; + if (text == NULL) return 0; + for (const unsigned char *at = (const unsigned char *)text; *at != 0; at += 1) { + const char *escape = NULL; + char unicode[7]; + if (*at == '"') escape = "\\\""; + else if (*at == '\\') escape = "\\\\"; + else if (*at == '\n') escape = "\\n"; + else if (*at == '\r') escape = "\\r"; + else if (*at == '\t') escape = "\\t"; + else if (*at < 0x20) { + snprintf(unicode, sizeof unicode, "\\u%04x", *at); + escape = unicode; + } + if (escape != NULL) { + size_t length = strlen(escape); + if (written + length >= capacity) break; + memcpy(out + written, escape, length); + written += length; + } else { + if (written + 1 >= capacity) break; + out[written++] = (char)*at; + } + } + if (capacity > 0) out[written < capacity ? written : capacity - 1] = '\0'; + return written; +} + +/* + * On the way out a control record may be as large as a frame. tx_buffer is + * sized for a whole frame and the screenshot path already pushes 48 KiB + * through it, whereas MAX_CTRL_BYTES bounds what the TOOL sends — ctrl_input, + * the inbound ring, is sized from it. Holding outgoing records to the inbound + * bound cost the devtools tree dump: past a few hundred nodes it went over + * 16 KiB and was discarded here without a word, which on the tool side is + * indistinguishable from a hung device until the 15 s timeout expires. + */ +void devtransport_send_ctrl(const char *line, size_t length) { + if (line == NULL || length == 0) return; + if (length > POCKET_RUNTIME_MAX_FRAME_BYTES) { + /* Too large for any frame. Say so, so the caller waiting on this record + * learns why it is never coming. */ + char notice[128]; + int written = snprintf( + notice, + sizeof notice, + "{\"t\":\"ctrlDropped\",\"bytes\":%u,\"cap\":%u}", + (unsigned)length, + (unsigned)POCKET_RUNTIME_MAX_FRAME_BYTES + ); + if (written > 0) { + queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, (const uint8_t *)notice, (size_t)written); + } + return; + } + static const char hello_marker[] = "\"t\":\"hello\""; + bool is_hello = false; + if (length >= sizeof hello_marker - 1) { + for (size_t offset = 0; offset + sizeof hello_marker - 1 <= length; offset += 1) { + if (memcmp(line + offset, hello_marker, sizeof hello_marker - 1) == 0) { + is_hello = true; + break; + } + } + } + if (is_hello && length < sizeof hello_cache) { + memcpy(hello_cache, line, length); + hello_cache[length] = '\0'; + hello_cache_length = length; + } + queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, (const uint8_t *)line, length); +} + +static void send_status(void) { + char message[640]; + char ip[16]; + format_ip(ip); + snprintf( + message, + sizeof message, + "{\"t\":\"runtime.status\",\"phase\":\"%s\",\"target\":\"%s\",\"hostAbi\":%u," + "\"ip\":\"%s\",\"port\":%u,\"generation\":%lu," + "\"active\":\"%016llx\",\"lastGood\":\"%016llx\",\"running\":\"%016llx\"," + "\"frame\":%lu}", + runtime_phase, + POCKETJS_TARGET_ID, + (unsigned)POCKETJS_HOST_ABI, + ip, + (unsigned)POCKET_RUNTIME_WIRE_PORT, + (unsigned long)runtime_state.generation, + (unsigned long long)runtime_state.active_hash, + (unsigned long long)runtime_state.last_good_hash, + (unsigned long long)running_hash, + (unsigned long)runtime_frame + ); + devtransport_send_ctrl(message, strlen(message)); +} + +void devtransport_report_install(const char *phase, uint64_t hash, const char *message) { + /* Receipts belong to the requesting connection, never a later client. */ + if (!devtransport_connected()) return; + char escaped[384] = {0}; + if (install_write - install_read == 8) { + /* Explicit disconnect beats silently losing the terminal install receipt. */ + disconnect_client(); return; + } + char *line = install_lines[install_write % 8]; + json_escape(escaped, sizeof escaped, message == NULL ? "" : message); + snprintf( + line, + sizeof install_lines[0], + "{\"t\":\"runtime.install\",\"phase\":\"%s\",\"hash\":\"%016llx\"," + "\"generation\":%lu,\"message\":\"%s\"}", + phase == NULL ? "unknown" : phase, + (unsigned long long)hash, + (unsigned long)runtime_state.generation, + escaped + ); + install_write += 1; +} + +void devtransport_report_log(const char *level, const char *message) { + char escaped[448] = {0}; + char line[560]; + json_escape(escaped, sizeof escaped, message == NULL ? "" : message); + snprintf( + line, + sizeof line, + "{\"t\":\"log\",\"level\":\"%s\",\"args\":[\"%s\"]}", + level == NULL ? "info" : level, + escaped + ); + devtransport_send_ctrl(line, strlen(line)); +} + +void devtransport_set_runtime( + const PocketRuntimeState *state, + const PocketRuntimePackage *package, + const char *phase, + uint32_t frame +) { + if (state != NULL) runtime_state = *state; + running_hash = package == NULL ? 0 : package->guest.package_hash; + variant_hash = package == NULL ? 0 : package->guest.variant_hash; + runtime_frame = frame; + snprintf(runtime_phase, sizeof runtime_phase, "%s", phase == NULL ? "unknown" : phase); +} + +void devtransport_set_frame_stats( + uint32_t frame, + uint32_t commands, + uint32_t vertices, + uint32_t dropped_vertices +) { + runtime_frame = frame; + frame_commands = commands; + frame_vertices = vertices; + frame_dropped_vertices = dropped_vertices; +} + +const char *devtransport_debug_stats(void) { + snprintf( + stats_json, + sizeof stats_json, + "{\"target\":\"%s\",\"hostAbi\":%u,\"package\":\"%016llx\"," + "\"variant\":\"%016llx\",\"generation\":%lu,\"frame\":%lu," + "\"gfx\":{\"commands\":%lu,\"vertices\":%lu,\"droppedVertices\":%lu}," + "\"net\":{\"connected\":%s,\"rxBytes\":%llu,\"txBytes\":%llu," + "\"connects\":%lu,\"authFailures\":%lu,\"timeouts\":%lu," + "\"discoveries\":%lu,\"uploads\":%lu,\"screenshots\":%lu}}", + POCKETJS_TARGET_ID, + (unsigned)POCKETJS_HOST_ABI, + (unsigned long long)running_hash, + (unsigned long long)variant_hash, + (unsigned long)runtime_state.generation, + (unsigned long)runtime_frame, + (unsigned long)frame_commands, + (unsigned long)frame_vertices, + (unsigned long)frame_dropped_vertices, + devtransport_connected() ? "true" : "false", + (unsigned long long)rx_bytes, + (unsigned long long)tx_bytes, + (unsigned long)connects, + (unsigned long)auth_failures, + (unsigned long)timeouts, + (unsigned long)discoveries, + (unsigned long)uploads, + (unsigned long)screenshots + ); + return stats_json; +} + +static void accept_client(void) { + if (client_fd >= 0 || server_fd < 0) return; + int fd = accept(server_fd, NULL, NULL); + if (fd < 0) return; + if (!set_nonblocking(fd)) { + close(fd); + return; + } + client_fd = fd; + authenticated = false; + handshake_pending = false; + handshake_accepted = false; + handshake_ack_offset = 0; + rx_length = 0; + ctrl_input_length = 0; + tx_length = 0; + tx_offset = 0; + install_read = install_write = 0; + pong_pending = false; + client_last_rx_ms = osGetTime(); +} + +static void poll_discovery(void) { + if (discovery_fd < 0) return; + for (uint32_t attempt = 0; attempt < 4; attempt += 1) { + uint8_t request[POCKET_RUNTIME_DISCOVERY_REQUEST_BYTES]; + struct sockaddr_in sender; + socklen_t sender_length = sizeof sender; + ssize_t length = recvfrom( + discovery_fd, + request, + sizeof request, + 0, + (struct sockaddr *)&sender, + &sender_length + ); + if (length < 0 && would_block()) return; + if (length <= 0) return; + if (!pocket_runtime_is_discovery_request(request, (size_t)length)) continue; + + uint8_t reply[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES]; + pocket_runtime_encode_discovery_reply( + reply, + POCKETJS_HOST_ABI, + POCKET_RUNTIME_WIRE_PORT, + devtransport_connected() ? 1u : 0u, + runtime_state.generation, + runtime_state.active_hash, + device_id, + POCKETJS_TARGET_ID, + "PocketJS 3DS" + ); + if (sendto( + discovery_fd, + reply, + sizeof reply, + 0, + (struct sockaddr *)&sender, + sender_length + ) == (ssize_t)sizeof reply) { + discoveries += 1; + } + } +} + +static bool append_ctrl_input(const uint8_t *bytes, size_t length) { + if (length == 0 || length + 1 > sizeof ctrl_input - ctrl_input_length) return false; + memcpy(ctrl_input + ctrl_input_length, bytes, length); + ctrl_input_length += length; + ctrl_input[ctrl_input_length++] = '\n'; + return true; +} + +static void abort_upload(const char *message) { + uint64_t rejected = upload_hash; + close_upload(); + upload_ready = false; + upload_discarding = false; + devtransport_report_install("transfer-error", rejected, message); +} + +static void handle_package_begin(const uint8_t *payload, size_t length) { + PocketRuntimePackageBegin begin; + if (!pocket_runtime_parse_package_begin(payload, length, &begin)) { + abort_upload("invalid package begin frame"); + return; + } + if (upload_file) { abort_upload("another begin interrupted the transfer"); return; } + if (upload_busy || upload_ready || begin.length > POCKET_RUNTIME_UPDATE_MAX_BYTES) { + upload_discarding = true; + devtransport_report_install("rejected", begin.footer_hash, + upload_busy || upload_ready ? "another update is awaiting acceptance" : "update exceeds native memory budget"); + return; + } + close_upload(); + upload_discarding = false; + upload_ready = false; + upload_file = fopen(POCKET_RUNTIME_UPLOAD, "wb"); + if (upload_file == NULL) { + devtransport_report_install("transfer-error", begin.footer_hash, "open network staging file failed"); + return; + } + upload_expected = begin.length; + upload_received = 0; + upload_hash = begin.footer_hash; + devtransport_report_install("receiving", upload_hash, "binary package transfer started"); +} + +static void handle_package_chunk(const uint8_t *payload, size_t length) { + if (upload_discarding) return; + if (upload_file == NULL || length <= 4) { + abort_upload("package chunk arrived without an active transfer"); + return; + } + uint32_t offset = pocket_runtime_read_u32(payload); + size_t bytes = length - 4; + if (upload_received > upload_expected || offset != upload_received || + bytes > upload_expected - upload_received || + fwrite(payload + 4, 1, bytes, upload_file) != bytes) { + abort_upload("package chunk offset, length, or SD write failed"); + return; + } + upload_received += (uint32_t)bytes; +} + +static void handle_package_commit(void) { + if (upload_discarding) { upload_discarding = false; return; } + if (upload_file == NULL || upload_received != upload_expected) { + abort_upload("package commit arrived before every declared byte"); + return; + } + bool written = fflush(upload_file) == 0 && fsync(fileno(upload_file)) == 0; + if (fclose(upload_file) != 0) written = false; + upload_file = NULL; + if (!written) { + abort_upload("flush network package staging file failed"); + return; + } + upload_ready = true; + uploads += 1; + devtransport_report_install("received", upload_hash, "binary package transfer complete"); +} + +static void handle_frame(uint8_t type, uint8_t flags, const uint8_t *payload, size_t length) { + if (flags != 0) { + disconnect_client(); + return; + } + switch (type) { + case POCKET_RUNTIME_MSG_PING: + if (length == sizeof pong_payload) { + memcpy(pong_payload, payload, sizeof pong_payload); + pong_pending = true; + } + break; + case POCKET_RUNTIME_MSG_CTRL: + if (length <= POCKET_RUNTIME_MAX_CTRL_BYTES && + memchr(payload, '\n', length) == NULL && + memchr(payload, '\r', length) == NULL && + append_ctrl_input(payload, length)) break; + disconnect_client(); + break; + case POCKET_RUNTIME_MSG_PACKAGE_BEGIN: + handle_package_begin(payload, length); + break; + case POCKET_RUNTIME_MSG_PACKAGE_CHUNK: + handle_package_chunk(payload, length); + break; + case POCKET_RUNTIME_MSG_PACKAGE_COMMIT: + if (length == 0) handle_package_commit(); + else abort_upload("package commit payload must be empty"); + break; + case POCKET_RUNTIME_MSG_PACKAGE_ABORT: + if (upload_discarding) { upload_discarding = false; break; } + abort_upload("host aborted package transfer"); + break; + case POCKET_RUNTIME_MSG_STATUS_REQUEST: + if (length == 0) send_status(); + break; + default: + /* Unknown length-framed messages are skipped for forward compatibility. */ + break; + } +} + +static void receive_client(void) { + if (client_fd < 0) return; + if (handshake_pending || upload_ready) return; + while (rx_length < sizeof rx_buffer) { + ssize_t read = recv(client_fd, rx_buffer + rx_length, sizeof rx_buffer - rx_length, 0); + if (read > 0) { + rx_length += (size_t)read; + rx_bytes += (uint64_t)read; + client_last_rx_ms = osGetTime(); + continue; + } + if (read == 0) { + disconnect_client(); + return; + } + if (would_block()) break; + disconnect_client(); + return; + } + + if (!authenticated) { + if (rx_length < POCKET_RUNTIME_HELLO_BYTES) return; + bool accepted = pocket_runtime_verify_hello( + rx_buffer, + POCKET_RUNTIME_HELLO_BYTES, + pairing_token + ); + pocket_runtime_encode_ack( + handshake_ack, + accepted ? 0 : 2, + POCKETJS_HOST_ABI, + runtime_state.generation, + initialized ? 1u : 0u, + runtime_state.active_hash + ); + memmove(rx_buffer, rx_buffer + POCKET_RUNTIME_HELLO_BYTES, rx_length - POCKET_RUNTIME_HELLO_BYTES); + rx_length -= POCKET_RUNTIME_HELLO_BYTES; + handshake_pending = true; + handshake_accepted = accepted; + handshake_ack_offset = 0; + if (!accepted) auth_failures += 1; + return; + } + + while (authenticated && rx_length >= POCKET_RUNTIME_FRAME_HEADER_BYTES) { + PocketRuntimeFrameHeader header; + if (!pocket_runtime_parse_frame_header(rx_buffer, rx_length, &header)) { + disconnect_client(); + return; + } + size_t total = POCKET_RUNTIME_FRAME_HEADER_BYTES + (size_t)header.length; + if (rx_length < total) break; + handle_frame( + header.type, + header.flags, + rx_buffer + POCKET_RUNTIME_FRAME_HEADER_BYTES, + header.length + ); + if (client_fd < 0) return; + memmove(rx_buffer, rx_buffer + total, rx_length - total); + rx_length -= total; + if (upload_ready) break; /* Hand the completed file to admission before reading another transfer. */ + } + if (rx_length == sizeof rx_buffer) disconnect_client(); +} + +static void queue_screenshot_frame(void) { + if (!screenshot_ready || tx_length != tx_offset) return; + if (screenshot_stage == 0) { + uint8_t begin[POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES]; + pocket_runtime_encode_screenshot_begin( + begin, + screenshot_frame, + screenshot_top_width, + screenshot_top_height, + screenshot_auxiliary_width, + screenshot_auxiliary_height, + screenshot_top_bytes, + screenshot_auxiliary_bytes + ); + if (queue_frame(POCKET_RUNTIME_MSG_SCREENSHOT_BEGIN, 0, begin, sizeof begin)) { + screenshot_stage = 1; + } + return; + } + if (screenshot_stage == 1) { + uint8_t *surface = screenshot_surface == 0 ? screenshot_top : screenshot_auxiliary; + uint32_t bytes = screenshot_surface == 0 ? screenshot_top_bytes : screenshot_auxiliary_bytes; + if (screenshot_offset < bytes) { + uint32_t amount = bytes - screenshot_offset; + if (amount > SCREENSHOT_CHUNK_BYTES) amount = SCREENSHOT_CHUNK_BYTES; + /* Worker stack is 32 KiB. This bounded scratch buffer is worker-owned. */ + static uint8_t payload[4 + SCREENSHOT_CHUNK_BYTES]; + pocket_runtime_write_u32(payload, screenshot_offset); + memcpy(payload + 4, surface + screenshot_offset, amount); + if (queue_frame( + POCKET_RUNTIME_MSG_SCREENSHOT_CHUNK, + screenshot_surface, + payload, + 4 + amount + )) { + screenshot_offset += amount; + } + return; + } + if (screenshot_surface == 0) { + screenshot_surface = 1; + screenshot_offset = 0; + return; + } + screenshot_stage = 2; + } + if (screenshot_stage == 2) { + uint8_t end[4]; + pocket_runtime_write_u32(end, screenshot_frame); + if (queue_frame(POCKET_RUNTIME_MSG_SCREENSHOT_END, 0, end, sizeof end)) { + screenshot_stage = 3; + } + return; + } + if (screenshot_stage == 3) { + screenshots += 1; + devtransport_screenshot_cancel(); + } +} + +static void send_client(void) { + if (client_fd < 0) return; + if (handshake_pending) { + ssize_t sent = send( + client_fd, + handshake_ack + handshake_ack_offset, + sizeof handshake_ack - handshake_ack_offset, + 0 + ); + if (sent > 0) { + handshake_ack_offset += (size_t)sent; + tx_bytes += (uint64_t)sent; + if (handshake_ack_offset == sizeof handshake_ack) { + bool accepted = handshake_accepted; + handshake_pending = false; + handshake_ack_offset = 0; + if (!accepted) { + disconnect_client(); + return; + } + authenticated = true; + connects += 1; + if (hello_cache_length > 0) { + queue_frame( + POCKET_RUNTIME_MSG_CTRL, + 0, + (const uint8_t *)hello_cache, + hello_cache_length + ); + } + send_status(); + } + return; + } + if (sent < 0 && would_block()) return; + disconnect_client(); + return; + } + if (!devtransport_connected()) return; + if (pong_pending && tx_length == tx_offset && + queue_frame(POCKET_RUNTIME_MSG_PONG, 0, pong_payload, sizeof pong_payload)) { + pong_pending = false; + } + queue_screenshot_frame(); + if (tx_offset >= tx_length) return; + ssize_t sent = send(client_fd, tx_buffer + tx_offset, tx_length - tx_offset, 0); + if (sent > 0) { + tx_offset += (size_t)sent; + tx_bytes += (uint64_t)sent; + if (tx_offset == tx_length) { + tx_offset = 0; + tx_length = 0; + } + return; + } + if (sent < 0 && would_block()) return; + disconnect_client(); +} + +void devtransport_poll(void) { + if (!initialized) return; + poll_discovery(); + accept_client(); + receive_client(); + if (client_fd >= 0 && osGetTime() - client_last_rx_ms > 15u * 1000u) { + timeouts += 1; + disconnect_client(); + return; + } + if (install_read != install_write && devtransport_connected()) { + const char *line = install_lines[install_read % 8]; + if (queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, (const uint8_t *)line, strlen(line))) install_read += 1; + } + send_client(); +} + +size_t devtransport_recv_ctrl(char *out, size_t capacity) { + if (out == NULL || capacity <= 1 || ctrl_input_length == 0) return 0; + size_t length = ctrl_input_length < capacity - 1 ? ctrl_input_length : capacity - 1; + /* Every accepted control frame has a synthetic newline. Never hand QuickJS + * a partial JSON record when several queued frames approach its poll cap. */ + while (length > 0 && ctrl_input[length - 1] != '\n') length -= 1; + if (length == 0) return 0; + memcpy(out, ctrl_input, length); + out[length] = '\0'; + memmove(ctrl_input, ctrl_input + length, ctrl_input_length - length); + ctrl_input_length -= length; + return length; +} + +bool devtransport_take_upload(uint64_t *declared_hash) { + if (!upload_ready) return false; + upload_ready = false; + if (declared_hash != NULL) *declared_hash = upload_hash; + upload_expected = 0; + upload_received = 0; + upload_hash = 0; + return true; +} + +bool devtransport_request_screenshot(void) { + if (!devtransport_connected() || screenshot_requested || screenshot_ready) return false; + screenshot_requested = true; + return true; +} + +bool devtransport_take_screenshot_request(void) { + if (!screenshot_requested) return false; + screenshot_requested = false; + return true; +} + +void devtransport_screenshot_ready(void) { + if (screenshot_top == NULL || screenshot_auxiliary == NULL) return; + screenshot_ready = true; + screenshot_stage = 0; + screenshot_surface = 0; + screenshot_offset = 0; +} diff --git a/hosts/3ds/src/dev_transport.h b/hosts/3ds/src/dev_transport.h new file mode 100644 index 000000000..ea72914e8 --- /dev/null +++ b/hosts/3ds/src/dev_transport.h @@ -0,0 +1,61 @@ +#ifndef POCKETJS_3DS_DEV_TRANSPORT_H +#define POCKETJS_3DS_DEV_TRANSPORT_H +#include "devserver.h" +void devtransport_set_upload_busy(bool busy); +void devtransport_reset_guest(void); +bool devtransport_ctrl_available(size_t length); +void devtransport_adopt_screenshot(uint32_t frame, uint16_t tw, uint16_t th, + uint16_t aw, uint16_t ah, uint8_t *top, uint8_t *aux); +bool devtransport_screenshot_busy(void); +/* Starts the paired LAN listener. A missing dev.key is an intentional + * DISABLED state; malformed key or socket initialization is ERROR. */ +DevserverInitResult devtransport_init( + const PocketRuntimeState *state, + char *error, + size_t error_length +); +void devtransport_shutdown(void); + +/* Worker-only network pump. libctru IPC and SD may block this thread. */ +void devtransport_poll(void); +bool devtransport_active(void); +bool devtransport_connected(void); +void devtransport_snapshot(DevserverSnapshot *out); + +/* Pocket DevTools JSON-line transport exposed through ui.__dbg*. */ +size_t devtransport_recv_ctrl(char *out, size_t capacity); +void devtransport_send_ctrl(const char *line, size_t length); +bool devtransport_request_screenshot(void); +const char *devtransport_debug_stats(void); + +/* Completed binary upload. The caller admits POCKET_RUNTIME_UPLOAD, then + * reports staged/rejected/accepted at the same lifecycle boundaries used by + * FTP packages. */ +bool devtransport_take_upload(uint64_t *declared_hash); +void devtransport_report_install( + const char *phase, + uint64_t hash, + const char *message +); +void devtransport_report_log(const char *level, const char *message); + +/* Current runtime facts are cached for connect/status/debugStats receipts. */ +void devtransport_set_runtime( + const PocketRuntimeState *state, + const PocketRuntimePackage *package, + const char *phase, + uint32_t frame +); +void devtransport_set_frame_stats( + uint32_t frame, + uint32_t commands, + uint32_t vertices, + uint32_t dropped_vertices +); + +/* Transport borrows UI-owned linear buffers until completion/disconnect. */ +bool devtransport_take_screenshot_request(void); +void devtransport_screenshot_ready(void); +void devtransport_screenshot_cancel(void); + +#endif diff --git a/hosts/3ds/src/devserver.c b/hosts/3ds/src/devserver.c index 840d58574..f3e6f6a9c 100644 --- a/hosts/3ds/src/devserver.c +++ b/hosts/3ds/src/devserver.c @@ -1,942 +1,367 @@ -/* - * Paired in-process development transport for Pocket Runtime. - * - * The main/render thread owns this bounded non-blocking pump. JSON control - * frames feed the existing Pocket DevTools shim; `.pocket` uploads stream to - * SD and screenshots stream from linear memory. Bulk bytes never enter JS. - */ - +/* UI facade for the native development worker. All shared payloads have one + * producer and one consumer; release/acquire transfers ownership. No UI call + * waits for a lock, a socket, SD, hashing, or generation-marker persistence. */ #include "devserver.h" - +#include "dev_transport.h" +#include "dev_protocol.h" #include <3ds.h> -#include -#include -#include -#include -#include -#include +#include #include #include #include -#include -#include - -#include "dev_protocol.h" -#include "soc.h" - -#ifndef POCKETJS_HOST_ABI -#error "POCKETJS_HOST_ABI must come from the verified ResolvedBuildPlan" -#endif -#ifndef POCKETJS_TARGET_ID -#error "POCKETJS_TARGET_ID must come from the verified ResolvedBuildPlan" -#endif - -#define RX_BYTES (POCKET_RUNTIME_MAX_FRAME_BYTES + POCKET_RUNTIME_FRAME_HEADER_BYTES) -#define CTRL_IN_BYTES (4u * (POCKET_RUNTIME_MAX_CTRL_BYTES + 1u)) -#define CTRL_OUT_BYTES (POCKET_RUNTIME_MAX_FRAME_BYTES + POCKET_RUNTIME_FRAME_HEADER_BYTES) -#define SCREENSHOT_CHUNK_BYTES (48u * 1024u) - -static int server_fd = -1; -static int discovery_fd = -1; -static int client_fd = -1; -static bool initialized; -static bool authenticated; -static bool handshake_pending; -static bool handshake_accepted; -static uint8_t handshake_ack[POCKET_RUNTIME_ACK_BYTES]; -static size_t handshake_ack_offset; -static uint64_t client_last_rx_ms; -static uint8_t pairing_token[POCKET_RUNTIME_TOKEN_BYTES]; -static uint64_t device_id; - -static uint8_t rx_buffer[RX_BYTES]; -static size_t rx_length; -static uint8_t ctrl_input[CTRL_IN_BYTES]; -static size_t ctrl_input_length; -static uint8_t tx_buffer[CTRL_OUT_BYTES]; -static size_t tx_length; -static size_t tx_offset; -static bool pong_pending; -static uint8_t pong_payload[4]; - -static char hello_cache[1024]; -static size_t hello_cache_length; - -static FILE *upload_file; -static uint32_t upload_expected; -static uint32_t upload_received; -static uint64_t upload_hash; -static bool upload_ready; - -static bool screenshot_requested; -static bool screenshot_ready; -static uint8_t *screenshot_top; -static uint8_t *screenshot_auxiliary; -static uint32_t screenshot_top_bytes; -static uint32_t screenshot_auxiliary_bytes; -static uint32_t screenshot_frame; -static uint16_t screenshot_top_width; -static uint16_t screenshot_top_height; -static uint16_t screenshot_auxiliary_width; -static uint16_t screenshot_auxiliary_height; -static uint8_t screenshot_stage; -static uint8_t screenshot_surface; -static uint32_t screenshot_offset; - -static PocketRuntimeState runtime_state; -static uint64_t running_hash; -static uint64_t variant_hash; -static uint32_t runtime_frame; -static char runtime_phase[32] = "starting"; - -static uint64_t rx_bytes; -static uint64_t tx_bytes; -static uint32_t connects; -static uint32_t auth_failures; -static uint32_t uploads; -static uint32_t screenshots; -static uint32_t timeouts; -static uint32_t discoveries; -static uint32_t frame_commands; -static uint32_t frame_vertices; -static uint32_t frame_dropped_vertices; -static char stats_json[768]; - -static void set_error(char *out, size_t length, const char *format, ...) { - if (out == NULL || length == 0) return; - va_list arguments; - va_start(arguments, format); - vsnprintf(out, length, format, arguments); - va_end(arguments); -} - -static bool would_block(void) { - return errno == EAGAIN || errno == EWOULDBLOCK; -} - -static void format_ip(char out[16]) { - uint32_t ip = initialized ? gethostid() : 0; - snprintf( - out, - 16, - "%lu.%lu.%lu.%lu", - (unsigned long)(ip & 0xff), - (unsigned long)((ip >> 8) & 0xff), - (unsigned long)((ip >> 16) & 0xff), - (unsigned long)((ip >> 24) & 0xff) - ); -} - -static void close_upload(void) { - if (upload_file != NULL) fclose(upload_file); - upload_file = NULL; - upload_expected = 0; - upload_received = 0; - upload_hash = 0; -} - -void devserver_screenshot_cancel(void) { - if (screenshot_top != NULL) linearFree(screenshot_top); - if (screenshot_auxiliary != NULL) linearFree(screenshot_auxiliary); - screenshot_top = NULL; - screenshot_auxiliary = NULL; - screenshot_top_bytes = 0; - screenshot_auxiliary_bytes = 0; - screenshot_ready = false; - screenshot_stage = 0; - screenshot_surface = 0; - screenshot_offset = 0; -} - -static void disconnect_client(void) { - if (client_fd >= 0) close(client_fd); - client_fd = -1; - authenticated = false; - handshake_pending = false; - handshake_accepted = false; - handshake_ack_offset = 0; - rx_length = 0; - ctrl_input_length = 0; - tx_length = 0; - tx_offset = 0; - pong_pending = false; - screenshot_requested = false; - devserver_screenshot_cancel(); - if (upload_file != NULL && !upload_ready) close_upload(); -} - -static bool set_nonblocking(int fd) { - int flags = fcntl(fd, F_GETFL, 0); - return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; -} - -static int hex_digit(int value) { - if (value >= '0' && value <= '9') return value - '0'; - if (value >= 'a' && value <= 'f') return value - 'a' + 10; - if (value >= 'A' && value <= 'F') return value - 'A' + 10; - return -1; -} - -static DevserverInitResult load_key(char *error, size_t error_length) { - FILE *file = fopen(POCKET_RUNTIME_DEV_KEY, "rb"); - if (file == NULL) { - if (errno == ENOENT) return DEVSERVER_DISABLED; - set_error(error, error_length, "open %s failed (%d)", POCKET_RUNTIME_DEV_KEY, errno); - return DEVSERVER_ERROR; - } - char hex[66] = {0}; - size_t length = fread(hex, 1, sizeof hex, file); - int close_result = fclose(file); - if (close_result != 0 || (length != 64 && length != 65) || - (length == 65 && hex[64] != '\n')) { - set_error(error, error_length, "dev.key must contain exactly 64 hexadecimal characters"); - return DEVSERVER_ERROR; - } - for (size_t index = 0; index < POCKET_RUNTIME_TOKEN_BYTES; index += 1) { - int high = hex_digit(hex[index * 2]); - int low = hex_digit(hex[index * 2 + 1]); - if (high < 0 || low < 0) { - set_error(error, error_length, "dev.key contains a non-hexadecimal character"); - return DEVSERVER_ERROR; - } - pairing_token[index] = (uint8_t)((high << 4) | low); - } - device_id = pocket_runtime_device_id(pairing_token); - return DEVSERVER_READY; -} -DevserverInitResult devserver_init( - const PocketRuntimeState *state, - char *error, - size_t error_length -) { - if (initialized) return DEVSERVER_READY; - if (state != NULL) runtime_state = *state; - DevserverInitResult key = load_key(error, error_length); - if (key != DEVSERVER_READY) return key; - - if (!soc_ensure(error, error_length)) return DEVSERVER_ERROR; - - server_fd = socket(AF_INET, SOCK_STREAM, 0); - if (server_fd < 0) { - set_error(error, error_length, "Pocket Runtime socket failed (%d)", errno); - devserver_shutdown(); - return DEVSERVER_ERROR; - } - int reuse = 1; - setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof reuse); - struct sockaddr_in address; - memset(&address, 0, sizeof address); - address.sin_family = AF_INET; - address.sin_addr.s_addr = INADDR_ANY; - address.sin_port = htons(POCKET_RUNTIME_WIRE_PORT); - if (bind(server_fd, (struct sockaddr *)&address, sizeof address) != 0 || - listen(server_fd, 1) != 0 || !set_nonblocking(server_fd)) { - set_error(error, error_length, "Pocket Runtime listen on %u failed (%d)", POCKET_RUNTIME_WIRE_PORT, errno); - devserver_shutdown(); - return DEVSERVER_ERROR; - } - - discovery_fd = socket(AF_INET, SOCK_DGRAM, 0); - if (discovery_fd >= 0) { - setsockopt(discovery_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof reuse); - if (bind(discovery_fd, (struct sockaddr *)&address, sizeof address) != 0 || - !set_nonblocking(discovery_fd)) { - close(discovery_fd); - discovery_fd = -1; - } - } - initialized = true; - return DEVSERVER_READY; -} - -void devserver_shutdown(void) { - disconnect_client(); - close_upload(); - if (server_fd >= 0) close(server_fd); - server_fd = -1; - if (discovery_fd >= 0) close(discovery_fd); - discovery_fd = -1; - /* SOC itself is shared with the svc transport; main owns soc_shutdown. */ - initialized = false; -} - -bool devserver_active(void) { - return initialized; -} - -bool devserver_connected(void) { - return authenticated && client_fd >= 0; -} - -void devserver_snapshot(DevserverSnapshot *out) { - if (out == NULL) return; - memset(out, 0, sizeof *out); - out->enabled = initialized; - out->discoverable = discovery_fd >= 0; - out->connected = devserver_connected(); - format_ip(out->ip); - snprintf(out->phase, sizeof out->phase, "%s", runtime_phase); - out->port = POCKET_RUNTIME_WIRE_PORT; - out->host_abi = POCKETJS_HOST_ABI; - out->generation = runtime_state.generation; - out->running_hash = running_hash; - out->device_id = device_id; - out->connects = connects; - out->auth_failures = auth_failures; - out->timeouts = timeouts; - out->uploads = uploads; - out->screenshots = screenshots; -} - -static bool queue_frame(uint8_t type, uint8_t flags, const uint8_t *payload, size_t length) { - if (length > POCKET_RUNTIME_MAX_FRAME_BYTES) return false; - uint8_t header[POCKET_RUNTIME_FRAME_HEADER_BYTES]; - pocket_runtime_encode_frame_header(header, type, flags, (uint32_t)length); - if (tx_offset > 0) { - if (tx_offset < tx_length) { - memmove(tx_buffer, tx_buffer + tx_offset, tx_length - tx_offset); - tx_length -= tx_offset; - } else { - tx_length = 0; - } - tx_offset = 0; - } - if (sizeof header + length > sizeof tx_buffer - tx_length) return false; - memcpy(tx_buffer + tx_length, header, sizeof header); - tx_length += sizeof header; - if (length > 0) { - memcpy(tx_buffer + tx_length, payload, length); - tx_length += length; - } - return true; -} - -static size_t json_escape(char *out, size_t capacity, const char *text) { - size_t written = 0; - if (text == NULL) return 0; - for (const unsigned char *at = (const unsigned char *)text; *at != 0; at += 1) { - const char *escape = NULL; - char unicode[7]; - if (*at == '"') escape = "\\\""; - else if (*at == '\\') escape = "\\\\"; - else if (*at == '\n') escape = "\\n"; - else if (*at == '\r') escape = "\\r"; - else if (*at == '\t') escape = "\\t"; - else if (*at < 0x20) { - snprintf(unicode, sizeof unicode, "\\u%04x", *at); - escape = unicode; - } - if (escape != NULL) { - size_t length = strlen(escape); - if (written + length >= capacity) break; - memcpy(out + written, escape, length); - written += length; - } else { - if (written + 1 >= capacity) break; - out[written++] = (char)*at; - } - } - if (capacity > 0) out[written < capacity ? written : capacity - 1] = '\0'; - return written; -} - -/* - * On the way out a control record may be as large as a frame. tx_buffer is - * sized for a whole frame and the screenshot path already pushes 48 KiB - * through it, whereas MAX_CTRL_BYTES bounds what the TOOL sends — ctrl_input, - * the inbound ring, is sized from it. Holding outgoing records to the inbound - * bound cost the devtools tree dump: past a few hundred nodes it went over - * 16 KiB and was discarded here without a word, which on the tool side is - * indistinguishable from a hung device until the 15 s timeout expires. - */ +#define QUEUE_SLOTS 4u +#define IN_BYTES (POCKET_RUNTIME_MAX_CTRL_BYTES + 2u) +#define OUT_BYTES POCKET_RUNTIME_MAX_FRAME_BYTES + +typedef struct { unsigned epoch; size_t length; char bytes[IN_BYTES]; } Input; +typedef struct { + unsigned epoch, kind; + size_t length; + uint64_t hash; + char label[32], bytes[OUT_BYTES]; +} Output; +static Input inputs[QUEUE_SLOTS]; +static Output outputs[QUEUE_SLOTS]; +static atomic_uint in_read, in_write, out_read, out_write, epoch; + +typedef struct { + uint64_t hash, variant; + uint32_t frame, commands, vertices, dropped; + char phase[32]; +} Facts; +static Facts ui_facts, shared_facts; +static atomic_bool facts_ready; +typedef struct { DevserverSnapshot info; char stats[768]; } Snapshot; +static Snapshot snapshot, shared_snapshot; +static atomic_bool snapshot_ready; + +/* The worker never dereferences a package after OFFER -> HELD. */ +enum { TX_IDLE, TX_OFFER, TX_HELD, TX_ACCEPT, TX_REJECT, TX_OK, TX_ERROR }; +static atomic_int transaction; +static PocketRuntimePackage *candidate; +static char rejection[256]; +static atomic_bool reload_requested; +static struct { uint64_t hash; char error[256]; } recoveries[QUEUE_SLOTS]; +static atomic_uint recovery_read, recovery_write; + +/* GPU allocation is UI-owned, borrowed by transport from READY until DONE. */ +enum { SHOT_IDLE, SHOT_REQUEST, SHOT_CAPTURE, SHOT_READY, SHOT_SEND, SHOT_DONE }; +static atomic_int shot; +static atomic_bool shot_requested; +static struct { uint8_t *top, *aux; uint32_t frame; uint16_t tw, th, aw, ah; } capture; +static Thread worker; +static atomic_bool stopping; +static const PocketRuntimePackage *recovery_package; + +static Output *output_slot(unsigned kind) { + unsigned w = atomic_load_explicit(&out_write, memory_order_relaxed); + if (w - atomic_load_explicit(&out_read, memory_order_acquire) == QUEUE_SLOTS) return NULL; + Output *slot = &outputs[w % QUEUE_SLOTS]; + slot->kind = kind; + slot->epoch = atomic_load_explicit(&epoch, memory_order_relaxed); + return slot; +} +static void output_publish(void) { atomic_fetch_add_explicit(&out_write, 1, memory_order_release); } void devserver_send_ctrl(const char *line, size_t length) { - if (line == NULL || length == 0) return; - if (length > POCKET_RUNTIME_MAX_FRAME_BYTES) { - /* Too large for any frame. Say so, so the caller waiting on this record - * learns why it is never coming. */ - char notice[128]; - int written = snprintf( - notice, - sizeof notice, - "{\"t\":\"ctrlDropped\",\"bytes\":%u,\"cap\":%u}", - (unsigned)length, - (unsigned)POCKET_RUNTIME_MAX_FRAME_BYTES - ); - if (written > 0) { - queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, (const uint8_t *)notice, (size_t)written); - } - return; - } - static const char hello_marker[] = "\"t\":\"hello\""; - bool is_hello = false; - if (length >= sizeof hello_marker - 1) { - for (size_t offset = 0; offset + sizeof hello_marker - 1 <= length; offset += 1) { - if (memcmp(line + offset, hello_marker, sizeof hello_marker - 1) == 0) { - is_hello = true; - break; - } - } - } - if (is_hello && length < sizeof hello_cache) { - memcpy(hello_cache, line, length); - hello_cache[length] = '\0'; - hello_cache_length = length; - } - queue_frame(POCKET_RUNTIME_MSG_CTRL, 0, (const uint8_t *)line, length); -} - -static void send_status(void) { - char message[640]; - char ip[16]; - format_ip(ip); - snprintf( - message, - sizeof message, - "{\"t\":\"runtime.status\",\"phase\":\"%s\",\"target\":\"%s\",\"hostAbi\":%u," - "\"ip\":\"%s\",\"port\":%u,\"generation\":%lu," - "\"active\":\"%016llx\",\"lastGood\":\"%016llx\",\"running\":\"%016llx\"," - "\"frame\":%lu}", - runtime_phase, - POCKETJS_TARGET_ID, - (unsigned)POCKETJS_HOST_ABI, - ip, - (unsigned)POCKET_RUNTIME_WIRE_PORT, - (unsigned long)runtime_state.generation, - (unsigned long long)runtime_state.active_hash, - (unsigned long long)runtime_state.last_good_hash, - (unsigned long long)running_hash, - (unsigned long)runtime_frame - ); - devserver_send_ctrl(message, strlen(message)); + if (!line || !length || length > OUT_BYTES) return; + Output *slot = output_slot(0); + if (!slot) return; + memcpy(slot->bytes, line, length); slot->length = length; output_publish(); } - void devserver_report_install(const char *phase, uint64_t hash, const char *message) { - char escaped[384] = {0}; - char line[640]; - json_escape(escaped, sizeof escaped, message == NULL ? "" : message); - snprintf( - line, - sizeof line, - "{\"t\":\"runtime.install\",\"phase\":\"%s\",\"hash\":\"%016llx\"," - "\"generation\":%lu,\"message\":\"%s\"}", - phase == NULL ? "unknown" : phase, - (unsigned long long)hash, - (unsigned long)runtime_state.generation, - escaped - ); - devserver_send_ctrl(line, strlen(line)); + Output *slot = output_slot(1); + if (!slot) return; + slot->hash = hash; + snprintf(slot->label, sizeof slot->label, "%s", phase); + snprintf(slot->bytes, 512, "%s", message); output_publish(); } - void devserver_report_log(const char *level, const char *message) { - char escaped[448] = {0}; - char line[560]; - json_escape(escaped, sizeof escaped, message == NULL ? "" : message); - snprintf( - line, - sizeof line, - "{\"t\":\"log\",\"level\":\"%s\",\"args\":[\"%s\"]}", - level == NULL ? "info" : level, - escaped - ); - devserver_send_ctrl(line, strlen(line)); + Output *slot = output_slot(2); + if (!slot) return; + snprintf(slot->label, sizeof slot->label, "%s", level); + snprintf(slot->bytes, 512, "%s", message); output_publish(); } - -void devserver_set_runtime( - const PocketRuntimeState *state, - const PocketRuntimePackage *package, - const char *phase, - uint32_t frame -) { - if (state != NULL) runtime_state = *state; - running_hash = package == NULL ? 0 : package->guest.package_hash; - variant_hash = package == NULL ? 0 : package->guest.variant_hash; - runtime_frame = frame; - snprintf(runtime_phase, sizeof runtime_phase, "%s", phase == NULL ? "unknown" : phase); +size_t devserver_recv_ctrl(char *out, size_t capacity) { + unsigned r = atomic_load_explicit(&in_read, memory_order_relaxed); + unsigned w = atomic_load_explicit(&in_write, memory_order_acquire); + unsigned current = atomic_load_explicit(&epoch, memory_order_relaxed); + for (; r != w; r++) { + Input *slot = &inputs[r % QUEUE_SLOTS]; + if (slot->epoch == current && capacity > slot->length) { + memcpy(out, slot->bytes, slot->length + 1); + size_t n = slot->length; + atomic_store_explicit(&in_read, r + 1, memory_order_release); return n; + } + atomic_store_explicit(&in_read, r + 1, memory_order_release); + } + return 0; } - -void devserver_set_frame_stats( - uint32_t frame, - uint32_t commands, - uint32_t vertices, - uint32_t dropped_vertices -) { - runtime_frame = frame; - frame_commands = commands; - frame_vertices = vertices; - frame_dropped_vertices = dropped_vertices; +void devserver_reset_guest(void) { atomic_fetch_add_explicit(&epoch, 1, memory_order_release); } +void devserver_set_frame_stats(uint32_t frame, uint32_t commands, uint32_t vertices, uint32_t dropped) { + ui_facts.frame = frame; ui_facts.commands = commands; + ui_facts.vertices = vertices; ui_facts.dropped = dropped; } - -const char *devserver_debug_stats(void) { - snprintf( - stats_json, - sizeof stats_json, - "{\"target\":\"%s\",\"hostAbi\":%u,\"package\":\"%016llx\"," - "\"variant\":\"%016llx\",\"generation\":%lu,\"frame\":%lu," - "\"gfx\":{\"commands\":%lu,\"vertices\":%lu,\"droppedVertices\":%lu}," - "\"net\":{\"connected\":%s,\"rxBytes\":%llu,\"txBytes\":%llu," - "\"connects\":%lu,\"authFailures\":%lu,\"timeouts\":%lu," - "\"discoveries\":%lu,\"uploads\":%lu,\"screenshots\":%lu}}", - POCKETJS_TARGET_ID, - (unsigned)POCKETJS_HOST_ABI, - (unsigned long long)running_hash, - (unsigned long long)variant_hash, - (unsigned long)runtime_state.generation, - (unsigned long)runtime_frame, - (unsigned long)frame_commands, - (unsigned long)frame_vertices, - (unsigned long)frame_dropped_vertices, - devserver_connected() ? "true" : "false", - (unsigned long long)rx_bytes, - (unsigned long long)tx_bytes, - (unsigned long)connects, - (unsigned long)auth_failures, - (unsigned long)timeouts, - (unsigned long)discoveries, - (unsigned long)uploads, - (unsigned long)screenshots - ); - return stats_json; +void devserver_set_runtime(const PocketRuntimeState *state, const PocketRuntimePackage *package, + const char *phase, uint32_t frame) { + (void)state; /* The committed state belongs exclusively to the worker. */ + ui_facts.hash = package ? package->guest.package_hash : 0; + ui_facts.variant = package ? package->guest.variant_hash : 0; + ui_facts.frame = frame; + snprintf(ui_facts.phase, sizeof ui_facts.phase, "%s", phase); } - -static void accept_client(void) { - if (client_fd >= 0 || server_fd < 0) return; - int fd = accept(server_fd, NULL, NULL); - if (fd < 0) return; - if (!set_nonblocking(fd)) { - close(fd); - return; +void devserver_poll(void) { + if (!atomic_load_explicit(&facts_ready, memory_order_acquire)) { + shared_facts = ui_facts; atomic_store_explicit(&facts_ready, true, memory_order_release); } - client_fd = fd; - authenticated = false; - handshake_pending = false; - handshake_accepted = false; - handshake_ack_offset = 0; - rx_length = 0; - ctrl_input_length = 0; - tx_length = 0; - tx_offset = 0; - pong_pending = false; - client_last_rx_ms = osGetTime(); -} - -static void poll_discovery(void) { - if (discovery_fd < 0) return; - for (uint32_t attempt = 0; attempt < 4; attempt += 1) { - uint8_t request[POCKET_RUNTIME_DISCOVERY_REQUEST_BYTES]; - struct sockaddr_in sender; - socklen_t sender_length = sizeof sender; - ssize_t length = recvfrom( - discovery_fd, - request, - sizeof request, - 0, - (struct sockaddr *)&sender, - &sender_length - ); - if (length < 0 && would_block()) return; - if (length <= 0) return; - if (!pocket_runtime_is_discovery_request(request, (size_t)length)) continue; - - uint8_t reply[POCKET_RUNTIME_DISCOVERY_REPLY_BYTES]; - pocket_runtime_encode_discovery_reply( - reply, - POCKETJS_HOST_ABI, - POCKET_RUNTIME_WIRE_PORT, - devserver_connected() ? 1u : 0u, - runtime_state.generation, - runtime_state.active_hash, - device_id, - POCKETJS_TARGET_ID, - "PocketJS 3DS" - ); - if (sendto( - discovery_fd, - reply, - sizeof reply, - 0, - (struct sockaddr *)&sender, - sender_length - ) == (ssize_t)sizeof reply) { - discoveries += 1; - } + if (atomic_load_explicit(&snapshot_ready, memory_order_acquire)) { + snapshot = shared_snapshot; atomic_store_explicit(&snapshot_ready, false, memory_order_release); + } + if (atomic_load_explicit(&shot, memory_order_acquire) == SHOT_DONE) { + if (capture.top) linearFree(capture.top); + if (capture.aux) linearFree(capture.aux); + memset(&capture, 0, sizeof capture); + atomic_store_explicit(&shot, SHOT_IDLE, memory_order_release); } } +/* This is channel availability, not connection state: the JS shim attaches + * during boot, before the asynchronous key/network initialization finishes. */ +bool devserver_active(void) { return worker != NULL; } +bool devserver_connected(void) { return snapshot.info.connected; } +void devserver_snapshot(DevserverSnapshot *out) { if (out) *out = snapshot.info; } +const char *devserver_debug_stats(void) { return snapshot.stats[0] ? snapshot.stats : "{}"; } -static bool append_ctrl_input(const uint8_t *bytes, size_t length) { - if (length == 0 || length + 1 > sizeof ctrl_input - ctrl_input_length) return false; - memcpy(ctrl_input + ctrl_input_length, bytes, length); - ctrl_input_length += length; - ctrl_input[ctrl_input_length++] = '\n'; - return true; +bool devserver_take_candidate(PocketRuntimePackage **out) { + if (atomic_load_explicit(&transaction, memory_order_acquire) != TX_OFFER) return false; + *out = candidate; + atomic_store_explicit(&transaction, TX_HELD, memory_order_release); return true; } - -static void abort_upload(const char *message) { - uint64_t rejected = upload_hash; - close_upload(); - upload_ready = false; - devserver_report_install("transfer-error", rejected, message); +void devserver_finish_candidate(bool accepted, const char *error) { + if (atomic_load_explicit(&transaction, memory_order_acquire) != TX_HELD) return; + snprintf(rejection, sizeof rejection, "%s", error ? error : "guest rejected"); + atomic_store_explicit(&transaction, accepted ? TX_ACCEPT : TX_REJECT, memory_order_release); } - -static void handle_package_begin(const uint8_t *payload, size_t length) { - PocketRuntimePackageBegin begin; - if (!pocket_runtime_parse_package_begin(payload, length, &begin)) { - abort_upload("invalid package begin frame"); - return; - } - close_upload(); - upload_ready = false; - upload_file = fopen(POCKET_RUNTIME_UPLOAD, "wb"); - if (upload_file == NULL) { - devserver_report_install("transfer-error", begin.footer_hash, "open network staging file failed"); - return; - } - upload_expected = begin.length; - upload_received = 0; - upload_hash = begin.footer_hash; - devserver_report_install("receiving", upload_hash, "binary package transfer started"); +bool devserver_take_outcome(bool *committed) { + int phase = atomic_load_explicit(&transaction, memory_order_acquire); + if (phase != TX_OK && phase != TX_ERROR) return false; + *committed = phase == TX_OK; + atomic_store_explicit(&transaction, TX_IDLE, memory_order_release); return true; } - -static void handle_package_chunk(const uint8_t *payload, size_t length) { - if (upload_file == NULL || length <= 4) { - abort_upload("package chunk arrived without an active transfer"); - return; - } - uint32_t offset = pocket_runtime_read_u32(payload); - size_t bytes = length - 4; - if (upload_received > upload_expected || offset != upload_received || - bytes > upload_expected - upload_received || - fwrite(payload + 4, 1, bytes, upload_file) != bytes) { - abort_upload("package chunk offset, length, or SD write failed"); - return; - } - upload_received += (uint32_t)bytes; +bool devserver_reload(void) { + return !atomic_exchange_explicit(&reload_requested, true, memory_order_acq_rel); } - -static void handle_package_commit(void) { - if (upload_file == NULL || upload_received != upload_expected) { - abort_upload("package commit arrived before every declared byte"); - return; - } - bool written = fflush(upload_file) == 0 && fsync(fileno(upload_file)) == 0; - if (fclose(upload_file) != 0) written = false; - upload_file = NULL; - if (!written) { - abort_upload("flush network package staging file failed"); - return; - } - upload_ready = true; - uploads += 1; - devserver_report_install("received", upload_hash, "binary package transfer complete"); +bool devserver_recover(uint64_t hash, const char *error) { + unsigned w = atomic_load_explicit(&recovery_write, memory_order_relaxed); + if (w - atomic_load_explicit(&recovery_read, memory_order_acquire) == QUEUE_SLOTS) return false; + recoveries[w % QUEUE_SLOTS].hash = hash; + snprintf(recoveries[w % QUEUE_SLOTS].error, sizeof recoveries[0].error, "%s", error); + atomic_store_explicit(&recovery_write, w + 1, memory_order_release); return true; } -static void handle_frame(uint8_t type, uint8_t flags, const uint8_t *payload, size_t length) { - if (flags != 0) { - disconnect_client(); - return; - } - switch (type) { - case POCKET_RUNTIME_MSG_PING: - if (length == sizeof pong_payload) { - memcpy(pong_payload, payload, sizeof pong_payload); - pong_pending = true; - } - break; - case POCKET_RUNTIME_MSG_CTRL: - if (length <= POCKET_RUNTIME_MAX_CTRL_BYTES && - memchr(payload, '\n', length) == NULL && - memchr(payload, '\r', length) == NULL && - append_ctrl_input(payload, length)) break; - disconnect_client(); - break; - case POCKET_RUNTIME_MSG_PACKAGE_BEGIN: - handle_package_begin(payload, length); - break; - case POCKET_RUNTIME_MSG_PACKAGE_CHUNK: - handle_package_chunk(payload, length); - break; - case POCKET_RUNTIME_MSG_PACKAGE_COMMIT: - if (length == 0) handle_package_commit(); - else abort_upload("package commit payload must be empty"); - break; - case POCKET_RUNTIME_MSG_PACKAGE_ABORT: - abort_upload("host aborted package transfer"); - break; - case POCKET_RUNTIME_MSG_STATUS_REQUEST: - if (length == 0) send_status(); - break; - default: - /* Unknown length-framed messages are skipped for forward compatibility. */ - break; - } +bool devserver_request_screenshot(void) { + if (!devserver_connected() || atomic_load_explicit(&shot, memory_order_acquire) != SHOT_IDLE) return false; + return !atomic_exchange_explicit(&shot_requested, true, memory_order_acq_rel); } - -static void receive_client(void) { - if (client_fd < 0) return; - if (handshake_pending) return; - while (rx_length < sizeof rx_buffer) { - ssize_t read = recv(client_fd, rx_buffer + rx_length, sizeof rx_buffer - rx_length, 0); - if (read > 0) { - rx_length += (size_t)read; - rx_bytes += (uint64_t)read; - client_last_rx_ms = osGetTime(); - continue; +bool devserver_take_screenshot_request(void) { + int expected = SHOT_REQUEST; + return atomic_compare_exchange_strong_explicit(&shot, &expected, SHOT_CAPTURE, memory_order_acq_rel, memory_order_relaxed); +} +bool devserver_screenshot_begin(uint32_t frame, uint16_t tw, uint16_t th, + uint16_t aw, uint16_t ah, uint8_t **top, uint8_t **aux) { + if (atomic_load_explicit(&shot, memory_order_acquire) != SHOT_CAPTURE) return false; + if (!top || !aux || tw != 400 || th != 240 || aw != 320 || ah != 240) { + devserver_screenshot_cancel(); return false; + } + capture.top = linearAlloc((size_t)tw * th * 3); + capture.aux = linearAlloc((size_t)aw * ah * 3); + if (!capture.top || !capture.aux) { devserver_screenshot_cancel(); return false; } + capture.frame = frame; capture.tw = tw; capture.th = th; capture.aw = aw; capture.ah = ah; + *top = capture.top; *aux = capture.aux; return true; +} +void devserver_screenshot_ready(void) { atomic_store_explicit(&shot, SHOT_READY, memory_order_release); } +void devserver_screenshot_cancel(void) { + /* In-flight buffers are returned by the transport, never freed underneath it. */ + int expected = SHOT_CAPTURE; + atomic_compare_exchange_strong_explicit(&shot, &expected, SHOT_DONE, memory_order_acq_rel, memory_order_relaxed); +} + +static void pump_screenshot(void) { + if (atomic_exchange_explicit(&shot_requested, false, memory_order_acq_rel)) devtransport_request_screenshot(); + if (devtransport_take_screenshot_request()) { + int expected = SHOT_IDLE; + atomic_compare_exchange_strong_explicit(&shot, &expected, SHOT_REQUEST, memory_order_acq_rel, memory_order_relaxed); + } + int phase = atomic_load_explicit(&shot, memory_order_acquire); + if (phase == SHOT_READY) { + if (devtransport_connected()) { + devtransport_adopt_screenshot(capture.frame, capture.tw, capture.th, capture.aw, capture.ah, capture.top, capture.aux); + atomic_store_explicit(&shot, SHOT_SEND, memory_order_release); + } else atomic_store_explicit(&shot, SHOT_DONE, memory_order_release); + } else if (phase == SHOT_SEND && !devtransport_screenshot_busy()) { + atomic_store_explicit(&shot, SHOT_DONE, memory_order_release); + } else if (phase == SHOT_REQUEST && !devtransport_connected()) { + int expected = SHOT_REQUEST; + atomic_compare_exchange_strong_explicit(&shot, &expected, SHOT_IDLE, memory_order_acq_rel, memory_order_relaxed); + } +} + +static bool compatible(PocketRuntimePackage *package, char *error, size_t length) { + if (!package) return false; + if (pocket_package_same_app(recovery_package->bytes, recovery_package->length, package->bytes, package->length)) return true; + snprintf(error, length, "app identity or native plan differs; install a new .3dsx"); return false; +} +static void offer(PocketRuntimePackage *package, PocketRuntimePackage *metadata) { + const PocketRuntimePackage *source = package ? package : recovery_package; + metadata->guest.package_hash = source->guest.package_hash; + metadata->guest.variant_hash = source->guest.variant_hash; + snprintf(metadata->origin, sizeof metadata->origin, "%s", source->origin); + candidate = package; + atomic_store_explicit(&transaction, TX_OFFER, memory_order_release); +} +static void serve(void *unused) { + (void)unused; + PocketRuntimeState state = {0}; + PocketRuntimeFailureLineage failures = {0}; + char error[256] = {0}; + bool storage = runtime_storage_init(&state, error, sizeof error); + bool startup = storage, recovering = false, retry_recovery = false; + uint64_t next_active = 0, next_good = 0, report_hash = 0; + uint64_t retry_at = 0, storage_retry_at = osGetTime() + 3000; + unsigned guest_epoch = 0; + Facts facts = { .hash = recovery_package->guest.package_hash, .variant = recovery_package->guest.variant_hash }; + snprintf(facts.phase, sizeof facts.phase, "booted"); + PocketRuntimePackage running = {0}, admitted = {0}; + while (!atomic_load_explicit(&stopping, memory_order_acquire)) { + if (!storage && osGetTime() >= storage_retry_at) { + storage = runtime_storage_init(&state, error, sizeof error); + startup = storage; storage_retry_at = osGetTime() + 3000; } - if (read == 0) { - disconnect_client(); - return; + if (!devtransport_active() && osGetTime() >= retry_at) { + devtransport_init(&state, error, sizeof error); retry_at = osGetTime() + 3000; } - if (would_block()) break; - disconnect_client(); - return; - } - - if (!authenticated) { - if (rx_length < POCKET_RUNTIME_HELLO_BYTES) return; - bool accepted = pocket_runtime_verify_hello( - rx_buffer, - POCKET_RUNTIME_HELLO_BYTES, - pairing_token - ); - pocket_runtime_encode_ack( - handshake_ack, - accepted ? 0 : 2, - POCKETJS_HOST_ABI, - runtime_state.generation, - initialized ? 1u : 0u, - runtime_state.active_hash - ); - memmove(rx_buffer, rx_buffer + POCKET_RUNTIME_HELLO_BYTES, rx_length - POCKET_RUNTIME_HELLO_BYTES); - rx_length -= POCKET_RUNTIME_HELLO_BYTES; - handshake_pending = true; - handshake_accepted = accepted; - handshake_ack_offset = 0; - if (!accepted) auth_failures += 1; - return; - } - - while (authenticated && rx_length >= POCKET_RUNTIME_FRAME_HEADER_BYTES) { - PocketRuntimeFrameHeader header; - if (!pocket_runtime_parse_frame_header(rx_buffer, rx_length, &header)) { - disconnect_client(); - return; + unsigned current = atomic_load_explicit(&epoch, memory_order_acquire); + if (current != guest_epoch) { guest_epoch = current; devtransport_reset_guest(); } + if (atomic_load_explicit(&facts_ready, memory_order_acquire)) { + facts = shared_facts; atomic_store_explicit(&facts_ready, false, memory_order_release); } - size_t total = POCKET_RUNTIME_FRAME_HEADER_BYTES + (size_t)header.length; - if (rx_length < total) break; - handle_frame( - header.type, - header.flags, - rx_buffer + POCKET_RUNTIME_FRAME_HEADER_BYTES, - header.length - ); - if (client_fd < 0) return; - memmove(rx_buffer, rx_buffer + total, rx_length - total); - rx_length -= total; - } - if (rx_length == sizeof rx_buffer) disconnect_client(); -} - -static void queue_screenshot_frame(void) { - if (!screenshot_ready || tx_length != tx_offset) return; - if (screenshot_stage == 0) { - uint8_t begin[POCKET_RUNTIME_SCREENSHOT_BEGIN_BYTES]; - pocket_runtime_encode_screenshot_begin( - begin, - screenshot_frame, - screenshot_top_width, - screenshot_top_height, - screenshot_auxiliary_width, - screenshot_auxiliary_height, - screenshot_top_bytes, - screenshot_auxiliary_bytes - ); - if (queue_frame(POCKET_RUNTIME_MSG_SCREENSHOT_BEGIN, 0, begin, sizeof begin)) { - screenshot_stage = 1; - } - return; - } - if (screenshot_stage == 1) { - uint8_t *surface = screenshot_surface == 0 ? screenshot_top : screenshot_auxiliary; - uint32_t bytes = screenshot_surface == 0 ? screenshot_top_bytes : screenshot_auxiliary_bytes; - if (screenshot_offset < bytes) { - uint32_t amount = bytes - screenshot_offset; - if (amount > SCREENSHOT_CHUNK_BYTES) amount = SCREENSHOT_CHUNK_BYTES; - uint8_t payload[4 + SCREENSHOT_CHUNK_BYTES]; - pocket_runtime_write_u32(payload, screenshot_offset); - memcpy(payload + 4, surface + screenshot_offset, amount); - if (queue_frame( - POCKET_RUNTIME_MSG_SCREENSHOT_CHUNK, - screenshot_surface, - payload, - 4 + amount - )) { - screenshot_offset += amount; + running.guest.package_hash = facts.hash; running.guest.variant_hash = facts.variant; + devtransport_set_runtime(&state, &running, facts.phase, facts.frame); + devtransport_set_frame_stats(facts.frame, facts.commands, facts.vertices, facts.dropped); + int phase = atomic_load_explicit(&transaction, memory_order_acquire); + devtransport_set_upload_busy(!storage || (phase != TX_IDLE && phase != TX_OK && phase != TX_ERROR)); + devtransport_poll(); + pump_screenshot(); + + /* Each pass copies at most four records in either direction. */ + unsigned r = atomic_load_explicit(&out_read, memory_order_relaxed); + unsigned w = atomic_load_explicit(&out_write, memory_order_acquire); + for (; r != w; r++) { + Output *slot = &outputs[r % QUEUE_SLOTS]; + if (slot->epoch == guest_epoch) { + if (!devtransport_ctrl_available(slot->kind == 0 ? slot->length : 640)) break; + if (slot->kind == 0) devtransport_send_ctrl(slot->bytes, slot->length); + else if (slot->kind == 1) devtransport_report_install(slot->label, slot->hash, slot->bytes); + else devtransport_report_log(slot->label, slot->bytes); } - return; + atomic_store_explicit(&out_read, r + 1, memory_order_release); } - if (screenshot_surface == 0) { - screenshot_surface = 1; - screenshot_offset = 0; - return; + w = atomic_load_explicit(&in_write, memory_order_relaxed); + r = atomic_load_explicit(&in_read, memory_order_acquire); + for (unsigned count = 0; w - r < QUEUE_SLOTS && count < QUEUE_SLOTS; count++, w++) { + Input *slot = &inputs[w % QUEUE_SLOTS]; + slot->length = devtransport_recv_ctrl(slot->bytes, sizeof slot->bytes); + if (!slot->length) break; + slot->epoch = guest_epoch; + atomic_store_explicit(&in_write, w + 1, memory_order_release); } - screenshot_stage = 2; - } - if (screenshot_stage == 2) { - uint8_t end[4]; - pocket_runtime_write_u32(end, screenshot_frame); - if (queue_frame(POCKET_RUNTIME_MSG_SCREENSHOT_END, 0, end, sizeof end)) { - screenshot_stage = 3; - } - return; - } - if (screenshot_stage == 3) { - screenshots += 1; - devserver_screenshot_cancel(); - } -} -static void send_client(void) { - if (client_fd < 0) return; - if (handshake_pending) { - ssize_t sent = send( - client_fd, - handshake_ack + handshake_ack_offset, - sizeof handshake_ack - handshake_ack_offset, - 0 - ); - if (sent > 0) { - handshake_ack_offset += (size_t)sent; - tx_bytes += (uint64_t)sent; - if (handshake_ack_offset == sizeof handshake_ack) { - bool accepted = handshake_accepted; - handshake_pending = false; - handshake_ack_offset = 0; - if (!accepted) { - disconnect_client(); - return; - } - authenticated = true; - connects += 1; - if (hello_cache_length > 0) { - queue_frame( - POCKET_RUNTIME_MSG_CTRL, - 0, - (const uint8_t *)hello_cache, - hello_cache_length - ); + if (phase == TX_ACCEPT || phase == TX_REJECT) { + bool accepted = phase == TX_ACCEPT; + if (accepted && (state.active_hash != next_active || state.last_good_hash != next_good)) + accepted = runtime_commit(&state, next_active, next_good, error, sizeof error); + if (accepted) { + runtime_failure_lineage_reset(&failures); retry_recovery = false; + runtime_write_status(&state, &admitted, "accepted"); + devtransport_set_runtime(&state, &admitted, "accepted", facts.frame); + devtransport_report_install("accepted", report_hash, "first GPU frame retired; generation committed"); + } else { + const char *message = phase == TX_REJECT ? rejection : error; + runtime_write_error("update", message); + devtransport_report_install("rejected", report_hash, message); + if (retry_recovery && next_active != 0) { runtime_failure_lineage_add(&failures, next_active); recovering = true; } + } + atomic_store_explicit(&transaction, accepted ? TX_OK : TX_ERROR, memory_order_release); + } else if (phase == TX_IDLE && storage) { + bool reload = atomic_exchange_explicit(&reload_requested, false, memory_order_acq_rel); + unsigned rr = atomic_load_explicit(&recovery_read, memory_order_relaxed); + unsigned rw = atomic_load_explicit(&recovery_write, memory_order_acquire); + for (; rr != rw; rr++) { + runtime_failure_lineage_add(&failures, recoveries[rr % QUEUE_SLOTS].hash); + runtime_write_error("guest", recoveries[rr % QUEUE_SLOTS].error); recovering = true; + atomic_store_explicit(&recovery_read, rr + 1, memory_order_release); + } + PocketRuntimePackage *package = NULL; + uint64_t upload = 0; + RuntimePendingResult result = RUNTIME_PENDING_NONE; + bool boot = startup; + if (!recovering && devtransport_take_upload(&upload)) { + result = runtime_prepare_file(POCKET_RUNTIME_UPLOAD, upload, &package, error, sizeof error); + } else if (!recovering && (startup || reload)) { + result = runtime_prepare_pending(&package, error, sizeof error); + } + startup = false; + if (result == RUNTIME_PENDING_READY && !compatible(package, error, sizeof error)) { + report_hash = package->guest.package_hash; runtime_package_free(package); package = NULL; + result = RUNTIME_PENDING_ERROR; + } + if (result == RUNTIME_PENDING_ERROR) { + runtime_write_error("admission", error); + devtransport_report_install("rejected", upload, error); + } + if (package) { + next_active = package->guest.package_hash; + next_good = next_active == state.active_hash ? state.last_good_hash : state.active_hash; + report_hash = next_active; retry_recovery = boot; + offer(package, &admitted); + } else if (recovering || (boot && state.active_hash != 0)) { + recovering = false; retry_recovery = true; + for (;;) { + next_active = runtime_recovery_hash(&state, &failures); + if (!next_active) break; + package = runtime_package_load_hash(next_active, error, sizeof error); + if (compatible(package, error, sizeof error)) break; + runtime_package_free(package); package = NULL; + runtime_write_error("recovery", error); + if (!runtime_failure_lineage_add(&failures, next_active)) { next_active = 0; break; } } - send_status(); + next_good = next_active == state.active_hash ? state.last_good_hash : 0; + report_hash = package ? package->guest.package_hash : recovery_package->guest.package_hash; + offer(package, &admitted); } - return; } - if (sent < 0 && would_block()) return; - disconnect_client(); - return; - } - if (!devserver_connected()) return; - if (pong_pending && tx_length == tx_offset && - queue_frame(POCKET_RUNTIME_MSG_PONG, 0, pong_payload, sizeof pong_payload)) { - pong_pending = false; - } - queue_screenshot_frame(); - if (tx_offset >= tx_length) return; - ssize_t sent = send(client_fd, tx_buffer + tx_offset, tx_length - tx_offset, 0); - if (sent > 0) { - tx_offset += (size_t)sent; - tx_bytes += (uint64_t)sent; - if (tx_offset == tx_length) { - tx_offset = 0; - tx_length = 0; + if (!atomic_load_explicit(&snapshot_ready, memory_order_acquire)) { + devtransport_snapshot(&shared_snapshot.info); + snprintf(shared_snapshot.stats, sizeof shared_snapshot.stats, "%s", devtransport_debug_stats()); + atomic_store_explicit(&snapshot_ready, true, memory_order_release); } - return; - } - if (sent < 0 && would_block()) return; - disconnect_client(); -} - -void devserver_poll(void) { - if (!initialized) return; - poll_discovery(); - accept_client(); - receive_client(); - if (client_fd >= 0 && osGetTime() - client_last_rx_ms > 15u * 1000u) { - timeouts += 1; - disconnect_client(); - return; + svcSleepThread(4 * 1000 * 1000); } - send_client(); -} - -size_t devserver_recv_ctrl(char *out, size_t capacity) { - if (out == NULL || capacity <= 1 || ctrl_input_length == 0) return 0; - size_t length = ctrl_input_length < capacity - 1 ? ctrl_input_length : capacity - 1; - /* Every accepted control frame has a synthetic newline. Never hand QuickJS - * a partial JSON record when several queued frames approach its poll cap. */ - while (length > 0 && ctrl_input[length - 1] != '\n') length -= 1; - if (length == 0) return 0; - memcpy(out, ctrl_input, length); - out[length] = '\0'; - memmove(ctrl_input, ctrl_input + length, ctrl_input_length - length); - ctrl_input_length -= length; - return length; -} - -bool devserver_take_upload(uint64_t *declared_hash) { - if (!upload_ready) return false; - upload_ready = false; - if (declared_hash != NULL) *declared_hash = upload_hash; - upload_expected = 0; - upload_received = 0; - upload_hash = 0; - return true; -} - -bool devserver_request_screenshot(void) { - if (!devserver_connected() || screenshot_requested || screenshot_ready) return false; - screenshot_requested = true; - return true; + devtransport_shutdown(); + /* An offered buffer still belongs to the worker; all taken ones to UI. */ + if (atomic_load_explicit(&transaction, memory_order_acquire) == TX_OFFER) runtime_package_free(candidate); } - -bool devserver_take_screenshot_request(void) { - if (!screenshot_requested) return false; - screenshot_requested = false; - return true; +bool devserver_start(const PocketRuntimePackage *embedded) { + if (worker) return true; + recovery_package = embedded; + atomic_store(&stopping, false); + worker = threadCreate(serve, NULL, 32 * 1024, 0x3f, -2, false); + return worker != NULL; } - -bool devserver_screenshot_begin( - uint32_t frame, - uint16_t top_width, - uint16_t top_height, - uint16_t auxiliary_width, - uint16_t auxiliary_height, - uint8_t **top, - uint8_t **auxiliary -) { - if (top == NULL || auxiliary == NULL || screenshot_ready || screenshot_top != NULL) return false; - uint32_t top_bytes = (uint32_t)top_width * top_height * 3u; - uint32_t auxiliary_bytes = (uint32_t)auxiliary_width * auxiliary_height * 3u; - screenshot_top = linearAlloc(top_bytes); - screenshot_auxiliary = linearAlloc(auxiliary_bytes); - if (screenshot_top == NULL || screenshot_auxiliary == NULL) { - devserver_screenshot_cancel(); - return false; - } - screenshot_frame = frame; - screenshot_top_width = top_width; - screenshot_top_height = top_height; - screenshot_auxiliary_width = auxiliary_width; - screenshot_auxiliary_height = auxiliary_height; - screenshot_top_bytes = top_bytes; - screenshot_auxiliary_bytes = auxiliary_bytes; - *top = screenshot_top; - *auxiliary = screenshot_auxiliary; - return true; -} - -void devserver_screenshot_ready(void) { - if (screenshot_top == NULL || screenshot_auxiliary == NULL) return; - screenshot_ready = true; - screenshot_stage = 0; - screenshot_surface = 0; - screenshot_offset = 0; +void devserver_shutdown(void) { + if (!worker) return; + atomic_store_explicit(&stopping, true, memory_order_release); + threadJoin(worker, U64_MAX); threadFree(worker); worker = NULL; + if (capture.top) linearFree(capture.top); + if (capture.aux) linearFree(capture.aux); + memset(&capture, 0, sizeof capture); } diff --git a/hosts/3ds/src/devserver.h b/hosts/3ds/src/devserver.h index 9c312a13b..1e0b7709f 100644 --- a/hosts/3ds/src/devserver.h +++ b/hosts/3ds/src/devserver.h @@ -1,95 +1,51 @@ #ifndef POCKETJS_3DS_DEVSERVER_H #define POCKETJS_3DS_DEVSERVER_H - #include #include #include - #include "runtime.h" -typedef enum { - DEVSERVER_DISABLED = 0, - DEVSERVER_READY = 1, - DEVSERVER_ERROR = -1, -} DevserverInitResult; - +typedef enum { DEVSERVER_DISABLED = 0, DEVSERVER_READY = 1, DEVSERVER_ERROR = -1 } DevserverInitResult; typedef struct { - bool enabled; - bool discoverable; - bool connected; - char ip[16]; - char phase[32]; - uint16_t port; - uint16_t host_abi; + bool enabled, discoverable, connected; + char ip[16], phase[32]; + uint16_t port, host_abi; uint32_t generation; - uint64_t running_hash; - uint64_t device_id; - uint32_t connects; - uint32_t auth_failures; - uint32_t timeouts; - uint32_t uploads; - uint32_t screenshots; + uint64_t running_hash, device_id; + uint32_t connects, auth_failures, timeouts, uploads, screenshots; } DevserverSnapshot; -/* Starts the paired LAN listener. A missing dev.key is an intentional - * DISABLED state; malformed key or socket initialization is ERROR. */ -DevserverInitResult devserver_init( - const PocketRuntimeState *state, - char *error, - size_t error_length -); +/* The worker owns sockets, pairing, SD and package verification. The embedded + * buffer is immutable and must remain alive until shutdown joins the worker. */ +bool devserver_start(const PocketRuntimePackage *embedded); void devserver_shutdown(void); - -/* Non-blocking bounded network pump. Call once per application frame. */ void devserver_poll(void); bool devserver_active(void); bool devserver_connected(void); void devserver_snapshot(DevserverSnapshot *out); -/* Pocket DevTools JSON-line transport exposed through ui.__dbg*. */ +/* One candidate at a time. A true return with NULL selects embedded recovery. + * Buffer ownership moves to main; the worker retains only hashes afterwards. */ +bool devserver_take_candidate(PocketRuntimePackage **out); +void devserver_finish_candidate(bool accepted, const char *error); +bool devserver_take_outcome(bool *committed); +bool devserver_reload(void); +bool devserver_recover(uint64_t failed_hash, const char *error); +void devserver_reset_guest(void); + +/* Bounded, generation-fenced control mailboxes. No filesystem/socket calls. */ size_t devserver_recv_ctrl(char *out, size_t capacity); void devserver_send_ctrl(const char *line, size_t length); -bool devserver_request_screenshot(void); const char *devserver_debug_stats(void); - -/* Completed binary upload. The caller admits POCKET_RUNTIME_UPLOAD, then - * reports staged/rejected/accepted at the same lifecycle boundaries used by - * FTP packages. */ -bool devserver_take_upload(uint64_t *declared_hash); -void devserver_report_install( - const char *phase, - uint64_t hash, - const char *message -); +void devserver_set_frame_stats(uint32_t frame, uint32_t commands, uint32_t vertices, uint32_t dropped); +void devserver_set_runtime(const PocketRuntimeState *state, const PocketRuntimePackage *package, const char *phase, uint32_t frame); +void devserver_report_install(const char *phase, uint64_t hash, const char *message); void devserver_report_log(const char *level, const char *message); -/* Current runtime facts are cached for connect/status/debugStats receipts. */ -void devserver_set_runtime( - const PocketRuntimeState *state, - const PocketRuntimePackage *package, - const char *phase, - uint32_t frame -); -void devserver_set_frame_stats( - uint32_t frame, - uint32_t commands, - uint32_t vertices, - uint32_t dropped_vertices -); - -/* On-demand two-surface screenshot. Main owns the GPU-idle transfer into the - * returned linear buffers; the server owns and frees them after binary send. */ +/* One borrowed screenshot slot; GPU work and linear storage remain UI-owned. */ +bool devserver_request_screenshot(void); bool devserver_take_screenshot_request(void); -bool devserver_screenshot_begin( - uint32_t frame, - uint16_t top_width, - uint16_t top_height, - uint16_t auxiliary_width, - uint16_t auxiliary_height, - uint8_t **top, - uint8_t **auxiliary -); +bool devserver_screenshot_begin(uint32_t frame, uint16_t tw, uint16_t th, uint16_t aw, uint16_t ah, uint8_t **top, uint8_t **aux); void devserver_screenshot_ready(void); void devserver_screenshot_cancel(void); - #endif diff --git a/hosts/3ds/src/main.c b/hosts/3ds/src/main.c index 681b59262..2dad6acd8 100644 --- a/hosts/3ds/src/main.c +++ b/hosts/3ds/src/main.c @@ -372,12 +372,12 @@ static void fail(const char *message) { typedef struct { PocketRuntimePackage *package; - /* 0 names the ROMFS recovery package; stored packages use their footer hash. */ - uint64_t state_hash; - bool commit_on_accept; - uint64_t next_active_hash; - uint64_t next_last_good_hash; + PocketRuntimePackage *previous; uint32_t submitted_frames; + /* 1 awaits a retired GPU frame; 2 awaits the worker's durable commit. */ + unsigned pending; + bool failed; + uint64_t rejected_hash; } GuestChoice; static bool boot_guest( @@ -389,6 +389,8 @@ static bool boot_guest( snprintf(error, error_length, "guest package has no JavaScript"); return false; } + /* Failed eval also gets a distinct control generation before recovery. */ + devserver_reset_guest(); ui_init(POCKETJS_RASTER_DENSITY); ui_set_viewport((float)VIEW_W, (float)VIEW_H); if (ui_create_auxiliary_surface((float)AUX_VIEW_W, (float)AUX_VIEW_H) == 0) { @@ -421,6 +423,7 @@ static void teardown_guest(void) { ui_shutdown(); /* The svc reset contract: the next guest never sees this guest's lines. */ svcwire_reset(); + devserver_reset_guest(); } static void release_choice(GuestChoice *choice, PocketRuntimePackage *embedded) { @@ -430,146 +433,6 @@ static void release_choice(GuestChoice *choice, PocketRuntimePackage *embedded) memset(choice, 0, sizeof *choice); } -static GuestChoice package_choice( - PocketRuntimePackage *package, - uint64_t state_hash, - const PocketRuntimeState *state -) { - GuestChoice choice = { - .package = package, - .state_hash = state_hash, - .commit_on_accept = state_hash != state->active_hash, - .next_active_hash = state_hash, - .next_last_good_hash = state_hash == state->active_hash ? state->last_good_hash : state->active_hash, - .submitted_frames = 0, - }; - return choice; -} - -static GuestChoice recovery_choice( - const PocketRuntimeState *state, - PocketRuntimePackage *embedded, - PocketRuntimeFailureLineage *failures -) { - for (;;) { - uint64_t hash = runtime_recovery_hash(state, failures); - if (hash == 0) break; - char error[256] = {0}; - PocketRuntimePackage *package = runtime_package_load_hash(hash, error, sizeof error); - if (package != NULL) { - GuestChoice choice = package_choice(package, hash, state); - /* A rollback never keeps the rejected artifact as last-good. */ - if (choice.commit_on_accept) choice.next_last_good_hash = 0; - return choice; - } - runtime_write_error("load-recovery", error); - if (!runtime_failure_lineage_add(failures, hash)) { - runtime_write_error("load-recovery", "recovery failure lineage exhausted"); - break; - } - } - GuestChoice choice = package_choice(embedded, 0, state); - if (choice.commit_on_accept) choice.next_last_good_hash = 0; - return choice; -} - -static GuestChoice startup_choice( - PocketRuntimeState *state, - PocketRuntimePackage *embedded, - PocketRuntimeFailureLineage *failures -) { -#ifdef POCKETJS_CAPTURE - (void)failures; - return package_choice(embedded, 0, state); -#else - char error[256] = {0}; - PocketRuntimePackage *pending = NULL; - RuntimePendingResult pending_result = runtime_prepare_pending( - &pending, - error, - sizeof error - ); - if (pending_result == RUNTIME_PENDING_READY) { - return package_choice(pending, pending->guest.package_hash, state); - } - if (pending_result == RUNTIME_PENDING_ERROR) { - runtime_write_error("prepare-pending", error); - } - if (state->active_hash != 0) { - PocketRuntimePackage *active = runtime_package_load_hash( - state->active_hash, - error, - sizeof error - ); - if (active != NULL) return package_choice(active, state->active_hash, state); - runtime_write_error("load-active", error); - if (!runtime_failure_lineage_add(failures, state->active_hash)) { - return package_choice(embedded, 0, state); - } - return recovery_choice(state, embedded, failures); - } - return package_choice(embedded, 0, state); -#endif -} - -static bool boot_with_recovery( - GuestChoice *choice, - PocketRuntimeState *state, - PocketRuntimePackage *embedded, - PocketRuntimeFailureLineage *failures, - char *fatal, - size_t fatal_length -) { - /* pending + active + last-good + embedded recovery are four distinct - * artifacts in the longest failure chain. */ - for (uint32_t attempt = 0; attempt < 4; attempt += 1) { - char error[256] = {0}; - if (boot_guest(choice->package, error, sizeof error)) { - runtime_write_status(state, choice->package, choice->commit_on_accept ? "candidate" : "booted"); - return true; - } - runtime_write_error("boot-guest", error); - snprintf(fatal, fatal_length, "%s", error); - uint64_t rejected = choice->state_hash; - bool embedded_failed = choice->package == embedded; - release_choice(choice, embedded); - if (embedded_failed) return false; - if (!runtime_failure_lineage_add(failures, rejected)) { - snprintf(fatal, fatal_length, "recovery failure lineage exhausted"); - return false; - } - *choice = recovery_choice(state, embedded, failures); - } - snprintf(fatal, fatal_length, "guest recovery attempts exhausted"); - return false; -} - -static void accept_guest( - GuestChoice *choice, - PocketRuntimeState *state, - PocketRuntimeFailureLineage *failures, - uint32_t frame -) { - if (!choice->commit_on_accept || choice->submitted_frames == 0) return; - uint64_t accepted_hash = choice->next_active_hash; - char error[256] = {0}; - if (!runtime_commit( - state, - choice->next_active_hash, - choice->next_last_good_hash, - error, - sizeof error - )) { - runtime_write_error("accept-guest", error); - fail(error); - } - choice->commit_on_accept = false; - runtime_failure_lineage_reset(failures); - runtime_write_status(state, choice->package, "accepted"); - devserver_set_runtime(state, choice->package, "accepted", frame); - devserver_report_install("accepted", accepted_hash, "first PICA command list retired"); -} - static void begin_frame_wait(uint32_t run_frame) { #ifdef POCKETJS_CAPTURE (void)run_frame; @@ -597,72 +460,91 @@ static void begin_frame_wait(uint32_t run_frame) { #endif } -static void recover_running_guest( - GuestChoice *choice, - PocketRuntimeState *state, - PocketRuntimePackage *embedded, - PocketRuntimeFailureLineage *failures, - uint32_t run_frame, - const char *phase, - const char *message -) { - runtime_write_error(phase, message); - if (choice->package == embedded) fail(message); - uint64_t rejected = choice->state_hash; - bool candidate = choice->commit_on_accept; - if (!runtime_failure_lineage_add(failures, rejected)) { - fail("recovery failure lineage exhausted"); +#ifndef POCKETJS_CAPTURE +static void release_package(PocketRuntimePackage *package, PocketRuntimePackage *embedded) { + if (package != NULL && package != embedded) runtime_package_free(package); +} + +/* All callers own a GPU-idle frame. The prior accepted buffer stays resident + * until commit finishes, so restoring it never performs SD IO on this thread. */ +static void restore_guest(GuestChoice *choice, PocketRuntimePackage *embedded, const char *message, bool initialized) { + uint64_t rejected = choice->package->guest.package_hash; + if (initialized) teardown_guest(); + release_package(choice->package, embedded); + choice->package = choice->previous ? choice->previous : embedded; + choice->previous = NULL; + choice->submitted_frames = 0; + char error[256] = {0}; + if (!boot_guest(choice->package, error, sizeof error)) { + if (choice->package == embedded) fail(error); + uint64_t failed = choice->package->guest.package_hash; + release_package(choice->package, embedded); + choice->package = embedded; + if (!boot_guest(embedded, error, sizeof error)) fail(error); + devserver_recover(failed, message); } + choice->failed = true; + choice->rejected_hash = rejected; + devserver_set_runtime(NULL, choice->package, "recovered", 0); +} + +static void recover_running_guest(GuestChoice *choice, PocketRuntimePackage *embedded, + uint32_t run_frame, const char *message) { + if (choice->package == embedded && !choice->pending) fail(message); begin_frame_wait(run_frame); - teardown_guest(); - release_choice(choice, embedded); - *choice = recovery_choice(state, embedded, failures); - char fatal[256] = {0}; - if (!boot_with_recovery(choice, state, embedded, failures, fatal, sizeof fatal)) fail(fatal); - devserver_set_runtime(state, choice->package, "recovered", run_frame); - devserver_report_install( - candidate ? "rejected" : "recovered", - rejected, - message - ); + if (choice->pending == 1) { + devserver_finish_candidate(false, message); + choice->pending = 2; + } else if (!choice->pending) { + devserver_recover(choice->package->guest.package_hash, message); + } + restore_guest(choice, embedded, message, true); C3D_FrameEnd(0); } -/* Swap a fully admitted candidate inside a GPU-idle C3D frame. This is the - * one path used by the boot-time FTP chord and the in-process TCP transport. */ -static void install_candidate( - GuestChoice *choice, - PocketRuntimeState *state, - PocketRuntimePackage *embedded, - PocketRuntimeFailureLineage *failures, - PocketRuntimePackage *candidate, - uint32_t *run_frame, - char *error, - size_t error_length -) { - uint64_t candidate_hash = candidate->guest.package_hash; - begin_frame_wait(*run_frame); - accept_guest(choice, state, failures, *run_frame); - teardown_guest(); - release_choice(choice, embedded); - runtime_failure_lineage_reset(failures); - *choice = package_choice(candidate, candidate_hash, state); - if (!boot_with_recovery(choice, state, embedded, failures, error, error_length)) fail(error); - if (choice->state_hash == candidate_hash) { - if (choice->commit_on_accept) { - devserver_set_runtime(state, choice->package, "candidate", *run_frame); - devserver_report_install("staged", candidate_hash, "guest booted; waiting for retired frame"); +static bool update_guest(GuestChoice *choice, PocketRuntimePackage *embedded, uint32_t run_frame) { + bool committed; + if (devserver_take_outcome(&committed)) { + if (committed && choice->failed) { + /* A failure during fsync cannot cancel a generation already committed. + * The worker appends a recovery generation after a fallback retires. */ + devserver_recover(choice->rejected_hash, "guest failed during commit"); + } + choice->pending = 0; + if (!committed && !choice->failed) { + begin_frame_wait(run_frame); + restore_guest(choice, embedded, "could not commit update", true); + C3D_FrameEnd(0); + return true; + } + release_package(choice->previous, embedded); + choice->previous = NULL; + devserver_set_runtime(NULL, choice->package, committed ? "accepted" : "recovered", run_frame); + } + PocketRuntimePackage *candidate = NULL; + if (!choice->pending && devserver_take_candidate(&candidate)) { + begin_frame_wait(run_frame); + teardown_guest(); + choice->previous = choice->package; + choice->package = candidate ? candidate : embedded; + choice->submitted_frames = 0; + choice->pending = 1; + choice->failed = false; + char error[256] = {0}; + if (!boot_guest(choice->package, error, sizeof error)) { + devserver_finish_candidate(false, error); + choice->pending = 2; + restore_guest(choice, embedded, error, false); } else { - devserver_set_runtime(state, choice->package, "booted", *run_frame); - devserver_report_install("accepted", candidate_hash, "package already active; guest restarted"); + devserver_set_runtime(NULL, choice->package, "candidate", run_frame); + devserver_report_install("staged", choice->package->guest.package_hash, "guest booted; awaiting first GPU frame"); } - } else { - devserver_set_runtime(state, choice->package, "recovered", *run_frame); - devserver_report_install("rejected", candidate_hash, error); + C3D_FrameEnd(0); + return true; } - C3D_FrameEnd(0); - *run_frame += 1; + return false; } +#endif // --------------------------------------------------------------------------- // boot @@ -709,55 +591,19 @@ int main(void) { if (embedded == NULL) fail(runtime_error); snprintf(embedded->origin, sizeof embedded->origin, "romfs:/app.pocket (recovery)"); - PocketRuntimeState runtime_state = {0}; -#if !defined(POCKETJS_CAPTURE) && !defined(POCKETJS_OFFLOAD) - if (!runtime_storage_init(&runtime_state, runtime_error, sizeof runtime_error)) { - fail(runtime_error); - } - DevserverInitResult devserver_result = devserver_init( - &runtime_state, - runtime_error, - sizeof runtime_error - ); - if (devserver_result == DEVSERVER_ERROR) { - /* Pairing/network failure must not make the accepted guest unbootable. - * Persist it for the next FTP inspection and continue without DevTools. - * The main loop retries: socInit fails transiently when the app starts - * while WiFi is still re-associating (e.g. right after ftpd exits). */ - runtime_write_error("devserver-init", runtime_error); - } -#endif - PocketRuntimeFailureLineage failures = {0}; input_init(); #ifdef POCKETJS_ASSET_PACK asset_pack_start(); #endif #ifdef POCKETJS_OFFLOAD - GuestChoice guest = package_choice(embedded, 0, &runtime_state); - guest.commit_on_accept = false; offload_start(); - if (!boot_guest(embedded, runtime_error, sizeof runtime_error)) fail(runtime_error); -#else - GuestChoice guest = startup_choice(&runtime_state, embedded, &failures); - if (!boot_with_recovery( - &guest, - &runtime_state, - embedded, - &failures, - runtime_error, - sizeof runtime_error - )) { - fail(runtime_error); - } +#endif + GuestChoice guest = { .package = embedded }; #ifndef POCKETJS_CAPTURE - devserver_set_runtime( - &runtime_state, - guest.package, - guest.commit_on_accept ? "candidate" : "booted", - 0 - ); + devserver_set_runtime(NULL, embedded, "booted", 0); + if (!devserver_start(embedded)) fail("development worker creation failed"); #endif -#endif /* ordinary recovery boot */ + if (!boot_guest(embedded, runtime_error, sizeof runtime_error)) fail(runtime_error); #ifdef POCKETJS_CAPTURE mkdir(CAPTURE_DIR, 0777); @@ -780,29 +626,12 @@ int main(void) { int32_t right_analog = ANALOG_CENTER; uint32_t touch = 0; size_t touch_count = scripted_touch(frame, &touch); -#elif defined(POCKETJS_OFFLOAD) - if (input_offload_exit_requested()) break; - int32_t buttons = input_buttons(); - int32_t analog = input_analog(); - int32_t right_analog = input_right_analog(); - uint32_t touch = 0; - size_t touch_count = input_touch(&touch); #else if (input_exit_requested()) break; - if (devserver_result == DEVSERVER_ERROR && run_frame % 300 == 299) { - /* One retry every ~5 s until the transient boot-time failure clears. */ - devserver_result = devserver_init(&runtime_state, runtime_error, sizeof runtime_error); - if (devserver_result == DEVSERVER_READY) { - devserver_set_runtime( - &runtime_state, - guest.package, - guest.commit_on_accept ? "candidate" : "booted", - run_frame - ); - } - } devserver_poll(); +#ifndef POCKETJS_OFFLOAD svcwire_pump(); +#endif if (input_devmenu_toggle_requested()) devmenu_toggle(); if (devmenu_visible() && input_devmenu_close_requested()) devmenu_hide(); if (devmenu_visible() && input_devmenu_screenshot_requested()) { @@ -811,64 +640,8 @@ int main(void) { ); } bool devmenu_blocks_guest = input_devmenu_blocks_guest(devmenu_visible()); - uint64_t upload_hash = 0; - if (devserver_take_upload(&upload_hash)) { - PocketRuntimePackage *uploaded = NULL; - RuntimePendingResult result = runtime_prepare_file( - POCKET_RUNTIME_UPLOAD, - upload_hash, - &uploaded, - runtime_error, - sizeof runtime_error - ); - if (result != RUNTIME_PENDING_READY) { - if (result == RUNTIME_PENDING_NONE) { - snprintf( - runtime_error, - sizeof runtime_error, - "%s disappeared before package admission", - POCKET_RUNTIME_UPLOAD - ); - } - runtime_write_error("network-package", runtime_error); - devserver_report_install("rejected", upload_hash, runtime_error); - } else { - install_candidate( - &guest, - &runtime_state, - embedded, - &failures, - uploaded, - &run_frame, - runtime_error, - sizeof runtime_error - ); - continue; - } - } - if (!devmenu_blocks_guest && input_reload_requested()) { - PocketRuntimePackage *pending = NULL; - RuntimePendingResult result = runtime_prepare_pending( - &pending, - runtime_error, - sizeof runtime_error - ); - if (result == RUNTIME_PENDING_ERROR) { - runtime_write_error("manual-reload", runtime_error); - } else if (result == RUNTIME_PENDING_READY) { - install_candidate( - &guest, - &runtime_state, - embedded, - &failures, - pending, - &run_frame, - runtime_error, - sizeof runtime_error - ); - continue; - } - } + if (!devmenu_blocks_guest && input_reload_requested()) devserver_reload(); + if (update_guest(&guest, embedded, run_frame)) { run_frame += 1; continue; } int32_t buttons = devmenu_blocks_guest ? 0 : input_buttons(); int32_t analog = devmenu_blocks_guest ? ANALOG_CENTER : input_analog(); int32_t right_analog = devmenu_blocks_guest ? ANALOG_CENTER : input_right_analog(); @@ -896,19 +669,11 @@ int main(void) { ); if (hit_count != touch_count) fail("auxiliary touch hit resolution failed"); if (!qjs_frame(buttons, analog, &touch, &touch_hit, touch_count, right_analog, input_elapsed_us)) { -#if defined(POCKETJS_CAPTURE) || defined(POCKETJS_OFFLOAD) +#ifdef POCKETJS_CAPTURE fail(qjs_last_error()); #else snprintf(runtime_error, sizeof runtime_error, "%s", qjs_last_error()); - recover_running_guest( - &guest, - &runtime_state, - embedded, - &failures, - run_frame, - "guest-frame", - runtime_error - ); + recover_running_guest(&guest, embedded, run_frame, runtime_error); run_frame += 1; continue; #endif @@ -933,11 +698,12 @@ int main(void) { run_frame #endif ); -#if !defined(POCKETJS_CAPTURE) && !defined(POCKETJS_OFFLOAD) - /* Reaching the next FrameBegin proves the candidate's first submitted list - * retired without tripping the GPU watchdog. Only now does it become the - * active generation on SD. */ - accept_guest(&guest, &runtime_state, &failures, run_frame); +#ifndef POCKETJS_CAPTURE + /* FrameBegin retired the candidate's previous GPU submission. */ + if (guest.pending == 1 && guest.submitted_frames > 0) { + devserver_finish_candidate(true, NULL); + guest.pending = 2; + } #endif offload_cpu_start = svcGetSystemTick(); gfx_begin_frame(); @@ -949,19 +715,11 @@ int main(void) { AUX_VIEW_W, AUX_VIEW_H )) { -#if defined(POCKETJS_CAPTURE) || defined(POCKETJS_OFFLOAD) +#ifdef POCKETJS_CAPTURE fail("PICA200 surface preparation failed"); #else C3D_FrameEnd(0); - recover_running_guest( - &guest, - &runtime_state, - embedded, - &failures, - run_frame + 1, - "guest-render", - "PICA200 surface preparation failed" - ); + recover_running_guest(&guest, embedded, run_frame + 1, "PICA200 surface preparation failed"); run_frame += 2; continue; #endif @@ -984,7 +742,7 @@ int main(void) { (unsigned)(offload_ui_ticks * 1000000 / SYSCLOCK_ARM11), (unsigned)((offload_prepared_at - offload_cpu_start) * 1000000 / SYSCLOCK_ARM11), (unsigned)((svcGetSystemTick() - offload_prepared_at) * 1000000 / SYSCLOCK_ARM11)); -#if !defined(POCKETJS_CAPTURE) && !defined(POCKETJS_OFFLOAD) +#ifndef POCKETJS_CAPTURE guest.submitted_frames += 1; devserver_set_frame_stats( run_frame, @@ -1021,9 +779,6 @@ int main(void) { } run_frame += 1; #endif -#if defined(POCKETJS_OFFLOAD) && !defined(POCKETJS_CAPTURE) - run_frame += 1; -#endif #ifdef POCKETJS_CAPTURE if (capture_wants(frame)) { @@ -1065,11 +820,14 @@ int main(void) { input_shutdown(); teardown_guest(); C3D_FrameEnd(0); +#ifndef POCKETJS_CAPTURE + devserver_shutdown(); + release_package(guest.previous, embedded); +#endif release_choice(&guest, embedded); runtime_package_free(embedded); #ifndef POCKETJS_CAPTURE svcwire_shutdown(); - devserver_shutdown(); soc_shutdown(); devmenu_shutdown(); #endif diff --git a/hosts/3ds/src/runtime.c b/hosts/3ds/src/runtime.c index fca230a73..dd3b17e58 100644 --- a/hosts/3ds/src/runtime.c +++ b/hosts/3ds/src/runtime.c @@ -152,7 +152,7 @@ PocketRuntimePackage *runtime_package_load( return NULL; } long raw_length = ftell(file); - if (raw_length <= 0 || (unsigned long)raw_length > MAX_PACKAGE_BYTES || + if (raw_length <= 0 || (unsigned long)raw_length > (strncmp(path, "romfs:/", 7) == 0 ? MAX_PACKAGE_BYTES : POCKET_RUNTIME_UPDATE_MAX_BYTES) || fseek(file, 0, SEEK_SET) != 0) { set_error(error, error_length, "%s has invalid package size %ld", path, raw_length); fclose(file); @@ -244,6 +244,26 @@ RuntimePendingResult runtime_prepare_pending( return runtime_prepare_file(POCKET_RUNTIME_PENDING, 0, out, error, error_length); } +/* Compare an existing blob with the already verified candidate in bounded + * chunks. A duplicate upload must not allocate a second whole package. */ +static bool matches_package(const char *path, const PocketRuntimePackage *package) { + FILE *file = fopen(path, "rb"); + if (file == NULL) return false; + uint8_t chunk[4096]; + bool same = true; + for (size_t offset = 0; offset < package->length;) { + size_t count = package->length - offset; + if (count > sizeof chunk) count = sizeof chunk; + if (fread(chunk, 1, count, file) != count || memcmp(chunk, package->bytes + offset, count) != 0) { + same = false; break; + } + offset += count; + } + if (same) same = fgetc(file) == EOF && !ferror(file); + if (fclose(file) != 0) same = false; + return same; +} + RuntimePendingResult runtime_prepare_file( const char *path, uint64_t expected_hash, @@ -285,18 +305,11 @@ RuntimePendingResult runtime_prepare_file( char destination[192]; blob_path(pending->guest.package_hash, destination, sizeof destination); if (stat(destination, &info) == 0) { - char duplicate_error[192] = {0}; - PocketRuntimePackage *existing = runtime_package_load_hash( - pending->guest.package_hash, - duplicate_error, - sizeof duplicate_error - ); - if (existing == NULL) { - set_error(error, error_length, "existing blob is invalid: %s", duplicate_error); + if (!matches_package(destination, pending)) { + set_error(error, error_length, "existing blob is invalid or differs from the admitted package"); runtime_package_free(pending); return RUNTIME_PENDING_ERROR; } - runtime_package_free(existing); if (remove(path) != 0) { set_error(error, error_length, "remove duplicate staged package failed (%d)", errno); runtime_package_free(pending); diff --git a/hosts/3ds/src/runtime.h b/hosts/3ds/src/runtime.h index 9d8cde9c3..0e3e5a8b6 100644 --- a/hosts/3ds/src/runtime.h +++ b/hosts/3ds/src/runtime.h @@ -20,6 +20,8 @@ #define POCKET_RUNTIME_PENDING POCKET_RUNTIME_APP_ROOT "/pending.pocket" #define POCKET_RUNTIME_UPLOAD POCKET_RUNTIME_APP_ROOT "/network-upload.pocket" #define POCKET_RUNTIME_DEV_KEY POCKET_RUNTIME_ROOT "/dev.key" +/* At most one prepared candidate is transferred to the UI at a time. */ +#define POCKET_RUNTIME_UPDATE_MAX_BYTES (8u * 1024u * 1024u) typedef struct { uint8_t *bytes; diff --git a/tests/3ds-dev-worker.test.ts b/tests/3ds-dev-worker.test.ts new file mode 100644 index 000000000..fd193e133 --- /dev/null +++ b/tests/3ds-dev-worker.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +test("3DS development IO cannot stall the UI or overwrite the accepted generation", () => { + const scratch = mkdtempSync(join(tmpdir(), "pocket-dev-worker-")); + try { + const root = resolve(import.meta.dir, ".."); + const fixture = `${root}/tests/fixtures/3ds-dev-worker`; + const flags = ["-std=c11", "-O1", "-pthread", "-fsanitize=address,undefined", "-D_POSIX_C_SOURCE=200809L", + '-DPOCKETJS_TARGET_ID="3ds-dev"', '-DPOCKETJS_RUNTIME_SLOT="0123456789abcdef"', "-DPOCKETJS_HOST_ABI=8", + `-I${fixture}`, `-I${root}/hosts/3ds/src`, `-I${root}/hosts/3ds/include`]; + const storage = Bun.spawnSync(["cc", ...flags, "-Dfsync=test_fsync", "-include", `${fixture}/io.h`, "-c", + `${root}/hosts/3ds/src/runtime.c`, "-o", `${scratch}/runtime.o`]); + expect(storage.exitCode, storage.stderr.toString()).toBe(0); + const compile = Bun.spawnSync(["cc", ...flags, `${fixture}/harness.c`, `${root}/hosts/3ds/src/devserver.c`, + `${scratch}/runtime.o`, "-o", `${scratch}/worker`]); + expect(compile.exitCode, compile.stderr.toString()).toBe(0); + const run = Bun.spawnSync([`${scratch}/worker`, scratch], { timeout: 15000 }); + expect(run.exitCode, run.stderr.toString()).toBe(0); + expect(run.stdout.toString()).toContain("worker admission, commit, rejection, recovery, bounded queues and epochs verified"); + } finally { rmSync(scratch, { recursive: true, force: true }); } +}, 20000); + + +test("3DS development transport bounds its stack and retains install/screenshot ownership", () => { + const scratch = mkdtempSync(join(tmpdir(), "pocket-dev-transport-")); + try { + const root = resolve(import.meta.dir, ".."); + const fixture = `${root}/tests/fixtures/3ds-dev-worker`; + const compile = Bun.spawnSync(["cc", "-std=c11", "-O1", "-pthread", "-fsanitize=address,undefined", + "-D_DEFAULT_SOURCE", "-Wframe-larger-than=8192", "-Werror", + '-DPOCKETJS_TARGET_ID="3ds-dev"', '-DPOCKETJS_RUNTIME_SLOT="0123456789abcdef"', "-DPOCKETJS_HOST_ABI=8", + `-I${fixture}`, `-I${root}/hosts/3ds/src`, `-I${root}/hosts/3ds/include`, + `${fixture}/transport.c`, `${root}/hosts/3ds/src/dev_protocol.c`, "-o", `${scratch}/transport`]); + expect(compile.exitCode, compile.stderr.toString()).toBe(0); + const run = Bun.spawnSync([`${scratch}/transport`, scratch], { timeout: 5000 }); + expect(run.exitCode, run.stderr.toString()).toBe(0); + expect(run.stdout.toString()).toContain("retained receipts and borrowed screenshots verified"); + } finally { rmSync(scratch, { recursive: true, force: true }); } +}); diff --git a/tests/3ds-pairing.test.ts b/tests/3ds-pairing.test.ts new file mode 100644 index 000000000..f792d1502 --- /dev/null +++ b/tests/3ds-pairing.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +test("3DS pairing adopts an existing console key and rotates only explicitly", () => { + const root = resolve(import.meta.dir, ".."); + const scratch = mkdtempSync(join(tmpdir(), "pocket-pairing-")); + const host = `pair-test-${crypto.randomUUID()}.invalid`; + const local = `${root}/.pocket/3ds/devices/${host}-8131.key`; + const remote = `${scratch}/remote.key`; + try { + const curl = `${scratch}/curl`; + writeFileSync(curl, `#!/usr/bin/env python3 +import os,pathlib,sys +remote=pathlib.Path(os.environ['PAIR_TEST_REMOTE']) +if os.environ.get('PAIR_TEST_FAILURE'):sys.exit(7) +if '-T' in sys.argv: + remote.write_bytes(pathlib.Path(sys.argv[sys.argv.index('-T')+1]).read_bytes()) + with open(str(remote)+'.writes','a') as f:f.write('write\\n') +elif remote.exists():sys.stdout.buffer.write(remote.read_bytes()) +else:sys.exit(78) +`); chmodSync(curl, 0o700); + const invoke = (args: string[] = [], fail = false) => Bun.spawnSync([process.execPath, `${root}/tools/3ds-dev.ts`, "pair", "--host", host, ...args], { + env: { ...process.env, PATH: `${scratch}:${process.env.PATH}`, PAIR_TEST_REMOTE: remote, ...(fail ? { PAIR_TEST_FAILURE: "1" } : {}) }, timeout: 10000, + }); + writeFileSync(remote, "11".repeat(32) + "\n"); + expect(invoke().exitCode).toBe(0); + expect(readFileSync(local).equals(readFileSync(remote))).toBe(true); + expect(existsSync(`${remote}.writes`)).toBe(false); + expect(invoke(["--rotate"]).exitCode).toBe(0); + expect(readFileSync(local).equals(readFileSync(remote))).toBe(true); + expect(readFileSync(remote, "utf8")).not.toBe("11".repeat(32) + "\n"); + const preserved = readFileSync(local); + expect(invoke([], true).exitCode).toBe(1); + expect(readFileSync(local).equals(preserved)).toBe(true); + rmSync(remote); expect(invoke().exitCode).toBe(0); + expect(readFileSync(remote).equals(preserved)).toBe(true); + } finally { rmSync(local, { force: true }); rmSync(scratch, { recursive: true, force: true }); } +}); diff --git a/tests/e2e/3ds-hot-update.ts b/tests/e2e/3ds-hot-update.ts new file mode 100644 index 000000000..2c3c2fbd8 --- /dev/null +++ b/tests/e2e/3ds-hot-update.ts @@ -0,0 +1,97 @@ +// Run against a paired, already-running 3DS runtime (console or Azahar). +// The given package must match that runtime's embedded app/native plan. +// Restores the supplied production package after exercising failure recovery. +// bun tests/e2e/3ds-hot-update.ts --host IP --key device.key --package app.pocket --out receipts +import { strict as assert } from "node:assert"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { PocketRuntimeClient, parsePocketRuntimeToken } from "../../tools/3ds-runtime-client.ts"; +import { POCKET_SECTION, decodePocketPackage, encodePocketPackage } from "../../contracts/spec/pocket-package.ts"; +import { POCKET_RUNTIME_MSG, encodePocketRuntimePackageBegin, encodePocketRuntimePackageChunk, pocketPackageFooterHash } from "../../contracts/spec/pocket-runtime-wire.ts"; +const arg = (key: string) => { const i = process.argv.indexOf(key); if (i < 0 || !process.argv[i + 1]) throw new Error(`missing ${key}`); return process.argv[i + 1]!; }; +const original = readFileSync(arg("--package")); +const output = resolve(arg("--out")); mkdirSync(output, { recursive: true }); +const client = new PocketRuntimeClient({ host: arg("--host"), token: parsePocketRuntimeToken(readFileSync(arg("--key"), "utf8")), timeoutMs: 30000 }); +const receipts: unknown[] = []; +client.on("ctrl", (m) => { if (m.t === "runtime.install") { receipts.push(m); console.log(JSON.stringify(m)); } }); +const hash = (b: Uint8Array) => pocketPackageFooterHash(b).toString(16).padStart(16, "0"); +function variant(suffix: string, kind: number = POCKET_SECTION.js): Uint8Array { + const pkg = decodePocketPackage(original); + for (const v of pkg.variants) { + const section = v.sections.find(s => s.kind === kind)!; + const text = new TextDecoder().decode(section.bytes); + section.bytes = new TextEncoder().encode(kind === POCKET_SECTION.js ? text.slice(0, -1) + "\n" + suffix + "\0" : text + suffix); + } + return encodePocketPackage(pkg); +} +async function status() { + const receipt = client.waitForCtrl(m => m.t === "runtime.status"); await client.requestStatus(); return await receipt; +} +async function install(bytes: Uint8Array, expected: "accepted" | "rejected") { + const receipt = client.waitForCtrl(m => m.t === "runtime.install" && m.hash === hash(bytes) && (m.phase === "accepted" || m.phase === "rejected")); + await client.install(bytes); const result = await receipt; assert.equal(result.phase, expected); return result; +} +async function evaluate(code: string) { + const id = `qa-${Date.now()}`; + const result = client.waitForCtrl(m => m.t === "evalResult" && m.id === id); + await client.sendCtrl({ t: "eval", id, code }); return await result; +} +let connected = false; +try { + receipts.push(await client.connect()); connected = true; + const before = await status(); receipts.push({ before }); + const good = variant('globalThis.__runtimeProbe = "hot-update-ok";'); + const accepted = client.waitForCtrl(m => m.t === "runtime.install" && m.hash === hash(good) && m.phase === "accepted"); + await client.sendFrame(POCKET_RUNTIME_MSG.packageBegin, encodePocketRuntimePackageBegin(good.length, pocketPackageFooterHash(good))); + // Deliberately stretch transfer over several UI frames. Query the native + // status before commit to prove no guest replacement happened mid-transfer. + const mid: Record[] = []; + for (let offset = 0; offset < good.length; offset += 16384) { + await client.sendFrame(POCKET_RUNTIME_MSG.packageChunk, encodePocketRuntimePackageChunk(offset, good.subarray(offset, offset + 16384))); + if (offset % 65536 === 0) mid.push(await status()); + await Bun.sleep(30); + } + assert(mid.every(m => m.generation === before.generation && m.running === before.running)); + assert(Number(mid.at(-1)!.frame) > Number(mid[0]!.frame) + 10, "UI stopped during upload"); + receipts.push({ transfer: mid }); + await client.sendFrame(POCKET_RUNTIME_MSG.packageCommit); await accepted; + assert.equal((await status()).active, hash(good)); + assert(String((await evaluate("globalThis.__runtimeProbe")).value).includes("hot-update-ok")); + + await install(variant('throw new Error("hot-update eval rejection");'), "rejected"); + assert.equal((await status()).active, hash(good)); + await install(variant('globalThis.frame = () => { throw new Error("hot-update first-frame rejection"); };'), "rejected"); + assert.equal((await status()).active, hash(good)); + await install(variant(" ", POCKET_SECTION.plan), "rejected"); + assert.equal((await status()).active, hash(good)); + const corrupt = Uint8Array.from(original); corrupt[corrupt.length - 20]! ^= 1; + await install(corrupt, "rejected"); + assert.equal((await status()).active, hash(good)); + assert(String((await evaluate("globalThis.__runtimeProbe")).value).includes("hot-update-ok")); + + // A later frame can fail after acceptance; recovery must append a generation + // selecting last-good instead of repeatedly booting the failing package. + const late = variant('var qaFrame = globalThis.frame, qaCount = 0; globalThis.frame = (...args) => { if (++qaCount > 90) throw new Error("hot-update running failure"); return qaFrame(...args); };'); + await install(late, "accepted"); + const recoveryDeadline = Date.now() + 30000; + let recovered = await status(); + while (recovered.active !== hash(good) || recovered.running !== hash(good)) { + assert(Date.now() < recoveryDeadline, "accepted guest did not recover to last-good"); + await Bun.sleep(100); recovered = await status(); + } + receipts.push({ recovered }); +} finally { + try { + if (connected && client.connected) { + await install(original, "accepted"); + await Bun.sleep(1000); + const screenshot = client.waitForScreenshot(20000); + await client.sendCtrl({ t: "screenshot" }); + const shot = await screenshot; writeFileSync(`${output}/final.png`, shot.png); + receipts.push({ final: await status(), screenshotFrame: shot.frame }); + } + } finally { + client.close(); writeFileSync(`${output}/receipts.json`, JSON.stringify(receipts, (_, v) => typeof v === "bigint" ? v.toString(16) : v, 2)); + } +} +console.log("PASS: streaming UI, admission, eval/frame rejection, last-good recovery, restoration and screenshot"); diff --git a/tests/fixtures/3ds-dev-worker/3ds.h b/tests/fixtures/3ds-dev-worker/3ds.h new file mode 100644 index 000000000..512294735 --- /dev/null +++ b/tests/fixtures/3ds-dev-worker/3ds.h @@ -0,0 +1,5 @@ +#include "../offload-native/3ds.h" +void *test_linear_alloc(size_t); +void test_linear_free(void *); +#define linearAlloc test_linear_alloc +#define linearFree test_linear_free diff --git a/tests/fixtures/3ds-dev-worker/harness.c b/tests/fixtures/3ds-dev-worker/harness.c new file mode 100644 index 000000000..21d592c4f --- /dev/null +++ b/tests/fixtures/3ds-dev-worker/harness.c @@ -0,0 +1,128 @@ +#include +#include +#include +#include +#include +#include +#include +#include <3ds.h> +#include "devserver.h" +#include "dev_transport.h" + +static pthread_t ui_thread; +static atomic_bool fail_commit, connected, upload_ready, incoming, screenshot_busy, want_shot; +static atomic_bool writable = true; +static atomic_uint starts, reports, sends; +static unsigned linear_live; +static PocketRuntimePackage embedded; +static uint8_t embedded_bytes[32] = {0x50, 1}; +static uint64_t upload_hash; +static unsigned ticks; +static void worker_only(void) { assert(!pthread_equal(pthread_self(), ui_thread)); } +void *test_linear_alloc(size_t size) { assert(pthread_equal(pthread_self(), ui_thread)); linear_live++; return malloc(size); } +void test_linear_free(void *p) { assert(pthread_equal(pthread_self(), ui_thread)); linear_live--; free(p); } +int test_fsync(int fd) { + worker_only(); svcSleepThread(200000000); + if (atomic_load(&fail_commit)) { errno = EIO; return -1; } + return fsync(fd); +} +int32_t pocket_package_open(const uint8_t *b, size_t n, const uint8_t *t, size_t tl, uint32_t abi, PocketGuestPackage *out) { + (void)t; (void)tl; (void)abi; + worker_only(); atomic_fetch_add(&starts, 1); svcSleepThread(200000000); + if (n != 32 || b[0] != 0x50) return 4; + memset(out, 0, sizeof *out); memcpy(&out->package_hash, b + 24, 8); out->javascript = b; out->javascript_length = 2; return 0; +} +bool pocket_package_same_app(const uint8_t *a, size_t an, const uint8_t *b, size_t bn) { + worker_only(); return an == bn && a[1] == b[1]; +} +DevserverInitResult devtransport_init(const PocketRuntimeState *s, char *e, size_t n) { (void)s;(void)e;(void)n;worker_only(); return DEVSERVER_READY; } +void devtransport_shutdown(void) { worker_only(); atomic_store(&screenshot_busy,false); } +bool devtransport_active(void) { worker_only(); return true; } +bool devtransport_connected(void) { worker_only(); return atomic_load(&connected); } +void devtransport_poll(void) { worker_only(); if (!atomic_load(&connected)) atomic_store(&screenshot_busy,false); } +void devtransport_snapshot(DevserverSnapshot *o) { worker_only(); memset(o,0,sizeof *o);o->enabled=true;o->connected=atomic_load(&connected); } +void devtransport_set_runtime(const PocketRuntimeState *s,const PocketRuntimePackage *p,const char *f,uint32_t n) {(void)s;(void)p;(void)f;(void)n;worker_only();} +void devtransport_set_frame_stats(uint32_t f,uint32_t c,uint32_t v,uint32_t d) {(void)f;(void)c;(void)v;(void)d;worker_only();} +const char *devtransport_debug_stats(void) {worker_only();return "{}";} +void devtransport_set_upload_busy(bool b) {(void)b;worker_only();} +void devtransport_reset_guest(void) {worker_only();} +bool devtransport_ctrl_available(size_t n) {(void)n;worker_only();return atomic_load(&writable);} +void devtransport_send_ctrl(const char *b,size_t n) {(void)b;(void)n;worker_only();atomic_fetch_add(&sends,1);} +void devtransport_report_install(const char *p,uint64_t h,const char *m) {(void)p;(void)h;(void)m;worker_only();atomic_fetch_add(&reports,1);} +void devtransport_report_log(const char *p,const char *m) {(void)p;(void)m;worker_only();} +size_t devtransport_recv_ctrl(char *b,size_t n) { + worker_only();if (!atomic_exchange(&incoming,false))return 0; + const char *line="{\"t\":\"eval\",\"code\":\"oldGuest()\"}\n";assert(n>strlen(line));strcpy(b,line);return strlen(line); +} +bool devtransport_take_upload(uint64_t *hash) { + worker_only();if(!atomic_exchange_explicit(&upload_ready,false,memory_order_acq_rel))return false; + *hash=upload_hash;return true; +} +bool devtransport_request_screenshot(void) {worker_only();atomic_store(&want_shot,true);return true;} +bool devtransport_take_screenshot_request(void) {worker_only();return atomic_exchange(&want_shot,false);} +bool devtransport_screenshot_busy(void) {worker_only();return atomic_load(&screenshot_busy);} +void devtransport_adopt_screenshot(uint32_t f,uint16_t tw,uint16_t th,uint16_t aw,uint16_t ah,uint8_t *top,uint8_t *aux) { + (void)f;(void)tw;(void)th;(void)aw;(void)ah;worker_only();assert(top[0]==0x33 && aux[0]==0x44);atomic_store(&screenshot_busy,true); +} +static void tick(void) { + uint64_t start=osGetTime();devserver_poll();devserver_set_frame_stats(ticks++,1,1,0); + assert(osGetTime()-start<50);svcSleepThread(1000000); +} +static void advance(unsigned ms) {uint64_t end=osGetTime()+ms;while(osGetTime()guest.package_hash==1 && ticks-before>50);assert(state().generation==0); + before=ticks;devserver_finish_candidate(true,NULL);assert(outcome());assert(ticks-before>50);runtime_package_free(p); + s=state();assert(s.generation==1 && s.active_hash==1 && s.last_good_hash==0); + + upload(2,true);p=take();devserver_finish_candidate(false,"bad eval");assert(!outcome());runtime_package_free(p);assert(state().generation==1); + upload(3,true);p=take();atomic_store(&fail_commit,true);devserver_finish_candidate(true,NULL);assert(!outcome());runtime_package_free(p);assert(state().active_hash==1);atomic_store(&fail_commit,false); + unsigned old=atomic_load(&reports);upload(4,false);advance(350);assert(atomic_load(&reports)>old);assert(!devserver_take_candidate(&p));assert(state().active_hash==1); + + upload(5,true);p=take();devserver_finish_candidate(true,NULL);assert(outcome());runtime_package_free(p); + assert(state().last_good_hash==1);assert(devserver_recover(5,"frame failed"));p=take();assert(p && p->guest.package_hash==1); + devserver_finish_candidate(false,"last good failed too");assert(!outcome());runtime_package_free(p); + p=take();assert(p==NULL);devserver_finish_candidate(true,NULL);assert(outcome());s=state();assert(s.active_hash==0 && s.last_good_hash==0); + + atomic_store(&incoming,true);advance(30);devserver_reset_guest();char line[256];assert(devserver_recv_ctrl(line,sizeof line)==0); + atomic_store(&incoming,true);advance(30);assert(devserver_recv_ctrl(line,sizeof line)>0); + atomic_store(&writable,false);advance(10); + for(unsigned i=0;i<100;i++)devserver_send_ctrl("{}",2); + atomic_store(&writable,true);advance(30);assert(atomic_load(&sends)==4); + atomic_store(&connected,true);advance(20);assert(devserver_request_screenshot());advance(20); + assert(devserver_take_screenshot_request());uint8_t *top,*aux; + assert(devserver_screenshot_begin(1,400,240,320,240,&top,&aux));top[0]=0x33;aux[0]=0x44; + devserver_screenshot_ready();advance(20);assert(atomic_load(&screenshot_busy));assert(linear_live==2); + // UI cancellation cannot free a buffer while transport still borrows it. + devserver_screenshot_cancel();assert(linear_live==2); + atomic_store(&connected,false);advance(20);assert(linear_live==0); + atomic_store(&connected,true);advance(20);assert(devserver_request_screenshot());advance(20); + assert(devserver_take_screenshot_request());assert(devserver_screenshot_begin(2,400,240,320,240,&top,&aux)); + atomic_store(&connected,false);advance(20);assert(linear_live==2); + devserver_screenshot_ready();advance(20);assert(linear_live==0); + devserver_shutdown();assert(linear_live==0); + printf("worker admission, commit, rejection, recovery, bounded queues and epochs verified (%u UI ticks)\n",ticks); +} diff --git a/tests/fixtures/3ds-dev-worker/io.h b/tests/fixtures/3ds-dev-worker/io.h new file mode 100644 index 000000000..840a0beba --- /dev/null +++ b/tests/fixtures/3ds-dev-worker/io.h @@ -0,0 +1,3 @@ +#include +#include +int test_fsync(int fd); diff --git a/tests/fixtures/3ds-dev-worker/transport.c b/tests/fixtures/3ds-dev-worker/transport.c new file mode 100644 index 000000000..4723a9220 --- /dev/null +++ b/tests/fixtures/3ds-dev-worker/transport.c @@ -0,0 +1,52 @@ +/* The real socket/file implementation, with only libctru services replaced. */ +#include +#include +#include +#include "../../../hosts/3ds/src/dev_transport.c" +static unsigned frees; +void *test_linear_alloc(size_t n) { return malloc(n); } +void test_linear_free(void *p) { frees++; free(p); } +bool soc_ensure(char *error, size_t n) { (void)error; (void)n; return true; } + +int main(int argc, char **argv) { + assert(argc == 2 && chdir(argv[1]) == 0); + assert(mkdir("sdmc:", 0777) == 0); + const char *dirs[] = {"sdmc:/pocketjs", POCKET_RUNTIME_ROOT, POCKET_RUNTIME_APPS, POCKET_RUNTIME_APP_ROOT}; + for (unsigned i=0;i0)received+=(size_t)n; + } + assert(received>400*240*3+320*240*3 && frees==0); + // Disconnect returns the borrowed buffers too, without freeing UI memory. + devtransport_adopt_screenshot(8,400,240,320,240,top,aux); + disconnect_client();assert(!devtransport_screenshot_busy() && frees==0); + free(top);free(aux);close(pair[1]); + puts("transport limits, rejected stream drain, retained receipts and borrowed screenshots verified"); +} diff --git a/tools/3ds-dev.ts b/tools/3ds-dev.ts index ae7dcdc31..a7ca7730f 100644 --- a/tools/3ds-dev.ts +++ b/tools/3ds-dev.ts @@ -209,19 +209,30 @@ async function pair(): Promise { const port = configuredPort; const keyPath = keyPathFor(host, port); mkdirSync(keyDirectory, { recursive: true }); - let token: Uint8Array; - if (existsSync(keyPath) && !has("--rotate")) { - token = tokenAt(keyPath); - } else { - token = crypto.getRandomValues(new Uint8Array(32)); - writeFileSync(keyPath, `${Buffer.from(token).toString("hex")}\n`, { mode: 0o600 }); - } - chmodSync(keyPath, 0o600); const ftpPort = Number(value("--ftp-port") ?? 5000); if (!Number.isInteger(ftpPort) || ftpPort <= 0 || ftpPort > 65535) { usage("--ftp-port is invalid"); } const url = `ftp://${host}:${ftpPort}/pocketjs/runtime/dev.key`; + let token: Uint8Array; + if (!has("--rotate")) { + // A new checkout must adopt the device-wide key, preserving other Pocket + // apps and Macs already paired with this console. Never rotate on timeout. + const existing = Bun.spawnSync(["curl", "--silent", "--show-error", "--fail", "--ftp-method", "nocwd", + "--connect-timeout", "3", "--max-time", "15", url]); + if (existing.exitCode === 0) { + token = parsePocketRuntimeToken(existing.stdout.toString()); + writeFileSync(keyPath, `${Buffer.from(token).toString("hex")}\n`, { mode: 0o600 }); + chmodSync(keyPath, 0o600); + console.log(`paired ${host}:${port} — adopted existing device key`); + console.log(`local key: ${keyPath}`); + return; + } + if (existing.exitCode !== 78) throw new Error(existing.stderr.toString().trim() || "cannot read device pairing key"); + } + token = existsSync(keyPath) && !has("--rotate") ? tokenAt(keyPath) : crypto.getRandomValues(new Uint8Array(32)); + writeFileSync(keyPath, `${Buffer.from(token).toString("hex")}\n`, { mode: 0o600 }); + chmodSync(keyPath, 0o600); const upload = Bun.spawnSync([ "curl", "--silent",