From bfee8b0a08507c309df19ec58eb02b9122de8470 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:40:16 -0700 Subject: [PATCH 01/13] feat(offload): isolate provider IO behind bounded frame queues --- contracts/spec/offload.ts | 21 +++++ contracts/spec/platforms.ts | 1 + docs/OFFLOAD.md | 130 +++++++++++++++++++++++++++ framework/compiler/build-inputs.ts | 2 +- framework/compiler/subpaths.ts | 3 + framework/src/offload.ts | 87 ++++++++++++++++++ hosts/3ds/Makefile | 9 +- hosts/3ds/src/input.c | 5 ++ hosts/3ds/src/input.h | 2 + hosts/3ds/src/main.c | 32 +++++-- hosts/3ds/src/offload.c | 138 +++++++++++++++++++++++++++++ hosts/3ds/src/offload.h | 12 +++ hosts/3ds/src/offload_coverage.h | 42 +++++++++ hosts/3ds/src/offload_queue.h | 33 +++++++ hosts/3ds/src/qjs.c | 46 ++++++++++ package.json | 5 ++ tests/3ds-profile.test.ts | 1 + tests/fixtures/offload-queue.c | 42 +++++++++ tests/offload.test.ts | 86 ++++++++++++++++++ tools/3ds-profile.ts | 1 + tools/3ds.ts | 2 + tools/offload-capabilities.ts | 38 ++++++++ tools/offload-provider.ts | 88 ++++++++++++++++++ tools/offload-wire.ts | 32 +++++++ tools/test.ts | 1 + 25 files changed, 852 insertions(+), 7 deletions(-) create mode 100644 contracts/spec/offload.ts create mode 100644 docs/OFFLOAD.md create mode 100644 framework/src/offload.ts create mode 100644 hosts/3ds/src/offload.c create mode 100644 hosts/3ds/src/offload.h create mode 100644 hosts/3ds/src/offload_coverage.h create mode 100644 hosts/3ds/src/offload_queue.h create mode 100644 tests/fixtures/offload-queue.c create mode 100644 tests/offload.test.ts create mode 100644 tools/offload-capabilities.ts create mode 100644 tools/offload-provider.ts create mode 100644 tools/offload-wire.ts diff --git a/contracts/spec/offload.ts b/contracts/spec/offload.ts new file mode 100644 index 000000000..24fe7646c --- /dev/null +++ b/contracts/spec/offload.ts @@ -0,0 +1,21 @@ +/** Offload v1. JSON records are length-prefixed UTF-8 on the wire. + * The UI copies bounded records; only the provider executes capabilities. */ +export const OFFLOAD = Object.freeze({ + version: 1, recordBytes: 4096, payloadChars: 2500, pending: 8, + deliveriesPerFrame: 1, submissionsPerFrame: 2, timeoutFrames: 600, + port: 8741, +}); + +export interface OffloadOps { + /** Positive authenticated connection generation; zero/negative = offline. */ + session(): number; + /** Nonwaiting bounded copy. False means no credit; caller retains work. */ + submit(record: string): boolean; + /** At most one complete record per host frame. Never performs IO. */ + take(): string | undefined; + /** Optional bounded 2-bit coverage upload. At most 512x16, one per frame. + * Foreground is ABGR; alpha comes from coverage. Returns a texture handle. */ + uploadCoverage?(base64: string, width: number, height: number, foreground: number): number; +} +export interface OffloadRequest { v: 1; id: number; method: string; payload: string } +export interface OffloadReply { id: number; payload?: string; error?: string } diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index efd2f9d4f..e08684d10 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -174,6 +174,7 @@ export const POCKET_CAPABILITIES = defineCapabilityRegistry([ // namespace (`globalThis.net`, contracts/spec/net.ts). Transport adapters // remain host-owned; the browser dev host, deterministic sim and reference // core exercise the contract without granting network access to every host. + "io.offload", "net.http", // SQLite behind the db module's own namespace (`globalThis.db`, // contracts/spec/db.ts): five synchronous ops, rows as one JSON line per diff --git a/docs/OFFLOAD.md b/docs/OFFLOAD.md new file mode 100644 index 000000000..5ff1d23a8 --- /dev/null +++ b/docs/OFFLOAD.md @@ -0,0 +1,130 @@ +# Offload + +`io.offload` lets a single-threaded PocketJS guest submit bounded work to a +paired provider. **The guest does not receive a socket, filesystem handle, SQL +connection, or synchronous provider call.** The provider owns those resources. + +This implementation is independent of `feat/companion` (#360). It adds +`@pocketjs/framework/offload`, a 3DS worker transport, and a Bun provider +transport. Pocket Folio is a separate application using the capability. + +## Frame contract + +| Boundary | Enforced limit | +| --- | --- | +| Wire record | 4,096 UTF-8 bytes, 4-byte big-endian length prefix | +| Request/result payload | 2,500 UTF-16 code units, serialized string | +| Outstanding guest requests | 8 | +| Native outgoing/incoming queues | 8 records each | +| Native submissions | 2 per host frame | +| Native result copies | 1 per host frame | +| JS completion callbacks | 1 per service-pump tick, including failures | +| Coverage resource | At most 512×16 pixels, one upload per host frame | +| Request deadline | 600 guest frames; provider worker deadline 9 seconds | + +`submit` and `take` only copy fixed-size memory slots. **They do not call socket +functions, wait on locks, inspect the SD card, or run provider code.** The native +queues use single producer/single consumer ownership and acquire/release +atomics. Compilation requires lock-free 32-bit atomics. A full outgoing queue +returns false; a full incoming queue stops socket reads and lets TCP exert +backpressure. A connection generation fences both queues. + +The service pump sends queued requests and delivers one completion before the +UI frame hooks. Deadlines, cancellation and disconnects release guest tickets. +**Sent requests are never automatically replayed.** A disconnected save can +have an unknown outcome. A provider mutation should use a durable operation +identity and a revision check if its caller needs retry after reconnect. + +These limits remove IO waits from the UI thread. They are **not a hard real-time +guarantee for arbitrary JavaScript or rendering**. Application callbacks, +reconciliation, garbage collection, native allocations, GPU submission and OS +scheduling still cost time. Applications must keep their completion handlers +and visible trees bounded and measure the resulting device frames. + +## Guest API + +Declare `io.offload` in `engine.capabilities.requires`, then use: + +```ts +import { offload } from "@pocketjs/framework/offload"; + +const work = offload(); +const ticket = work.request("notes.page", "[0]", result => { + if (result.ok) applyVisiblePage(JSON.parse(result.value)); + else showStatus(result.error); +}); +// ticket === 0 means the caller retains the work because capacity is full. +// work.cancel(ticket) suppresses delivery; it does not undo provider work. +``` + +The realm owns one client. Applications do not step it themselves. Input is a +bounded string so the API never traverses an arbitrary application object to +serialize it. Providers return equally bounded strings. Pagination and resource +chunking belong to the capability contract, not to an unbounded accumulator in +the guest. + +`uploadCoverage(base64, width, height, foreground)` uploads a bounded 2-bit alpha +mask when a host implements it. Width must be a multiple of four and at most +512; height is 1–16. Foreground is ABGR. The texture uses the next power-of-two +envelope, with a minimum dimension of eight. The 3DS decodes in C into reusable +scratch storage; a guest does not need a pixel expansion loop. Undefined means +unsupported; a negative handle means invalid input or exhausted frame credit. +The guest owns returned texture handles and releases them with `freeTexture`. + +## Provider + +`@pocketjs/framework/offload/provider` exports `connectOffloadProvider` and +`dispatchOffload`. The former owns TCP and a dedicated Worker for a connection; +the latter dispatches only own properties of an explicit method allowlist. +Worker initialization arrives as `{ init: data }`. Subsequent messages are +versioned requests. An over-budget response or malformed frame closes the +connection. A stalled worker is terminated instead of retaining its requests +indefinitely. + +`@pocketjs/framework/offload/capabilities` exports two provider-side helpers: + +- `sqliteQueries(db, queries)`: named, provider-owned SQL with device-supplied + scalar parameters. Queries must return pages of at most 32 rows within the + result-string budget. Never accept SQL source from the device. +- `httpResources(resources)`: named, fixed URLs. Requests reject redirects, + time out after five seconds, and stop reading at 2,000 response bytes. + +Install these helpers in the provider Worker. A file service can use the same +method allowlist while resolving opaque document IDs inside its granted root. +No document, Markdown, terminal, or platform SDK concept occurs in the guest +offload client or native queue. + +## 3DS deployment + +The current 3DS profile compiles a dedicated offload execution mode when the +resolved plan enables `io.offload`. **It boots its own embedded package and does +not run the SD package admission or development socket pumps during UI frames.** +It therefore cannot fall back to another application's shared active package. +Package replacement requires a new `.3dsx` and relaunch. L+R+START returns to HBL. + +The worker listens on TCP 8741. Before framed traffic it requires the 64-byte +hex pairing key stored at +`sdmc:/pocketjs/offload/.key`. The Mac connects to +the selected device address. Pairing is scoped to the application. **This +transport assumes a trusted LAN: records and the pairing preface are not +encrypted.** It is not an Internet-facing transport. + +Every two seconds the worker reports frame count, maximum measured CPU work, +and the count above 16,667 microseconds. CPU measurement includes JS, core draw +list generation, texture preparation and submission; it excludes the intentional +vblank wait. A frame-count delta also reveals lost presentation cadence. + +The same request contract can use a provider on the device itself. That host +still needs a worker/process transport with enforced queues and resource +budgets; calling a local provider directly from JS would violate the contract. +**An iPhone-local provider backend is not implemented by this change.** + +## Validation + +Run `bun test tests/offload.test.ts tests/3ds-profile.test.ts` and compile/run +`tests/fixtures/offload-queue.c` with pthreads and address/undefined sanitizers. +The fixture exercises 100,000 full-size concurrent records, queue saturation, +counter wrap and coverage decoding. Tests also exercise fragmented UTF-8, +timeouts, cancellation, stale sessions, no mutation replay, provider grants, +SQLite result budgets, HTTP redirects and oversized bodies. These checks are +separate from device performance and interaction acceptance. diff --git a/framework/compiler/build-inputs.ts b/framework/compiler/build-inputs.ts index f94ac585a..a0d81bd5a 100644 --- a/framework/compiler/build-inputs.ts +++ b/framework/compiler/build-inputs.ts @@ -31,7 +31,7 @@ export class BuildInputs { } } async compiler(entrypoints: string[], frameworkRoot: string): Promise { - const result = await Bun.build({ entrypoints, root: process.cwd(), target: "bun", packages: "external", metafile: true, write: false }); + const result = await Bun.build({ entrypoints, root: process.cwd(), target: "bun", packages: "external", metafile: true }); if (!result.success) throw new Error("cannot resolve compiler dependency graph: " + result.logs.join("\n")); this.metafile(result.metafile); for (const [path, input] of Object.entries(result.metafile!.inputs)) { diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts index 370963715..78d8d4c2a 100644 --- a/framework/compiler/subpaths.ts +++ b/framework/compiler/subpaths.ts @@ -55,6 +55,9 @@ export const SUBPATHS: Record = { aliases: ALL, }, animation: { file: "framework/src/animation.ts", aliases: TWINS }, + "offload/provider": { file: "tools/offload-provider.ts" }, + "offload/capabilities": { file: "tools/offload-capabilities.ts" }, + offload: { file: "framework/src/offload.ts", aliases: TWINS }, audio: { file: "framework/src/audio-api.ts", aliases: TWINS }, clock: { file: "framework/src/clock.ts", aliases: TWINS }, config: { file: "framework/src/config.ts" }, diff --git a/framework/src/offload.ts b/framework/src/offload.ts new file mode 100644 index 000000000..00b9b6181 --- /dev/null +++ b/framework/src/offload.ts @@ -0,0 +1,87 @@ +import { OFFLOAD, type OffloadOps, type OffloadReply } from "../../contracts/spec/offload.ts"; +import { registerServicePump } from "./services.ts"; + +export { OFFLOAD }; +export type { OffloadOps }; +/** Fixed-budget native resource upload when implemented by the host. */ +export function uploadCoverage(base64: string, width: number, height: number, foreground: number): number | undefined { + return (globalThis as unknown as { offload?: OffloadOps }).offload?.uploadCoverage?.(base64, width, height, foreground); +} +export type OffloadResult = { ok: true; value: string } | { ok: false; error: string }; +type Pending = { record: string; callback: (result: OffloadResult) => void; deadline: number; sent: boolean; session: number }; + +/** One client per JS realm. Inputs are already serialized bounded strings: + * arbitrary object traversal/serialization is never hidden inside this API. */ +export function createOffloadClient(ops: OffloadOps) { + const pending = new Map(); + let nextId = 1, frame = 0, disposed = false; + const finish = (id: number, result: OffloadResult) => { + const item = pending.get(id); + if (!item) return; + pending.delete(id); + item.callback(result); + }; + return { + connected: () => !disposed && ops.session() > 0, + pending: () => pending.size, + request(method: string, payload: string, callback: Pending["callback"]): number { + if (disposed || pending.size >= OFFLOAD.pending) return 0; + if (!/^[a-z][a-z0-9_.-]{0,63}$/.test(method)) throw new Error("Invalid offload capability"); + if (typeof payload !== "string" || payload.length > OFFLOAD.payloadChars) throw new Error("Offload payload exceeds budget"); + const id = nextId++; + const record = JSON.stringify({ v: 1, id, method, payload }); + // Conservative UTF-8 bound, refined without allocating a byte buffer. + let bytes = 0; + for (let i = 0; i < record.length; i++) { + const c = record.charCodeAt(i); + if (c >= 0xd800 && c <= 0xdbff && i + 1 < record.length) { bytes += 4; i++; } + else bytes += c < 128 ? 1 : c < 2048 ? 2 : 3; + } + if (bytes > OFFLOAD.recordBytes) throw new Error("Offload record exceeds budget"); + pending.set(id, { record, callback, deadline: frame + OFFLOAD.timeoutFrames, sent: false, session: 0 }); + return id; + }, + cancel(id: number) { pending.delete(id); }, + /** Called exactly once at the frame boundary by the realm service pump. */ + step() { + if (disposed) return; + frame++; + const session = ops.session(); + let delivered = false; + const raw = ops.take(); + if (raw && raw.length <= OFFLOAD.recordBytes) { + try { + const reply = JSON.parse(raw) as OffloadReply; + const item = pending.get(reply.id); + if (item?.sent && item.session === session && session > 0) { + delivered = true; + finish(reply.id, typeof reply.payload === "string" && reply.payload.length <= OFFLOAD.payloadChars + ? { ok: true, value: reply.payload } + : { ok: false, error: typeof reply.error === "string" ? reply.error.slice(0, 160) : "Malformed reply" }); + } + } catch { /* A malformed bounded record cannot stop the UI. */ } + } + let submitted = 0; + for (const [id, item] of pending) { + if (!delivered && (frame >= item.deadline || (item.sent && item.session !== session))) { + delivered = true; + finish(id, { ok: false, error: item.sent ? "Connection lost or request expired; outcome may be unknown" : "Provider unavailable" }); + } else if (!item.sent && session > 0 && submitted < OFFLOAD.submissionsPerFrame && ops.submit(item.record)) { + item.sent = true; item.session = session; submitted++; + } + } + }, + dispose() { disposed = true; pending.clear(); }, + }; +} + +let client: ReturnType | undefined; +/** No synchronous FS, DB, DNS or socket operation is exposed to the guest. */ +export function offload() { + if (client) return client; + const ops = (globalThis as unknown as { offload?: OffloadOps }).offload; + if (!ops) throw new Error("Host does not implement io.offload"); + client = createOffloadClient(ops); + registerServicePump(() => client!.step()); + return client; +} diff --git a/hosts/3ds/Makefile b/hosts/3ds/Makefile index 7d7e74b36..cf619faa8 100644 --- a/hosts/3ds/Makefile +++ b/hosts/3ds/Makefile @@ -81,6 +81,10 @@ CFLAGS := -Wall -Wextra -O2 -g -std=gnu11 -mword-relocations -ffunction-sections -I$(INCLUDE_DIR) -I$(BUILD) -I$(POCKETJS_QUICKJS_DIR) \ -I$(DEVKITPRO)/libctru/include +ifeq ($(POCKETJS_OFFLOAD),1) +CFLAGS += -DPOCKETJS_OFFLOAD -DPOCKETJS_OFFLOAD_KEY='"sdmc:/pocketjs/offload/$(POCKETJS_OFFLOAD_SLOT).key"' +endif + ifeq ($(POCKETJS_CAPTURE),1) CFLAGS += -DPOCKETJS_CAPTURE \ -DPOCKETJS_CAPTURE_INPUT='"$(POCKETJS_CAPTURE_INPUT)"' \ @@ -93,7 +97,7 @@ LDFLAGS := -specs=3dsx.specs $(ARCH) -Wl,--gc-sections -Wl,-Map,$(BUILD)/pocketj LIBPATHS := -L$(DEVKITPRO)/libctru/lib LIBS := -lcitro3d -lctru -lm -OBJECTS := $(BUILD)/main.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)/offload.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 ELF := $(BUILD)/pocketjs-3ds.elf SMDH := $(BUILD)/pocketjs-3ds.smdh @@ -137,6 +141,9 @@ $(FLAGS_STAMP): $(FLAGS_STAMP).probe ; $(BUILD)/%.o: $(SOURCE)/%.c $(BUILD)/vshader_shbin.h $(FLAGS_STAMP) | $(BUILD) $(CC) $(CFLAGS) -c $< -o $@ +$(BUILD)/offload.o: $(SOURCE)/offload.h $(SOURCE)/offload_queue.h +$(BUILD)/qjs.o: $(SOURCE)/offload.h $(SOURCE)/offload_coverage.h + $(ELF): $(OBJECTS) $(CC) $(LDFLAGS) $(OBJECTS) $(POCKETJS_CORE_LIB) $(POCKETJS_QUICKJS_DIR)/libquickjs.a \ $(LIBPATHS) $(LIBS) -o $@ diff --git a/hosts/3ds/src/input.c b/hosts/3ds/src/input.c index cb7ea89a1..f21c634d0 100644 --- a/hosts/3ds/src/input.c +++ b/hosts/3ds/src/input.c @@ -127,3 +127,8 @@ size_t input_touch(uint32_t *packed) { *packed = ((uint32_t)touch.py << 9) | (uint32_t)touch.px; return 1; } + +bool input_offload_exit_requested(void) { + const uint32_t keys = KEY_L | KEY_R | KEY_START; + return (hidKeysHeld() & keys) == keys; +} diff --git a/hosts/3ds/src/input.h b/hosts/3ds/src/input.h index fd4949719..081033e8d 100644 --- a/hosts/3ds/src/input.h +++ b/hosts/3ds/src/input.h @@ -24,4 +24,6 @@ bool input_devmenu_blocks_guest(bool menu_visible); * 1 while down and writes one legacy-packed touch word, else 0. */ size_t input_touch(uint32_t *packed); +bool input_offload_exit_requested(void); + #endif diff --git a/hosts/3ds/src/main.c b/hosts/3ds/src/main.c index e9e6a66ab..45b3fe79c 100644 --- a/hosts/3ds/src/main.c +++ b/hosts/3ds/src/main.c @@ -32,6 +32,7 @@ #include "input.h" #include "pocket_core.h" #include "qjs.h" +#include "offload.h" #include "devserver.h" #include "devmenu.h" #include "runtime.h" @@ -704,7 +705,7 @@ int main(void) { snprintf(embedded->origin, sizeof embedded->origin, "romfs:/app.pocket (recovery)"); PocketRuntimeState runtime_state = {0}; -#ifndef POCKETJS_CAPTURE +#if !defined(POCKETJS_CAPTURE) && !defined(POCKETJS_OFFLOAD) if (!runtime_storage_init(&runtime_state, runtime_error, sizeof runtime_error)) { fail(runtime_error); } @@ -720,6 +721,12 @@ int main(void) { } #endif PocketRuntimeFailureLineage failures = {0}; +#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, @@ -739,6 +746,7 @@ int main(void) { 0 ); #endif +#endif /* ordinary recovery boot */ #ifdef POCKETJS_CAPTURE mkdir(CAPTURE_DIR, 0777); @@ -760,6 +768,12 @@ int main(void) { int32_t 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(); + uint32_t touch = 0; + size_t touch_count = input_touch(&touch); #else devserver_poll(); if (input_devmenu_toggle_requested()) devmenu_toggle(); @@ -834,6 +848,7 @@ int main(void) { size_t touch_count = devmenu_blocks_guest ? 0 : input_touch(&touch); #endif + u64 offload_cpu_start = svcGetSystemTick(); int32_t touch_hit = 0; size_t hit_count = ui_touch_hits_auxiliary( touch_count > 0 ? &touch : NULL, @@ -843,7 +858,7 @@ int main(void) { ); if (hit_count != touch_count) fail("auxiliary touch hit resolution failed"); if (!qjs_frame(buttons, analog, &touch, &touch_hit, touch_count)) { -#ifdef POCKETJS_CAPTURE +#if defined(POCKETJS_CAPTURE) || defined(POCKETJS_OFFLOAD) fail(qjs_last_error()); #else snprintf(runtime_error, sizeof runtime_error, "%s", qjs_last_error()); @@ -872,6 +887,7 @@ int main(void) { } #endif + u64 offload_ui_ticks = svcGetSystemTick() - offload_cpu_start; begin_frame_wait( #ifdef POCKETJS_CAPTURE frame @@ -879,12 +895,13 @@ int main(void) { run_frame #endif ); -#ifndef POCKETJS_CAPTURE +#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); #endif + offload_cpu_start = svcGetSystemTick(); gfx_begin_frame(); if (!gfx_prepare_surface(0, ui_draw_list_ptr(), words, VIEW_W, VIEW_H) || !gfx_prepare_surface( @@ -894,7 +911,7 @@ int main(void) { AUX_VIEW_W, AUX_VIEW_H )) { -#ifdef POCKETJS_CAPTURE +#if defined(POCKETJS_CAPTURE) || defined(POCKETJS_OFFLOAD) fail("PICA200 surface preparation failed"); #else C3D_FrameEnd(0); @@ -924,7 +941,8 @@ int main(void) { C3D_SetViewport(0, 0, AUX_VIEW_H, AUX_VIEW_W); gfx_draw_surface(1); C3D_FrameEnd(0); -#ifndef POCKETJS_CAPTURE + offload_measure((unsigned)((offload_ui_ticks + svcGetSystemTick() - offload_cpu_start) * 1000000 / SYSCLOCK_ARM11)); +#if !defined(POCKETJS_CAPTURE) && !defined(POCKETJS_OFFLOAD) guest.submitted_frames += 1; devserver_set_frame_stats( run_frame, @@ -961,6 +979,9 @@ 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)) { @@ -995,6 +1016,7 @@ int main(void) { run_frame #endif ); + offload_stop(); teardown_guest(); C3D_FrameEnd(0); release_choice(&guest, embedded); diff --git a/hosts/3ds/src/offload.c b/hosts/3ds/src/offload.c new file mode 100644 index 000000000..85169724e --- /dev/null +++ b/hosts/3ds/src/offload.c @@ -0,0 +1,138 @@ +/* All network service calls and key IO belong to this worker. The UI only + * reads atomics and copies fixed-size SPSC slots. No mutex or socket on UI. */ +#include "offload.h" +#include "offload_queue.h" +#include <3ds.h> +#include +#include +#include +#include +#include +#include +#include + +#ifndef POCKETJS_OFFLOAD_KEY +#define POCKETJS_OFFLOAD_KEY "sdmc:/pocketjs/offload/unpaired.key" +#endif +_Static_assert(ATOMIC_INT_LOCK_FREE == 2, "Offload requires lock-free 32-bit atomics"); +static OffloadQueue outgoing, incoming; +static _Atomic int connection; +static _Atomic bool running; +static Thread worker; +static unsigned sends, takes; +static _Atomic unsigned measured_frames, max_us, over_budget; +void offload_measure(unsigned us) { + atomic_fetch_add_explicit(&measured_frames, 1, memory_order_relaxed); + if (us > 16667) atomic_fetch_add_explicit(&over_budget, 1, memory_order_relaxed); + unsigned previous = atomic_load_explicit(&max_us, memory_order_relaxed); + if (us > previous) atomic_store_explicit(&max_us, us, memory_order_relaxed); +} +static OffloadRecord ui_record; + +void offload_frame(void) { sends = takes = 0; } +int offload_session(void) { return atomic_load_explicit(&connection, memory_order_acquire); } +bool offload_submit(const char *bytes, size_t length) { + int epoch = offload_session(); + if (epoch <= 0 || sends >= 2 || length > OFFLOAD_BYTES) return false; + sends++; + return offload_push(&outgoing, bytes, (uint32_t)length, (uint32_t)epoch); +} +size_t offload_take(char *out) { + if (takes++ >= 1 || !offload_pop(&incoming, &ui_record)) return 0; + if ((int)ui_record.generation != offload_session()) return 0; + memcpy(out, ui_record.bytes, ui_record.length); + return ui_record.length; +} +static bool transfer(int fd, char *p, size_t n, bool send_data) { + u64 deadline = osGetTime() + 10000; + while (n && atomic_load(&running)) { + int done = send_data ? send(fd, p, n, 0) : recv(fd, p, n, 0); + if (done > 0) { n -= (size_t)done; p += done; continue; } + if (done == 0 || (errno != EAGAIN && errno != EWOULDBLOCK) || osGetTime() > deadline) return false; + svcSleepThread(1000000); + } + return n == 0; +} +static void serve(void *unused) { + (void)unused; + char key[64]; + FILE *file = fopen(POCKETJS_OFFLOAD_KEY, "rb"); + if (!file) return; + size_t count = fread(key, 1, sizeof key, file); + fclose(file); + if (count != sizeof key) return; + void *soc = memalign(0x1000, 1024 * 1024); + if (!soc) return; + if (R_FAILED(socInit(soc, 1024 * 1024))) { free(soc); return; } + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) goto shutdown_soc; + int reuse = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof reuse); + struct sockaddr_in address = { .sin_family = AF_INET, .sin_port = htons(8741), .sin_addr.s_addr = INADDR_ANY }; + if (bind(listener, (struct sockaddr *)&address, sizeof address) || listen(listener, 1)) goto close_listener; + fcntl(listener, F_SETFL, O_NONBLOCK); + int generation = 0; + while (atomic_load(&running)) { + int fd = accept(listener, NULL, NULL); + if (fd < 0) { svcSleepThread(10000000); continue; } + fcntl(fd, F_SETFL, O_NONBLOCK); + char offered[64]; unsigned mismatch = 0; + if (!transfer(fd, offered, sizeof offered, false)) { close(fd); continue; } + for (unsigned i = 0; i < sizeof key; i++) mismatch |= key[i] ^ offered[i]; + if (mismatch) { close(fd); continue; } + generation++; + atomic_store_explicit(&connection, generation, memory_order_release); + OffloadRecord record; + char rx[OFFLOAD_BYTES + 4]; size_t have = 0, want = 4; + u64 last_progress = osGetTime(); + bool alive = true, ready = false; + u64 metrics_at = osGetTime(); + while (alive && atomic_load(&running)) { + if (osGetTime() - metrics_at >= 2000) { + metrics_at = osGetTime(); + char metrics[256]; + int size = snprintf(metrics, sizeof metrics, + "{\"v\":1,\"id\":0,\"method\":\"offload.metrics\",\"payload\":\"frames=%u maxCpuUs=%u over16ms=%u\"}", + atomic_load(&measured_frames), atomic_load(&max_us), atomic_load(&over_budget)); + uint32_t length = htonl((uint32_t)size); + alive = transfer(fd, (char *)&length, 4, true) && transfer(fd, metrics, size, true); + if (!alive) break; + } + if (offload_pop(&outgoing, &record) && record.generation == (uint32_t)generation) { + uint32_t length = htonl(record.length); + alive = transfer(fd, (char *)&length, 4, true) && transfer(fd, record.bytes, record.length, true); + } + if (ready) { + if (offload_push(&incoming, rx + 4, (uint32_t)(want - 4), (uint32_t)generation)) { ready = false; have = 0; want = 4; } + } else { + int n = recv(fd, rx + have, want - have, 0); + if (n > 0) { + have += (size_t)n; last_progress = osGetTime(); + if (have == 4 && want == 4) { + uint32_t length; memcpy(&length, rx, 4); length = ntohl(length); + if (!length || length > OFFLOAD_BYTES) { alive = false; continue; } + want = 4 + length; + } else if (have == want) ready = true; + } else if (n == 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) alive = false; + if (have && osGetTime() - last_progress > 10000) alive = false; + } + svcSleepThread(1000000); + } + atomic_store_explicit(&connection, -generation, memory_order_release); + close(fd); + } +close_listener: + close(listener); +shutdown_soc: + socExit(); free(soc); +} +bool offload_start(void) { + atomic_store(&running, true); + /* Lower priority than rendering; core -2 supports Old and New 3DS. */ + worker = threadCreate(serve, NULL, 32 * 1024, 0x3f, -2, false); + return worker != NULL; +} +void offload_stop(void) { + atomic_store(&running, false); + if (worker) { threadJoin(worker, U64_MAX); threadFree(worker); worker = NULL; } +} diff --git a/hosts/3ds/src/offload.h b/hosts/3ds/src/offload.h new file mode 100644 index 000000000..a7125c8c2 --- /dev/null +++ b/hosts/3ds/src/offload.h @@ -0,0 +1,12 @@ +#ifndef POCKET_OFFLOAD_H +#define POCKET_OFFLOAD_H +#include +#include +bool offload_start(void); +void offload_stop(void); +void offload_frame(void); +void offload_measure(unsigned microseconds); +int offload_session(void); +bool offload_submit(const char *bytes, size_t length); +size_t offload_take(char *out); +#endif diff --git a/hosts/3ds/src/offload_coverage.h b/hosts/3ds/src/offload_coverage.h new file mode 100644 index 000000000..cfa59201d --- /dev/null +++ b/hosts/3ds/src/offload_coverage.h @@ -0,0 +1,42 @@ +#ifndef POCKET_OFFLOAD_COVERAGE_H +#define POCKET_OFFLOAD_COVERAGE_H +#include +#include +#include +static inline int coverage_digit(char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + return c == '+' ? 62 : c == '/' ? 63 : -1; +} +/* Maximum envelope 512x16 RGBA. The caller owns one reusable scratch buffer. + * Input is 2-bit alpha, four pixels per byte, low bits first. */ +static inline int coverage_decode(const char *base64, size_t length, unsigned width, unsigned height, uint32_t color, uint8_t *rgba) { + if (!width || width > 512 || width % 4 || !height || height > 16) return 0; + unsigned count = width * height, bytes = count / 4; + if (length != ((bytes + 2) / 3) * 4) return 0; + unsigned envelope = 8; while (envelope < width) envelope *= 2; + memset(rgba, 0, 512 * 16 * 4); + unsigned pixel = 0; + for (size_t i = 0; i < length; i += 4) { + uint32_t value = 0; + for (unsigned j = 0; j < 4; j++) { + int digit = coverage_digit(base64[i + j]); + if (digit < 0) { + if (base64[i + j] != '=' || i + 4 != length || j < 2) return 0; + digit = 0; + } + value = (value << 6) | (unsigned)digit; + } + for (int byte = 2; byte >= 0 && pixel < count; byte--) { + unsigned packed = (value >> (byte * 8)) & 255; + for (unsigned part = 0; part < 4; part++, pixel++) { + uint8_t *p = rgba + ((pixel / width) * envelope + pixel % width) * 4; + p[0] = color; p[1] = color >> 8; p[2] = color >> 16; + p[3] = ((packed >> (part * 2)) & 3) * 85; + } + } + } + return (int)envelope; +} +#endif diff --git a/hosts/3ds/src/offload_queue.h b/hosts/3ds/src/offload_queue.h new file mode 100644 index 000000000..33742b972 --- /dev/null +++ b/hosts/3ds/src/offload_queue.h @@ -0,0 +1,33 @@ +#ifndef POCKET_OFFLOAD_QUEUE_H +#define POCKET_OFFLOAD_QUEUE_H +#include +#include +#include +#include +#define OFFLOAD_BYTES 4096 +#define OFFLOAD_SLOTS 8 +typedef struct { uint32_t generation, length; char bytes[OFFLOAD_BYTES]; } OffloadRecord; +/* Single producer, single consumer. Neither endpoint waits on the other. + * A published slot is immutable until its consumer releases it. */ +typedef struct { + _Atomic uint32_t read, write; + OffloadRecord slots[OFFLOAD_SLOTS]; +} OffloadQueue; +static inline bool offload_push(OffloadQueue *q, const char *p, uint32_t n, uint32_t generation) { + uint32_t w = atomic_load_explicit(&q->write, memory_order_relaxed); + uint32_t r = atomic_load_explicit(&q->read, memory_order_acquire); + if (n == 0 || n > OFFLOAD_BYTES || w - r >= OFFLOAD_SLOTS) return false; + OffloadRecord *s = &q->slots[w % OFFLOAD_SLOTS]; + s->length = n; s->generation = generation; memcpy(s->bytes, p, n); + atomic_store_explicit(&q->write, w + 1, memory_order_release); + return true; +} +static inline bool offload_pop(OffloadQueue *q, OffloadRecord *out) { + uint32_t r = atomic_load_explicit(&q->read, memory_order_relaxed); + uint32_t w = atomic_load_explicit(&q->write, memory_order_acquire); + if (r == w) return false; + *out = q->slots[r % OFFLOAD_SLOTS]; + atomic_store_explicit(&q->read, r + 1, memory_order_release); + return true; +} +#endif diff --git a/hosts/3ds/src/qjs.c b/hosts/3ds/src/qjs.c index 069781ee8..3ac8d5973 100644 --- a/hosts/3ds/src/qjs.c +++ b/hosts/3ds/src/qjs.c @@ -19,6 +19,8 @@ */ #include "qjs.h" +#include "offload.h" +#include "offload_coverage.h" #include #include @@ -41,6 +43,7 @@ #define POCKETJS_JS_STACK_SIZE (192 * 1024) typedef enum { + HostOffloadSession, HostOffloadSubmit, HostOffloadTake, HostOffloadCoverage, HostCreateNode, HostDestroyNode, HostInsertBefore, @@ -89,6 +92,8 @@ static const uint8_t *installed_pack; static size_t installed_pack_length; static char last_error[512]; static char debug_poll_buffer[32 * 1024]; +static uint8_t coverage_pixels[512 * 16 * 4]; +static bool coverage_used; static void set_error(const char *message) { size_t length = message == NULL ? 0 : strlen(message); @@ -419,6 +424,37 @@ static JSValue host_operation( JS_FreeCString(ctx, text); } return JS_UNDEFINED; + case HostOffloadCoverage: { + if (coverage_used || argc < 4 || !JS_IsString(argv[0])) return JS_NewInt32(ctx, -1); + JSValue length_value = JS_GetPropertyStr(ctx, argv[0], "length"); + int32_t chars = 0; JS_ToInt32(ctx, &chars, length_value); JS_FreeValue(ctx, length_value); + if (chars > 2732) return JS_NewInt32(ctx, -1); + coverage_used = true; + text = JS_ToCStringLen2(ctx, &text_length, argv[0], 0); + int height = argument_int(ctx, argc, argv, 2); + int envelope = text ? coverage_decode(text, text_length, argument_int(ctx, argc, argv, 1), height, + (uint32_t)argument_int(ctx, argc, argv, 3), coverage_pixels) : 0; + if (text) JS_FreeCString(ctx, text); + if (!envelope) return JS_NewInt32(ctx, -1); + unsigned padded_height = 8; while (padded_height < (unsigned)height) padded_height *= 2; + return JS_NewInt32(ctx, ui_upload_texture(coverage_pixels, envelope * padded_height * 4, envelope, padded_height, 3)); + } + case HostOffloadSession: return JS_NewInt32(ctx, offload_session()); + case HostOffloadSubmit: { + if (argc < 1 || !JS_IsString(argv[0])) return JS_FALSE; + /* Reject before UTF-8 flattening: string length is a constant-time op. */ + JSValue length_value = JS_GetPropertyStr(ctx, argv[0], "length"); + int32_t chars = 0; JS_ToInt32(ctx, &chars, length_value); JS_FreeValue(ctx, length_value); + if (chars > 4096) return JS_FALSE; + text = JS_ToCStringLen2(ctx, &text_length, argv[0], 0); + bool ok = text && offload_submit(text, text_length); + if (text) JS_FreeCString(ctx, text); + return JS_NewBool(ctx, ok); + } + case HostOffloadTake: { + size_t length = offload_take(debug_poll_buffer); + return length ? JS_NewStringLen(ctx, debug_poll_buffer, length) : JS_UNDEFINED; + } case HostDbgShot: return JS_NewBool(ctx, devserver_request_screenshot()); } @@ -461,6 +497,14 @@ static void set_named_property(JSValueConst object, const uint8_t *name, size_t } static void install_host(void) { +#ifdef POCKETJS_OFFLOAD + JSValue offload = JS_NewObject(context); + add_operation(offload, "uploadCoverage", 4, HostOffloadCoverage); + add_operation(offload, "session", 0, HostOffloadSession); + add_operation(offload, "submit", 1, HostOffloadSubmit); + add_operation(offload, "take", 0, HostOffloadTake); + JS_SetPropertyStr(context, global, "offload", offload); +#endif JSValue ui = JS_NewObject(context); add_operation(ui, "createNode", 1, HostCreateNode); @@ -668,6 +712,8 @@ bool qjs_frame( size_t touch_count ) { if (context == NULL) return false; + offload_frame(); + coverage_used = false; JSValue arguments[5] = { JS_NewInt32(context, buttons), JS_NewInt32(context, analog), diff --git a/package.json b/package.json index 32b9ff933..38e9fdfd2 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,9 @@ "exports": { ".": "./framework/src/index.ts", "./animation": "./framework/src/animation.ts", + "./offload/provider": "./tools/offload-provider.ts", + "./offload/capabilities": "./tools/offload-capabilities.ts", + "./offload": "./framework/src/offload.ts", "./audio": "./framework/src/audio-api.ts", "./clock": "./framework/src/clock.ts", "./config": "./framework/src/config.ts", @@ -173,6 +176,7 @@ "./solid/renderer": "./framework/src/renderer-solid.ts", "./vue-vapor": "./framework/src/index-vue-vapor.ts", "./vue-vapor/animation": "./framework/src/animation.ts", + "./vue-vapor/offload": "./framework/src/offload.ts", "./vue-vapor/audio": "./framework/src/audio-api.ts", "./vue-vapor/clock": "./framework/src/clock.ts", "./vue-vapor/db": "./framework/src/db-api.ts", @@ -188,6 +192,7 @@ "./vue-vapor/renderer": "./framework/src/renderer-vue-vapor.ts", "./octane": "./framework/src/index-octane.ts", "./octane/animation": "./framework/src/animation.ts", + "./octane/offload": "./framework/src/offload.ts", "./octane/audio": "./framework/src/audio-api.ts", "./octane/clock": "./framework/src/clock.ts", "./octane/db": "./framework/src/db-api.ts", diff --git a/tests/3ds-profile.test.ts b/tests/3ds-profile.test.ts index 4df4f5dee..0c50b30f8 100644 --- a/tests/3ds-profile.test.ts +++ b/tests/3ds-profile.test.ts @@ -101,6 +101,7 @@ describe("private Nintendo 3DS build profile", () => { }, }, capabilities: [ + "io.offload", "input.analog.left", "input.buttons", "input.cursor", diff --git a/tests/fixtures/offload-queue.c b/tests/fixtures/offload-queue.c new file mode 100644 index 000000000..2c9aa365d --- /dev/null +++ b/tests/fixtures/offload-queue.c @@ -0,0 +1,42 @@ +#include "../../hosts/3ds/src/offload_queue.h" +#include "../../hosts/3ds/src/offload_coverage.h" +#include +#include +#include +static OffloadQueue queue; +static void *produce(void *unused) { + (void)unused; + for (uint32_t i = 0; i < 100000; i++) { + char bytes[OFFLOAD_BYTES]; memset(bytes, i & 255, sizeof bytes); + while (!offload_push(&queue, bytes, sizeof bytes, i)) {} + } + return NULL; +} +int main(void) { + _Static_assert(ATOMIC_INT_LOCK_FREE == 2, "UI queue requires lock-free atomics"); + uint8_t rgba[512 * 16 * 4]; + assert(coverage_decode("5OTk", 4, 12, 1, 0xff123456, rgba) == 16); + for (unsigned i = 0; i < 12; i++) { + assert(rgba[i * 4] == 0x56 && rgba[i * 4 + 1] == 0x34 && rgba[i * 4 + 2] == 0x12); + assert(rgba[i * 4 + 3] == (i % 4) * 85); + } + assert(rgba[12 * 4] == 0); + assert(!coverage_decode("!!!!", 4, 12, 1, 0, rgba)); + assert(!coverage_decode("5OTk", 4, 516, 1, 0, rgba)); + assert(!coverage_decode("5OTk", 4, 12, 17, 0, rgba)); + char byte = 0; OffloadRecord record; + assert(!offload_pop(&queue, &record)); + assert(!offload_push(&queue, &byte, OFFLOAD_BYTES + 1, 0)); + for (int i = 0; i < OFFLOAD_SLOTS; i++) assert(offload_push(&queue, &byte, 1, i)); + assert(!offload_push(&queue, &byte, 1, 9)); + for (int i = 0; i < OFFLOAD_SLOTS; i++) { assert(offload_pop(&queue, &record)); assert(record.generation == (uint32_t)i); } + // Exercise monotonic counters over uint32 wrap. + atomic_store(&queue.read, UINT32_MAX - 4); atomic_store(&queue.write, UINT32_MAX - 4); + pthread_t thread; assert(!pthread_create(&thread, NULL, produce, NULL)); + for (uint32_t i = 0; i < 100000; i++) { + while (!offload_pop(&queue, &record)) {} + assert(record.generation == i && record.length == OFFLOAD_BYTES); + for (int j = 0; j < OFFLOAD_BYTES; j++) assert((unsigned char)record.bytes[j] == (i & 255)); + } + pthread_join(thread, NULL); puts("100000 SPSC records verified, including full, empty and counter wrap"); +} diff --git a/tests/offload.test.ts b/tests/offload.test.ts new file mode 100644 index 000000000..5d128b034 --- /dev/null +++ b/tests/offload.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { createOffloadClient, OFFLOAD } from "../framework/src/offload.ts"; +import { OffloadDecoder, encodeOffloadRecord } from "../tools/offload-wire.ts"; +import { dispatchOffload } from "../tools/offload-provider.ts"; +import { sqliteQueries, httpResources } from "../tools/offload-capabilities.ts"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +function rig() { + let session = 1; + const sent: string[] = [], replies: string[] = []; + const client = createOffloadClient({ session: () => session, submit: record => { sent.push(record); return true; }, take: () => replies.shift() }); + return { client, sent, replies, disconnect: () => session = -1, reconnect: () => session = 2 }; +} +describe("offload budgets and failure delivery", () => { + test("native SPSC concurrency, wrap and coverage decoder pass sanitizers", () => { + const scratch = mkdtempSync(join(tmpdir(), "pocket-offload-")); + try { + const binary = join(scratch, "queue"); + const compile = Bun.spawnSync(["cc", "-std=c11", "-O2", "-pthread", "-fsanitize=address,undefined", resolve(import.meta.dir, "fixtures/offload-queue.c"), "-o", binary]); + if (compile.exitCode) throw new Error(compile.stderr.toString()); + const run = Bun.spawnSync([binary]); + if (run.exitCode) throw new Error(run.stderr.toString()); + expect(run.stdout.toString()).toContain("100000 SPSC records verified"); + } finally { rmSync(scratch, { recursive: true }); } + }); + test("limits tickets, submissions and deliveries independently", () => { + const r = rig(); let delivered = 0; + for (let i = 0; i < 8; i++) expect(r.client.request("db.page", "{}", () => delivered++)).toBeGreaterThan(0); + expect(r.client.request("db.page", "{}", () => {})).toBe(0); + r.client.step(); expect(r.sent.length).toBe(2); + for (const s of r.sent) r.replies.push(JSON.stringify({ id: JSON.parse(s).id, payload: "[]" })); + r.client.step(); expect(delivered).toBe(1); expect(r.sent.length).toBe(4); + r.client.step(); expect(delivered).toBe(2); + }); + test("does not replay sent mutations or deliver old connection results", () => { + const r = rig(); const results: unknown[] = []; + const id = r.client.request("file.save", "edit", p => results.push(p)); + r.client.step(); r.disconnect(); r.client.step(); + expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ ok: false }); + r.reconnect(); r.replies.push(JSON.stringify({ id, payload: "saved" })); r.client.step(); + expect(results).toHaveLength(1); expect(r.sent).toHaveLength(1); + }); + test("cancellation, timeout and malformed records leave bounded state", () => { + const r = rig(); let delivered = 0; + const id = r.client.request("slow.query", "{}", () => delivered++); + r.client.cancel(id); r.client.step(); expect(r.sent).toHaveLength(0); + r.client.request("slow.query", "{}", () => delivered++); + r.replies.push("{bad"); + for (let i = 0; i <= OFFLOAD.timeoutFrames; i++) r.client.step(); + expect(delivered).toBe(1); expect(r.client.pending()).toBe(0); + expect(() => r.client.request("db.page", "中".repeat(2500), () => {})).toThrow(); + }); + test("UTF-8 records survive every split and reject oversized length immediately", () => { + const record = JSON.stringify({ text: "文档 😀" }), bytes = encodeOffloadRecord(record); + for (let split = 0; split <= bytes.length; split++) { + const decoder = new OffloadDecoder(), out: string[] = []; + decoder.push(bytes.subarray(0, split), s => out.push(s)); decoder.push(bytes.subarray(split), s => out.push(s)); + expect(out).toEqual([record]); + } + expect(() => new OffloadDecoder().push(Buffer.from([0, 0, 16, 1]), () => {})).toThrow(); + }); + test("provider enforces grants and reply budgets", async () => { + expect(await dispatchOffload({}, { v: 1, id: 1, method: "constructor", payload: "" })).toHaveProperty("error"); + expect(await dispatchOffload({ large: () => "x".repeat(3000) }, { v: 1, id: 2, method: "large", payload: "" })).toHaveProperty("error"); + expect(await dispatchOffload({ query: () => "[]" }, { v: 1, id: 3, method: "query", payload: "" })).toEqual({ id: 3, payload: "[]" }); + }); + test("SQLite query grants accept values and reject unbounded results", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE notes(id INTEGER, title TEXT); INSERT INTO notes VALUES(1,'hello'),(2,'world')"); + const methods = sqliteQueries(db, { find: "SELECT title FROM notes WHERE id=? LIMIT 1", large: "SELECT hex(zeroblob(2000))" }); + expect(methods.find("[2]")).toBe('[{"title":"world"}]'); + expect(() => methods.large("[]")).toThrow("bounded page"); + expect(() => methods.find('{"sql":"DROP TABLE notes"}')).toThrow(); + db.close(); + }); + test("HTTP grants reject redirects and stop oversized streams", async () => { + const server = Bun.serve({ port: 0, fetch: r => new URL(r.url).pathname === "/redirect" ? Response.redirect("https://example.com") : new Response(new URL(r.url).pathname === "/big" ? "x".repeat(3000) : "remote data") }); + try { + const m = httpResources({ small: `${server.url}small`, big: `${server.url}big`, redirect: `${server.url}redirect` }); + expect(await m.small("")).toBe("remote data"); + await expect(m.big("")).rejects.toThrow(); await expect(m.redirect("")).rejects.toThrow(); + } finally { server.stop(true); } + }); +}); diff --git a/tools/3ds-profile.ts b/tools/3ds-profile.ts index a0dd1785e..40e1666cf 100644 --- a/tools/3ds-profile.ts +++ b/tools/3ds-profile.ts @@ -42,6 +42,7 @@ export const THREE_DS_DEV_CONTRACTS = definePlatformContractRegistry( }, }, capabilities: [ + "io.offload", "input.analog.left", "input.buttons", "input.cursor", diff --git a/tools/3ds.ts b/tools/3ds.ts index 678e3a5fe..da962f113 100644 --- a/tools/3ds.ts +++ b/tools/3ds.ts @@ -937,6 +937,8 @@ export async function build3ds(argv: readonly string[]): Promise { POCKETJS_APP_POCKET: containerPathFor(pocketOutput, mounts), POCKETJS_BUILD_DIR: containerPathFor(buildDirectory, mounts), POCKETJS_OUT_3DSX: containerPathFor(output, mounts), + POCKETJS_OFFLOAD: plan.features["io.offload"] ? "1" : "", + POCKETJS_OFFLOAD_SLOT: createHash("sha256").update(plan.app.id).digest("hex").slice(0, 16), POCKETJS_SMDH_TITLE: plan.app.title, POCKETJS_SMDH_AUTHOR: plan.app.id, POCKETJS_SMDH_DESC: `PocketJS ${plan.app.title}`, diff --git a/tools/offload-capabilities.ts b/tools/offload-capabilities.ts new file mode 100644 index 000000000..a279bd651 --- /dev/null +++ b/tools/offload-capabilities.ts @@ -0,0 +1,38 @@ +import type { Database, SQLQueryBindings } from "bun:sqlite"; +import { OFFLOAD } from "../contracts/spec/offload.ts"; +export type OffloadMethods = Record string | Promise>; + +/** Provider-owned SQL, parameter values only from the device. Install on a + * Worker, with a DB confined to the provider's granted application directory. */ +export function sqliteQueries(db: Database, queries: Record): OffloadMethods { + return Object.fromEntries(Object.entries(queries).map(([name, sql]) => [name, (payload: string) => { + const params = JSON.parse(payload) as SQLQueryBindings[]; + if (!Array.isArray(params) || params.length > 16 || params.some(p => p !== null && typeof p !== "string" && typeof p !== "number")) throw new Error("Invalid SQL parameters"); + const rows = db.query(sql).all(...params); + const result = JSON.stringify(rows); + if (rows.length > 32 || result.length > OFFLOAD.payloadChars) throw new Error("Query must return a bounded page"); + return result; + }])); +} + +/** Exact URLs are granted by the provider. No device-supplied destination, + * redirects, headers, credentials, or unbounded whole-response allocation. */ +export function httpResources(resources: Record): OffloadMethods { + return Object.fromEntries(Object.entries(resources).map(([name, url]) => [name, async () => { + const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(5000) }); + if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; let bytes = 0; + try { + while (true) { + const { value, done } = await reader.read(); if (done) break; + bytes += value.length; + if (bytes > 2000) throw new Error("HTTP resource exceeds page budget"); + chunks.push(value); + } + } finally { await reader.cancel(); } + const out = new Uint8Array(bytes); let offset = 0; + for (const chunk of chunks) { out.set(chunk, offset); offset += chunk.length; } + return new TextDecoder("utf-8", { fatal: true }).decode(out); + }])); +} diff --git a/tools/offload-provider.ts b/tools/offload-provider.ts new file mode 100644 index 000000000..1512dc4b9 --- /dev/null +++ b/tools/offload-provider.ts @@ -0,0 +1,88 @@ +/** Desktop transport. Capability implementations execute in a Worker owned by + * each authenticated device connection, never inside the socket callbacks. */ +import { connect } from "node:net"; +import { OFFLOAD, type OffloadRequest, type OffloadReply } from "../contracts/spec/offload.ts"; +import { OffloadDecoder, encodeOffloadRecord } from "./offload-wire.ts"; + +export function connectOffloadProvider(options: { + address: string; key: string; worker: string | URL; data?: unknown; + port?: number; log?: (message: string) => void; +}) { + if (!/^[0-9a-f]{64}$/.test(options.key)) throw new Error("Expected a 256-bit pairing key"); + let stopped = false; + let closeCurrent = () => {}; + let retry: ReturnType | undefined; + const attach = () => { + if (stopped) return; + const socket = connect({ host: options.address, port: options.port ?? OFFLOAD.port }); + const decoder = new OffloadDecoder(); + let worker: Worker | undefined; + const pending = new Set(); + const deadlines = new Map>(); + closeCurrent = () => socket.destroy(); + socket.setNoDelay(true); + socket.setTimeout(15000, () => socket.destroy()); + socket.on("connect", () => { + socket.write(options.key); + worker = new Worker(options.worker, { type: "module" }); + worker.postMessage({ init: options.data }); + worker.onerror = () => socket.destroy(); + worker.onmessage = (event: MessageEvent) => { + const reply = event.data; + if (!pending.delete(reply.id)) return socket.destroy(); + clearTimeout(deadlines.get(reply.id)); deadlines.delete(reply.id); + try { + if (typeof reply.payload === "string" && reply.payload.length > OFFLOAD.payloadChars) throw new Error("Result budget exceeded"); + const record = encodeOffloadRecord(JSON.stringify(reply)); + if (socket.writableLength > OFFLOAD.recordBytes * OFFLOAD.pending) return socket.destroy(); + socket.write(record); + } catch { socket.destroy(); } + }; + options.log?.("Transport connected; waiting for paired device requests"); + }); + socket.on("data", chunk => { + try { + decoder.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk, raw => { + const request = JSON.parse(raw) as OffloadRequest; + if (request.v === 1 && request.id === 0 && request.method === "offload.metrics" && typeof request.payload === "string" && request.payload.length < 160) { + options.log?.(`Device ${request.payload}`); return; + } + if (request.v !== 1 || !Number.isSafeInteger(request.id) || request.id < 1 || + typeof request.method !== "string" || !/^[a-z][a-z0-9_.-]{0,63}$/.test(request.method) || + typeof request.payload !== "string" || request.payload.length > OFFLOAD.payloadChars || + pending.size >= OFFLOAD.pending || pending.has(request.id)) throw new Error("Invalid request"); + pending.add(request.id); + // Terminate a wedged provider worker. Sent mutations are never retried. + deadlines.set(request.id, setTimeout(() => socket.destroy(), 9000)); + worker!.postMessage(request); + }); + } catch { socket.destroy(); } + }); + socket.on("error", () => {}); + socket.on("close", () => { + worker?.terminate(); + for (const timer of deadlines.values()) clearTimeout(timer); + if (!stopped) retry = setTimeout(attach, 1500); + }); + }; + attach(); + return { close() { stopped = true; clearTimeout(retry); closeCurrent(); } }; +} + +/** Worker-side allowlist. A missing method cannot open arbitrary resources. */ +export async function dispatchOffload( + methods: Readonly string | Promise>>, + request: OffloadRequest, +): Promise { + try { + const handler = Object.prototype.hasOwnProperty.call(methods, request.method) ? methods[request.method] : undefined; + if (!handler) throw new Error("Capability not granted"); + const payload = await handler(request.payload); + if (payload.length > OFFLOAD.payloadChars) throw new Error("Result budget exceeded"); + const reply = { id: request.id, payload }; + encodeOffloadRecord(JSON.stringify(reply)); + return reply; + } catch (error) { + return { id: request.id, error: error instanceof Error ? error.message.slice(0, 160) : "Provider failed" }; + } +} diff --git a/tools/offload-wire.ts b/tools/offload-wire.ts new file mode 100644 index 000000000..f2a185326 --- /dev/null +++ b/tools/offload-wire.ts @@ -0,0 +1,32 @@ +import { OFFLOAD } from "../contracts/spec/offload.ts"; + +export function encodeOffloadRecord(record: string): Buffer { + const payload = Buffer.from(record); + if (!payload.length || payload.length > OFFLOAD.recordBytes) throw new Error("Offload record budget exceeded"); + const out = Buffer.allocUnsafe(payload.length + 4); + out.writeUInt32BE(payload.length); payload.copy(out, 4); + return out; +} +/** Fixed allocation, including partial headers and arbitrarily split UTF-8. */ +export class OffloadDecoder { + private bytes = Buffer.alloc(OFFLOAD.recordBytes + 4); + private have = 0; + private want = 4; + push(chunk: Uint8Array, deliver: (record: string) => void) { + let offset = 0; + while (offset < chunk.length) { + const n = Math.min(chunk.length - offset, this.want - this.have); + this.bytes.set(chunk.subarray(offset, offset + n), this.have); + this.have += n; offset += n; + if (this.have !== this.want) continue; + if (this.want === 4) { + const size = this.bytes.readUInt32BE(); + if (!size || size > OFFLOAD.recordBytes) throw new Error("Invalid offload frame length"); + this.want = size + 4; + } else { + const record = this.bytes.toString("utf8", 4, this.want); + this.have = 0; this.want = 4; deliver(record); + } + } + } +} diff --git a/tools/test.ts b/tools/test.ts index 73c2ccba9..98fa0b3da 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -80,6 +80,7 @@ const SUITE: readonly Stage[] = [ "tests/db.test.ts", "tests/fs.test.ts", "tests/net.test.ts", + "tests/offload.test.ts", "tests/net-web.test.js", "tests/vita-package.test.ts", "tests/psp-toolchain.test.ts", From 745e8a7f97655006938c48f1da82ccf7a3dcd987 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:44:57 -0700 Subject: [PATCH 02/13] fix(offload): expose reconnect generations for cache validation --- docs/OFFLOAD.md | 4 ++++ framework/src/offload.ts | 1 + tests/offload.test.ts | 1 + 3 files changed, 6 insertions(+) diff --git a/docs/OFFLOAD.md b/docs/OFFLOAD.md index 5ff1d23a8..574798a3a 100644 --- a/docs/OFFLOAD.md +++ b/docs/OFFLOAD.md @@ -63,6 +63,10 @@ serialize it. Providers return equally bounded strings. Pagination and resource chunking belong to the capability contract, not to an unbounded accumulator in the guest. +`work.session()` exposes the authenticated generation (nonpositive when +offline). Applications can revalidate cached revisions when it changes, even +if a reconnect happens between two UI frames without an observed offline frame. + `uploadCoverage(base64, width, height, foreground)` uploads a bounded 2-bit alpha mask when a host implements it. Width must be a multiple of four and at most 512; height is 1–16. Foreground is ABGR. The texture uses the next power-of-two diff --git a/framework/src/offload.ts b/framework/src/offload.ts index 00b9b6181..23f8ade04 100644 --- a/framework/src/offload.ts +++ b/framework/src/offload.ts @@ -23,6 +23,7 @@ export function createOffloadClient(ops: OffloadOps) { }; return { connected: () => !disposed && ops.session() > 0, + session: () => disposed ? 0 : ops.session(), pending: () => pending.size, request(method: string, payload: string, callback: Pending["callback"]): number { if (disposed || pending.size >= OFFLOAD.pending) return 0; diff --git a/tests/offload.test.ts b/tests/offload.test.ts index 5d128b034..352b3938f 100644 --- a/tests/offload.test.ts +++ b/tests/offload.test.ts @@ -40,6 +40,7 @@ describe("offload budgets and failure delivery", () => { r.client.step(); r.disconnect(); r.client.step(); expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ ok: false }); r.reconnect(); r.replies.push(JSON.stringify({ id, payload: "saved" })); r.client.step(); + expect(r.client.session()).toBe(2); expect(results).toHaveLength(1); expect(r.sent).toHaveLength(1); }); test("cancellation, timeout and malformed records leave bounded state", () => { From 3e4291e5dbbd7606e02c03940d049e7e65658fd7 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:04:10 -0700 Subject: [PATCH 03/13] feat(resource): add deferred subtree and image fallbacks --- docs/RESOURCES.md | 39 +++++++++++++++++++ framework/compiler/subpaths.ts | 2 + framework/src/resource-state.ts | 39 +++++++++++++++++++ framework/src/resource.ts | 69 +++++++++++++++++++++++++++++++++ package.json | 4 ++ tests/resource.test.ts | 45 +++++++++++++++++++++ tools/test.ts | 1 + 7 files changed, 199 insertions(+) create mode 100644 docs/RESOURCES.md create mode 100644 framework/src/resource-state.ts create mode 100644 framework/src/resource.ts create mode 100644 tests/resource.test.ts diff --git a/docs/RESOURCES.md b/docs/RESOURCES.md new file mode 100644 index 000000000..d91b28ade --- /dev/null +++ b/docs/RESOURCES.md @@ -0,0 +1,39 @@ +# Deferred content + +`@pocketjs/framework/resource-state` represents a value as **pending, ready or +error**. It has no transport or renderer dependency. `createResourceSlot()` +issues completion tickets; a superseded, duplicate or disposed completion is +rejected. The caller schedules requests and owns cancellation and native assets. + +`@pocketjs/framework/resource` provides the Solid `ResourceBoundary` and +`ResourceImage` components. **Only the affected subtree shows a fallback.** +Rendering does not start IO, await a Promise or stop input and frame hooks. + +```tsx +import { ResourceBoundary } from "@pocketjs/framework/resource"; + + } + errorFallback={() => }> + {row => } + +``` + +The application supplies lazy factories for its fallback and content. Ready +values update without remounting the content. Returning to pending disposes +that subtree. Preserve cached content by keeping its state ready while a +separate refresh is outstanding when that behavior is appropriate. + +`ResourceImage` accepts the same state and fallback props for a +`{ handle, width, height }` texture. Its outer View keeps the application's +layout and clipping while the texture is unavailable. Dimensions describe the +texture envelope; a smaller outer View can crop padded pixels. **The image +borrows the texture handle**; its cache or resource owner calls `freeTexture`. + +An offload completion may publish a resource state during the existing bounded +service pump. This adds no polling, chunk accumulator or request scheduler. +Text pages, table rows and image tiles use the same availability contract; +the application chooses their placeholder geometry. Animated skeletons should +use a shared animation clock or native animation, rather than one timer per row. + +The state model is renderer-neutral. The UI boundary in this change supports +Solid; Vue Vapor and Octane boundary components are not implemented. diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts index 78d8d4c2a..2dea220ba 100644 --- a/framework/compiler/subpaths.ts +++ b/framework/compiler/subpaths.ts @@ -58,6 +58,8 @@ export const SUBPATHS: Record = { "offload/provider": { file: "tools/offload-provider.ts" }, "offload/capabilities": { file: "tools/offload-capabilities.ts" }, offload: { file: "framework/src/offload.ts", aliases: TWINS }, + "resource-state": { file: "framework/src/resource-state.ts", aliases: TWINS }, + resource: { file: { solid: "framework/src/resource.ts" } }, audio: { file: "framework/src/audio-api.ts", aliases: TWINS }, clock: { file: "framework/src/clock.ts", aliases: TWINS }, config: { file: "framework/src/config.ts" }, diff --git a/framework/src/resource-state.ts b/framework/src/resource-state.ts new file mode 100644 index 000000000..9e4dcb99d --- /dev/null +++ b/framework/src/resource-state.ts @@ -0,0 +1,39 @@ +/** A value's availability, independent of its transport or UI framework. */ +export type ResourceState = + | { status: "pending" } + | { status: "ready"; value: T } + | { status: "error"; error: unknown }; + +const PENDING = Object.freeze({ status: "pending" as const }); +export const pending = (): ResourceState => PENDING; +export const ready = (value: T): ResourceState => ({ status: "ready", value }); +export const failed = (error: unknown): ResourceState => ({ status: "error", error }); + +/** Completion tickets fence superseded requests and disposed resources. + * The caller owns scheduling, cancellation and any native texture lifetime. */ +export function createResourceSlot(changed: () => void = () => {}) { + let generation = 0; + let disposed = false; + let state: ResourceState = pending(); + const publish = (next: ResourceState) => { state = next; changed(); }; + return { + state: () => state, + begin() { + if (disposed) return 0; + generation++; + publish(pending()); + return generation; + }, + resolve(ticket: number, value: T) { + if (disposed || !ticket || ticket !== generation || state.status !== "pending") return false; + publish(ready(value)); + return true; + }, + reject(ticket: number, error: unknown) { + if (disposed || !ticket || ticket !== generation || state.status !== "pending") return false; + publish(failed(error)); + return true; + }, + dispose() { disposed = true; generation++; state = pending(); }, + }; +} diff --git a/framework/src/resource.ts b/framework/src/resource.ts new file mode 100644 index 000000000..d073a2c76 --- /dev/null +++ b/framework/src/resource.ts @@ -0,0 +1,69 @@ +import { createMemo, createRenderEffect, Show, type Accessor, type JSX } from "solid-js"; +import { Image, View, type ViewProps } from "./primitives.ts"; +import type { NodeMirror } from "./renderer.ts"; +import { getOps } from "./host.ts"; +import type { ResourceState } from "./resource-state.ts"; +export { createResourceSlot, pending, ready, failed, type ResourceState } from "./resource-state.ts"; + +export interface ResourceBoundaryProps { + state: Accessor>; + fallback: () => JSX.Element; + errorFallback?: (error: unknown) => JSX.Element; + children: (value: Accessor) => JSX.Element; +} + +/** Reveals only this subtree. Rendering never starts IO or waits for a Promise. + * Factories are lazy, and superseded content is disposed by Solid's owner. */ +export function ResourceBoundary(props: ResourceBoundaryProps): JSX.Element { + const state = createMemo(props.state); + const status = createMemo(() => state().status); + const error = createMemo(() => { const value = state(); return value.status === "error" ? value.error : undefined; }); + return Show({ + get when() { return status() === "ready"; }, + get fallback() { + return status() === "error" && props.errorFallback + ? props.errorFallback(error()) : props.fallback(); + }, + get children() { + return props.children(() => { + const current = state(); + if (current.status !== "ready") throw new Error("Resource read outside its ready subtree"); + return current.value; + }); + }, + }); +} + +/** A decoded/uploaded image, including its texture envelope dimensions. */ +export interface TextureResource { handle: number; width: number; height: number } +export interface ResourceImageProps extends Pick { + state: Accessor>; + fallback: () => JSX.Element; + errorFallback?: (error: unknown) => JSX.Element; +} + +/** The outer View reserves layout and clipping while content is pending. + * It borrows the texture: eviction and freeTexture belong to the resource owner. */ +export function ResourceImage(props: ResourceImageProps): JSX.Element { + return View({ + get class() { return props.class; }, + get style() { return props.style; }, + get debugName() { return props.debugName; }, + get children() { + return ResourceBoundary({ + state: props.state, + fallback: props.fallback, + errorFallback: props.errorFallback, + children: value => { + let node: NodeMirror | undefined; + const result = Image({ + ref: n => { node = n; }, + get style() { return { posType: 1, insetL: 0, insetT: 0, width: value().width, height: value().height }; }, + }); + createRenderEffect(() => { if (node) getOps().setImage(node.id, value().handle); }); + return result; + }, + }); + }, + }); +} diff --git a/package.json b/package.json index 38e9fdfd2..99adde0fe 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,8 @@ "./offload/provider": "./tools/offload-provider.ts", "./offload/capabilities": "./tools/offload-capabilities.ts", "./offload": "./framework/src/offload.ts", + "./resource-state": "./framework/src/resource-state.ts", + "./resource": "./framework/src/resource.ts", "./audio": "./framework/src/audio-api.ts", "./clock": "./framework/src/clock.ts", "./config": "./framework/src/config.ts", @@ -177,6 +179,7 @@ "./vue-vapor": "./framework/src/index-vue-vapor.ts", "./vue-vapor/animation": "./framework/src/animation.ts", "./vue-vapor/offload": "./framework/src/offload.ts", + "./vue-vapor/resource-state": "./framework/src/resource-state.ts", "./vue-vapor/audio": "./framework/src/audio-api.ts", "./vue-vapor/clock": "./framework/src/clock.ts", "./vue-vapor/db": "./framework/src/db-api.ts", @@ -193,6 +196,7 @@ "./octane": "./framework/src/index-octane.ts", "./octane/animation": "./framework/src/animation.ts", "./octane/offload": "./framework/src/offload.ts", + "./octane/resource-state": "./framework/src/resource-state.ts", "./octane/audio": "./framework/src/audio-api.ts", "./octane/clock": "./framework/src/clock.ts", "./octane/db": "./framework/src/db-api.ts", diff --git a/tests/resource.test.ts b/tests/resource.test.ts new file mode 100644 index 000000000..0bdcc3a55 --- /dev/null +++ b/tests/resource.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test"; +import { createRoot, createSignal, onCleanup, type JSX } from "solid-js"; +import { createResourceSlot, pending, ready, failed, type ResourceState } from "../framework/src/resource-state.ts"; +import { ResourceBoundary } from "../framework/src/resource.ts"; + +if (Bun.resolveSync("solid-js", import.meta.dir).endsWith("server.js")) + throw new Error("Resource UI tests require --conditions=browser"); + +test("resource tickets fence stale, duplicate and disposed completions", () => { + let changes = 0; + const slot = createResourceSlot(() => changes++); + const old = slot.begin(), current = slot.begin(); + expect(slot.resolve(old, 1)).toBe(false); + expect(slot.resolve(current, 2)).toBe(true); + expect(slot.reject(current, "late failure")).toBe(false); + expect(slot.state()).toEqual(ready(2)); + const next = slot.begin(); + expect(slot.reject(next, "offline")).toBe(true); + expect(slot.state()).toEqual(failed("offline")); + slot.dispose(); + expect(slot.resolve(next, 3)).toBe(false); + expect(slot.begin()).toBe(0); + expect(changes).toBe(5); +}); + +test("boundary lazily reveals content, updates ready values and disposes only its subtree", () => { + createRoot(dispose => { + const [state, setState] = createSignal>(pending()); + let mounts = 0, cleanups = 0; + const output = ResourceBoundary({ + state, + fallback: () => "skeleton", + errorFallback: error => `error:${error}`, + children: value => { mounts++; onCleanup(() => cleanups++); return (() => `value:${value()}`) as unknown as JSX.Element; }, + }); + const read = () => { let value: unknown = output; while (typeof value === "function") value = value(); return value; }; + expect(read()).toBe("skeleton"); expect(mounts).toBe(0); + setState(ready(7)); expect(read()).toBe("value:7"); + setState(ready(8)); expect(read()).toBe("value:8"); expect(mounts).toBe(1); + setState(pending()); expect(read()).toBe("skeleton"); expect(cleanups).toBe(1); + setState(failed("offline")); expect(read()).toBe("error:offline"); + setState(ready(9)); expect(read()).toBe("value:9"); + dispose(); expect(cleanups).toBe(2); + }); +}); diff --git a/tools/test.ts b/tools/test.ts index 98fa0b3da..5df1f5a3e 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -101,6 +101,7 @@ const SUITE: readonly Stage[] = [ tests: [ "tests/tailwind.test.ts", "tests/renderer.test.ts", + "tests/resource.test.ts", "tests/virtual-list.test.ts", "tests/touch-activation.test.ts", "tests/portal-hit.test.ts", From 80940c95f888ac21c3f54ad65c7389758c04a856 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:04:11 -0700 Subject: [PATCH 04/13] feat(3ds): expose additional shoulder buttons through host input --- contracts/generated/pocket_spec.h | 2 ++ contracts/spec/spec.ts | 2 ++ engine/core/src/spec.rs | 2 ++ hosts/3ds/src/input.c | 20 ++++++++++++++++++++ hosts/3ds/src/input.h | 2 ++ hosts/3ds/src/main.c | 2 ++ 6 files changed, 30 insertions(+) diff --git a/contracts/generated/pocket_spec.h b/contracts/generated/pocket_spec.h index b794591fc..558f480ef 100644 --- a/contracts/generated/pocket_spec.h +++ b/contracts/generated/pocket_spec.h @@ -14,6 +14,8 @@ #define POCKET_BTN_LEFT 0x0080U #define POCKET_BTN_LTRIGGER 0x0100U #define POCKET_BTN_RTRIGGER 0x0200U +#define POCKET_BTN_ZL 0x0400U +#define POCKET_BTN_ZR 0x0800U #define POCKET_BTN_TRIANGLE 0x1000U #define POCKET_BTN_CIRCLE 0x2000U #define POCKET_BTN_CROSS 0x4000U diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index 8a8a7a50e..d2416326d 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -1498,6 +1498,8 @@ export const BTN = { LEFT: 0x0080, LTRIGGER: 0x0100, RTRIGGER: 0x0200, + ZL: 0x0400, + ZR: 0x0800, TRIANGLE: 0x1000, CIRCLE: 0x2000, CROSS: 0x4000, diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index 0d052062f..c6d68e7c9 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -473,6 +473,8 @@ pub mod btn { pub const LEFT: u32 = 0x0080; pub const LTRIGGER: u32 = 0x0100; pub const RTRIGGER: u32 = 0x0200; + pub const ZL: u32 = 0x0400; + pub const ZR: u32 = 0x0800; pub const TRIANGLE: u32 = 0x1000; pub const CIRCLE: u32 = 0x2000; pub const CROSS: u32 = 0x4000; diff --git a/hosts/3ds/src/input.c b/hosts/3ds/src/input.c index f21c634d0..10ea678e1 100644 --- a/hosts/3ds/src/input.c +++ b/hosts/3ds/src/input.c @@ -25,6 +25,8 @@ #define BTN_LEFT 0x0080 #define BTN_LTRIGGER 0x0100 #define BTN_RTRIGGER 0x0200 +#define BTN_ZL 0x0400 +#define BTN_ZR 0x0800 #define BTN_TRIANGLE 0x1000 #define BTN_CIRCLE 0x2000 #define BTN_CROSS 0x4000 @@ -36,6 +38,20 @@ #define RUNTIME_DEVMENU_KEYS (KEY_L | KEY_R | KEY_SELECT) static bool devmenu_input_latched; +static bool extra_input; + +void input_init(void) { +#ifndef POCKETJS_CAPTURE + bool supported = false; + if (R_SUCCEEDED(APT_CheckNew3DS(&supported)) && supported) + extra_input = R_SUCCEEDED(irrstInit()); +#endif +} + +void input_shutdown(void) { + if (extra_input) irrstExit(); + extra_input = false; +} static const struct { uint32_t key; @@ -47,6 +63,8 @@ static const struct { { KEY_Y, BTN_SQUARE }, { KEY_L, BTN_LTRIGGER }, { KEY_R, BTN_RTRIGGER }, + { KEY_ZL, BTN_ZL }, + { KEY_ZR, BTN_ZR }, { KEY_START, BTN_START }, { KEY_SELECT, BTN_SELECT }, { KEY_DUP, BTN_UP }, @@ -57,6 +75,8 @@ static const struct { int32_t input_buttons(void) { uint32_t held = hidKeysHeld(); + /* Shared-memory scan only; service initialization happens before UI boot. */ + if (extra_input) { irrstScanInput(); held |= irrstKeysHeld(); } if ((held & RUNTIME_RELOAD_KEYS) == RUNTIME_RELOAD_KEYS) { held &= ~RUNTIME_RELOAD_KEYS; } diff --git a/hosts/3ds/src/input.h b/hosts/3ds/src/input.h index 081033e8d..61d65b086 100644 --- a/hosts/3ds/src/input.h +++ b/hosts/3ds/src/input.h @@ -10,6 +10,8 @@ * as (x << 8) | y with 128 the centre of each axis. Call hidScanInput() once * per frame before either. */ int32_t input_buttons(void); +void input_init(void); +void input_shutdown(void); int32_t input_analog(void); /** Host-owned L+R+X edge. The complete chord is removed from app buttons. */ bool input_reload_requested(void); diff --git a/hosts/3ds/src/main.c b/hosts/3ds/src/main.c index 45b3fe79c..9f8977e3a 100644 --- a/hosts/3ds/src/main.c +++ b/hosts/3ds/src/main.c @@ -721,6 +721,7 @@ int main(void) { } #endif PocketRuntimeFailureLineage failures = {0}; + input_init(); #ifdef POCKETJS_OFFLOAD GuestChoice guest = package_choice(embedded, 0, &runtime_state); guest.commit_on_accept = false; @@ -1017,6 +1018,7 @@ int main(void) { #endif ); offload_stop(); + input_shutdown(); teardown_guest(); C3D_FrameEnd(0); release_choice(&guest, embedded); From 153a1e7af0810936ddde88f3dd502d394a92695f Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:15:46 -0700 Subject: [PATCH 05/13] test(resource): verify texture fallback ownership and padded coverage --- tests/fixtures/offload-queue.c | 6 ++++++ tests/renderer.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/tests/fixtures/offload-queue.c b/tests/fixtures/offload-queue.c index 2c9aa365d..ed57c920e 100644 --- a/tests/fixtures/offload-queue.c +++ b/tests/fixtures/offload-queue.c @@ -21,6 +21,12 @@ int main(void) { assert(rgba[i * 4 + 3] == (i % 4) * 85); } assert(rgba[12 * 4] == 0); + /* Narrow document pane: 1024 packed bytes require two base64 padding bytes. */ + char narrow[1368]; memset(narrow, 'A', sizeof narrow); + memcpy(narrow + sizeof narrow - 4, "5A==", 4); + assert(coverage_decode(narrow, sizeof narrow, 256, 16, 0xff123456, rgba) == 256); + for (unsigned i = 4092; i < 4096; i++) assert(rgba[i * 4 + 3] == (i % 4) * 85); + assert(coverage_decode("5OQ=", 4, 8, 1, 0xff123456, rgba) == 8); assert(!coverage_decode("!!!!", 4, 12, 1, 0, rgba)); assert(!coverage_decode("5OTk", 4, 516, 1, 0, rgba)); assert(!coverage_decode("5OTk", 4, 12, 17, 0, rgba)); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 75972ffe4..8c5b14956 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -93,6 +93,7 @@ import { type TileDoc, } from "../framework/src/deepzoom.ts"; import { resetPack } from "../framework/src/pak.ts"; +import { ResourceImage, pending, ready, failed, type ResourceState, type TextureResource } from "../framework/src/resource.ts"; import { encodeImageEntry, pack } from "../framework/compiler/pak.ts"; import { BTN, @@ -194,6 +195,39 @@ function childIds(node: NodeMirror): number[] { let host: MockHost; let root: NodeMirror; +test("resource images retain their layout through fallback, retry and borrowed texture replacement", () => { + const [state, setState] = createSignal>(pending()); + let skeletons = 0, frees = 0; + host.ops.freeTexture = () => { frees++; }; + const dispose = render(() => ResourceImage({ + state, style: { width: 256, height: 16, overflow: 1 }, + fallback: () => { skeletons++; return Text({ children: "Skeleton" }); }, + errorFallback: () => Text({ children: "Retry" }), + }) as unknown as NodeMirror, root); + const frame = root.children[0]; + expect(skeletons).toBe(1); + expect(host.of("setImage")).toHaveLength(0); + host.clear(); + setState(pending()); + expect(skeletons).toBe(1); + // Handle zero is valid. The component never uploads or frees borrowed images. + setState(ready({ handle: 0, width: 256, height: 16 })); runSweep(); + const image = frame.children[0]; + expect(host.of("setImage")).toEqual([["setImage", image.id, 0]]); + host.clear(); + setState(ready({ handle: 7, width: 256, height: 16 })); + expect(frame.children[0]).toBe(image); + expect(host.of("setImage")).toEqual([["setImage", image.id, 7]]); + setState(failed("offline")); runSweep(); + expect(host.of("setText").some(call => call[2] === "Retry")).toBe(true); + setState(pending()); runSweep(); + expect(skeletons).toBe(2); + expect(root.children[0]).toBe(frame); + expect(host.of("setProp").filter(call => call[1] === frame.id)).toHaveLength(0); + expect(host.of("uploadTexture")).toHaveLength(0); + dispose(); runSweep(); expect(frees).toBe(0); +}); + beforeEach(() => { host = makeMockHost(); installHost(host); From 7659028790d0a92b6bef7d74c45ede75fa73e28a Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:17:07 -0700 Subject: [PATCH 06/13] fix(resource): retain unchanged native image bindings --- framework/src/resource.ts | 3 ++- tests/renderer.test.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/framework/src/resource.ts b/framework/src/resource.ts index d073a2c76..ce6dc5360 100644 --- a/framework/src/resource.ts +++ b/framework/src/resource.ts @@ -56,11 +56,12 @@ export function ResourceImage(props: ResourceImageProps): JSX.Element { errorFallback: props.errorFallback, children: value => { let node: NodeMirror | undefined; + const handle = createMemo(() => value().handle); const result = Image({ ref: n => { node = n; }, get style() { return { posType: 1, insetL: 0, insetT: 0, width: value().width, height: value().height }; }, }); - createRenderEffect(() => { if (node) getOps().setImage(node.id, value().handle); }); + createRenderEffect(() => { if (node) getOps().setImage(node.id, handle()); }); return result; }, }); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 8c5b14956..b9df3a1f8 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -218,6 +218,9 @@ test("resource images retain their layout through fallback, retry and borrowed t setState(ready({ handle: 7, width: 256, height: 16 })); expect(frame.children[0]).toBe(image); expect(host.of("setImage")).toEqual([["setImage", image.id, 7]]); + host.clear(); + setState(ready({ handle: 7, width: 256, height: 16 })); + expect(host.of("setImage")).toHaveLength(0); setState(failed("offline")); runSweep(); expect(host.of("setText").some(call => call[2] === "Retry")).toBe(true); setState(pending()); runSweep(); From 08b20a4a4092eb0c2b2c32965d4badbd8c65ebe2 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:35:41 -0700 Subject: [PATCH 07/13] fix(resource): flatten synchronous subtree construction --- framework/src/resource.ts | 56 ++++++++++++++++++++------------------- tests/resource.test.ts | 5 +++- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/framework/src/resource.ts b/framework/src/resource.ts index ce6dc5360..b9407ce96 100644 --- a/framework/src/resource.ts +++ b/framework/src/resource.ts @@ -1,6 +1,6 @@ -import { createMemo, createRenderEffect, Show, type Accessor, type JSX } from "solid-js"; +import { createMemo, createRenderEffect, untrack, type Accessor, type JSX } from "solid-js"; import { Image, View, type ViewProps } from "./primitives.ts"; -import type { NodeMirror } from "./renderer.ts"; +import { insert, type NodeMirror } from "./renderer.ts"; import { getOps } from "./host.ts"; import type { ResourceState } from "./resource-state.ts"; export { createResourceSlot, pending, ready, failed, type ResourceState } from "./resource-state.ts"; @@ -18,20 +18,19 @@ export function ResourceBoundary(props: ResourceBoundaryProps): JSX.Elemen const state = createMemo(props.state); const status = createMemo(() => state().status); const error = createMemo(() => { const value = state(); return value.status === "error" ? value.error : undefined; }); - return Show({ - get when() { return status() === "ready"; }, - get fallback() { - return status() === "error" && props.errorFallback - ? props.errorFallback(error()) : props.fallback(); - }, - get children() { + return createMemo(() => { + const phase = status(); + const reason = phase === "error" ? error() : undefined; + if (phase !== "ready") return phase === "error" && props.errorFallback + ? props.errorFallback(reason) : props.fallback(); + return untrack(() => { return props.children(() => { const current = state(); if (current.status !== "ready") throw new Error("Resource read outside its ready subtree"); return current.value; }); - }, - }); + }); + }) as unknown as JSX.Element; } /** A decoded/uploaded image, including its texture envelope dimensions. */ @@ -45,26 +44,29 @@ export interface ResourceImageProps extends Pick { - let node: NodeMirror | undefined; - const handle = createMemo(() => value().handle); - const result = Image({ - ref: n => { node = n; }, - get style() { return { posType: 1, insetL: 0, insetT: 0, width: value().width, height: value().height }; }, - }); - createRenderEffect(() => { if (node) getOps().setImage(node.id, handle()); }); - return result; - }, + }); + // Construct the content after the outer primitive has returned. Keeping the + // lazy subtree inside View's children getter retains its entire synchronous + // spread/effect stack while fallback components mount on recursive engines. + const content = ResourceBoundary({ + state: props.state, + fallback: props.fallback, + errorFallback: props.errorFallback, + children: value => { + let node: NodeMirror | undefined; + const handle = createMemo(() => value().handle); + const result = Image({ + ref: n => { node = n; }, + get style() { return { posType: 1, insetL: 0, insetT: 0, width: value().width, height: value().height }; }, }); + createRenderEffect(() => { if (node) getOps().setImage(node.id, handle()); }); + return result; }, }); + insert(frame as unknown as NodeMirror, content); + return frame; } diff --git a/tests/resource.test.ts b/tests/resource.test.ts index 0bdcc3a55..53d9a4858 100644 --- a/tests/resource.test.ts +++ b/tests/resource.test.ts @@ -26,15 +26,18 @@ test("resource tickets fence stale, duplicate and disposed completions", () => { test("boundary lazily reveals content, updates ready values and disposes only its subtree", () => { createRoot(dispose => { const [state, setState] = createSignal>(pending()); + const [label, setLabel] = createSignal("skeleton"); let mounts = 0, cleanups = 0; const output = ResourceBoundary({ state, - fallback: () => "skeleton", + fallback: label, errorFallback: error => `error:${error}`, children: value => { mounts++; onCleanup(() => cleanups++); return (() => `value:${value()}`) as unknown as JSX.Element; }, }); const read = () => { let value: unknown = output; while (typeof value === "function") value = value(); return value; }; expect(read()).toBe("skeleton"); expect(mounts).toBe(0); + setLabel("waiting"); expect(read()).toBe("waiting"); + setLabel("skeleton"); setState(ready(7)); expect(read()).toBe("value:7"); setState(ready(8)); expect(read()).toBe("value:8"); expect(mounts).toBe(1); setState(pending()); expect(read()).toBe("skeleton"); expect(cleanups).toBe(1); From d2f69aab78ab01947f419392f831c0a7e639e8ad Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:44:10 -0700 Subject: [PATCH 08/13] feat(animation): add deterministic caret visibility controller --- framework/src/animation.ts | 2 ++ framework/src/caret-blink.ts | 49 ++++++++++++++++++++++++++++++++++ site/content/docs/animation.md | 26 ++++++++++++++++++ tests/caret-blink.test.ts | 38 ++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 framework/src/caret-blink.ts create mode 100644 tests/caret-blink.test.ts diff --git a/framework/src/animation.ts b/framework/src/animation.ts index df3cfbee2..937f9dfc6 100644 --- a/framework/src/animation.ts +++ b/framework/src/animation.ts @@ -1,5 +1,7 @@ // Animation public API. +export { createCaretBlink, type CaretBlinkOptions } from "./caret-blink.ts"; + export { animate, spring, diff --git a/framework/src/caret-blink.ts b/framework/src/caret-blink.ts new file mode 100644 index 000000000..8da1c6de1 --- /dev/null +++ b/framework/src/caret-blink.ts @@ -0,0 +1,49 @@ +import { after } from "./clock.ts"; + +export interface CaretBlinkOptions { + /** Receives visibility changes only. Bind this to a signal or native prop. */ + onChange(visible: boolean): void; + /** Duration of each visible/hidden phase in virtual milliseconds. Default 500. */ + intervalMs?: number; +} + +/** + * A caret starts unfocused. Focus, typing and movement restart its visible + * phase; a held drag keeps it visible. Uses one cancellable virtual-clock + * deadline, with no per-frame UI writes. Call dispose when its owner unmounts. + * Geometry, selection and the UI library remain the caller's responsibility. + */ +export function createCaretBlink(options: CaretBlinkOptions) { + const interval = options.intervalMs ?? 500; + if (!Number.isFinite(interval) || interval <= 0) { + throw new RangeError("PocketJS: caret intervalMs must be finite and positive"); + } + let active = false, held = false, visible = false, disposed = false; + let cancel: (() => void) | undefined; + const emit = (next: boolean) => { + if (visible === next) return; + visible = next; + options.onChange(next); + }; + const stop = () => { cancel?.(); cancel = undefined; }; + const schedule = () => { + if (disposed || !active || held) return; + // onChange can synchronously reset through a reactive binding. + stop(); + cancel = after(interval / 1000, () => { + cancel = undefined; + emit(!visible); + schedule(); + }); + }; + const reset = () => { + if (disposed) return; + stop(); emit(active); schedule(); + }; + return { + setActive(next: boolean) { if (next !== active && !disposed) { active = next; reset(); } }, + setHeld(next: boolean) { if (next !== held && !disposed) { held = next; reset(); } }, + reset, + dispose() { if (!disposed) { disposed = true; stop(); emit(false); } }, + }; +} diff --git a/site/content/docs/animation.md b/site/content/docs/animation.md index 2a41a2f1c..9c903b917 100644 --- a/site/content/docs/animation.md +++ b/site/content/docs/animation.md @@ -396,4 +396,30 @@ onMount(() => { Two FFI calls buy 20+ seconds of motion with zero further JS. Reserve layout-prop animation for deliberate one-shots. +## Text caret + +`createCaretBlink` from `@pocketjs/framework/animation` controls visibility +independently of caret geometry and the UI library. **Focus and input restart +the visible phase; a held drag keeps it visible.** Each phase defaults to +500 virtual milliseconds, so input replay controls blinking on every host. + +```tsx +import { createEffect, createSignal, onCleanup } from "solid-js"; +import { createCaretBlink } from "@pocketjs/framework/animation"; + +const [caretVisible, setCaretVisible] = createSignal(false); +const blink = createCaretBlink({ onChange: setCaretVisible }); +createEffect(() => blink.setActive(editorFocused())); +createEffect(() => blink.setHeld(draggingCaret())); +createEffect(() => { caretOffset(); draftText(); blink.reset(); }); +onCleanup(blink.dispose); +// Bind caretVisible() to the caret node's opacity. +``` + +The controller starts inactive. It owns **one cancellable clock deadline** +while blinking and none while inactive or held; `onChange` runs only when +visibility changes. `intervalMs` sets the duration of each phase. Call +`dispose()` when the editor unmounts to cancel its deadline and hide the caret. +It performs no file access, network requests or wall-clock reads. + Try any of this live in the [playground](/playground/). diff --git a/tests/caret-blink.test.ts b/tests/caret-blink.test.ts new file mode 100644 index 000000000..82539b2a4 --- /dev/null +++ b/tests/caret-blink.test.ts @@ -0,0 +1,38 @@ +import { afterEach, expect, test } from "bun:test"; +import { createCaretBlink } from "../framework/src/animation.ts"; +import { __advanceClock, resetClock } from "../framework/src/clock.ts"; + +afterEach(() => { delete (globalThis as any).__simHz; resetClock(); }); +for (const hz of [30, 60]) test(`caret focus, edit reset, drag and disposal at ${hz} Hz`, () => { + (globalThis as any).__simHz = hz; resetClock(); __advanceClock(); + const states: boolean[] = []; + const blink = createCaretBlink({ onChange: value => states.push(value) }); + const frames = (n: number) => { for (let i = 0; i < n; i++) __advanceClock(); }; + frames(hz); expect(states).toEqual([]); + blink.setActive(true); frames(hz / 2 - 1); expect(states).toEqual([true]); + frames(1); expect(states).toEqual([true, false]); + blink.reset(); frames(hz / 2 - 1); expect(states).toEqual([true, false, true]); + blink.reset(); frames(1); expect(states.at(-1)).toBe(true); // old deadline cancelled + blink.setHeld(true); frames(hz * 2); expect(states).toEqual([true, false, true]); + blink.setHeld(false); frames(hz / 2); expect(states.at(-1)).toBe(false); + blink.setActive(false); const count = states.length; frames(hz * 2); expect(states).toHaveLength(count); + blink.setActive(true); blink.dispose(); frames(hz * 2); expect(states.slice(-2)).toEqual([true, false]); + blink.reset(); blink.setActive(true); blink.setHeld(false); frames(hz); expect(states.at(-1)).toBe(false); +}); +test("invalid caret timing is rejected", () => { + for (const intervalMs of [0, -1, NaN, Infinity]) { + expect(() => createCaretBlink({ intervalMs, onChange() {} })).toThrow(RangeError); + } +}); +test("a visibility callback may reset without creating duplicate deadlines", () => { + resetClock(); __advanceClock(); + const states: boolean[] = []; + const blink = createCaretBlink({ onChange(value) { + states.push(value); + if (states.length === 2) blink.reset(); + } }); + blink.setActive(true); + for (let i = 0; i < 60; i++) __advanceClock(); + expect(states).toEqual([true, false, true, false]); + blink.dispose(); +}); From 3db4c2a98bdf07a4ca86657ee07235d19de9e26a Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:13:30 -0700 Subject: [PATCH 09/13] feat(classic): share shaded controls and press feedback --- framework/compiler/subpaths.ts | 1 + framework/src/classic.ts | 111 ++++++++++++++++++++++++++++++++ package.json | 1 + site/content/docs/components.md | 32 +++++++++ tests/renderer.test.ts | 28 ++++++++ 5 files changed, 173 insertions(+) create mode 100644 framework/src/classic.ts diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts index 2dea220ba..c8120d4ef 100644 --- a/framework/compiler/subpaths.ts +++ b/framework/compiler/subpaths.ts @@ -55,6 +55,7 @@ export const SUBPATHS: Record = { aliases: ALL, }, animation: { file: "framework/src/animation.ts", aliases: TWINS }, + classic: { file: { solid: "framework/src/classic.ts" } }, "offload/provider": { file: "tools/offload-provider.ts" }, "offload/capabilities": { file: "tools/offload-capabilities.ts" }, offload: { file: "framework/src/offload.ts", aliases: TWINS }, diff --git a/framework/src/classic.ts b/framework/src/classic.ts new file mode 100644 index 000000000..69a2dc9a0 --- /dev/null +++ b/framework/src/classic.ts @@ -0,0 +1,111 @@ +import { createEffect, createMemo, createSignal } from "solid-js"; +import { View, Text, type ViewProps } from "./primitives.ts"; +import { insert, type NodeMirror } from "./renderer.ts"; +import { createGesture } from "./gesture.ts"; +import { resolveTouchHit } from "./input.ts"; +import type { SurfaceId } from "./display.ts"; + +export type ClassicTone = "neutral" | "primary" | "danger" | "key"; +const palettes = { + neutral: ["#fafcfe", "#cbd6e4", "#8397b1", "#304f78"], + primary: ["#69a5f2", "#2363c2", "#17478b", "#ffffff"], + danger: ["#e7817b", "#b12c25", "#81261f", "#ffffff"], + key: ["#ffffff", "#d1d8e2", "#8c99aa", "#263950"], + pressed: ["#234c82", "#497aad", "#173654", "#ffffff"], + dangerPressed: ["#7d201d", "#b4443d", "#661813", "#ffffff"], +} as const; + +/** Shared colors for controls, selected rows and their labels. */ +export function classicPalette(tone: ClassicTone = "neutral", pressed = false) { + const [gradFrom, gradTo, borderColor, textColor] = palettes[pressed ? tone === "danger" ? "dangerPressed" : "pressed" : tone]; + return { gradFrom, gradTo, borderColor, textColor }; +} + +export interface ClassicFaceProps extends Omit { + tone?: ClassicTone; + pressed?: boolean; + selected?: boolean; + disabled?: boolean; + /** Square the joining edge of adjacent toolbar actions. */ + edge?: "left" | "right"; +} + +/** Bezel, vertical shading and a depressed state; input can be supplied separately. */ +export function ClassicFace(props: ClassicFaceProps) { + const palette = createMemo(() => classicPalette(props.selected ? "primary" : props.tone, props.pressed)); + const frame = View({ + get class() { return props.class; }, get debugName() { return props.debugName; }, ref: props.ref, nodeRef: props.nodeRef, + get style() { return { radius: 4, borderWidth: 1, gradDir: 1, ...palette(), ...props.style, opacity: props.disabled ? 0.45 : 1 }; }, + }); + // Keep child construction outside the primitive's synchronous spread stack. + if (props.edge) { + const fill = View({ get style() { return { posType: 1, insetT: 1, insetB: 1, width: 5, + ...(props.edge === "left" ? { insetR: 0 } : { insetL: 0 }), gradDir: 1, ...palette() }; } }); + const divider = View({ get style() { return { posType: 1, insetT: 0, insetB: 0, width: 1, + ...(props.edge === "left" ? { insetR: 0 } : { insetL: 0 }), bgColor: palette().borderColor }; } }); + insert(frame as unknown as NodeMirror, [fill, divider]); + } + const lip = View({ get style() { return { posType: 1, insetL: 4, insetR: 4, insetT: 1, height: 1, + bgColor: props.pressed ? "#132e5066" : "#ffffff88" }; } }); + insert(frame as unknown as NodeMirror, lip); + insert(frame as unknown as NodeMirror, () => props.children); + return frame; +} + +export interface ClassicButtonProps extends Omit { + label: string; + onPress?(): void; + surface?: SurfaceId; + allowWhenBlocked?: boolean; +} + +/** Release-inside activation with press, slide-out, cancellation and disabled feedback. */ +export function ClassicButton(props: ClassicButtonProps) { + const [pressed, setPressed] = createSignal(false); + let node: NodeMirror | undefined, contact: number | undefined; + const clear = () => { contact = undefined; setPressed(false); }; + createEffect(() => { if (props.disabled) clear(); }); + createGesture({ surface: props.surface, allowWhenBlocked: props.allowWhenBlocked, + region: { node: () => node }, + onDown(c) { if (!props.disabled && contact === undefined) { contact = c.id; setPressed(true); } }, + onMove(c) { + if (c.id !== contact) return; + let hit = resolveTouchHit(c.x, c.y, undefined, c.surface); + while (hit && hit !== node) hit = hit.parent; + setPressed(!props.disabled && !!hit); + }, + onUp(c) { if (c.id !== contact) return; const fire = pressed() && !props.disabled; clear(); if (fire) props.onPress?.(); }, + onCancel: clear, + }); + const label = Text({ class: "text-xs font-bold", + get style() { return { posType: 1, insetL: 0, insetR: 0, insetT: Math.max(0, (Number(props.style?.height ?? 25) - 15) / 2), + textAlign: 1, textColor: classicPalette(props.selected ? "primary" : props.tone, pressed()).textColor }; }, + get children() { return props.label; }, + }); + return ClassicFace({ + ref: props.ref, nodeRef: n => { node = n; }, get class() { return props.class; }, get debugName() { return props.debugName; }, + get style() { return props.style; }, get tone() { return props.tone; }, + get pressed() { return pressed(); }, get selected() { return props.selected; }, + get disabled() { return props.disabled; }, edge: props.edge, children: label, + }); +} + +export interface ClassicPanelProps extends Omit { active?: boolean; headerHeight?: number } + +/** Inset paint layers preserve the rounded corners and the complete outer rim. + * Rounded backgrounds do not imply rounded child clipping on small hosts. */ +export function ClassicPanel(props: ClassicPanelProps) { + const header = createMemo(() => classicPalette(props.active ? "primary" : "neutral")); + const frame = View({ ref: props.ref, nodeRef: props.nodeRef, get debugName() { return props.debugName; }, + get class() { return props.class; }, + get style() { return { radius: 6, ...props.style, bgColor: header().borderColor }; } }); + const fill = View({ get style() { return { posType: 1, insetL: 1, insetT: 1, insetR: 1, + height: (props.headerHeight ?? 27) + 4, radius: 5, gradDir: 1, ...header() }; } }); + const body = View({ get style() { return { posType: 1, insetL: 1, insetR: 1, insetT: props.headerHeight ?? 27, + insetB: 1, radius: 5, gradDir: 1, gradFrom: "#f7f9fc", gradTo: "#e2e8f0" }; } }); + const squareTop = View({ get style() { return { posType: 1, insetL: 1, insetR: 1, insetT: props.headerHeight ?? 27, + height: 5, bgColor: "#f7f9fc" }; } }); + insert(frame as unknown as NodeMirror, [fill, body, squareTop]); + insert(frame as unknown as NodeMirror, () => props.children); + return frame; +} diff --git a/package.json b/package.json index 99adde0fe..1eb80201d 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "exports": { ".": "./framework/src/index.ts", "./animation": "./framework/src/animation.ts", + "./classic": "./framework/src/classic.ts", "./offload/provider": "./tools/offload-provider.ts", "./offload/capabilities": "./tools/offload-capabilities.ts", "./offload": "./framework/src/offload.ts", diff --git a/site/content/docs/components.md b/site/content/docs/components.md index 0b4403e69..e34240bd1 100644 --- a/site/content/docs/components.md +++ b/site/content/docs/components.md @@ -496,6 +496,38 @@ Pick one of several branches — the JSX form of a `switch` statement: The first `Match` whose `when` is truthy renders; if none match, `fallback` renders. +## Classic controls + +The Solid module `@pocketjs/framework/classic` shares a shaded bezel and color +palette across buttons, keyboard faces, panels and selection indicators. + +```tsx +import { ClassicButton, ClassicPanel } from "@pocketjs/framework/classic"; + + +``` + +**A touch activates a button on release inside its bounds.** Sliding outside, +gesture cancellation, a touch block or becoming disabled cancels the press. +The shared depressed palette provides feedback before release. `selected` +retains the blue state independently of a transient press; `tone` accepts +`neutral`, `primary`, `danger` and `key`. `edge="left"` or `edge="right"` +squares the joining edge of adjacent toolbar actions. Place them with one +shared border pixel. Labels use the small bold font; layout remains the caller's. + +`ClassicFace` supplies the same appearance for controls with their own input +model, such as a space key that also recognizes a long press. Bind its +`pressed`, `selected` and `disabled` properties to that model. `classicPalette` +returns matching gradient, border and label colors for other UI elements. + +`ClassicPanel` paints its header and body inside a complete rounded rim. +**Rounded background painting does not imply rounded child clipping** on the +small native hosts. Insets preserve its corners without requiring an image +mask. Its `active` property uses the blue header palette, and `headerHeight` +defaults to 27 logical pixels. + ## App-shell primitives `@pocketjs/framework/components` also exports a layer of higher-level primitives that diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index b9df3a1f8..3db28b36d 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -86,6 +86,10 @@ import { View, } from "../framework/src/components.ts"; import { getAuxiliarySurfaceRoots } from "../framework/src/display.ts"; +import { ClassicButton } from "../framework/src/classic.ts"; +import { __runGestures, resetGestures, pushTouchBlock } from "../framework/src/gesture.ts"; +import { __packTouch, __setTouches, __resetTouches } from "../framework/src/touch.ts"; +import { __advanceClock, resetClock } from "../framework/src/clock.ts"; import { DeepZoom, type DeepZoomGesture, @@ -195,6 +199,30 @@ function childIds(node: NodeMirror): number[] { let host: MockHost; let root: NodeMirror; +test("classic buttons provide press, slide-out, disabled and cancelled feedback", () => { + resetGestures(); __resetTouches(); resetClock(); + registerStyles({ "text-xs font-bold": 1 }); + let clicked = 0, hit = 0; + host.ops.hitTestBounds = () => hit; + const [disabled, setDisabled] = createSignal(false); + const dispose = render(() => ClassicButton({ label: "Save", style: { width: 80, height: 24 }, + get disabled() { return disabled(); }, onPress() { clicked++; }, + }) as unknown as NodeMirror, root); + setInputRoot(root); + const button = root.children[0]; hit = button.id; + const pump = (down: boolean) => { __advanceClock(); __setTouches(down ? [__packTouch(0, 10, 10)] : []); __runGestures(); }; + const changes = () => host.of("setProp").filter(call => call[1] === button.id && call[2] === PROP.gradFrom); + host.clear(); pump(true); expect(changes()).toHaveLength(1); expect(clicked).toBe(0); + pump(false); expect(changes()).toHaveLength(2); expect(clicked).toBe(1); + pump(true); hit = 0; + __advanceClock(); __setTouches([__packTouch(0, 100, 10)]); __runGestures(); + pump(false); expect(clicked).toBe(1); // release outside cancels + hit = button.id; pump(true); setDisabled(true); pump(false); expect(clicked).toBe(1); + setDisabled(false); pump(true); const unblock = pushTouchBlock(); pump(false); unblock(); expect(clicked).toBe(1); + pump(true); pump(false); expect(clicked).toBe(2); + dispose(); resetGestures(); __resetTouches(); +}); + test("resource images retain their layout through fallback, retry and borrowed texture replacement", () => { const [state, setState] = createSignal>(pending()); let skeletons = 0, frees = 0; From 3433b4a26c32056e3cf217f516706d41b12dd389 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:10:54 -0700 Subject: [PATCH 10/13] feat(classic): animate modal sheets and stream palette coverage --- contracts/spec/offload.ts | 6 ++- framework/src/classic.ts | 63 +++++++++++++++++++++++++++++++- framework/src/offload.ts | 9 +++-- hosts/3ds/src/offload_coverage.h | 25 +++++++++++++ hosts/3ds/src/qjs.c | 17 ++++++++- site/content/docs/components.md | 19 +++++++++- tests/fixtures/offload-queue.c | 8 ++++ tests/renderer.test.ts | 24 +++++++++++- 8 files changed, 161 insertions(+), 10 deletions(-) diff --git a/contracts/spec/offload.ts b/contracts/spec/offload.ts index 24fe7646c..56a4e1333 100644 --- a/contracts/spec/offload.ts +++ b/contracts/spec/offload.ts @@ -14,8 +14,10 @@ export interface OffloadOps { /** At most one complete record per host frame. Never performs IO. */ take(): string | undefined; /** Optional bounded 2-bit coverage upload. At most 512x16, one per frame. - * Foreground is ABGR; alpha comes from coverage. Returns a texture handle. */ - uploadCoverage?(base64: string, width: number, height: number, foreground: number): number; + * Foreground is ABGR; alpha comes from coverage. Optional columns provide one + * lowercase hex palette index per pixel column; palette is 1..16 RGB hex colors. + * Coloring uses the same scratch buffer and one upload. Returns a texture handle. */ + uploadCoverage?(base64: string, width: number, height: number, foreground: number, columns?: string, palette?: string): number; } export interface OffloadRequest { v: 1; id: number; method: string; payload: string } export interface OffloadReply { id: number; payload?: string; error?: string } diff --git a/framework/src/classic.ts b/framework/src/classic.ts index 69a2dc9a0..fd9a791fb 100644 --- a/framework/src/classic.ts +++ b/framework/src/classic.ts @@ -1,7 +1,9 @@ -import { createEffect, createMemo, createSignal } from "solid-js"; +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import { View, Text, type ViewProps } from "./primitives.ts"; import { insert, type NodeMirror } from "./renderer.ts"; -import { createGesture } from "./gesture.ts"; +import { createGesture, pushTouchBlock } from "./gesture.ts"; +import { animate, cancelAnim, jump } from "./anim.ts"; +import { after } from "./clock.ts"; import { resolveTouchHit } from "./input.ts"; import type { SurfaceId } from "./display.ts"; @@ -109,3 +111,60 @@ export function ClassicPanel(props: ClassicPanelProps) { insert(frame as unknown as NodeMirror, () => props.children); return frame; } + +export interface ClassicSheetProps { + open: boolean; + title: string; + message?: string; + actions: readonly { label: string; tone?: ClassicTone; disabled?: boolean; onPress(): void }[]; + cancelLabel?: string; + onCancel(): void; + surface?: SurfaceId; + /** Includes the closing transition, so callers can also gate hardware input. */ + onModalChange?(active: boolean): void; + debugName?: string; +} + +/** Native slide/fade transitions keep touch modal until the sheet leaves. + * Fixed action children are retained through close/reopen; no frame JS writes. */ +export function ClassicSheet(props: ClassicSheetProps) { + if (props.actions.length > 4) throw new RangeError("ClassicSheet supports at most four actions"); + const height = 64 + (props.actions.length + 1) * 38 + 8; + const [shown, setShown] = createSignal(false); + const frame = View({ debugName: props.debugName ?? "ClassicSheet", + get style() { return { posType: 1, insetL: 0, insetR: 0, insetT: 0, insetB: 0, display: shown() ? 0 : 1 }; } }); + const scrim = View({ style: { posType: 1, insetL: 0, insetR: 0, insetT: 0, insetB: 0, bgColor: "#10203866", opacity: 0 } }); + const body = View({ style: { posType: 1, insetL: 0, insetR: 0, insetB: 0, height, translateY: height, + borderWidth: 1, borderColor: "#657489", gradDir: 1, gradFrom: "#b1bbc9", gradTo: "#657891" } }); + const title = Text({ class: "text-sm font-bold", get children() { return props.title; }, + style: { posType: 1, insetL: 8, insetR: 8, insetT: 11, textAlign: 1, textColor: "#243955" } }); + const message = Text({ class: "text-xs", get children() { return props.message ?? ""; }, + style: { posType: 1, insetL: 8, insetR: 8, insetT: 32, textAlign: 1, textColor: "#344d6c" } }); + const buttons = [...props.actions, { get label() { return props.cancelLabel ?? "Cancel"; }, onPress: props.onCancel }].map((action, index) => + ClassicButton({ get label() { return action.label; }, get tone() { return "tone" in action ? action.tone : "neutral"; }, + get disabled() { return !props.open || ("disabled" in action && action.disabled); }, + surface: props.surface, allowWhenBlocked: true, onPress: () => { if (props.open) action.onPress(); }, + style: { posType: 1, insetL: 16, insetR: 16, insetT: 60 + index * 38, height: 34 } })); + insert(body as unknown as NodeMirror, [title, message, ...buttons]); + insert(frame as unknown as NodeMirror, [scrim, body]); + let unblock: (() => void) | undefined, deadline: (() => void) | undefined; + let slide = 0, fade = 0; + const release = () => { unblock?.(); unblock = undefined; props.onModalChange?.(false); }; + createEffect(() => { + const open = props.open; + deadline?.(); deadline = undefined; + if (slide) cancelAnim(slide); if (fade) cancelAnim(fade); + if (open) { + if (!unblock) { unblock = pushTouchBlock(); props.onModalChange?.(true); } + setShown(true); + slide = animate(body as unknown as NodeMirror, "translateY", 0, { dur: 220, easing: "out" }); + fade = animate(scrim as unknown as NodeMirror, "opacity", 1, { dur: 220 }); + } else if (unblock) { + slide = animate(body as unknown as NodeMirror, "translateY", height, { dur: 180, easing: "in" }); + fade = animate(scrim as unknown as NodeMirror, "opacity", 0, { dur: 180 }); + deadline = after(0.18, () => { setShown(false); release(); deadline = undefined; }); + } else { jump(body as unknown as NodeMirror, "translateY", height); } + }); + onCleanup(() => { deadline?.(); if (slide) cancelAnim(slide); if (fade) cancelAnim(fade); release(); }); + return frame; +} diff --git a/framework/src/offload.ts b/framework/src/offload.ts index 23f8ade04..217fae571 100644 --- a/framework/src/offload.ts +++ b/framework/src/offload.ts @@ -3,9 +3,12 @@ import { registerServicePump } from "./services.ts"; export { OFFLOAD }; export type { OffloadOps }; -/** Fixed-budget native resource upload when implemented by the host. */ -export function uploadCoverage(base64: string, width: number, height: number, foreground: number): number | undefined { - return (globalThis as unknown as { offload?: OffloadOps }).offload?.uploadCoverage?.(base64, width, height, foreground); +/** Fixed-budget native resource upload when implemented by the host. + * Optional column colors use one hex palette index per pixel column and + * up to 16 concatenated RGB hex colors. They retain the one-upload budget. */ +export function uploadCoverage(base64: string, width: number, height: number, foreground: number, + colors?: { columns: string; palette: string }): number | undefined { + return (globalThis as unknown as { offload?: OffloadOps }).offload?.uploadCoverage?.(base64, width, height, foreground, colors?.columns, colors?.palette); } export type OffloadResult = { ok: true; value: string } | { ok: false; error: string }; type Pending = { record: string; callback: (result: OffloadResult) => void; deadline: number; sent: boolean; session: number }; diff --git a/hosts/3ds/src/offload_coverage.h b/hosts/3ds/src/offload_coverage.h index cfa59201d..4d5ce772e 100644 --- a/hosts/3ds/src/offload_coverage.h +++ b/hosts/3ds/src/offload_coverage.h @@ -39,4 +39,29 @@ static inline int coverage_decode(const char *base64, size_t length, unsigned wi } return (int)envelope; } +static inline int coverage_hex(char c) { + return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1; +} +/* Optional horizontal palette: one hex index per column, up to 16 RGB colors. + * Fixed work, same scratch allocation and one uploaded texture. */ +static inline int coverage_colorize(const char *columns, size_t columns_length, + const char *palette, size_t palette_length, unsigned width, unsigned height, unsigned envelope, uint8_t *rgba) { + if (!width || width > 512 || !height || height > 16 || envelope < width || envelope > 512 || + columns_length != width || !palette_length || palette_length > 96 || palette_length % 6) return 0; + uint8_t colors[16][3]; + for (size_t i = 0; i < palette_length; i += 2) { + int a = coverage_hex(palette[i]), b = coverage_hex(palette[i + 1]); + if (a < 0 || b < 0) return 0; + colors[i / 6][(i % 6) / 2] = (uint8_t)((a << 4) | b); + } + for (unsigned x = 0; x < width; x++) { + int ink = coverage_hex(columns[x]); + if (ink < 0 || (unsigned)ink >= palette_length / 6) return 0; + } + for (unsigned y = 0; y < height; y++) for (unsigned x = 0; x < width; x++) { + uint8_t *p = rgba + (y * envelope + x) * 4; + memcpy(p, colors[coverage_hex(columns[x])], 3); + } + return 1; +} #endif diff --git a/hosts/3ds/src/qjs.c b/hosts/3ds/src/qjs.c index 3ac8d5973..356c243ba 100644 --- a/hosts/3ds/src/qjs.c +++ b/hosts/3ds/src/qjs.c @@ -436,6 +436,21 @@ static JSValue host_operation( (uint32_t)argument_int(ctx, argc, argv, 3), coverage_pixels) : 0; if (text) JS_FreeCString(ctx, text); if (!envelope) return JS_NewInt32(ctx, -1); + if (argc > 4 && !JS_IsUndefined(argv[4])) { + if (argc < 6 || !JS_IsString(argv[4]) || !JS_IsString(argv[5])) return JS_NewInt32(ctx, -1); + for (int i = 4; i < 6; i++) { + JSValue size_value = JS_GetPropertyStr(ctx, argv[i], "length"); + int32_t size = 0; JS_ToInt32(ctx, &size, size_value); JS_FreeValue(ctx, size_value); + if (size > (i == 4 ? 512 : 96)) return JS_NewInt32(ctx, -1); + } + size_t columns_length = 0, palette_length = 0; + const char *columns = JS_ToCStringLen2(ctx, &columns_length, argv[4], 0); + const char *palette = JS_ToCStringLen2(ctx, &palette_length, argv[5], 0); + int valid = columns && palette && coverage_colorize(columns, columns_length, palette, palette_length, + argument_int(ctx, argc, argv, 1), height, envelope, coverage_pixels); + if (columns) JS_FreeCString(ctx, columns); if (palette) JS_FreeCString(ctx, palette); + if (!valid) return JS_NewInt32(ctx, -1); + } unsigned padded_height = 8; while (padded_height < (unsigned)height) padded_height *= 2; return JS_NewInt32(ctx, ui_upload_texture(coverage_pixels, envelope * padded_height * 4, envelope, padded_height, 3)); } @@ -499,7 +514,7 @@ static void set_named_property(JSValueConst object, const uint8_t *name, size_t static void install_host(void) { #ifdef POCKETJS_OFFLOAD JSValue offload = JS_NewObject(context); - add_operation(offload, "uploadCoverage", 4, HostOffloadCoverage); + add_operation(offload, "uploadCoverage", 6, HostOffloadCoverage); add_operation(offload, "session", 0, HostOffloadSession); add_operation(offload, "submit", 1, HostOffloadSubmit); add_operation(offload, "take", 0, HostOffloadTake); diff --git a/site/content/docs/components.md b/site/content/docs/components.md index e34240bd1..b09f7d88a 100644 --- a/site/content/docs/components.md +++ b/site/content/docs/components.md @@ -502,7 +502,7 @@ The Solid module `@pocketjs/framework/classic` shares a shaded bezel and color palette across buttons, keyboard faces, panels and selection indicators. ```tsx -import { ClassicButton, ClassicPanel } from "@pocketjs/framework/classic"; +import { ClassicButton, ClassicPanel, ClassicSheet } from "@pocketjs/framework/classic"; setConfirmDiscard(false)} + onModalChange={setInputBlocked} /> +``` + + ## App-shell primitives `@pocketjs/framework/components` also exports a layer of higher-level primitives that diff --git a/tests/fixtures/offload-queue.c b/tests/fixtures/offload-queue.c index ed57c920e..95e041524 100644 --- a/tests/fixtures/offload-queue.c +++ b/tests/fixtures/offload-queue.c @@ -21,6 +21,14 @@ int main(void) { assert(rgba[i * 4 + 3] == (i % 4) * 85); } assert(rgba[12 * 4] == 0); + assert(coverage_colorize("000000111111", 12, "123456abcdef", 12, 12, 1, 16, rgba)); + assert(rgba[0] == 0x12 && rgba[1] == 0x34 && rgba[2] == 0x56); + assert(rgba[6 * 4] == 0xab && rgba[6 * 4 + 3] == 170); + assert(rgba[12 * 4] == 0); /* padded columns remain transparent */ + assert(!coverage_colorize("000000222222", 12, "123456abcdef", 12, 12, 1, 16, rgba)); + assert(!coverage_colorize("00000011111z", 12, "123456abcdef", 12, 12, 1, 16, rgba)); + assert(!coverage_colorize("000000111111", 11, "123456abcdef", 12, 12, 1, 16, rgba)); + assert(!coverage_colorize("000000111111", 12, "12345gabcdef", 12, 12, 1, 16, rgba)); /* Narrow document pane: 1024 packed bytes require two base64 padding bytes. */ char narrow[1368]; memset(narrow, 'A', sizeof narrow); memcpy(narrow + sizeof narrow - 4, "5A==", 4); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 3db28b36d..8327a951f 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -86,7 +86,7 @@ import { View, } from "../framework/src/components.ts"; import { getAuxiliarySurfaceRoots } from "../framework/src/display.ts"; -import { ClassicButton } from "../framework/src/classic.ts"; +import { ClassicButton, ClassicSheet } from "../framework/src/classic.ts"; import { __runGestures, resetGestures, pushTouchBlock } from "../framework/src/gesture.ts"; import { __packTouch, __setTouches, __resetTouches } from "../framework/src/touch.ts"; import { __advanceClock, resetClock } from "../framework/src/clock.ts"; @@ -223,6 +223,28 @@ test("classic buttons provide press, slide-out, disabled and cancelled feedback" dispose(); resetGestures(); __resetTouches(); }); +test("classic sheets animate natively and retain modality through close and reopen", () => { + resetGestures(); resetClock(); registerStyles({ "text-xs font-bold": 1, "text-xs": 2, "text-sm font-bold": 3 }); + const [open, setOpen] = createSignal(false); + const modal: boolean[] = []; + const dispose = render(() => ClassicSheet({ get open() { return open(); }, title: "Discard?", actions: [], + onCancel() {}, onModalChange(value) { modal.push(value); }, + }) as unknown as NodeMirror, root); + const frame = root.children[0], body = frame.children[1]; + host.clear(); setOpen(true); + expect(modal).toEqual([true]); + expect(host.of("animate").some(c => c[1] === body.id && c[2] === PROP.translateY && c[3] === 0)).toBe(true); + setOpen(false); for (let i = 0; i < 4; i++) __advanceClock(); + expect(modal).toEqual([true]); + setOpen(true); for (let i = 0; i < 15; i++) __advanceClock(); + expect(modal).toEqual([true]); // stale closing deadline cannot hide a reopened sheet + setOpen(false); for (let i = 0; i < 12; i++) __advanceClock(); + expect(modal).toEqual([true, false]); + setOpen(true); dispose(); + expect(modal.at(-1)).toBe(false); + resetGestures(); resetClock(); +}); + test("resource images retain their layout through fallback, retry and borrowed texture replacement", () => { const [state, setState] = createSignal>(pending()); let skeletons = 0, frees = 0; From d7826c2974bb745b98c33fc25c1ad531da3e8c5a Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:14:04 -0700 Subject: [PATCH 11/13] docs(offload): describe palette uploads and Pocket Doc --- docs/OFFLOAD.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/OFFLOAD.md b/docs/OFFLOAD.md index 574798a3a..759698f45 100644 --- a/docs/OFFLOAD.md +++ b/docs/OFFLOAD.md @@ -6,7 +6,7 @@ connection, or synchronous provider call.** The provider owns those resources. This implementation is independent of `feat/companion` (#360). It adds `@pocketjs/framework/offload`, a 3DS worker transport, and a Bun provider -transport. Pocket Folio is a separate application using the capability. +transport. Pocket Doc is a separate application using the capability. ## Frame contract @@ -75,6 +75,13 @@ scratch storage; a guest does not need a pixel expansion loop. Undefined means unsupported; a negative handle means invalid input or exhausted frame credit. The guest owns returned texture handles and releases them with `freeTexture`. +The optional fifth argument, `{ columns, palette }`, assigns a foreground color +to each pixel column. `columns` contains exactly `width` lowercase hexadecimal +indices. `palette` contains one to sixteen concatenated six-digit RGB colors. +**Palette coloring uses the same scratch buffer and one texture upload.** This +supports prearranged colored text without parsing tokens in the guest; it does +not change the queue, payload or per-frame upload limits. + ## Provider `@pocketjs/framework/offload/provider` exports `connectOffloadProvider` and From 39216b2f0491ad19efea03dba1e6954dadf9d387 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:35:21 -0700 Subject: [PATCH 12/13] feat(input): deliver and replay an optional right analog stick --- contracts/spec/platforms.ts | 1 + contracts/spec/spec.ts | 2 ++ docs/DEVTOOLS.md | 7 ++++++ framework/src/analog.ts | 12 +++++++--- framework/src/classic.ts | 2 +- framework/src/devtools.ts | 34 ++++++++++++++++++++++++++-- framework/src/frame-octane.tsx | 2 +- framework/src/frame-vue-vapor.ts | 2 +- framework/src/frame.ts | 2 +- framework/src/host.ts | 4 +++- framework/src/index-octane.ts | 3 ++- framework/src/index-vue-vapor.ts | 3 ++- framework/src/index.ts | 3 ++- framework/src/lifecycle-octane.ts | 3 +++ framework/src/lifecycle-vue-vapor.ts | 3 +++ framework/src/lifecycle.ts | 3 +++ hosts/3ds/README.md | 5 ++++ hosts/3ds/src/input.c | 6 +++++ hosts/3ds/src/input.h | 1 + hosts/3ds/src/main.c | 2 +- hosts/3ds/src/qjs.c | 10 ++++---- hosts/3ds/src/qjs.h | 3 ++- site/content/docs/components.md | 3 ++- tests/3ds-profile.test.ts | 1 + tests/devtools.test.ts | 18 ++++++++++++++- tools/3ds-profile.ts | 1 + tools/tape.ts | 8 +++++-- 27 files changed, 121 insertions(+), 23 deletions(-) diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index e08684d10..9d9ba690d 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -132,6 +132,7 @@ export type TargetId = Extract; export const POCKET_CAPABILITIES = defineCapabilityRegistry([ "input.analog.left", + "input.analog.right", "input.buttons", // Framework-synthesized pointer for targets without a native one: the // analog nub steers a screen cursor, hover applies `focus:`, the press diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index d2416326d..b43c686f8 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -1516,6 +1516,8 @@ export const BTN = { // is unchanged. Deadzone/normalization is runtime policy (framework/src/frame.ts), not // host policy — hosts pass the raw value through. +// Optional sixth frame argument carries the right stick with identical packing. +// Omission reads as center; touch/hit/surface arguments retain their positions. export const ANALOG_CENTER = 0x8080; // --------------------------------------------------------------------------- diff --git a/docs/DEVTOOLS.md b/docs/DEVTOOLS.md index 74958ac10..44c556cff 100644 --- a/docs/DEVTOOLS.md +++ b/docs/DEVTOOLS.md @@ -228,3 +228,10 @@ scrubbing; tape frames are the P-frames) · seek-on-PSP (multiple tick-only steps per vblank ≈ 10× fast-forward) · tape-in-URL for the playground (replays as shareable content) · causality index (pixel → DrawList op → node → signal → input edge) · cross-device state teleport (PSP heap → browser wasm). + + +The optional `rightAnalog` tape track uses the same packed coordinates and RLE +pairs as `analog`. **Absent right-stick samples replay as centered**, including +when live hardware moves during replay. The recorder allocates this track only +after the first noncenter sample. The sixth frame argument carries the raw right +stick; the touch, hit and surface arguments keep their existing positions. diff --git a/framework/src/analog.ts b/framework/src/analog.ts index 58a561c33..bf195e3f2 100644 --- a/framework/src/analog.ts +++ b/framework/src/analog.ts @@ -9,14 +9,15 @@ import { ANALOG_CENTER } from "../../contracts/spec/spec.ts"; /** Fraction of half-range ignored around the stick center (PSP nubs drift). */ const ANALOG_DEADZONE = 0.12; -let analogPacked = ANALOG_CENTER; +let analogPacked = ANALOG_CENTER, rightPacked = ANALOG_CENTER; -export function __setAnalog(packed: number | undefined): void { +export function __setAnalog(packed: number | undefined, right: number | undefined = undefined): void { analogPacked = packed === undefined ? ANALOG_CENTER : packed & 0xffff; + rightPacked = right === undefined ? ANALOG_CENTER : right & 0xffff; } export function __resetAnalog(): void { - analogPacked = ANALOG_CENTER; + analogPacked = rightPacked = ANALOG_CENTER; } /** Raw packed left-stick value ((x << 8) | y) delivered by the host. */ @@ -44,3 +45,8 @@ export function analogX(): number { export function analogY(): number { return axis(analogPacked & 0xff); } + +/** Right stick, with the same center/deadzone as the left; absent hosts read zero. */ +export function rightAnalogRaw(): number { return rightPacked; } +export function rightAnalogX(): number { return axis((rightPacked >> 8) & 0xff); } +export function rightAnalogY(): number { return axis(rightPacked & 0xff); } diff --git a/framework/src/classic.ts b/framework/src/classic.ts index fd9a791fb..16f17cee8 100644 --- a/framework/src/classic.ts +++ b/framework/src/classic.ts @@ -129,7 +129,7 @@ export interface ClassicSheetProps { * Fixed action children are retained through close/reopen; no frame JS writes. */ export function ClassicSheet(props: ClassicSheetProps) { if (props.actions.length > 4) throw new RangeError("ClassicSheet supports at most four actions"); - const height = 64 + (props.actions.length + 1) * 38 + 8; + const height = 60 + (props.actions.length + 1) * 38; const [shown, setShown] = createSignal(false); const frame = View({ debugName: props.debugName ?? "ClassicSheet", get style() { return { posType: 1, insetL: 0, insetR: 0, insetT: 0, insetB: 0, display: shown() ? 0 : 1 }; } }); diff --git a/framework/src/devtools.ts b/framework/src/devtools.ts index e739086fa..fe8004488 100644 --- a/framework/src/devtools.ts +++ b/framework/src/devtools.ts @@ -36,6 +36,8 @@ export interface Tape { * frame count as `masks`. Omitted when the whole session held center — * pre-analog tapes stay byte-identical and replay as center. */ analog?: [number, number][]; + /** Optional right-stick RLE track; omitted on hosts without a right stick. */ + rightAnalog?: [number, number][]; /** v2: sparse touch track — [frameIndex (relative to the tape start), * packed contacts] entries for exactly the frames that HAD contacts * (touch.ts __packTouch words). Contacts vary per frame during a drag, so @@ -63,6 +65,7 @@ interface DevtoolsState { // tape ring (masks + packed analog + touch share indices/start/len) tape: Uint16Array; tapeAnalog: Uint16Array; + tapeRightAnalog: Uint16Array | null; /** Touch ring — allocated lazily on the first frame that HAS contacts, so * touch-free sessions (every PSP session) never pay for it. */ tapeTouch: (number[] | null)[] | null; @@ -73,6 +76,7 @@ interface DevtoolsState { // replay replayMasks: Uint16Array | null; replayAnalog: Uint16Array | null; + replayRightAnalog: Uint16Array | null; replayTouch: (number[] | undefined)[] | null; replayTouchSurfaces: (number[] | undefined)[] | null; replayAt: number; @@ -98,6 +102,7 @@ const state: DevtoolsState = { frame: 0, tape: new Uint16Array(TAPE_CAP), tapeAnalog: new Uint16Array(TAPE_CAP), + tapeRightAnalog: null, tapeTouch: null, tapeTouchSurfaces: null, tapeStart: 0, @@ -105,6 +110,7 @@ const state: DevtoolsState = { tapeFirstFrame: 0, replayMasks: null, replayAnalog: null, + replayRightAnalog: null, replayTouch: null, replayTouchSurfaces: null, replayAt: 0, @@ -140,9 +146,11 @@ export function initDevtools(ops: HostOps): void { state.tapeLen = 0; state.tapeFirstFrame = 0; state.tapeTouch = null; + state.tapeRightAnalog = null; state.tapeTouchSurfaces = null; state.replayMasks = null; state.replayAnalog = null; + state.replayRightAnalog = null; state.replayTouch = null; state.replayTouchSurfaces = null; state.paused = false; @@ -190,6 +198,7 @@ export function wrapFrameHandler( touches?: readonly number[], hits?: readonly number[], touchSurfaces?: readonly number[], + rightAnalog?: number, ) => void, ): ( buttons: number, @@ -197,6 +206,7 @@ export function wrapFrameHandler( touches?: readonly number[], hits?: readonly number[], touchSurfaces?: readonly number[], + rightAnalog?: number, ) => void { return ( buttons: number, @@ -204,6 +214,7 @@ export function wrapFrameHandler( touchArg?: readonly number[], hitsArg?: readonly number[], touchSurfacesArg?: readonly number[], + rightAnalogArg?: number, ) => { state.hostCalls++; if (state.transport) { @@ -212,6 +223,7 @@ export function wrapFrameHandler( } let mask = buttons; let analog = analogArg === undefined ? ANALOG_CENTER : analogArg & 0xffff; + let rightAnalog = rightAnalogArg === undefined ? ANALOG_CENTER : rightAnalogArg & 0xffff; let touch = touchArg; let hits = hitsArg; let touchSurfaces = touchSurfacesArg; @@ -219,6 +231,7 @@ export function wrapFrameHandler( if (state.replayAt < state.replayMasks.length) { mask = state.replayMasks[state.replayAt]; analog = state.replayAnalog ? state.replayAnalog[state.replayAt] : ANALOG_CENTER; + rightAnalog = state.replayRightAnalog ? state.replayRightAnalog[state.replayAt] : ANALOG_CENTER; // Replay owns EVERY input track: live hardware touch must not leak // into the deterministic tape. A v1 tape (no touch track) replays // every frame as no-contacts. @@ -236,6 +249,7 @@ export function wrapFrameHandler( } else { state.replayMasks = null; // tape exhausted: back to live input state.replayAnalog = null; + state.replayRightAnalog = null; state.replayTouch = null; state.replayTouchSurfaces = null; send({ t: "replayDone", frame: state.frame }); @@ -246,10 +260,10 @@ export function wrapFrameHandler( state.stepQueued--; state.ops?.debugStep?.(); // arm exactly one core tick } - recordMask(mask, analog, touch, touchSurfaces); + recordMask(mask, analog, touch, touchSurfaces, rightAnalog); state.frame++; try { - h(mask, analog, touch, hits, touchSurfaces); + h(mask, analog, touch, hits, touchSurfaces, rightAnalog); } catch (e) { send({ t: "error", @@ -272,7 +286,10 @@ function recordMask( analog: number, touch?: readonly number[], touchSurfaces?: readonly number[], + rightAnalog?: number, ): void { + const right = rightAnalog ?? ANALOG_CENTER; + if (right !== ANALOG_CENTER && !state.tapeRightAnalog) state.tapeRightAnalog = new Uint16Array(TAPE_CAP).fill(ANALOG_CENTER); // Defensive copy: hosts may reuse the packed-contact buffer across frames. const contacts = touch && touch.length > 0 ? touch.slice(0, 8) : null; if (contacts && !state.tapeTouch) { @@ -290,12 +307,14 @@ function recordMask( const at = (state.tapeStart + state.tapeLen) % TAPE_CAP; state.tape[at] = mask; state.tapeAnalog[at] = analog; + if (state.tapeRightAnalog) state.tapeRightAnalog[at] = right; if (state.tapeTouch) state.tapeTouch[at] = contacts; if (state.tapeTouchSurfaces) state.tapeTouchSurfaces[at] = surfaces; state.tapeLen++; } else { state.tape[state.tapeStart] = mask; state.tapeAnalog[state.tapeStart] = analog; + if (state.tapeRightAnalog) state.tapeRightAnalog[state.tapeStart] = right; if (state.tapeTouch) state.tapeTouch[state.tapeStart] = contacts; if (state.tapeTouchSurfaces) state.tapeTouchSurfaces[state.tapeStart] = surfaces; state.tapeStart = (state.tapeStart + 1) % TAPE_CAP; @@ -328,6 +347,10 @@ function exportTape(): Tape { if (analog.length > 1 || (analog.length === 1 && analog[0][0] !== ANALOG_CENTER)) { tape.analog = analog; } + if (state.tapeRightAnalog) { + const right = rlePairs(state.tapeRightAnalog); + if (right.some(([value]) => value !== ANALOG_CENTER)) tape.rightAnalog = right; + } // Touch upgrades the tape to v2 only when a recorded frame actually had // contacts — touch-free exports stay v:1 byte-identical. if (state.tapeTouch) { @@ -380,6 +403,11 @@ export function expandTapeAnalog(tape: Tape): Uint16Array { return expandPairs(tape.analog ?? [], ANALOG_CENTER, total); } +/** Expand the optional right-stick lane; legacy recordings are centered. */ +export function expandTapeRightAnalog(tape: Tape): Uint16Array { + return expandPairs(tape.rightAnalog ?? [], ANALOG_CENTER, tape.masks.reduce((n, [, count]) => n + count, 0)); +} + /** Expand a tape's sparse touch track into one packed-contact array (or * undefined) per frame. A v1 tape yields all-undefined — no contacts. */ export function expandTapeTouch(tape: Tape): (number[] | undefined)[] { @@ -523,6 +551,7 @@ function handleMessage(line: string): void { if (tape && Array.isArray(tape.masks)) { state.replayMasks = expandTape(tape); state.replayAnalog = tape.analog ? expandTapeAnalog(tape) : null; + state.replayRightAnalog = tape.rightAnalog ? expandTapeRightAnalog(tape) : null; state.replayTouch = tape.touch ? expandTapeTouch(tape) : null; state.replayTouchSurfaces = tape.touchSurfaces ? expandTapeTouchSurfaces(tape) @@ -762,6 +791,7 @@ const api = { replay: (tape: Tape): void => { state.replayMasks = expandTape(tape); state.replayAnalog = tape.analog ? expandTapeAnalog(tape) : null; + state.replayRightAnalog = tape.rightAnalog ? expandTapeRightAnalog(tape) : null; state.replayTouch = tape.touch ? expandTapeTouch(tape) : null; state.replayTouchSurfaces = tape.touchSurfaces ? expandTapeTouchSurfaces(tape) diff --git a/framework/src/frame-octane.tsx b/framework/src/frame-octane.tsx index 84d389262..4b1140de8 100644 --- a/framework/src/frame-octane.tsx +++ b/framework/src/frame-octane.tsx @@ -9,7 +9,7 @@ import { useEffect, useEffectEvent, useRef, useState } from "octane"; import { __resetAnalog } from "./analog.ts"; -export { __setAnalog, analogRaw, analogX, analogY } from "./analog.ts"; +export { __setAnalog, analogRaw, analogX, analogY, rightAnalogRaw, rightAnalogX, rightAnalogY } from "./analog.ts"; type FrameCallback = (buttons: number) => void; diff --git a/framework/src/frame-vue-vapor.ts b/framework/src/frame-vue-vapor.ts index 6873b7efb..f6ff4e1e2 100644 --- a/framework/src/frame-vue-vapor.ts +++ b/framework/src/frame-vue-vapor.ts @@ -3,7 +3,7 @@ import { computed, onScopeDispose, shallowRef, type ComputedRef } from "vue"; import { __resetAnalog } from "./analog.ts"; -export { __setAnalog, analogRaw, analogX, analogY } from "./analog.ts"; +export { __setAnalog, analogRaw, analogX, analogY, rightAnalogRaw, rightAnalogX, rightAnalogY } from "./analog.ts"; type FrameCallback = (buttons: number) => void; diff --git a/framework/src/frame.ts b/framework/src/frame.ts index 9f2538d78..c73405b64 100644 --- a/framework/src/frame.ts +++ b/framework/src/frame.ts @@ -7,7 +7,7 @@ import { createSignal, onCleanup, type Accessor } from "solid-js"; import { __resetAnalog } from "./analog.ts"; -export { __setAnalog, analogRaw, analogX, analogY } from "./analog.ts"; +export { __setAnalog, analogRaw, analogX, analogY, rightAnalogRaw, rightAnalogX, rightAnalogY } from "./analog.ts"; type FrameCallback = (buttons: number) => void; diff --git a/framework/src/host.ts b/framework/src/host.ts index c7b8c5021..0bdb0951a 100644 --- a/framework/src/host.ts +++ b/framework/src/host.ts @@ -405,7 +405,7 @@ export function reportAppAction(name: string, value: number): void { // Frame hookup // --------------------------------------------------------------------------- // Every host drives frames the same way: once per vblank/rAF tick it calls -// `globalThis.frame(buttons, analog?, touches?, hits?, touchSurfaces?)` with the +// `globalThis.frame(buttons, analog?, touches?, hits?, touchSurfaces?, rightAnalog?)` with the // PSP button bitmask (spec BTN) // and, when the host has an analog stick, the packed nub value // (x << 8 | y, each axis 0..255, 128 = center — spec ANALOG_CENTER). Hosts @@ -421,6 +421,7 @@ export function installFrameHandler( touches?: readonly number[], hits?: readonly number[], touchSurfaces?: readonly number[], + rightAnalog?: number, ) => void, ): void { ( @@ -431,6 +432,7 @@ export function installFrameHandler( touches?: readonly number[], hits?: readonly number[], touchSurfaces?: readonly number[], + rightAnalog?: number, ) => void; } ).frame = fn; diff --git a/framework/src/index-octane.ts b/framework/src/index-octane.ts index 26b2ac25d..2e3112cf7 100644 --- a/framework/src/index-octane.ts +++ b/framework/src/index-octane.ts @@ -213,9 +213,10 @@ export function render(code: OctaneRenderRoot, opts: RenderOptions = {}): () => touches?: readonly number[], hits?: readonly number[], touchSurfaces?: readonly number[], + rightAnalog?: number, ) => { __advanceClock(); - __setAnalog(analog); + __setAnalog(analog, rightAnalog); __setTouches(touches, hits, touchSurfaces); runServicePumps(); __drainEffects(); diff --git a/framework/src/index-vue-vapor.ts b/framework/src/index-vue-vapor.ts index a569dfb6a..fe5a5cb25 100644 --- a/framework/src/index-vue-vapor.ts +++ b/framework/src/index-vue-vapor.ts @@ -223,9 +223,10 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v touches?: readonly number[], hits?: readonly number[], touchSurfaces?: readonly number[], + rightAnalog?: number, ) => { __advanceClock(); - __setAnalog(analog); + __setAnalog(analog, rightAnalog); __setTouches(touches, hits, touchSurfaces); // latch contacts + surface-specific hit facts runServicePumps(); __drainEffects(); diff --git a/framework/src/index.ts b/framework/src/index.ts index a33290a3e..08e266544 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -276,9 +276,10 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi touches?: readonly number[], hits?: readonly number[], touchSurfaces?: readonly number[], + rightAnalog?: number, ) => { __advanceClock(); // virtual frame++, fire due after() timers - __setAnalog(analog); // latch the nub before any app code reads it + __setAnalog(analog, rightAnalog); // latch the nub before any app code reads it __setTouches(touches, hits, touchSurfaces); // latch contacts + surface-specific hit facts runServicePumps(); // only modules with pending async work register here __drainEffects(); // frame-boundary deliveries enter the world first diff --git a/framework/src/lifecycle-octane.ts b/framework/src/lifecycle-octane.ts index 65f6ea543..8433a036b 100644 --- a/framework/src/lifecycle-octane.ts +++ b/framework/src/lifecycle-octane.ts @@ -6,6 +6,9 @@ export { analogX, analogY, analogRaw, + rightAnalogRaw, + rightAnalogX, + rightAnalogY, type ButtonPressOptions, type SpriteAnimationOptions, } from "./frame-octane.tsx"; diff --git a/framework/src/lifecycle-vue-vapor.ts b/framework/src/lifecycle-vue-vapor.ts index 0c512a13a..92a25a03d 100644 --- a/framework/src/lifecycle-vue-vapor.ts +++ b/framework/src/lifecycle-vue-vapor.ts @@ -6,6 +6,9 @@ export { analogX, analogY, analogRaw, + rightAnalogRaw, + rightAnalogX, + rightAnalogY, type ButtonPressOptions, type SpriteAnimationOptions, } from "./frame-vue-vapor.ts"; diff --git a/framework/src/lifecycle.ts b/framework/src/lifecycle.ts index 78817f5f0..6463942aa 100644 --- a/framework/src/lifecycle.ts +++ b/framework/src/lifecycle.ts @@ -8,6 +8,9 @@ export { analogX, analogY, analogRaw, + rightAnalogRaw, + rightAnalogX, + rightAnalogY, type ButtonPressOptions, type SpriteAnimationOptions, } from "./frame.ts"; diff --git a/hosts/3ds/README.md b/hosts/3ds/README.md index 9b402f4c5..8fbf8d9ae 100644 --- a/hosts/3ds/README.md +++ b/hosts/3ds/README.md @@ -373,3 +373,8 @@ part of it, so a run gets its own config and SD card by getting its own `$HOME`. to the **bottom auxiliary surface**, so it is exposed only as `input.touch.auxiliary`; contacts are never remapped into the top screen's coordinate space. `audio.pcm` is not implemented in v1. + +The New 3DS C-stick is exposed as the optional right analog lane. Applications +read `rightAnalogX()` / `rightAnalogY()` from the framework lifecycle API, using +the same normalized axes and deadzone as the left stick. Older hardware returns +centered values. IRRST scanning stays in the host input adapter. diff --git a/hosts/3ds/src/input.c b/hosts/3ds/src/input.c index 10ea678e1..cb8af57db 100644 --- a/hosts/3ds/src/input.c +++ b/hosts/3ds/src/input.c @@ -138,6 +138,12 @@ int32_t input_analog(void) { return (axis(pad.dx) << 8) | axis(-pad.dy); } +int32_t input_right_analog(void) { + if (!extra_input) return 0x8080; + circlePosition stick; irrstCstickRead(&stick); + return (axis(stick.dx) << 8) | axis(-stick.dy); +} + size_t input_touch(uint32_t *packed) { if (packed == NULL || (hidKeysHeld() & KEY_TOUCH) == 0) return 0; touchPosition touch; diff --git a/hosts/3ds/src/input.h b/hosts/3ds/src/input.h index 61d65b086..38218f606 100644 --- a/hosts/3ds/src/input.h +++ b/hosts/3ds/src/input.h @@ -13,6 +13,7 @@ int32_t input_buttons(void); void input_init(void); void input_shutdown(void); int32_t input_analog(void); +int32_t input_right_analog(void); /** Host-owned L+R+X edge. The complete chord is removed from app buttons. */ bool input_reload_requested(void); /** Host-owned L+R+SELECT edge that toggles the native development menu. */ diff --git a/hosts/3ds/src/main.c b/hosts/3ds/src/main.c index 9f8977e3a..81f77f570 100644 --- a/hosts/3ds/src/main.c +++ b/hosts/3ds/src/main.c @@ -858,7 +858,7 @@ int main(void) { 1 ); if (hit_count != touch_count) fail("auxiliary touch hit resolution failed"); - if (!qjs_frame(buttons, analog, &touch, &touch_hit, touch_count)) { + if (!qjs_frame(buttons, analog, &touch, &touch_hit, touch_count, devmenu_blocks_guest ? ANALOG_CENTER : input_right_analog())) { #if defined(POCKETJS_CAPTURE) || defined(POCKETJS_OFFLOAD) fail(qjs_last_error()); #else diff --git a/hosts/3ds/src/qjs.c b/hosts/3ds/src/qjs.c index 356c243ba..677bc036e 100644 --- a/hosts/3ds/src/qjs.c +++ b/hosts/3ds/src/qjs.c @@ -724,17 +724,19 @@ bool qjs_frame( int32_t analog, const uint32_t *touches, const int32_t *hits, - size_t touch_count + size_t touch_count, + int32_t right_analog ) { if (context == NULL) return false; offload_frame(); coverage_used = false; - JSValue arguments[5] = { + JSValue arguments[6] = { JS_NewInt32(context, buttons), JS_NewInt32(context, analog), JS_NewArray(context), JS_NewArray(context), JS_NewArray(context), + JS_NewInt32(context, right_analog), }; for (size_t index = 0; index < touch_count && index < 8; index += 1) { JS_SetPropertyUint32( @@ -752,8 +754,8 @@ bool qjs_frame( /* 1 = auxiliary output; the 3DS touch panel is the bottom screen. */ JS_SetPropertyUint32(context, arguments[4], (uint32_t)index, JS_NewInt32(context, 1)); } - JSValue result = JS_Call(context, frame_function, global, 5, arguments); - for (size_t index = 0; index < 5; index += 1) JS_FreeValue(context, arguments[index]); + JSValue result = JS_Call(context, frame_function, global, 6, arguments); + for (size_t index = 0; index < 6; index += 1) JS_FreeValue(context, arguments[index]); if (JS_IsException(result)) { take_exception(); JS_FreeValue(context, result); diff --git a/hosts/3ds/src/qjs.h b/hosts/3ds/src/qjs.h index f137ad94f..e6fc9d123 100644 --- a/hosts/3ds/src/qjs.h +++ b/hosts/3ds/src/qjs.h @@ -26,7 +26,8 @@ bool qjs_frame( int32_t analog, const uint32_t *touches, const int32_t *hits, - size_t touch_count + size_t touch_count, + int32_t right_analog ); const char *qjs_last_error(void); void qjs_shutdown(void); diff --git a/site/content/docs/components.md b/site/content/docs/components.md index b09f7d88a..c93c7ba06 100644 --- a/site/content/docs/components.md +++ b/site/content/docs/components.md @@ -533,7 +533,8 @@ defaults to 27 logical pixels. **The host animates the panel translation and backdrop opacity.** The component keeps its fixed action subtree mounted and blocks other touch gestures until the closing transition ends. `onModalChange` includes that closing interval so -applications can also gate hardware buttons. Reopening cancels the old closing +applications can also gate hardware buttons. Buttons keep a 4px gap and the final button sits 4px above the panel bottom. +Reopening cancels the old closing deadline; unmounting cancels animations, the deadline and the touch block. ```tsx diff --git a/tests/3ds-profile.test.ts b/tests/3ds-profile.test.ts index 0c50b30f8..1c38c9926 100644 --- a/tests/3ds-profile.test.ts +++ b/tests/3ds-profile.test.ts @@ -103,6 +103,7 @@ describe("private Nintendo 3DS build profile", () => { capabilities: [ "io.offload", "input.analog.left", + "input.analog.right", "input.buttons", "input.cursor", "input.touch.auxiliary", diff --git a/tests/devtools.test.ts b/tests/devtools.test.ts index 7e39b9b14..d69576c80 100644 --- a/tests/devtools.test.ts +++ b/tests/devtools.test.ts @@ -23,7 +23,7 @@ import { type Tape, } from "../framework/src/devtools.ts"; import { touches, __packTouch } from "../framework/src/touch.ts"; -import { onFrame } from "../framework/src/lifecycle.ts"; +import { onFrame, rightAnalogRaw, rightAnalogX, rightAnalogY } from "../framework/src/lifecycle.ts"; import { createComponent, createTextNode, @@ -576,3 +576,19 @@ describe("bundle hash", () => { expect(fnv1a64(bytes("he"), bytes("llo"))).toBe(fnv1a64(bytes("hello"))); }); }); + + +test("right stick records, replays independently, and centers absent legacy samples", () => { + const samples: number[][] = []; + mountApp(() => { onFrame(() => samples.push([rightAnalogX(), rightAnalogY(), rightAnalogRaw()])); return View({}); }); + const run = (right?: number) => (globalThis as any).frame(0, 0x8080, undefined, undefined, undefined, right); + run(); run(0xff80); run(0x8000); run(0x8181); + expect(samples).toEqual([[0, 0, 0x8080], [1, 0, 0xff80], [0, -1, 0x8000], [0, 0, 0x8181]]); + const api = (globalThis as any).__pocketDevtools; + const tape = api.dumpTape(); expect(tape.rightAnalog).toEqual([[0x8080, 1], [0xff80, 1], [0x8000, 1], [0x8181, 1]]); + api.replay(tape); samples.length = 0; + for (let i = 0; i < 4; i++) run(0x0080); + expect(samples).toEqual([[0, 0, 0x8080], [1, 0, 0xff80], [0, -1, 0x8000], [0, 0, 0x8181]]); + api.replay({ v: 1, frames: 1, masks: [[0, 1]] }); run(0xff80); + expect(samples.at(-1)).toEqual([0, 0, 0x8080]); +}); diff --git a/tools/3ds-profile.ts b/tools/3ds-profile.ts index 40e1666cf..33a3527d0 100644 --- a/tools/3ds-profile.ts +++ b/tools/3ds-profile.ts @@ -44,6 +44,7 @@ export const THREE_DS_DEV_CONTRACTS = definePlatformContractRegistry( capabilities: [ "io.offload", "input.analog.left", + "input.analog.right", "input.buttons", "input.cursor", "input.touch.auxiliary", diff --git a/tools/tape.ts b/tools/tape.ts index af20d4caf..7ee663571 100644 --- a/tools/tape.ts +++ b/tools/tape.ts @@ -22,6 +22,7 @@ import { createWasmUi } from "../hosts/web/wasm-ops.js"; import { expandTape, expandTapeAnalog, + expandTapeRightAnalog, expandTapeTouch, expandTapeTouchSurfaces, type Tape, @@ -83,6 +84,7 @@ interface BootResult { touches?: readonly number[], hits?: readonly number[], touchSurfaces?: readonly number[], + rightAnalog?: number, ) => void; tick: () => void; render: () => Uint8Array; @@ -153,6 +155,7 @@ async function cmdReplay(): Promise { const tape = loadTape(tapePathArg); const masks = expandTape(tape); const analogs = expandTapeAnalog(tape); + const rightAnalogs = expandTapeRightAnalog(tape); const touches = expandTapeTouch(tape); const touchSurfaces = expandTapeTouchSurfaces(tape); const hashesOut = argValue("--hashes"); @@ -169,7 +172,7 @@ async function cmdReplay(): Promise { if (pngFrames.size) mkdirSync(outdir, { recursive: true }); const hashes: string[] = []; for (let f = 0; f < masks.length; f++) { - b.frame(masks[f], analogs[f], touches[f], undefined, touchSurfaces[f]); + b.frame(masks[f], analogs[f], touches[f], undefined, touchSurfaces[f], rightAnalogs[f]); b.tick(); const fb = b.render(); const h = fnv1a(fb); @@ -206,13 +209,14 @@ async function cmdTree(): Promise { const tape = loadTape(tapePathArg); const masks = expandTape(tape); const analogs = expandTapeAnalog(tape); + const rightAnalogs = expandTapeRightAnalog(tape); const touches = expandTapeTouch(tape); const touchSurfaces = expandTapeTouchSurfaces(tape); const at = Number(argValue("--at") ?? masks.length); const upTo = Math.min(at, masks.length); const b = await boot(app); for (let f = 0; f < upTo; f++) { - b.frame(masks[f], analogs[f], touches[f], undefined, touchSurfaces[f]); + b.frame(masks[f], analogs[f], touches[f], undefined, touchSurfaces[f], rightAnalogs[f]); b.tick(); } b.outbox.length = 0; From 6bbb4a92f909946091e6130c150d65ef6c405e20 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:40:39 -0700 Subject: [PATCH 13/13] fix(3ds): initialize right stick for every host build mode --- hosts/3ds/src/main.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hosts/3ds/src/main.c b/hosts/3ds/src/main.c index 81f77f570..7684a3245 100644 --- a/hosts/3ds/src/main.c +++ b/hosts/3ds/src/main.c @@ -767,12 +767,14 @@ int main(void) { * runs stay deterministic. */ int32_t buttons = scripted_buttons(frame); int32_t analog = ANALOG_CENTER; + 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 @@ -845,6 +847,7 @@ int main(void) { } 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(); uint32_t touch = 0; size_t touch_count = devmenu_blocks_guest ? 0 : input_touch(&touch); #endif @@ -858,7 +861,7 @@ int main(void) { 1 ); if (hit_count != touch_count) fail("auxiliary touch hit resolution failed"); - if (!qjs_frame(buttons, analog, &touch, &touch_hit, touch_count, devmenu_blocks_guest ? ANALOG_CENTER : input_right_analog())) { + if (!qjs_frame(buttons, analog, &touch, &touch_hit, touch_count, right_analog)) { #if defined(POCKETJS_CAPTURE) || defined(POCKETJS_OFFLOAD) fail(qjs_last_error()); #else