Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions contracts/generated/pocket_spec.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions contracts/spec/offload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** 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. 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 }
2 changes: 2 additions & 0 deletions contracts/spec/platforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export type TargetId<T extends TargetRegistry> = Extract<keyof T, string>;

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
Expand Down Expand Up @@ -174,6 +175,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
Expand Down
4 changes: 4 additions & 0 deletions contracts/spec/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1498,6 +1498,8 @@ export const BTN = {
LEFT: 0x0080,
LTRIGGER: 0x0100,
RTRIGGER: 0x0200,
ZL: 0x0400,
ZR: 0x0800,
TRIANGLE: 0x1000,
CIRCLE: 0x2000,
CROSS: 0x4000,
Expand All @@ -1514,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;

// ---------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions docs/DEVTOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
141 changes: 141 additions & 0 deletions docs/OFFLOAD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# 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 Doc 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.

`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
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`.

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
`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/<first-16-hex-SHA256(app.id)>.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.
39 changes: 39 additions & 0 deletions docs/RESOURCES.md
Original file line number Diff line number Diff line change
@@ -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";

<ResourceBoundary state={rowState} fallback={() => <RowSkeleton />}
errorFallback={() => <UnavailableRow />}>
{row => <DocumentRow value={row()} />}
</ResourceBoundary>
```

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.
2 changes: 2 additions & 0 deletions engine/core/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion framework/compiler/build-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class BuildInputs {
}
}
async compiler(entrypoints: string[], frameworkRoot: string): Promise<void> {
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)) {
Expand Down
6 changes: 6 additions & 0 deletions framework/compiler/subpaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export const SUBPATHS: Record<string, SubpathDecl> = {
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 },
"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" },
Expand Down
12 changes: 9 additions & 3 deletions framework/src/analog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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); }
2 changes: 2 additions & 0 deletions framework/src/animation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Animation public API.

export { createCaretBlink, type CaretBlinkOptions } from "./caret-blink.ts";

export {
animate,
spring,
Expand Down
49 changes: 49 additions & 0 deletions framework/src/caret-blink.ts
Original file line number Diff line number Diff line change
@@ -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); } },
};
}
Loading