diff --git a/.github/workflows/3ds-runtime.yml b/.github/workflows/3ds-runtime.yml index c3c1adb87..716268c1b 100644 --- a/.github/workflows/3ds-runtime.yml +++ b/.github/workflows/3ds-runtime.yml @@ -1,10 +1,10 @@ name: 3DS runtime contracts on: pull_request: - paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml'] + paths: ['hosts/3ds/**', 'contracts/**', 'framework/src/offload.ts', 'framework/src/resource*.ts', 'framework/src/tile-viewport.ts', 'framework/src/drag-filter.ts', 'framework/src/gesture*.ts', 'tools/offload-*.ts', 'tools/3ds*.ts', 'tests/offload*.test.ts', 'tests/resource*.test.ts', 'tests/tile-viewport.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/offload*', 'tests/fixtures/offload*/**', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml'] push: branches: [main] - paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml'] + paths: ['hosts/3ds/**', 'contracts/**', 'framework/src/offload.ts', 'framework/src/resource*.ts', 'framework/src/tile-viewport.ts', 'framework/src/drag-filter.ts', 'framework/src/gesture*.ts', 'tools/offload-*.ts', 'tools/3ds*.ts', 'tests/offload*.test.ts', 'tests/resource*.test.ts', 'tests/tile-viewport.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/offload*', 'tests/fixtures/offload*/**', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml'] permissions: contents: read jobs: @@ -18,3 +18,4 @@ jobs: bun-version: 1.3.14 - run: bun install --frozen-lockfile - run: bun test tests/3ds-profile.test.ts tests/3ds-runtime-state.test.ts tests/3ds-runtime-wire.test.ts tests/3ds-soc.test.ts + - run: bun test --conditions=browser tests/offload.test.ts tests/offload-images.test.ts tests/offload-meshes.test.ts tests/offload-provider.test.ts tests/tile-viewport.test.ts tests/resource-cache.test.ts tests/resource-view.test.ts diff --git a/.github/workflows/psp-offload.yml b/.github/workflows/psp-offload.yml new file mode 100644 index 000000000..b7f5060d4 --- /dev/null +++ b/.github/workflows/psp-offload.yml @@ -0,0 +1,46 @@ +name: PSP USB offload +on: + pull_request: + paths: + - 'hosts/psp/src/**' + - 'hosts/psp/build.rs' + - 'tools/psp.ts' + - 'contracts/spec/platforms.ts' + - 'tests/platform-contracts.test.ts' + - 'tools/offload-usb-provider.ts' + - 'tools/offload-process.ts' + - 'tools/offload-provider.ts' + - 'tools/offload-wire.ts' + - 'tests/offload-usb.test.ts' + - 'tests/fixtures/offload-usb/**' + - '.github/workflows/psp-offload.yml' + push: + branches: [main] + paths: + - 'hosts/psp/src/**' + - 'hosts/psp/build.rs' + - 'tools/psp.ts' + - 'contracts/spec/platforms.ts' + - 'tests/platform-contracts.test.ts' + - 'tools/offload-usb-provider.ts' + - 'tools/offload-process.ts' + - 'tools/offload-provider.ts' + - 'tools/offload-wire.ts' + - 'tests/offload-usb.test.ts' + - 'tests/fixtures/offload-usb/**' + - '.github/workflows/psp-offload.yml' +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - run: bun test tests/offload-usb.test.ts tests/platform-contracts.test.ts + - run: rustc --test hosts/psp/src/offload_packet.rs -o /tmp/usb-packet-test && /tmp/usb-packet-test + - run: rustc --test hosts/psp/src/analog.rs -o /tmp/psp-analog-test && /tmp/psp-analog-test diff --git a/contracts/spec/offload.ts b/contracts/spec/offload.ts index 56a4e1333..e756b7fb3 100644 --- a/contracts/spec/offload.ts +++ b/contracts/spec/offload.ts @@ -1,8 +1,13 @@ /** 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, + version: 1, + recordBytes: 4096, + payloadChars: 2500, + pending: 8, + deliveriesPerFrame: 1, + submissionsPerFrame: 2, + timeoutFrames: 600, port: 8741, }); @@ -13,11 +18,84 @@ export interface OffloadOps { submit(record: string): boolean; /** At most one complete record per host frame. Never performs IO. */ take(): string | undefined; + /** Borrow one native image ticket. At most one <=256x256 upload per frame. + * Pixels stay outside the JS heap. releaseImage returns staging credit. */ + uploadImage?(token: number): number; + /** Shares image staging and one materialization per frame. */ + uploadMesh?(token: number): number; + releaseMesh?(token: number): void; + releaseImage?(token: number): void; /** 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; + 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; + response?: "image" | "mesh"; +} +export interface OffloadImageTicket { + token: number; + width: number; + height: number; +} +export interface OffloadReply { + id: number; + payload?: string; + error?: string; + image?: OffloadImageTicket; + mesh?: OffloadMeshTicket; +} + +/** Optional image response extension. The record length's high bit selects a + * binary image; JSON retains its 4096-byte limit. Header is 16 bytes: PIMG, + * u32 request ID, u16 width, u16 height, u32 format (all little endian). + * Format 0 = row-major R5G6B5 little endian (PSM_5650), 16..256 power-of-two + * dimensions. No codec, base64, palette expansion or pixels in guest JS. */ +export const OFFLOAD_IMAGE = Object.freeze({ + headerBytes: 16, + maxSide: 256, + maxBytes: 256 * 256 * 2, + slots: 8, + flag: 0x80000000, +}); +export interface OffloadImage { + width: number; + height: number; + pixels: Uint8Array; + format: "r5g6b5"; +} +export interface OffloadProviderReply { + id: number; + payload?: string; + error?: string; + image?: OffloadImage; + mesh?: OffloadMesh; +} + +/** PMH1: 16-byte header (magic, u16 width/height/vertices/triangles, u32 zero), + * u16 x/y in 1/16 logical pixels, then [u16 a,b,c,u32 ABGR] triangles. + * Coordinates are inside the declared envelope. No topology/codec in guest JS. + * PMSH wire wrapper is magic + u32 request ID + this entry. Shares eight slots. */ +export const OFFLOAD_MESH = Object.freeze({ maxVertices: 4096, maxTriangles: 2048, maxBytes: 36880 }); +export interface OffloadMesh { + format: "mesh2d-v1"; + bytes: Uint8Array; +} +export interface OffloadMeshTicket { + token: number; + width: number; + height: number; + bytes: 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 9d9ba690d..ceac353e8 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -254,6 +254,7 @@ export const POCKET_TARGETS = defineTargetRegistry { + camera.step(inputDeltaSeconds(), -analogX() * 320, -analogY() * 320); +}); +``` + +**This does not change virtual time, timer deadlines, core ticks or the number +of resource pumps per frame.** Valid samples are rounded to microseconds and +bounded to 1–66,666 µs. Missing, zero or invalid samples use `1 / simulationHz()`; +other hosts and deterministic captures keep that nominal step. A long pause +therefore advances the camera by at most 66.666 ms on resume, without catch-up +transactions or a large jump. Frames below 15 Hz cannot preserve real-time +travel under this bound. + +The flight recorder stores the normalized duration in tape v4. Replay uses +that track and ignores the live host duration; tapes without it use the +nominal step. Apps still do not read a wall clock: the same recorded input +and duration sequence produces the same trajectory on another host. + ## The effect shell (`@pocketjs/framework/effects`) Buttons were already part of `input[n]`. The effect shell makes *everything diff --git a/docs/DEVTOOLS.md b/docs/DEVTOOLS.md index 44c556cff..a0cb76e8a 100644 --- a/docs/DEVTOOLS.md +++ b/docs/DEVTOOLS.md @@ -235,3 +235,13 @@ 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. + +## Input duration track (v4) + +The optional `inputElapsedUs` RLE track records the seventh frame argument: +`[microseconds, repeatCount]`, bounded to 1–66,666 µs, with zero meaning the +nominal simulation step. The recorder allocates this track on the first +nonzero sample. Replay owns the duration along with buttons, sticks and +touch: **live host timing cannot alter a recorded trajectory**. Tapes without +this track retain nominal simulation time. `inputDeltaSeconds()` exposes the +latched sample to apps; virtual timers and core ticks remain frame-based. diff --git a/docs/OFFLOAD.md b/docs/OFFLOAD.md index 4071d9f60..c7c2f3748 100644 --- a/docs/OFFLOAD.md +++ b/docs/OFFLOAD.md @@ -12,7 +12,7 @@ transport. Pocket Doc is a separate application using the capability. | Boundary | Enforced limit | | --- | --- | -| Wire record | 4,096 UTF-8 bytes, 4-byte big-endian length prefix | +| JSON 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 | @@ -20,6 +20,9 @@ transport. Pocket Doc is a separate application using the capability. | 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 | +| Binary image response | At most 256×256 R5G6B5 pixels; 16-byte header | +| Native image staging | 8 slots, 131,088 bytes per slot plus metadata | +| Binary image uploads | 1 per host frame; bilinear filtering | | Request deadline | 600 guest frames; provider worker deadline 9 seconds | `submit` and `take` only copy fixed-size memory slots. **They do not call socket @@ -92,6 +95,35 @@ versioned requests. An over-budget response or malformed frame closes the connection. A stalled worker is terminated instead of retaining its requests indefinitely. +### Binary image resources + +`requestImage` opts a read into the image response extension. A provider method +returns `{ width, height, format: "r5g6b5", pixels: Uint8Array }` after decoding +or rasterizing on its worker. Dimensions are powers of two from 16 through 256. +Pixels are row-major little-endian words with red in the low five bits, green +in the next six, and blue in the high five. Alpha is not carried by this format. + +The wire length's high bit distinguishes an image from a JSON record. The +16-byte little-endian header contains `PIMG`, request ID, width, height and +format zero. **The 3DS network worker receives pixels into fixed native slots; +the JS heap receives only a token and dimensions.** Socket backpressure applies +when no staging slot is free. The UI copies no pixel array and runs no image +codec. It can upload one slot per frame through the core's IMG entry path. + +Use `createOffloadImageCollection` from `@pocketjs/framework/resource-offload` +to connect these tickets to resource demand and ownership. The collection +reserves staging and old-plus-new texture cost before starting a read. After +materialization, cancellation, a rejected envelope or late delivery, the +framework returns staging credit. Eviction frees the uploaded texture. A stale +token cannot free a newer allocation. A realm reset closes the connection +before new request IDs become eligible for submission. + +JSON-only requests and the coverage path retain their existing bounds. The +image extension is optional in `OffloadOps`; a host without `uploadImage` and +`releaseImage` reports an unsupported operation. It does not decode PNG/JPEG +in guest JavaScript. Other hosts can implement the same bounded staging +contract without changing application resource definitions. + `@pocketjs/framework/offload/capabilities` exports two provider-side helpers: - `sqliteQueries(db, queries)`: named, provider-owned SQL with device-supplied @@ -140,6 +172,50 @@ 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. +`bun test --conditions=browser tests/offload-images.test.ts` exercises binary +envelopes, staging cleanup and 4,000 concurrent native images under address and +undefined-behavior sanitizers. A socket test compiles the production 3DS worker +with POSIX thread shims, connects the real provider, compares every image byte, +and verifies connection replacement after a realm reset. It does not emulate +libctru, GPU upload cost or device presentation cadence. + Reusable reads can use the [shared resource scheduler](RESOURCES.md#shared-read-scheduling) for admission, priority, caching and bounded materialization. Commands retain the direct offload path and application-owned recovery semantics. + +## Prepared geometry response extension + +An opted-in request uses `response: "mesh"`. The length prefix retains the +binary high bit; the payload starts with `PMSH` and a little-endian u32 request +ID, followed by a standalone `PMH1` entry. `encodeOffloadMesh` validates the +entry before it enters the socket queue. Image requests retain `PIMG` and their +existing binary representation. + +**A mesh entry is at most 36,880 bytes: 4,096 vertices and 2,048 triangles.** +Its 16-byte header contains `PMH1`, u16 width, height, vertex count, triangle +count and a zero u32 reserved field. Vertices are u16 x/y in sixteenths of a +logical pixel. Each triangle contains three u16 indices and a u32 ABGR color. +All coordinates fit the declared width/height, and each index references a +vertex in the same entry. Integers inside the binary payload are little-endian. + +**Mesh reception uses the existing eight image staging slots.** The worker +validates size, indices and coordinates before publishing a ticket. The guest +receives only `{token, width, height, bytes}`. `uploadMesh` shares the one-per-frame +materialization credit with `uploadImage`; `releaseMesh` returns staging. +`createOffloadMeshCollection` owns both response cleanup and resident handle +disposal. Late, cancelled, wrong-kind and failed-consumer responses return their +tickets. Mesh resources never consume texture handles. + +The native core owns generation-tagged geometry. On 3DS, materialization also +uploads an immutable GPU vertex buffer; opaque Views submit its handle, +transform and clip rectangle each frame. Other backends receive TRI commands +after bounded CPU transformation and clipping. PBF parsing, topology, +style selection and triangulation remain provider responsibilities. See +[prepared geometry resources](RESOURCES.md#prepared-2d-geometry) for rendering +and cache ownership. + +Providers can use `prepareMesh` from `@pocketjs/framework/offload/provider` to +pack already tessellated geometry. Vertices use logical coordinates; triangle +records contain three indices and an unsigned ABGR color. The helper checks +bounds before integer conversion and quantizes positions to 1/16 pixel, so +applications do not duplicate the binary header or index encoding. diff --git a/docs/PSP_OFFLOAD.md b/docs/PSP_OFFLOAD.md new file mode 100644 index 000000000..9b9d66096 --- /dev/null +++ b/docs/PSP_OFFLOAD.md @@ -0,0 +1,43 @@ +# PSP USB offload + +The PSP host can serve an app that requires `io.offload` over **PSPLINK host0**. The build hashes the app ID into its mailbox directory. The Mac adapter mounts beneath the directory passed to `usbhostfs_pc`; it uses the same capability worker module as the LAN adapter. + +```ts +import { connectOffloadUsbProvider } from "@pocketjs/framework/offload/provider"; +connectOffloadUsbProvider({ + directory: "dist/psplink", + app: "dev.pocket-stack.map", + worker: new URL("./worker.ts", import.meta.url), + data: config, +}); +``` + +Device apps continue using `offload()`, `createResourceRuntime()`, `createOffloadMeshCollection()` and `ResourceMesh`. They do not read host0 files or decode protocol packets in JavaScript. Pocket Map's PSP entry shares its camera, prediction, resource collections, provider and saved-place model with the 3DS entry; it supplies a 480×272 viewport and a smaller residency budget. + +## Scheduling and ownership + +**One lower-priority native worker owns the offload file operations.** The UI submits at most one bounded record, consumes at most one completion and uploads at most one binary result per frame. These entry points copy bounded buffers and use atomic state transfers; they never open, read, seek, write or wait on a host0 file. PSPLINK itself has synchronous file RPCs, so this separation is necessary even with small read chunks. + +The worker uses **eight preallocated slots**, each with a 4 KiB request, 64-byte header and 128 KiB response buffer. It allocates no Rust, QuickJS or UI objects; PocketJS's PSP allocator remains confined to the UI thread. The single PSP CPU schedules the worker while UI work yields; GE executes submitted graphics independently. This implementation does not use the Media Engine or parse MVT on the device. + +Slot ownership moves through `FREE → QUEUED → SENT → READY → BORROWED → FREE`. The worker only accesses QUEUED/SENT payloads; the UI only accesses FREE/READY/BORROWED payloads. Release/acquire atomics publish each transfer. A borrowed binary remains immutable until the UI releases its ticket. An abandoned request continues occupying its native slot until its response or a session change. + +Each envelope contains a Mac epoch, device boot nonce, sequence, response kind, request ID, dimensions, payload length and FNV checksum. The checksum detects partial file reads; it is not authentication. **Physical USB access establishes this connection**, without the LAN pairing key. The Mac atomically replaces response files. Replies from an obsolete sequence cannot overwrite a newer result in the same slot. + +The Mac starts at most eight capability calls. A 12-second execution timeout terminates the capability subprocess; replacement begins only after its exit, with a new epoch. Queue credit is not released while the old computation continues. The UI independently expires an unrefreshed connection after three seconds, including when the worker is stuck inside a USB call. It retains locally materialized resources for cached navigation. + +## GPU resources + +A PMH1 mesh is validated once, expanded once into GE color/float vertices and written back to the data cache once. **Subsequent frames submit a retained vertex pointer and affine matrix.** They do not triangulate, expand geometry or retransmit pixels when the camera pans. A mesh is limited to 4,096 source vertices / 2,048 triangles / 36,880 wire bytes. The GE vertex pool is capped at 4 MiB, charged in PSP allocator size classes, including retired buffers. + +Mesh disposal preserves vertices until the preceding display list completes. `Ui.take_texture()` similarly invalidates an image handle immediately while returning its storage to the backend for delayed release. This prevents an evicted image from being overwritten while the GE still samples it. RGB565 images and PMH1 meshes use the existing resource fallback and eviction contracts; their CPU/GPU copies remain separate from transport staging. + +The resource runtime still samples demand accessors every frame. **Unchanged demands skip union reconstruction and cache reconciliation.** Scalar snapshots detect changes even when a caller mutates a reused array. Clear, invalidation, priority changes, cancellation and owner cleanup retain their existing behavior. + +## Diagnostics and limits + +The worker writes frame count, work duration, JS/core/GE submission duration and GE fence wait to the app mailbox. Durations exclude intentional vblank waiting but include OS scheduling and debugger preemption. Mac logs contain method, ID, payload size and elapsed provider time; they omit search text and other request payloads. + +The PSP offload build omits the old synchronous DevTools mailbox probe. Normal PSPLINK module reload remains available. This initial adapter is for an app-specific embedded PSP executable; it does not add arbitrary multi-app USB routing or video playback. Pocket YouTube's complete-frame replacement policy is appropriate for video, whereas map tiles keep independent identities and cache ownership. + +This transport prevents a blocked offload call from blocking the UI thread. It does not make arbitrary JavaScript, component mounting, garbage collection or GPU work free. Pocket Map's hardware record distinguishes settled 60 Hz rendering from the slower frames during new UI and resource materialization. diff --git a/docs/RESOURCES.md b/docs/RESOURCES.md index 503d93d1d..e76d5c3a1 100644 --- a/docs/RESOURCES.md +++ b/docs/RESOURCES.md @@ -182,6 +182,91 @@ can use a companion transport or an on-device worker without changing cache or UI code. It must provide the same nonwaiting admission/cancellation contract and bounded immutable response ownership. It must not execute IO on the UI thread. +### Remote images and tile viewports + +The image adapter defines transport, staging release and texture disposal once: + +```tsx +import { createResourceRuntime, createResourceView } from "@pocketjs/framework/resource-view"; +import { createOffloadImageCollection } from "@pocketjs/framework/resource-offload"; +import { ResourceImage } from "@pocketjs/framework/resource"; +import { offload } from "@pocketjs/framework/offload"; + +const io = offload(); +const runtime = createResourceRuntime({ + maxConcurrent: 3, startsPerFrame: 1, completionsPerFrame: 1, + maxCollections: 1, available: io.connected, +}); +const tiles = createOffloadImageCollection(runtime, io, { + key: (tile: TileAddress) => `${tile.source}/${tile.z}/${tile.x}/${tile.y}`, + method: "map.tile", payload: JSON.stringify, + width: 256, height: 256, maxEntries: 40, maxViews: 2, + maxDemandsPerView: 16, +}); +const view = createResourceView(tiles, { demand: visibleTileDemand }); + +// Component setup: input is an accessor for this tile's domain address. + view.state(input())} + fallback={() => } + errorFallback={() => } />; +``` + +**Component reads do not initiate requests.** Two view owners can demand the +same key and share one request and one texture. Removing the old zoom layer +withdraws its demand without releasing a texture still used by the new layer. +`ResourceImage` borrows its texture; the collection owns eviction and cleanup. + +The lower-level `releaseResponse(raw)` cache hook releases external staging +after materialization, including failure, or when cancellation or late delivery +prevents materialization. It is separate from `dispose(value)`, which releases +an adopted value. Both hooks must be bounded and must not throw. + +`@pocketjs/framework/tile-viewport` supplies `createTileCamera`, `visibleTiles` +`createTileIntent` and `planTileWindow`. +The camera stores level-zero pixel coordinates, integrates screen-space velocity +and inertia, and keeps the world point beneath an anchor fixed during zoom. +`visibleTiles` returns a near-first window and rejects an excessive window +before enumeration. `planTileWindow` returns separate visible and look-ahead +arrays, with an extra-tile cap and screen-pixel margins / directional lead. +With `directional: true`, extras occupy a forward corridor; they do not form a +ring behind the camera. `createTileIntent` accumulates screen-space camera +travel in constant space, preserving direction across separate gestures. Its +confidence holds through three seconds of rest, then decays; sustained turns +replace the earlier direction. Reset it on a teleport or coordinate change. +**The application selects look-ahead policy and priority.** These functions +perform no IO and contain no geographic projection. + +```ts +const window = planTileWindow({ ...camera.view(), level, width: 400, height: 240, + maxTiles: 12, margin: 128, leadX: predictedX, leadY: predictedY, + directional: confidence > 0.45, maxExtra: 4 }); +const demand = [ + ...window.visible.map(tile => ({ input: address(tile), priority: tile.priority, pin: true })), + ...window.lookAhead.map(tile => ({ input: address(tile), priority: 1000 + tile.priority, pin: false })), +]; +``` + +`camera.view().targetZoom` exposes the final level during a zoom animation. +An application can use it to demand a bounded next-level viewport after a zoom +input, subject to its source policy. These entries belong in the same collection +and concurrency budget as current tiles. `camera.setWorld({ minZoom, maxZoom, +bounds })` validates new limits before applying them, stops previous motion and +clamps the camera. It supports metadata received after a source opens without +replacing the camera object or resource owners. + +The extra entries share cache and concurrency budgets with visible entries. +Retaining an unused ready value costs residency but creates no new request. +A lower-priority read in flight still occupies its slot until completion or +cancellation; priority does not preempt a network request. + +[Pocket Map](https://github.com/pocket-stack/pocket-map) supplies Mercator +projection, wrapped tile identities, source selection, explicit place searches +and viewport demand. Its Mac worker owns HTTP caching, PNG decoding and label +rasterization. Its demand includes at most four nearby look-ahead tiles; a +previous zoom layer retains loaded tiles until the new layer fills. The native image +transport and resource APIs also apply to document pages, photo renditions and +other tile pyramids. + ### Freshness and recovery `invalidate(predicate)` fences outstanding reads and marks matching entries @@ -260,3 +345,93 @@ scan of file labels and editor text. Framework subscriptions notify consumers by key. Deduplication, generation checks, retries, admission, eviction and texture release share the same scheduler implementation. Documents, SQLite drafts, Markdown layout and rasterization remain in the Mac provider. No renderer ABI or companion protocol changes are required. + +### Desktop executor isolation + +`connectOffloadProvider` accepts `isolation: "process"` for capabilities that +use network clients, SQLite or native image codecs. **The connection manager +and capability executor occupy different OS processes.** The provider module +keeps its `self.onmessage` / `self.postMessage` interface. Bun IPC carries +structured replies, including typed image planes; pairing keys remain in the +connection manager. + +```ts +import { connectOffloadProvider } from "@pocketjs/framework/offload/provider"; +connectOffloadProvider({ address, key, worker: new URL("./worker.ts", import.meta.url), + isolation: "process", data: providerConfig, log: console.log }); +``` + +Process mode kills and reaps the executor on connection loss or a nine-second +request deadline. Its exit cannot terminate the connection manager. A new +connection starts a new executor after the old process exits; request IDs and +late replies stay scoped to their connection. Sent commands are not replayed. +Connect attempts have a five-second timeout. Logs record the session, process +exit, request deadline or socket failure without request payloads. + +The default `"thread"` mode retains the existing Web Worker behavior. A native +fault in that mode can terminate the whole host daemon; use process mode when +fault containment is required. Process mode adds an OS process and IPC copies +on the desktop. Guest budgets and the 3DS wire protocol remain unchanged. + +### Transmission credit and cancellation + +**Cancelling a sent request removes UI interest, not its transmission credit.** +`offload().pending()` includes those reservations until a reply arrives or the +connection generation changes. A sent request that times out delivers one +error, drops its callback and retains the reservation. Late image replies +return staging credit without a texture upload. Unsent cancellation releases +its reservation because no remote work exists. + +The desktop transport pauses input at eight admitted requests or while output +waits for `drain`. It retains the unconsumed suffix of one input chunk, the +bounded frame decoder and at most eight admitted results. A slow receiver +pauses progress instead of triggering a backlog disconnect. This keeps rapid +viewport cancellation from creating an unbounded queue of remote image work. + +### Prepared 2D geometry + +`createOffloadMeshCollection` gives prepared geometry the same demand, retry, +materialization, fallback and eviction lifecycle as remote images. The provider +returns `{ format: "mesh2d-v1", bytes }`; `ResourceMesh` borrows the resulting +native handle. Mesh handles and texture handles have separate namespaces. + +```tsx +const geometry = createOffloadMeshCollection(runtime, io, { + key: tile => `${tile.source}/${tile.z}/${tile.x}/${tile.y}`, + method: "map.mesh", + payload: JSON.stringify, + maxEntries: 40, + maxViews: 2, + maxDemandsPerView: 24, +}); +const view = createResourceView(geometry, { + demand: () => visible().map(input => ({ input, priority: 0, pin: true })), +}); + view.state(tile)} fallback={() => } /> +``` + +**Each entry contains at most 4,096 vertices and 2,048 triangles.** The provider +quantizes coordinates to sixteenths of a logical pixel and sends indexed triangles +with ABGR colors. Native validation rejects coordinates outside the declared +rectangle, invalid indices, unsupported versions and inconsistent byte counts. +The entry is at most 36,880 bytes. No PBF parser, polygon tessellator or network +operation runs in guest JavaScript. + +**Images and meshes share eight staging slots and one native materialization +per frame on 3DS.** Cancellation retains transport credit until the response or +connection loss. Late and mismatched responses return staging without acquiring +residency. A collection frees its mesh handles on eviction and cleanup; old +handles cannot draw a newly allocated mesh. Hosts without mesh operations cannot +request this response extension. + +**The 3DS backend uploads one immutable vertex buffer during materialization.** +Opaque mesh Views emit a ten-word command containing a handle, affine transform +and clip rectangle. PICA transforms, clips and rasterizes the resident vertices; +pan and zoom do not rebuild triangles on the CPU. The backend caps these buffers +at 8 MiB, including replacements awaiting the preceding GPU frame's fence. +Eviction releases the buffer after that fence. Upload failure follows the +resource error and retry path, rather than allocating during drawing. + +Other backends use native CPU transformation and bounded clipping into existing +TRI commands. A translucent mesh View also uses this path on 3DS. Bound visible +mesh count and provider detail when choosing an application frame budget. diff --git a/engine/core/src/draw.rs b/engine/core/src/draw.rs index d59aa91a4..662adbb51 100644 --- a/engine/core/src/draw.rs +++ b/engine/core/src/draw.rs @@ -689,6 +689,7 @@ fn claims_hit( w: f32, h: f32, ) -> bool { + if node.mesh >= 0 { return true; } if node.node_type == spec::NodeType::Text as u8 { return true; // the glyph run } @@ -848,6 +849,8 @@ struct Walker<'a> { tree: &'a Tree, styles: &'a StyleTable, fonts: &'a Fonts, + meshes: &'a crate::mesh::Meshes, + mesh_commands: bool, /// Global vblank counter — drives deterministic sprite frame selection. frame: u64, /// Viewport bounds in px — every emitted coordinate is clipped to @@ -885,7 +888,8 @@ pub fn build( tree: &Tree, styles: &StyleTable, fonts: &Fonts, - frame: u64, + meshes: &crate::mesh::Meshes, + mesh_commands: bool, frame: u64, screen: (f32, f32), textures: &mut Vec, tex_free: &mut Vec, @@ -900,6 +904,8 @@ pub fn build( tree, styles, fonts, + meshes, + mesh_commands, frame, spec::ROOT_ID, screen, @@ -921,7 +927,8 @@ pub fn build_root( tree: &Tree, styles: &StyleTable, fonts: &Fonts, - frame: u64, + meshes: &crate::mesh::Meshes, + mesh_commands: bool, frame: u64, root_id: i32, screen: (f32, f32), textures: &mut Vec, @@ -946,6 +953,8 @@ pub fn build_root( tree, styles, fonts, + meshes, + mesh_commands, frame, screen, glyph_scratch: Vec::new(), @@ -1162,6 +1171,21 @@ impl<'a> Walker<'a> { } // -- image / animated sprite ------------------------------------------- + if let Some(mesh) = self.meshes.get(node.mesh) { + let bounds = clip.intersect(&world_aabb_of(self.screen, &world, l.w, l.h)); + if self.mesh_commands && op == 1.0 && bounds.x1 > bounds.x0 && bounds.y1 > bounds.y0 { + let sx = l.w / mesh.width as f32; + let sy = l.h / mesh.height as f32; + dl.words.extend_from_slice(&[ + spec::draw_op::MESH, node.mesh as u32, + (world.a*sx).to_bits(), (world.b*sx).to_bits(), (world.c*sy).to_bits(), (world.d*sy).to_bits(), + world.tx.to_bits(), world.ty.to_bits(), + xy_word(roundf(clip.x0),roundf(clip.y0)), xy_word(roundf(clip.x1)-roundf(clip.x0),roundf(clip.y1)-roundf(clip.y0)), + ]); + } else if bounds.x1 > bounds.x0 && bounds.y1 > bounds.y0 { + paint_mesh(dl, mesh, &world, l.w, l.h, &clip, self.screen, op); + } + } if node.node_type == spec::NodeType::Image as u8 && node.tex >= 0 { // Plain image samples the whole texture; a sprite samples the // current frame's atlas cell (auto-played from the vblank counter). @@ -2776,3 +2800,175 @@ fn emit_tri( dl.words.push(pack(v1.color)); dl.words.push(pack(v2.color)); } + +/// Fixed scratch clipping: at most seven vertices after clipping a triangle. +/// Core TRI output keeps all software/GPU backends and damage snapshots valid. +fn paint_mesh( + dl: &mut DrawList, + mesh: &crate::mesh::Mesh, + world: &Affine, + width: f32, + height: f32, + clip: &Clip, + screen: (f32, f32), + opacity: f32, +) { + let sx = width / (mesh.width as f32 * 16.0); + let sy = height / (mesh.height as f32 * 16.0); + let empty = ClipVert { + x: 0.0, + y: 0.0, + color: [0.0; 4], + u: 0.0, + v: 0.0, + }; + let mut cur = [empty; 8]; + let mut next = [empty; 8]; + for triangle in &mesh.triangles { + let mut color = unpack(triangle.color); + color[3] *= opacity; + for (i, index) in triangle.indices.iter().enumerate() { + let p = mesh.vertices[*index as usize]; + let (x, y) = world.apply(p[0] as f32 * sx, p[1] as f32 * sy); + cur[i] = ClipVert { + x, + y, + color, + ..empty + }; + } + if cur[..3].iter().all(|p| p.x < clip.x0) + || cur[..3].iter().all(|p| p.x > clip.x1) + || cur[..3].iter().all(|p| p.y < clip.y0) + || cur[..3].iter().all(|p| p.y > clip.y1) { continue; } + if cur[..3].iter().all(|p| p.x >= clip.x0 && p.x <= clip.x1 && p.y >= clip.y0 && p.y <= clip.y1) { + emit_tri(dl, &cur[0], &cur[1], &cur[2], clip, screen); + continue; + } + let mut count = 3; + for (axis, bound, le) in [ + (0, clip.x0, false), + (0, clip.x1, true), + (1, clip.y0, false), + (1, clip.y1, true), + ] { + if count == 0 { + break; + } + let coord = |v: ClipVert| if axis == 0 { v.x } else { v.y }; + let mut n = 0; + for i in 0..count { + let a = cur[i]; + let b = cur[(i + 1) % count]; + let da = coord(a) - bound; + let db = coord(b) - bound; + let ia = if le { da <= 0.0 } else { da >= 0.0 }; + let ib = if le { db <= 0.0 } else { db >= 0.0 }; + if ia { + next[n] = a; + n += 1; + } + if ia != ib { + next[n] = lerp_vert(&a, &b, da / (da - db)); + n += 1; + } + } + core::mem::swap(&mut cur, &mut next); + count = n; + } + for i in 1..count.saturating_sub(1) { + emit_tri(dl, &cur[0], &cur[i], &cur[i + 1], clip, screen); + } + } +} + +#[cfg(test)] +mod mesh_tests { + use super::*; + #[test] + fn retained_mesh_is_opt_in_bounded_and_falls_back_for_opacity() { + let mut bytes = alloc::vec![0u8; 16 + 12 + 10]; + bytes[..4].copy_from_slice(b"PMH1"); + for (offset, value) in [(4, 256u16), (6, 256), (8, 3), (10, 1), (20, 4096), (26, 4096), (30, 1), (32, 2)] { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + bytes[34..38].copy_from_slice(&0xff112233u32.to_le_bytes()); + let mut ui = crate::Ui::new(); + let handle = ui.upload_mesh(&bytes); + assert!(handle >= 0); + let node = ui.create_node(spec::NodeType::View as u8); + ui.insert_before(spec::ROOT_ID, node, 0); + ui.set_prop(node, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop(node, spec::prop::WIDTH, 512.0); + ui.set_prop(node, spec::prop::HEIGHT, 512.0); + ui.set_mesh(node, handle); + // Other backends receive only their established triangle contract. + assert_eq!(ui.draw().words[0], spec::draw_op::TRI); + ui.set_mesh_commands(true); + let words = &ui.draw().words; + assert_eq!(words.len(), 10); + assert_eq!(&words[..2], &[spec::draw_op::MESH, handle as u32]); + assert_eq!(f32::from_bits(words[2]), 2.0); + assert_eq!(f32::from_bits(words[5]), 2.0); + ui.set_prop(node, spec::prop::OPACITY, 0.5); + assert_eq!(ui.draw().words[0], spec::draw_op::TRI); + ui.set_prop(node, spec::prop::OPACITY, 1.0); + ui.set_prop(node, spec::prop::TRANSLATE_X, 2000.0); + assert!(ui.draw().words.is_empty()); + ui.set_prop(node, spec::prop::TRANSLATE_X, 0.0); + ui.free_mesh(handle); + assert!(ui.mesh(handle).is_none()); + assert!(ui.draw().words.is_empty()); + } + #[test] + fn prepared_mesh_clips_without_changing_the_drawlist_contract() { + let mesh = crate::mesh::Mesh { + width: 256, + height: 256, + vertices: alloc::vec![[0, 0], [4096, 0], [0, 4096]], + triangles: alloc::vec![crate::mesh::Triangle { + indices: [0, 1, 2], + color: 0xff112233 + }], + }; + let mut dl = DrawList::new(); + for angle in 0..100 { + dl.words.clear(); + let f = angle as f32 / 15.0; + let world = Affine { + a: cosf(f) * 4.0, + b: sinf(f) * 4.0, + c: -sinf(f) * 4.0, + d: cosf(f) * 4.0, + tx: -100.0, + ty: 30.0, + }; + paint_mesh( + &mut dl, + &mesh, + &world, + 256.0, + 256.0, + &Clip { + x0: 0.0, + y0: 20.0, + x1: 400.0, + y1: 220.0, + }, + (400.0, 240.0), + 0.5, + ); + for op in dl.words.chunks_exact(7) { + assert_eq!(op[0], spec::draw_op::TRI); + for p in &op[1..4] { + assert!((*p & 65535) <= 400); + assert!((20..=220).contains(&(p >> 16))); + } + for color in &op[4..7] { + assert_eq!(*color & 0xffffff, 0x112233); + assert!((127..=128).contains(&(color >> 24))); + } + } + } + } +} diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index 185390ea6..1d87cddf7 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -40,6 +40,7 @@ pub mod anim; pub mod codec; pub mod damage; pub mod draw; +pub mod mesh; pub mod layout; pub mod package; pub mod pak; @@ -254,6 +255,8 @@ pub struct Ui { textures: Vec, /// LIFO free list of texture slots (freed most recently, reused first). tex_free: Vec, + meshes: mesh::Meshes, + mesh_commands: bool, /// Baked rounded-corner disc sprites (see draw::DiscCache). discs: draw::DiscCache, /// Raster pixels baked for each logical UI pixel. Layout and DrawList @@ -341,6 +344,8 @@ impl Ui { auxiliary: None, textures: Vec::new(), tex_free: Vec::new(), + meshes: mesh::Meshes::new(), + mesh_commands: false, discs: draw::DiscCache::new(), raster_density, raster_revision: 1, @@ -799,19 +804,50 @@ impl Ui { /// core-internal texture (a baked corner disc) is safe: the DiscCache /// re-validates its handles each use and re-bakes dead ones. pub fn free_texture(&mut self, handle: i32) { - let Some(slot) = tex_resolve(&self.textures, handle) else { - return; - }; + drop(self.take_texture(handle)); + } + + /// Invalidate a handle immediately and transfer its storage to the backend. + /// A pipelined GPU keeps this owner until its previous commands complete. + pub fn take_texture(&mut self, handle: i32) -> Option { + let slot = tex_resolve(&self.textures, handle)?; let s = &mut self.textures[slot as usize]; - s.tex = None; + let texture = s.tex.take(); s.gen = ((s.gen as u32 + 1) & TEX_GEN_MASK) as u16; self.tex_free.push(slot); self.bump_raster_revision(); + texture + } + + /// Validate a bounded prepared geometry entry and own its native storage. + /// Opt into retained geometry commands only when the backend implements them. + pub fn set_mesh_commands(&mut self, enabled: bool) { self.mesh_commands = enabled; self.bump_raster_revision(); } + pub fn mesh(&self, handle: i32) -> Option<&mesh::Mesh> { self.meshes.get(handle) } + + pub fn upload_mesh(&mut self, bytes: &[u8]) -> i32 { + let handle = self.meshes.upload(bytes); + if handle >= 0 { self.bump_raster_revision(); } + handle + } + + pub fn free_mesh(&mut self, handle: i32) { + self.meshes.free(handle); + self.bump_raster_revision(); + } + + /// Views borrow a generation-tagged geometry handle; a negative value clears it. + pub fn set_mesh(&mut self, id: i32, handle: i32) { + if handle >= 0 && self.meshes.get(handle).is_none() { return; } + let Some(slot) = self.tree.resolve(id) else { return; }; + let node = &mut self.tree.slots[slot as usize]; + if node.node_type == spec::NodeType::View as u8 { + node.mesh = handle.max(-1); + self.bump_raster_revision(); + } } /// Bind an uploaded texture to an image node. Handles are 0-based, so - /// tex < 0 CLEARS the binding (node.tex = -1, the "none" sentinel); - /// unknown/stale positive handles are ignored. + /// tex < 0 clears the binding; unknown/stale positive handles are ignored. pub fn set_image(&mut self, id: i32, tex: i32) { if tex >= 0 && tex_resolve(&self.textures, tex).is_none() { return; @@ -1465,6 +1501,8 @@ impl Ui { &self.tree, &self.styles, &self.fonts, + &self.meshes, + self.mesh_commands, self.frame, self.layout.viewport, &mut self.textures, @@ -1491,6 +1529,8 @@ impl Ui { &self.tree, &self.styles, &self.fonts, + &self.meshes, + self.mesh_commands, self.frame, self.layout.viewport, &mut self.textures, @@ -1537,6 +1577,8 @@ impl Ui { &self.tree, &self.styles, &self.fonts, + &self.meshes, + self.mesh_commands, self.frame, auxiliary.root, auxiliary.layout.viewport, @@ -1563,6 +1605,8 @@ impl Ui { &self.tree, &self.styles, &self.fonts, + &self.meshes, + self.mesh_commands, self.frame, auxiliary.root, auxiliary.layout.viewport, diff --git a/engine/core/src/mesh.rs b/engine/core/src/mesh.rs new file mode 100644 index 000000000..7c3739593 --- /dev/null +++ b/engine/core/src/mesh.rs @@ -0,0 +1,174 @@ +//! Prepared 2D geometry. No topology, codecs or unbounded inputs in the guest. +use alloc::vec::Vec; +pub const MAX_VERTICES: usize = 4096; +pub const MAX_TRIANGLES: usize = 2048; +pub const MAX_BYTES: usize = 16 + MAX_VERTICES * 4 + MAX_TRIANGLES * 10; +#[derive(Clone, Copy)] +#[repr(C)] +pub struct Triangle { + pub indices: [u16; 3], + pub color: u32, +} +pub struct Mesh { + pub width: u16, + pub height: u16, + pub vertices: Vec<[u16; 2]>, + pub triangles: Vec, +} +fn u16_at(b: &[u8], i: usize) -> u16 { + u16::from_le_bytes([b[i], b[i + 1]]) +} +fn u32_at(b: &[u8], i: usize) -> u32 { + u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) +} +impl Mesh { + pub fn parse(b: &[u8]) -> Option { + if b.len() < 16 || b.len() > MAX_BYTES || &b[..4] != b"PMH1" || u32_at(b, 12) != 0 { + return None; + } + let (width, height) = (u16_at(b, 4), u16_at(b, 6)); + let (nv, nt) = (u16_at(b, 8) as usize, u16_at(b, 10) as usize); + if width == 0 + || height == 0 + || width > 4095 + || height > 4095 + || nv > MAX_VERTICES + || nt > MAX_TRIANGLES + || b.len() != 16 + nv * 4 + nt * 10 + { + return None; + } + // Validate before allocating; malformed geometry never acquires residency. + for i in 0..nv { + if u16_at(b, 16 + i * 4) > width * 16 || u16_at(b, 18 + i * 4) > height * 16 { + return None; + } + } + for i in 0..nt { + for j in 0..3 { + if u16_at(b, 16 + nv * 4 + i * 10 + j * 2) as usize >= nv { + return None; + } + } + } + let vertices = (0..nv) + .map(|i| [u16_at(b, 16 + i * 4), u16_at(b, 18 + i * 4)]) + .collect(); + let triangles = (0..nt) + .map(|i| { + let p = 16 + nv * 4 + i * 10; + Triangle { + indices: [u16_at(b, p), u16_at(b, p + 2), u16_at(b, p + 4)], + color: u32_at(b, p + 6), + } + }) + .collect(); + Some(Self { + width, + height, + vertices, + triangles, + }) + } +} +struct Slot { + generation: u32, + value: Option, +} +pub struct Meshes { + slots: Vec, +} +impl Meshes { + pub fn new() -> Self { + Self { slots: Vec::new() } + } + pub fn get(&self, handle: i32) -> Option<&Mesh> { + if handle < 0 { + return None; + } + let s = self.slots.get(handle as usize & 127)?; + if s.generation != handle as u32 >> 7 { + return None; + } + s.value.as_ref() + } + pub fn upload(&mut self, bytes: &[u8]) -> i32 { + let Some(value) = Mesh::parse(bytes) else { + return -1; + }; + let slot = self + .slots + .iter() + .position(|s| s.value.is_none() && s.generation < 0xffffff) + .unwrap_or(self.slots.len()); + if slot >= 128 { + return -1; + } + if slot == self.slots.len() { + self.slots.push(Slot { + generation: 0, + value: None, + }); + } + let s = &mut self.slots[slot]; + s.value = Some(value); + ((s.generation << 7) | slot as u32) as i32 + } + pub fn free(&mut self, handle: i32) { + if self.get(handle).is_none() { + return; + } + let s = &mut self.slots[handle as usize & 127]; + s.value = None; + s.generation += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn entry() -> Vec { + let mut b = alloc::vec![0;38]; + b[..4].copy_from_slice(b"PMH1"); + for (p, n) in [ + (4, 256u16), + (6, 256), + (8, 3), + (10, 1), + (20, 4096), + (26, 4096), + (30, 1), + (32, 2), + ] { + b[p..p + 2].copy_from_slice(&n.to_le_bytes()); + } + b[34..38].copy_from_slice(&0xff112233u32.to_le_bytes()); + b + } + #[test] + fn envelopes_indices_and_generations() { + let b = entry(); + assert!(Mesh::parse(&b).is_some()); + for length in 0..b.len() { + assert!(Mesh::parse(&b[..length]).is_none()); + } + for (at, value) in [(8, 4097u16), (10, 2049), (28, 3), (16, 4097), (4, 0)] { + let mut bad = b.clone(); + bad[at..at + 2].copy_from_slice(&value.to_le_bytes()); + assert!(Mesh::parse(&bad).is_none()); + } + let mut pool = Meshes::new(); + let a = pool.upload(&b); + assert_eq!(a, 0); + pool.free(a); + let next = pool.upload(&b); + assert_ne!(next, a); + pool.free(a); + assert!(pool.get(next).is_some()); + assert!(pool.get(a).is_none()); + for _ in 1..128 { + assert!(pool.upload(&b) >= 0); + } + assert_eq!(pool.upload(&b), -1); + } +} diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index c6d68e7c9..3073e53c6 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -440,6 +440,7 @@ pub mod draw_op { pub const TEX_TRI: u32 = 8; pub const TEXT_RUN: u32 = 9; pub const SURFACE_QUAD: u32 = 10; + pub const MESH: u32 = 11; } /// .pak container constants (byte-compatible with dreamcart's format; diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index ac549a880..6c78c68ef 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -3906,3 +3906,16 @@ fn clearing_native_measure_restores_baked_goldens_path() { assert_eq!(counts[spec::draw_op::TEXT_RUN as usize], 0); assert_eq!(counts[spec::draw_op::GLYPH_RUN as usize], 1); } + +#[test] +fn retiring_texture_invalidates_handle_before_gpu_owner_drops() { + let mut ui = Ui::new(); + let old = ui.upload_texture(&[3u8; 16 * 16 * 2], 16, 16, spec::psm::PSM_5650); + let owner = ui.take_texture(old).unwrap(); + assert!(ui.texture(old).is_none()); + let next = ui.upload_texture(&[9u8; 16 * 16 * 2], 16, 16, spec::psm::PSM_5650); + assert_ne!(old, next); + assert_eq!(owner.view().pixels[0], 3); + assert_eq!(ui.texture(next).unwrap().pixels[0], 9); + assert!(ui.take_texture(old).is_none()); +} diff --git a/engine/core/src/tree.rs b/engine/core/src/tree.rs index 42eea7f70..1cbd48a8e 100644 --- a/engine/core/src/tree.rs +++ b/engine/core/src/tree.rs @@ -67,6 +67,8 @@ pub struct Node { /// Uploaded texture handle (image nodes only; -1 = none). For an animated /// sprite this is the ATLAS texture; the drawn frame is a UV sub-rect of it. pub tex: i32, + /// Borrowed generation-tagged prepared geometry; -1 = none. + pub mesh: i32, /// Pocket System package surface handle (surface nodes only; -1 = none). pub compositor_surface: i32, /// Shell focus fact carried by SURFACE_QUAD for native scheduling/input. @@ -109,6 +111,7 @@ impl Node { anim_values: Vec::new(), text: String::new(), tex: -1, + mesh: -1, compositor_surface: -1, compositor_focused: false, sprite_frames: 0, diff --git a/engine/ui-cabi/include/pocket_ui_cabi.h b/engine/ui-cabi/include/pocket_ui_cabi.h index 6467bfb20..65cc4d23d 100644 --- a/engine/ui-cabi/include/pocket_ui_cabi.h +++ b/engine/ui-cabi/include/pocket_ui_cabi.h @@ -23,6 +23,9 @@ int32_t ui_upload_texture( uint32_t height, uint32_t pixel_storage ); +int32_t ui_upload_mesh(const uint8_t *bytes, size_t length); +void ui_free_mesh(int32_t handle); +void ui_set_mesh(int32_t id, int32_t handle); int32_t ui_upload_img_entry(const uint8_t *bytes, size_t length); void ui_free_texture(int32_t handle); void ui_set_image(int32_t id, int32_t texture); diff --git a/engine/ui-cabi/src/lib.rs b/engine/ui-cabi/src/lib.rs index f9a3300ae..d7bfed7dd 100644 --- a/engine/ui-cabi/src/lib.rs +++ b/engine/ui-cabi/src/lib.rs @@ -918,3 +918,10 @@ mod tests { assert!(unsafe { with_initialized_ui_unchecked(|_| ()) }.is_none()); } } + +#[no_mangle] +pub extern "C" fn ui_upload_mesh(ptr: *const u8, len: usize) -> i32 { ui().upload_mesh(unsafe { bytes(ptr,len) }) } +#[no_mangle] +pub extern "C" fn ui_free_mesh(handle: i32) { ui().free_mesh(handle); } +#[no_mangle] +pub extern "C" fn ui_set_mesh(id: i32, handle: i32) { ui().set_mesh(id,handle); } diff --git a/engine/wasm/src/lib.rs b/engine/wasm/src/lib.rs index 250e525d1..f7d6885fa 100644 --- a/engine/wasm/src/lib.rs +++ b/engine/wasm/src/lib.rs @@ -801,3 +801,10 @@ mod tests { ); } } + +#[no_mangle] +pub extern "C" fn ui_upload_mesh(ptr: *const u8, len: usize) -> i32 { ui().upload_mesh(unsafe { bytes(ptr,len) }) } +#[no_mangle] +pub extern "C" fn ui_free_mesh(handle: i32) { ui().free_mesh(handle); } +#[no_mangle] +pub extern "C" fn ui_set_mesh(id: i32, handle: i32) { ui().set_mesh(id,handle); } diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts index b071aa4b3..4f24a11c5 100644 --- a/framework/compiler/subpaths.ts +++ b/framework/compiler/subpaths.ts @@ -63,6 +63,7 @@ export const SUBPATHS: Record = { "resource-cache": { file: "framework/src/resource-cache.ts", aliases: TWINS }, "resource-offload": { file: "framework/src/resource-offload.ts", aliases: TWINS }, "resource-view": { file: { solid: "framework/src/resource-view.ts" } }, + "tile-viewport": { file: "framework/src/tile-viewport.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 }, diff --git a/framework/src/classic.ts b/framework/src/classic.ts index 16f17cee8..0bd8cf71e 100644 --- a/framework/src/classic.ts +++ b/framework/src/classic.ts @@ -118,6 +118,7 @@ export interface ClassicSheetProps { message?: string; actions: readonly { label: string; tone?: ClassicTone; disabled?: boolean; onPress(): void }[]; cancelLabel?: string; + cancelDisabled?: boolean; onCancel(): void; surface?: SurfaceId; /** Includes the closing transition, so callers can also gate hardware input. */ @@ -140,7 +141,7 @@ export function ClassicSheet(props: ClassicSheetProps) { 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) => + const buttons = [...props.actions, { get label() { return props.cancelLabel ?? "Cancel"; }, get disabled() { return props.cancelDisabled; }, 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(); }, diff --git a/framework/src/clock.ts b/framework/src/clock.ts index fef8ae6a7..5cf889e17 100644 --- a/framework/src/clock.ts +++ b/framework/src/clock.ts @@ -1,4 +1,4 @@ -// The virtual clock — the runtime's ONLY notion of time (docs/DETERMINISM.md). +// Virtual clock and recorded input-sampling durations (docs/DETERMINISM.md). // // PocketJS time is not the wall clock. It is a frame counter: every host // drives one `globalThis.frame(buttons)` call per VIRTUAL frame, and the @@ -50,6 +50,18 @@ function divisorsOf(n: number): number[] { } let hz = TICKS_PER_SECOND; +let inputSeconds = 1 / hz; + +/** Bounded elapsed input-sampling time, supplied by the host as frame data. + * Missing samples use the nominal simulation step. This does not advance the + * virtual clock or run catch-up frames; recordings include the elapsed lane. */ +export function inputDeltaSeconds(): number { return inputSeconds; } + +/** Zero denotes an absent sample; resumes cannot inject an unbounded step. */ +export function __normalizeInputElapsed(us: number | undefined): number { + return typeof us === "number" && Number.isFinite(us) && us > 0 + ? Math.max(1, Math.min(66666, Math.round(us))) : 0; +} let frame = -1; // advanced to 0 on the first pump; -1 = "before boot frame" let timerSeq = 0; interface Timer { @@ -114,6 +126,7 @@ export function after(seconds: number, cb: () => void): () => void { export function resetClock(): void { const raw = (globalThis as { __simHz?: unknown }).__simHz; hz = typeof raw === "number" ? normalizeHz(raw) : TICKS_PER_SECOND; + inputSeconds = 1 / hz; frame = -1; timers = []; timerSeq = 0; @@ -124,7 +137,9 @@ export function resetClock(): void { * order. Called by the frame pump FIRST, before effect delivery and app * hooks — "time reached t" happens before anything scheduled at t observes t. */ -export function __advanceClock(): void { +export function __advanceClock(inputElapsedUs?: number): void { + const elapsed = __normalizeInputElapsed(inputElapsedUs); + inputSeconds = elapsed ? elapsed / 1_000_000 : 1 / hz; frame = frame < 0 ? 0 : frame + 1; if (timers.length === 0) return; const due = timers.filter((t) => t.at <= frame).sort((a, b) => a.at - b.at || a.seq - b.seq); diff --git a/framework/src/devtools.ts b/framework/src/devtools.ts index fe8004488..a15dbd0ed 100644 --- a/framework/src/devtools.ts +++ b/framework/src/devtools.ts @@ -10,6 +10,7 @@ // step/inspect/eval) stay live inside a frozen world — the core side of the // freeze is ui.debugPause (spec op 21). +import { __normalizeInputElapsed } from "./clock.ts"; import { ANALOG_CENTER } from "../../contracts/spec/spec.ts"; import type { HostOps } from "./host.ts"; import { rootMirror, setTreeMutationHook, type NodeMirror } from "./native-tree.ts"; @@ -26,7 +27,7 @@ export interface DevtoolsTransport { /** Input tape: the complete session input, RLE-encoded (docs/DEVTOOLS.md §4). */ export interface Tape { - v: 1 | 2 | 3; + v: 1 | 2 | 3 | 4; app?: string; /** Total frames represented by `masks`. */ frames: number; @@ -38,6 +39,8 @@ export interface Tape { analog?: [number, number][]; /** Optional right-stick RLE track; omitted on hosts without a right stick. */ rightAnalog?: [number, number][]; + /** v4: bounded host input-sampling microseconds; zero means nominal time. */ + inputElapsedUs?: [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 @@ -66,6 +69,7 @@ interface DevtoolsState { tape: Uint16Array; tapeAnalog: Uint16Array; tapeRightAnalog: Uint16Array | null; + tapeInputElapsed: Uint32Array | 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; @@ -77,6 +81,7 @@ interface DevtoolsState { replayMasks: Uint16Array | null; replayAnalog: Uint16Array | null; replayRightAnalog: Uint16Array | null; + replayInputElapsed: Uint32Array | null; replayTouch: (number[] | undefined)[] | null; replayTouchSurfaces: (number[] | undefined)[] | null; replayAt: number; @@ -103,6 +108,7 @@ const state: DevtoolsState = { tape: new Uint16Array(TAPE_CAP), tapeAnalog: new Uint16Array(TAPE_CAP), tapeRightAnalog: null, + tapeInputElapsed: null, tapeTouch: null, tapeTouchSurfaces: null, tapeStart: 0, @@ -111,6 +117,7 @@ const state: DevtoolsState = { replayMasks: null, replayAnalog: null, replayRightAnalog: null, + replayInputElapsed: null, replayTouch: null, replayTouchSurfaces: null, replayAt: 0, @@ -147,10 +154,12 @@ export function initDevtools(ops: HostOps): void { state.tapeFirstFrame = 0; state.tapeTouch = null; state.tapeRightAnalog = null; + state.tapeInputElapsed = null; state.tapeTouchSurfaces = null; state.replayMasks = null; state.replayAnalog = null; state.replayRightAnalog = null; + state.replayInputElapsed = null; state.replayTouch = null; state.replayTouchSurfaces = null; state.paused = false; @@ -198,7 +207,8 @@ export function wrapFrameHandler( touches?: readonly number[], hits?: readonly number[], touchSurfaces?: readonly number[], - rightAnalog?: number, + rightAnalog?: number, + inputElapsedUs?: number, ) => void, ): ( buttons: number, @@ -207,6 +217,7 @@ export function wrapFrameHandler( hits?: readonly number[], touchSurfaces?: readonly number[], rightAnalog?: number, + inputElapsedUs?: number, ) => void { return ( buttons: number, @@ -215,6 +226,7 @@ export function wrapFrameHandler( hitsArg?: readonly number[], touchSurfacesArg?: readonly number[], rightAnalogArg?: number, + inputElapsedArg?: number, ) => { state.hostCalls++; if (state.transport) { @@ -224,6 +236,7 @@ export function wrapFrameHandler( let mask = buttons; let analog = analogArg === undefined ? ANALOG_CENTER : analogArg & 0xffff; let rightAnalog = rightAnalogArg === undefined ? ANALOG_CENTER : rightAnalogArg & 0xffff; + let inputElapsed = __normalizeInputElapsed(inputElapsedArg); let touch = touchArg; let hits = hitsArg; let touchSurfaces = touchSurfacesArg; @@ -232,6 +245,7 @@ export function wrapFrameHandler( mask = state.replayMasks[state.replayAt]; analog = state.replayAnalog ? state.replayAnalog[state.replayAt] : ANALOG_CENTER; rightAnalog = state.replayRightAnalog ? state.replayRightAnalog[state.replayAt] : ANALOG_CENTER; + inputElapsed = state.replayInputElapsed?.[state.replayAt] ?? 0; // 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. @@ -250,6 +264,7 @@ export function wrapFrameHandler( state.replayMasks = null; // tape exhausted: back to live input state.replayAnalog = null; state.replayRightAnalog = null; + state.replayInputElapsed = null; state.replayTouch = null; state.replayTouchSurfaces = null; send({ t: "replayDone", frame: state.frame }); @@ -260,10 +275,10 @@ export function wrapFrameHandler( state.stepQueued--; state.ops?.debugStep?.(); // arm exactly one core tick } - recordMask(mask, analog, touch, touchSurfaces, rightAnalog); + recordMask(mask, analog, touch, touchSurfaces, rightAnalog, inputElapsed); state.frame++; try { - h(mask, analog, touch, hits, touchSurfaces, rightAnalog); + h(mask, analog, touch, hits, touchSurfaces, rightAnalog, inputElapsed); } catch (e) { send({ t: "error", @@ -287,7 +302,10 @@ function recordMask( touch?: readonly number[], touchSurfaces?: readonly number[], rightAnalog?: number, + inputElapsedUs?: number, ): void { + const elapsed = __normalizeInputElapsed(inputElapsedUs); + if (elapsed && !state.tapeInputElapsed) state.tapeInputElapsed = new Uint32Array(TAPE_CAP); 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. @@ -308,6 +326,7 @@ function recordMask( state.tape[at] = mask; state.tapeAnalog[at] = analog; if (state.tapeRightAnalog) state.tapeRightAnalog[at] = right; + if (state.tapeInputElapsed) state.tapeInputElapsed[at] = elapsed; if (state.tapeTouch) state.tapeTouch[at] = contacts; if (state.tapeTouchSurfaces) state.tapeTouchSurfaces[at] = surfaces; state.tapeLen++; @@ -315,6 +334,7 @@ function recordMask( state.tape[state.tapeStart] = mask; state.tapeAnalog[state.tapeStart] = analog; if (state.tapeRightAnalog) state.tapeRightAnalog[state.tapeStart] = right; + if (state.tapeInputElapsed) state.tapeInputElapsed[state.tapeStart] = elapsed; if (state.tapeTouch) state.tapeTouch[state.tapeStart] = contacts; if (state.tapeTouchSurfaces) state.tapeTouchSurfaces[state.tapeStart] = surfaces; state.tapeStart = (state.tapeStart + 1) % TAPE_CAP; @@ -322,7 +342,7 @@ function recordMask( } } -function rlePairs(ring: Uint16Array): [number, number][] { +function rlePairs(ring: Uint16Array | Uint32Array): [number, number][] { const out: [number, number][] = []; for (let i = 0; i < state.tapeLen; i++) { const v = ring[(state.tapeStart + i) % TAPE_CAP]; @@ -375,6 +395,10 @@ function exportTape(): Tape { tape.touchSurfaces = touchSurfaces; } } + if (state.tapeInputElapsed) { + const elapsed = rlePairs(state.tapeInputElapsed); + if (elapsed.some(([us]) => us !== 0)) { tape.v = 4; tape.inputElapsedUs = elapsed; } + } return tape; } @@ -408,6 +432,18 @@ export function expandTapeRightAnalog(tape: Tape): Uint16Array { return expandPairs(tape.rightAnalog ?? [], ANALOG_CENTER, tape.masks.reduce((n, [, count]) => n + count, 0)); } +/** Missing elapsed tracks replay in nominal time, ignoring the live host. */ +export function expandTapeInputElapsed(tape: Tape): Uint32Array { + const total = tape.masks.reduce((n, [, count]) => n + count, 0); + const out = new Uint32Array(total); + let at = 0; + for (const [us, count] of tape.inputElapsedUs ?? []) { + out.fill(__normalizeInputElapsed(us), at, Math.min(at + count, total)); + at += count; + } + return out; +} + /** 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)[] { @@ -552,6 +588,7 @@ function handleMessage(line: string): void { state.replayMasks = expandTape(tape); state.replayAnalog = tape.analog ? expandTapeAnalog(tape) : null; state.replayRightAnalog = tape.rightAnalog ? expandTapeRightAnalog(tape) : null; + state.replayInputElapsed = tape.inputElapsedUs ? expandTapeInputElapsed(tape) : null; state.replayTouch = tape.touch ? expandTapeTouch(tape) : null; state.replayTouchSurfaces = tape.touchSurfaces ? expandTapeTouchSurfaces(tape) @@ -792,6 +829,7 @@ const api = { state.replayMasks = expandTape(tape); state.replayAnalog = tape.analog ? expandTapeAnalog(tape) : null; state.replayRightAnalog = tape.rightAnalog ? expandTapeRightAnalog(tape) : null; + state.replayInputElapsed = tape.inputElapsedUs ? expandTapeInputElapsed(tape) : null; state.replayTouch = tape.touch ? expandTapeTouch(tape) : null; state.replayTouchSurfaces = tape.touchSurfaces ? expandTapeTouchSurfaces(tape) diff --git a/framework/src/drag-filter.ts b/framework/src/drag-filter.ts new file mode 100644 index 000000000..eb4396ad8 --- /dev/null +++ b/framework/src/drag-filter.ts @@ -0,0 +1,33 @@ +export interface DragFilterOptions { + /** Position hysteresis in logical pixels. */ + deadband?: number; + /** Maximum smoothing lag beyond the hysteresis, in logical pixels. */ + maxLag?: number; +} + +/** Filters total contact travel, not incremental deltas. Call once per pan + * frame, including stationary frames. The returned scratch record is reused. + * reset() starts a new contact; velocity is suitable for a camera's fling. */ +export function createDragFilter(options: DragFilterOptions = {}) { + const deadband = options.deadband ?? 1, maxLag = options.maxLag ?? 3; + if (![deadband, maxLag].every(Number.isFinite) || deadband < 0 || maxLag < 0) throw new Error("Invalid drag filter"); + let x = 0, y = 0, targetX = 0, targetY = 0; + const output = { dx: 0, dy: 0, vx: 0, vy: 0 }; + return { + reset() { x = y = targetX = targetY = 0; output.dx = output.dy = output.vx = output.vy = 0; }, + update(totalX: number, totalY: number, seconds: number) { + if (![totalX, totalY, seconds].every(Number.isFinite) || seconds <= 0 || seconds > 1 / 15 + 1e-8) throw new Error("Invalid drag sample"); + const rx = totalX - targetX, ry = totalY - targetY, distance = Math.hypot(rx, ry); + if (distance > deadband) { const gain = 1 - deadband / distance; targetX += rx * gain; targetY += ry * gain; } + const ex = targetX - x, ey = targetY - y, error = Math.hypot(ex, ey); + // Slow motion rejects quantization; fast motion catches up within maxLag. + const gain = Math.max(1 - Math.exp(-(18 + Math.min(162, error * 8)) * seconds), error ? 1 - maxLag / error : 0); + output.dx = ex * gain; output.dy = ey * gain; x += output.dx; y += output.dy; + const velocityGain = 1 - Math.exp(-20 * seconds); + output.vx += (output.dx / seconds - output.vx) * velocityGain; + output.vy += (output.dy / seconds - output.vy) * velocityGain; + return output; + }, + velocity: () => ({ x: Math.abs(output.vx) < 12 ? 0 : output.vx, y: Math.abs(output.vy) < 12 ? 0 : output.vy }), + }; +} diff --git a/framework/src/gesture.ts b/framework/src/gesture.ts index 4fa50d48e..736b63c32 100644 --- a/framework/src/gesture.ts +++ b/framework/src/gesture.ts @@ -5,6 +5,7 @@ // gesture.vue-vapor.ts instead (the compiler's framework-variant rule). import { onCleanup } from "solid-js"; +export { createDragFilter, type DragFilterOptions } from "./drag-filter.ts"; import { attachGesture, type GestureHandle, type GestureOptions } from "./gesture-core.ts"; export type { diff --git a/framework/src/gesture.vue-vapor.ts b/framework/src/gesture.vue-vapor.ts index bcc5668ef..d4b38e583 100644 --- a/framework/src/gesture.vue-vapor.ts +++ b/framework/src/gesture.vue-vapor.ts @@ -5,6 +5,7 @@ // mount, which is why onScopeDispose is called with failSilently. import { onScopeDispose } from "vue"; +export { createDragFilter, type DragFilterOptions } from "./drag-filter.ts"; import { attachGesture, type GestureHandle, type GestureOptions } from "./gesture-core.ts"; export type { diff --git a/framework/src/host.ts b/framework/src/host.ts index 0bdb0951a..13ba4d3c1 100644 --- a/framework/src/host.ts +++ b/framework/src/host.ts @@ -64,6 +64,10 @@ export interface HostOps { uploadTexture(buf: Uint8Array, w: number, h: number, psm: number): number; /** texHandle < 0 clears the image (handles are 0-based: 0 is a real one). */ setImage(id: number, texHandle: number): void; + /** Optional prepared 2D geometry; handles have a separate ownership namespace. */ + setMesh?(id: number, handle: number): void; + freeMesh?(handle: number): void; + uploadMesh?(bytes: Uint8Array): number; /** Bind a native application surface to a surface node. Optional on * hosts without ui.compositor-surfaces; handle < 0 clears the binding. */ setCompositorSurface?(id: number, handle: number, focused: number): void; @@ -422,6 +426,7 @@ export function installFrameHandler( hits?: readonly number[], touchSurfaces?: readonly number[], rightAnalog?: number, + inputElapsedUs?: number, ) => void, ): void { ( @@ -433,6 +438,7 @@ export function installFrameHandler( hits?: readonly number[], touchSurfaces?: readonly number[], rightAnalog?: number, + inputElapsedUs?: number, ) => void; } ).frame = fn; diff --git a/framework/src/index-octane.ts b/framework/src/index-octane.ts index 2e3112cf7..8ea445dde 100644 --- a/framework/src/index-octane.ts +++ b/framework/src/index-octane.ts @@ -214,8 +214,9 @@ export function render(code: OctaneRenderRoot, opts: RenderOptions = {}): () => hits?: readonly number[], touchSurfaces?: readonly number[], rightAnalog?: number, + inputElapsedUs?: number, ) => { - __advanceClock(); + __advanceClock(inputElapsedUs); __setAnalog(analog, rightAnalog); __setTouches(touches, hits, touchSurfaces); runServicePumps(); diff --git a/framework/src/index-vue-vapor.ts b/framework/src/index-vue-vapor.ts index fe5a5cb25..c06a90bd8 100644 --- a/framework/src/index-vue-vapor.ts +++ b/framework/src/index-vue-vapor.ts @@ -224,8 +224,9 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v hits?: readonly number[], touchSurfaces?: readonly number[], rightAnalog?: number, + inputElapsedUs?: number, ) => { - __advanceClock(); + __advanceClock(inputElapsedUs); __setAnalog(analog, rightAnalog); __setTouches(touches, hits, touchSurfaces); // latch contacts + surface-specific hit facts runServicePumps(); diff --git a/framework/src/index.ts b/framework/src/index.ts index 08e266544..4e2c89396 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -277,8 +277,9 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi hits?: readonly number[], touchSurfaces?: readonly number[], rightAnalog?: number, + inputElapsedUs?: number, ) => { - __advanceClock(); // virtual frame++, fire due after() timers + __advanceClock(inputElapsedUs); // virtual frame++, fire due after() timers __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 diff --git a/framework/src/offload.ts b/framework/src/offload.ts index 217fae571..a4cd7cdf2 100644 --- a/framework/src/offload.ts +++ b/framework/src/offload.ts @@ -1,4 +1,10 @@ -import { OFFLOAD, type OffloadOps, type OffloadReply } from "../../contracts/spec/offload.ts"; +import { + OFFLOAD, + type OffloadOps, + type OffloadReply, + type OffloadImageTicket, + type OffloadMeshTicket, +} from "../../contracts/spec/offload.ts"; import { registerServicePump } from "./services.ts"; export { OFFLOAD }; @@ -6,46 +12,149 @@ export type { OffloadOps }; /** 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 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 }; +type Callback = (result: OffloadResult) => void; +type Pending = { + record: string; + callback: Callback | undefined; + deadline: number; + sent: boolean; + session: number; + response?: "image" | "mesh"; +}; + +function meshTicket(value: unknown): value is OffloadMeshTicket { + const v = value as OffloadMeshTicket | undefined; + return ( + !!v && + Number.isSafeInteger(v.token) && + v.token > 0 && + v.token <= 0xffffffff && + Number.isInteger(v.width) && + v.width > 0 && + v.width <= 4095 && + Number.isInteger(v.height) && + v.height > 0 && + v.height <= 4095 && + Number.isInteger(v.bytes) && + v.bytes >= 16 && + v.bytes <= 36880 + ); +} +function imageTicket(value: unknown): value is OffloadImageTicket { + const v = value as OffloadImageTicket | undefined; + const side = (n: number) => Number.isInteger(n) && n >= 16 && n <= 256 && (n & (n - 1)) === 0; + return ( + !!v && Number.isSafeInteger(v.token) && v.token > 0 && v.token <= 0xffffffff && side(v.width) && side(v.height) + ); +} /** 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; + 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); + try { item.callback?.(result); } + catch (error) { + // A callback that throws has not retained a usable response owner. + // Returning staging is safe even if it uploaded the value first. + if (result.ok && item.response) { + const ticket: unknown = JSON.parse(result.value); + if (item.response === "mesh" && meshTicket(ticket)) ops.releaseMesh?.(ticket.token); + if (item.response === "image" && imageTicket(ticket)) ops.releaseImage?.(ticket.token); + } + throw error; + } }; + function request(method: string, payload: string, callback: Callback, response?: "image" | "mesh"): 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"); + if (nextId > 0xffffffff) throw new Error("Offload request ID exhausted; restart the realm"); + const id = nextId++; + const record = JSON.stringify({ v: 1, id, method, payload, ...(response ? { response } : {}) }); + // 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, response }); + return id; + } return { connected: () => !disposed && ops.session() > 0, - session: () => disposed ? 0 : ops.session(), + session: () => (disposed ? 0 : ops.session()), + /** Transport reservations, including cancelled/timed-out sent requests. + * Those retain credit until a reply arrives or their session ends. */ 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; + request, + requestImage(method: string, payload: string, callback: Callback): number { + if (!ops.uploadImage || !ops.releaseImage) throw new Error("Host does not implement offload images"); + return request(method, payload, callback, "image"); + }, + requestMesh(method: string, payload: string, callback: Callback): number { + if (!ops.uploadMesh || !ops.releaseMesh) throw new Error("Host does not implement offload meshes"); + return request(method, payload, callback, "mesh"); + }, + uploadMesh(raw: string) { + const ticket: unknown = JSON.parse(raw); + if (!meshTicket(ticket)) throw new Error("Invalid mesh ticket"); + const handle = ops.uploadMesh?.(ticket.token) ?? -1; + if (handle < 0) throw new Error("Mesh staging or frame upload credit unavailable"); + return { handle, width: ticket.width, height: ticket.height }; + }, + releaseMesh(raw: string) { + const ticket: unknown = JSON.parse(raw); + if (meshTicket(ticket)) ops.releaseMesh?.(ticket.token); + }, + /** The resource scheduler owns this small serialized ticket after delivery. */ + uploadImage(raw: string) { + const ticket: unknown = JSON.parse(raw); + if (!imageTicket(ticket)) throw new Error("Invalid offload image ticket"); + const handle = ops.uploadImage?.(ticket.token) ?? -1; + if (handle < 0) throw new Error("Image staging or frame upload credit unavailable"); + return { handle, width: ticket.width, height: ticket.height }; + }, + releaseImage(raw: string) { + const ticket: unknown = JSON.parse(raw); + if (imageTicket(ticket)) ops.releaseImage?.(ticket.token); + }, + cancel(id: number) { + const item = pending.get(id); + if (!item) return; + // Withdrawing UI interest does not cancel bytes or work already sent. + // Free the callback/owner, retaining the bounded transport reservation. + if (item.sent) item.callback = undefined; + else pending.delete(id); }, - cancel(id: number) { pending.delete(id); }, /** Called exactly once at the frame boundary by the realm service pump. */ step() { if (disposed) return; @@ -57,25 +166,64 @@ export function createOffloadClient(ops: OffloadOps) { 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" }); + const image = imageTicket(reply.image) ? reply.image : undefined; + const mesh = meshTicket(reply.mesh) ? reply.mesh : undefined; + const valid = item?.sent && item.session === session && session > 0; + const expected = !!valid && !!item.callback && !(image && mesh); + if (image && (!expected || item?.response !== "image")) ops.releaseImage?.(image.token); + if (mesh && (!expected || item?.response !== "mesh")) ops.releaseMesh?.(mesh.token); + if (valid) { + delivered = !!item.callback; + const ticket = !(image && mesh) + ? item.response === "image" + ? image + : item.response === "mesh" + ? mesh + : undefined + : undefined; + finish( + reply.id, + ticket + ? { ok: true, value: JSON.stringify(ticket) } + : !item.response && + !image && + !mesh && + 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. */ } + } 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))) { + if (item.sent && item.session !== session && (!delivered || !item.callback)) { + delivered = delivered || !!item.callback; + finish(id, { ok: false, error: "Connection lost; outcome may be unknown" }); + } else if (!delivered && frame >= item.deadline && item.callback) { delivered = true; - finish(id, { ok: false, error: item.sent ? "Connection lost or request expired; outcome may be unknown" : "Provider unavailable" }); + if (item.sent) { + const callback = item.callback; + item.callback = undefined; + callback({ ok: false, error: "Request expired; outcome may be unknown" }); + } else finish(id, { ok: false, error: "Provider unavailable" }); } else if (!item.sent && session > 0 && submitted < OFFLOAD.submissionsPerFrame && ops.submit(item.record)) { - item.sent = true; item.session = session; submitted++; + item.sent = true; + item.session = session; + submitted++; } } }, - dispose() { disposed = true; pending.clear(); }, + dispose() { + disposed = true; + pending.clear(); + }, }; } diff --git a/framework/src/resource-cache.ts b/framework/src/resource-cache.ts index f8fb00008..d20a888d0 100644 --- a/framework/src/resource-cache.ts +++ b/framework/src/resource-cache.ts @@ -17,6 +17,9 @@ export interface ResourceCacheOptions { load: ResourceLoad; /** Bounded decoding/upload only; executed by step(), never by a transport callback. */ materialize(raw: R, input: I): T; + /** Releases external staging owned by a response, after materialize (also + * on failure), or when cancellation/late delivery prevents materialization. */ + releaseResponse?(raw: R): void; dispose?(value: NoInfer): void; changed?(input: I): void; maxAgeFrames?: number; @@ -71,7 +74,9 @@ export function createResourceScheduler(options: ResourceSchedulerOptions) { if (entry.charged) entry.attempts--; entry.charged = false; entry.busy = false; active--; } - const cancel = entry.cancel; entry.cancel = undefined; entry.result = undefined; + const cancel = entry.cancel; const result = entry.result; + entry.cancel = undefined; entry.result = undefined; + if (result?.ok) config.releaseResponse?.(result.value); cancel?.(); } function drop(entry: Entry) { @@ -89,13 +94,15 @@ export function createResourceScheduler(options: ResourceSchedulerOptions) { if (!dead && entry.busy && entry.generation === generation && !entry.result) { const bytes = result.ok ? typeof result.value === "string" ? result.value.length * 2 : result.value instanceof Uint8Array ? result.value.byteLength : Infinity : 0; + if (bytes > config.maxResponseBytes && result.ok) config.releaseResponse?.(result.value); entry.result = bytes <= config.maxResponseBytes ? result : { ok: false, error: "Resource response exceeds budget" }; entry.resultOrder = completionOrder++; - } + } else if (result.ok && !(entry.result?.ok && entry.result.value === result.value)) config.releaseResponse?.(result.value); }); if (!task) { stop(entry); entry.declinedAt = frame; return false; } entry.cancel = task.cancel; entry.attempts++; entry.charged = true; notify(entry); return true; } catch (error) { + if (entry.result?.ok) config.releaseResponse?.(entry.result.value); entry.result = { ok: false, error }; entry.resultOrder = completionOrder++; entry.attempts++; entry.charged = true; return true; } @@ -123,7 +130,11 @@ export function createResourceScheduler(options: ResourceSchedulerOptions) { return { order: entry.resultOrder, run() { const result = entry.result!; entry.result = undefined; entry.cancel = undefined; entry.busy = false; entry.charged = false; active--; let next: ResourceState; - try { if (!result.ok) throw result.error; next = ready(config.materialize(result.value, entry.input)); } + try { + if (!result.ok) throw result.error; + try { next = ready(config.materialize(result.value, entry.input)); } + finally { config.releaseResponse?.(result.value); } + } catch (error) { entry.error = error; entry.stale = true; entry.retryAt = frame + Math.min(retry.maxDelayFrames, retry.delayFrames * 2 ** Math.min(20, entry.attempts - 1)); @@ -228,13 +239,15 @@ export function createResourceScheduler(options: ResourceSchedulerOptions) { if (candidate && (!chosen || candidate.priority < chosen.priority || candidate.priority === chosen.priority && candidate.order < chosen.order)) chosen = candidate; } if (!chosen) break; + // Do not discard useful in-flight prefetch if the replacement cannot + // even enter the transport. Sent offload cancellation retains credit. + if (options.available && !options.available()) break; if (active >= options.maxConcurrent) { let worst: ReturnType; for (const collection of collections) { const candidate = collection.speculative(); if (candidate && (!worst || candidate.priority > worst.priority)) worst = candidate; } if (!worst || worst.priority <= chosen.priority) break; worst.cancel(); } - if (options.available && !options.available()) break; if (chosen.start()) n++; } } finally { stepping = false; } diff --git a/framework/src/resource-offload.ts b/framework/src/resource-offload.ts index 340ed02d7..c78d54e2c 100644 --- a/framework/src/resource-offload.ts +++ b/framework/src/resource-offload.ts @@ -1,12 +1,92 @@ import type { createOffloadClient } from "./offload.ts"; import type { ResourceLoad } from "./resource-cache.ts"; +import type { ResourceCollectionOptions, createResourceRuntime } from "./resource-view.ts"; +import type { TextureResource, MeshResource } from "./resource.ts"; +import { getOps } from "./host.ts"; /** An opt-in adapter for reproducible read capabilities. Already-serialized * payloads retain offload's wire bound. Mutating methods must use offload directly. */ -export function offloadResource(client: Pick, "request" | "cancel">, - method: string, payload: (input: I) => string): ResourceLoad { +export function offloadResource( + client: Pick, "request" | "cancel">, + method: string, + payload: (input: I) => string, +): ResourceLoad { return (input, complete) => { - const id = client.request(method, payload(input), result => complete(result)); + const id = client.request(method, payload(input), (result) => complete(result)); return id ? { cancel: () => client.cancel(id) } : false; }; } + +/** Remote images share the same demand, retry, fallback and eviction lifecycle + * as data. Native staging is released on every path; decoded textures are + * owned by the collection. Configure one image materialization per frame. */ +export function createOffloadImageCollection( + runtime: ReturnType, + client: Pick, "requestImage" | "cancel" | "uploadImage" | "releaseImage">, + options: Omit< + ResourceCollectionOptions, + "load" | "materialize" | "dispose" | "releaseResponse" | "maxResponseBytes" | "cost" | "maxCost" + > & { + method: string; + payload(input: I): string; + /** Maximum rendition envelope. Reserves staging and old + new GPU values. */ + width: number; + height: number; + }, +) { + for (const n of [options.width, options.height]) + if (!Number.isInteger(n) || n < 16 || n > 256 || n & (n - 1)) + throw new Error("Invalid image collection dimensions"); + // Native staging (2), old + new core/GPU copies (2 * (2 + 4)), + // and the 3DS renderer's temporary tiled upload buffer (4), plus ticket data. + const cost = options.width * options.height * 18 + 512; + return runtime.createCollection({ + ...options, + maxResponseBytes: 512, + cost: () => cost, + maxCost: options.maxEntries * cost, + load: (input, complete) => { + const id = client.requestImage(options.method, options.payload(input), complete); + return id ? { cancel: () => client.cancel(id) } : false; + }, + materialize(raw) { + const ticket = JSON.parse(raw); + if (ticket.width > options.width || ticket.height > options.height) + throw new Error("Image exceeds collection envelope"); + return client.uploadImage(raw); + }, + releaseResponse: (raw) => client.releaseImage(raw), + dispose: (value) => getOps().freeTexture?.(value.handle), + }); +} + +/** Prepared geometry has the image lifecycle, but never consumes texture slots. + * One completion per frame covers the bounded native validation/copy. */ +export function createOffloadMeshCollection( + runtime: ReturnType, + client: Pick, "requestMesh" | "cancel" | "uploadMesh" | "releaseMesh">, + options: Omit< + ResourceCollectionOptions, + "load" | "materialize" | "dispose" | "releaseResponse" | "maxResponseBytes" | "cost" | "maxCost" + > & { + method: string; + payload(input: I): string; + }, +) { + // Staging plus old/new native records and 3DS retained VBOs. Replaced VBOs + // remain alive until the previous GPU frame retires. + const cost = 131088 + 2 * (4096 * 4 + 2048 * 12 + 2048 * 3 * 32 + 512); + return runtime.createCollection({ + ...options, + maxResponseBytes: 512, + cost: () => cost, + maxCost: options.maxEntries * cost, + load: (input, complete) => { + const id = client.requestMesh(options.method, options.payload(input), complete); + return id ? { cancel: () => client.cancel(id) } : false; + }, + materialize: (raw) => client.uploadMesh(raw), + releaseResponse: (raw) => client.releaseMesh(raw), + dispose: (value) => getOps().freeMesh?.(value.handle), + }); +} diff --git a/framework/src/resource-view.ts b/framework/src/resource-view.ts index f46d168c0..9dcd6a4ef 100644 --- a/framework/src/resource-view.ts +++ b/framework/src/resource-view.ts @@ -47,11 +47,12 @@ export function createResourceRuntime(options: ResourceSchedulerOptions) { positive(config.maxViews, "maxViews"); const maxDemands = positive(config.maxDemandsPerView ?? config.maxEntries, "maxDemandsPerView"); type Demand = ResourceDemand & { reserved: number }; - type View = { wanted: Map; plan: ResourceViewOptions["demand"]; notify(): void; dispose(): void }; + type Snapshot = { key: string; input: I; priority: number; pin: boolean; reserved: number }; + type View = { snapshot: Snapshot[]; wanted: Map; plan: ResourceViewOptions["demand"]; notify(): void; dispose(): void }; const views = new Set(); const lanes = new Map; notify(): void }>(); const dirty = new Set(); - let disposed = false; + let disposed = false, replan = true; const cache = scheduler.createCache({ ...config, changed(input) { dirty.add(config.key(input)); } }); function flush() { @@ -97,14 +98,38 @@ export function createResourceRuntime(options: ResourceSchedulerOptions) { } const collection = { plan() { - // Validate every view before replacing any of this collection's demand. - const planned = [...views].map(view => ({ view, wanted: validate(untrack(view.plan)) })); - for (const { view, wanted } of planned) { - const changed = wanted.size !== view.wanted.size || [...wanted.keys()].some(key => !view.wanted.has(key)); + // A frame samples all accessors, including non-reactive planners. + // Unchanged demand does not rebuild the union or touch the cache. + // Snapshot scalars also detect callers mutating a reused demand array. + const planned: { view: View; wanted: Map; snapshot: Snapshot[] }[] = []; + for (const view of views) { + const demands = untrack(view.plan); + if (demands.length > maxDemands) throw new Error("Resource view demand exceeds budget"); + const same = demands.length === view.snapshot.length && demands.every((d, i) => { + const old = view.snapshot[i]; + return config.key(d.input) === old.key && d.input === old.input + && d.priority === old.priority && !!d.pin === old.pin + && config.cost(d.input) === old.reserved; + }); + if (same) continue; + const wanted = validate(demands); + planned.push({ view, wanted, snapshot: demands.map(d => ({ + key: config.key(d.input), input: d.input, priority: d.priority, + pin: !!d.pin, reserved: config.cost(d.input), + })) }); + } + // Validate all views before publishing any changed membership. + for (const { view, wanted, snapshot } of planned) { + const changed = wanted.size !== view.wanted.size + || [...wanted.keys()].some(key => !view.wanted.has(key)); view.wanted = wanted; + view.snapshot = snapshot; if (changed) view.notify(); } - reconcile(); + if (replan || planned.length) { + reconcile(); + replan = false; + } }, flush, dispose() { @@ -123,7 +148,7 @@ export function createResourceRuntime(options: ResourceSchedulerOptions) { const [membership, notify] = createSignal(0); let closed = false; const view: View = { - wanted: new Map(), plan: options.demand, notify: () => notify(n => n + 1), + snapshot: [], wanted: new Map(), plan: options.demand, notify: () => notify(n => n + 1), dispose() { if (closed) return; closed = true; @@ -146,7 +171,7 @@ export function createResourceRuntime(options: ResourceSchedulerOptions) { return { state, value(input) { const current = state(input); return current.status === "ready" ? current.value : undefined; }, dispose: view.dispose }; }, invalidate: (matches, dropValue) => update(() => cache.invalidate(matches, dropValue)), - clear: () => update(cache.clear), cancel: () => update(cache.cancel), + clear: () => update(() => { cache.clear(); replan = true; }), cancel: () => update(cache.cancel), dispose: () => update(collection.dispose), stats: () => ({ ...cache.stats(), views: views.size, demands: lanes.size }), }; diff --git a/framework/src/resource.ts b/framework/src/resource.ts index b9407ce96..02a79ff17 100644 --- a/framework/src/resource.ts +++ b/framework/src/resource.ts @@ -17,12 +17,15 @@ export interface ResourceBoundaryProps { 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; }); + const error = createMemo(() => { + const value = state(); + return value.status === "error" ? value.error : undefined; + }); return createMemo(() => { const phase = status(); const reason = phase === "error" ? error() : undefined; - if (phase !== "ready") return phase === "error" && props.errorFallback - ? props.errorFallback(reason) : props.fallback(); + if (phase !== "ready") + return phase === "error" && props.errorFallback ? props.errorFallback(reason) : props.fallback(); return untrack(() => { return props.children(() => { const current = state(); @@ -34,7 +37,11 @@ export function ResourceBoundary(props: ResourceBoundaryProps): JSX.Elemen } /** A decoded/uploaded image, including its texture envelope dimensions. */ -export interface TextureResource { handle: number; width: number; height: number } +export interface TextureResource { + handle: number; + width: number; + height: number; +} export interface ResourceImageProps extends Pick { state: Accessor>; fallback: () => JSX.Element; @@ -45,9 +52,15 @@ export interface ResourceImageProps extends Pick { + 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 }; }, + 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; +} + +/** Borrowed prepared geometry. Coordinates fit the resource's logical envelope. */ +export interface MeshResource { + handle: number; + width: number; + height: number; +} +export interface ResourceMeshProps extends Pick { + state: Accessor>; + fallback: () => JSX.Element; + errorFallback?: (error: unknown) => JSX.Element; +} +export function ResourceMesh(props: ResourceMeshProps): JSX.Element { + const frame = View({ + get class() { + return props.class; + }, + get style() { + return props.style; + }, + get debugName() { + return props.debugName; + }, + }); + const content = ResourceBoundary({ + state: props.state, + fallback: props.fallback, + errorFallback: props.errorFallback, + children: (value) => { + let node: NodeMirror | undefined; + const result = View({ + ref: (n) => { + node = n; + }, + get style() { + return { posType: 1, insetL: 0, insetT: 0, width: value().width, height: value().height }; + }, + }); + createRenderEffect(() => { + if (node) { + const ops = getOps(); + if (!ops.setMesh) throw new Error("Host does not implement meshes"); + ops.setMesh(node.id, value().handle); + } }); - createRenderEffect(() => { if (node) getOps().setImage(node.id, handle()); }); return result; }, }); diff --git a/framework/src/tile-viewport.ts b/framework/src/tile-viewport.ts new file mode 100644 index 000000000..2a95ce59a --- /dev/null +++ b/framework/src/tile-viewport.ts @@ -0,0 +1,158 @@ +/** Geometry and motion for remote tile pyramids. Coordinates are level-zero + * pixels; zoom is log2 magnification. IO and projection belong to the caller. */ +export interface TileCameraOptions { + width: number; height: number; x: number; y: number; zoom: number; + minZoom: number; maxZoom: number; + bounds?: { width: number; height: number; wrapX?: boolean }; +} +const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); +const finite = (...values: number[]) => { if (values.some(v => !Number.isFinite(v))) throw new Error("Invalid tile geometry"); }; +export function createTileCamera(options: TileCameraOptions) { + finite(options.width, options.height, options.x, options.y, options.zoom, options.minZoom, options.maxZoom); + if (options.width <= 0 || options.height <= 0 || options.minZoom > options.maxZoom || options.minZoom < -20 || options.maxZoom > 24) throw new Error("Invalid tile camera bounds"); + if (options.bounds) { finite(options.bounds.width, options.bounds.height); if (options.bounds.width <= 0 || options.bounds.height <= 0) throw new Error("Invalid world bounds"); } + let x = options.x, y = options.y, zoom = clamp(options.zoom, options.minZoom, options.maxZoom); + let minZoom = options.minZoom, maxZoom = options.maxZoom, bounds = options.bounds; + let vx = 0, vy = 0, dragging = false; + let tween: { start: number; end: number; elapsed: number; anchorX: number; anchorY: number } | undefined; + function constrain() { + const b = bounds; if (!b) return; + if (b.wrapX) x = ((x % b.width) + b.width) % b.width; + else { const half = Math.min(b.width / 2, options.width / 2 / 2 ** zoom); x = clamp(x, half, b.width - half); } + const half = Math.min(b.height / 2, options.height / 2 / 2 ** zoom); + y = clamp(y, half, b.height - half); + } + function pan(dx: number, dy: number) { finite(dx, dy); x -= dx / 2 ** zoom; y -= dy / 2 ** zoom; constrain(); } + function setZoom(next: number, ax: number, ay: number) { + next = clamp(next, minZoom, maxZoom); + const before = 2 ** -zoom, after = 2 ** -next; + x += (ax - options.width / 2) * (before - after); y += (ay - options.height / 2) * (before - after); + zoom = next; constrain(); + } + constrain(); + return { + view: () => ({ x, y, zoom, scale: 2 ** zoom, targetZoom: tween?.end ?? zoom, moving: dragging || !!tween || Math.abs(vx) + Math.abs(vy) > 1 }), + stop() { vx = vy = 0; dragging = false; tween = undefined; }, + /** Replace the admitted world's bounds when an asynchronous source opens. */ + setWorld(world: Pick) { + finite(world.minZoom, world.maxZoom); + if (world.minZoom > world.maxZoom || world.minZoom < -20 || world.maxZoom > 24) throw new Error("Invalid tile camera bounds"); + if (world.bounds) { + finite(world.bounds.width, world.bounds.height); + if (world.bounds.width <= 0 || world.bounds.height <= 0) throw new Error("Invalid world bounds"); + } + minZoom = world.minZoom; maxZoom = world.maxZoom; bounds = world.bounds; + vx = vy = 0; dragging = false; tween = undefined; + zoom = clamp(zoom, minZoom, maxZoom); constrain(); + }, + beginDrag() { vx = vy = 0; dragging = true; tween = undefined; }, + drag: pan, + endDrag(dx: number, dy: number) { finite(dx, dy); dragging = false; vx = clamp(dx, -1800, 1800); vy = clamp(dy, -1800, 1800); }, + zoomBy(delta: number, anchorX = options.width / 2, anchorY = options.height / 2) { + finite(delta, anchorX, anchorY); vx = vy = 0; + tween = { start: zoom, end: clamp((tween?.end ?? zoom) + delta, minZoom, maxZoom), elapsed: 0, anchorX, anchorY }; + }, + jump(nextX: number, nextY: number, nextZoom = zoom) { + finite(nextX, nextY, nextZoom); x = nextX; y = nextY; zoom = clamp(nextZoom, minZoom, maxZoom); + vx = vy = 0; tween = undefined; dragging = false; constrain(); + }, + /** Screen-space controller velocity (pixels/second); positive moves map. + * Caller supplies bounded elapsed time (inputDeltaSeconds for live input). */ + step(seconds: number, inputX = 0, inputY = 0) { + finite(seconds, inputX, inputY); if (seconds <= 0 || seconds > 1 / 15 + 1e-8) throw new Error("Tile camera step exceeds budget"); + if (tween) { + tween.elapsed += seconds; const t = Math.min(1, tween.elapsed / 0.18), eased = 1 - (1 - t) ** 3; + setZoom(tween.start + (tween.end - tween.start) * eased, tween.anchorX, tween.anchorY); + if (t === 1) tween = undefined; + } + if (!dragging) { + const driven = inputX !== 0 || inputY !== 0; + const decay = Math.exp(-(driven ? 18 : 4.2) * seconds); + const tx = clamp(inputX, -1800, 1800), ty = clamp(inputY, -1800, 1800); + // Exact integral of exponential velocity approaches. Sampling at 30/60 + // Hz gives the same distance for a held input and for a released fling. + const k = driven ? 18 : 4.2; + pan(tx * seconds + (vx - tx) * (1 - decay) / k, ty * seconds + (vy - ty) * (1 - decay) / k); + vx = tx + (vx - tx) * decay; vy = ty + (vy - ty) * decay; + if (Math.abs(vx) < 0.1) vx = 0; if (Math.abs(vy) < 0.1) vy = 0; + } + }, + }; +} + +export interface VisibleTile { column: number; row: number; priority: number } +export interface TileWindowOptions { + x: number; y: number; zoom: number; level: number; width: number; height: number; tileSize?: number; maxTiles: number; +} +/** Explicit, bounded look-ahead, returned separately from visible demand. + * Margins and prediction are screen pixels; callers decide whether their + * source permits look-ahead and give these entries a lower load priority. */ +export function planTileWindow(options: TileWindowOptions & { margin: number; leadX?: number; leadY?: number; directional?: boolean; maxExtra: number }) { + const { margin, maxExtra } = options, leadX = options.leadX ?? 0, leadY = options.leadY ?? 0; + finite(margin, leadX, leadY, maxExtra); + if (margin < 0 || margin > 512 || Math.abs(leadX) > 512 || Math.abs(leadY) > 512 || !Number.isSafeInteger(maxExtra) || maxExtra < 0 || maxExtra > 16) throw new Error("Invalid tile look-ahead"); + const visible = visibleTiles(options); + if (!maxExtra) return { visible, lookAhead: [] as VisibleTile[] }; + const expanded = visibleTiles({ ...options, x: options.x + leadX / 2 / 2 ** options.zoom, y: options.y + leadY / 2 / 2 ** options.zoom, + width: options.width + 2 * margin + Math.abs(leadX), height: options.height + 2 * margin + Math.abs(leadY), maxTiles: 256 }); + let extras = expanded.filter(t => !visible.some(v => v.column === t.column && v.row === t.row)); + const length = Math.hypot(leadX, leadY); + if (options.directional && length > 1) { + const ux = leadX / length, uy = leadY / length, pixelScale = 2 ** (options.zoom - options.level), size = options.tileSize ?? 256; + const corridor = (Math.abs(uy) * options.width + Math.abs(ux) * options.height) / 2; + extras = extras.flatMap(t => { + const dx = ((t.column + 0.5) * size - options.x * 2 ** options.level) * pixelScale; + const dy = ((t.row + 0.5) * size - options.y * 2 ** options.level) * pixelScale; + const along = dx * ux + dy * uy, across = Math.abs(dx * uy - dy * ux); + // A forward corridor, not a growing ring around the viewport. + return along > 0 && across <= corridor + along * 0.35 + ? [{ ...t, priority: along + across * 2 }] : []; + }).sort((a, b) => a.priority - b.priority); + } + return { visible, lookAhead: extras.slice(0, maxExtra) }; +} +/** Current viewport only, near-first. Large/invalid windows throw before any + * enumeration. The app maps columns/rows to domain keys (including wrap). */ +export function visibleTiles(options: TileWindowOptions): VisibleTile[] { + const { x, y, zoom, level, width, height, maxTiles } = options, size = options.tileSize ?? 256; + finite(x, y, zoom, level, width, height, size, maxTiles); + if (!Number.isInteger(level) || level < -20 || level > 24 || zoom < -20 || zoom > 24 || size <= 0 || width <= 0 || height <= 0 || !Number.isSafeInteger(maxTiles) || maxTiles < 1 || maxTiles > 256) throw new Error("Invalid tile window"); + const scale = 2 ** level, screen = 2 ** zoom; + const x0 = Math.floor((x - width / 2 / screen) * scale / size), y0 = Math.floor((y - height / 2 / screen) * scale / size); + const x1 = Math.ceil((x + width / 2 / screen) * scale / size) - 1, y1 = Math.ceil((y + height / 2 / screen) * scale / size) - 1; + // A finite input can overflow projection math or exceed integer precision; + // ++ would then stop advancing, defeating the enumeration budget. + if (![x0, y0, x1, y1].every(Number.isSafeInteger)) throw new Error("Tile coordinates exceed integer range"); + if ((x1 - x0 + 1) * (y1 - y0 + 1) > maxTiles) throw new Error("Tile window exceeds budget"); + const tiles: VisibleTile[] = [], cx = x * scale / size - 0.5, cy = y * scale / size - 0.5; + for (let row = y0; row <= y1; row++) for (let column = x0; column <= x1; column++) tiles.push({ column, row, priority: (column - cx) ** 2 + (row - cy) ** 2 }); + return tiles.sort((a, b) => a.priority - b.priority); +} + +/** Constant-space history of camera travel in screen pixels. Finger lifts do + * not reset intent. Call reset for teleports or a source/coordinate change. */ +export function createTileIntent() { + let x = 0, y = 0, distance = 0, age = 0; + return { + reset() { x = y = distance = age = 0; }, + sample(dx: number, dy: number, seconds: number) { + finite(dx, dy, seconds); + if (seconds <= 0 || seconds > 1 / 15 + 1e-8) throw new Error("Tile intent step exceeds budget"); + const travel = Math.hypot(dx, dy); + if (travel > 128) { x = y = distance = age = 0; return; } + age += seconds; + if (age > 8) x = y = distance = 0; + if (travel < 0.05) return; + // Weight distance rather than frame count, preserving intent across lifts + // while allowing a sustained turn to outweigh the earlier direction. + const weight = 1 - Math.exp(-travel / 32); + x += (dx / travel - x) * weight; y += (dy / travel - y) * weight; + distance = Math.min(128, distance + travel); age = 0; + }, + predict(maxLead: number) { + finite(maxLead); if (maxLead < 0 || maxLead > 512) throw new Error("Invalid tile prediction range"); + const length = Math.hypot(x, y), confidence = length * Math.min(1, distance / 64) * Math.exp(-Math.max(0, age - 3) / 1.2); + return { x: length ? x / length * maxLead * confidence : 0, y: length ? y / length * maxLead * confidence : 0, confidence }; + }, + }; +} diff --git a/hosts/3ds/Makefile b/hosts/3ds/Makefile index 9a3620e5a..fe606616a 100644 --- a/hosts/3ds/Makefile +++ b/hosts/3ds/Makefile @@ -146,7 +146,7 @@ $(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)/offload.o: $(SOURCE)/offload.h $(SOURCE)/offload_queue.h $(SOURCE)/offload_image.h $(BUILD)/qjs.o: $(SOURCE)/offload.h $(SOURCE)/offload_coverage.h $(ELF): $(OBJECTS) diff --git a/hosts/3ds/core/src/lib.rs b/hosts/3ds/core/src/lib.rs index ec120fec6..ef3f9af2b 100644 --- a/hosts/3ds/core/src/lib.rs +++ b/hosts/3ds/core/src/lib.rs @@ -212,7 +212,9 @@ fn clear_draw_snapshot() { #[no_mangle] pub extern "C" fn ui_init(raster_density: u32) { unsafe { - UI = Some(Ui::new_with_raster_density(raster_density.max(1))); + let mut instance = Ui::new_with_raster_density(raster_density.max(1)); + instance.set_mesh_commands(true); + UI = Some(instance); PAK_TEXTURES = Vec::new(); PAK_SPRITES = Vec::new(); } @@ -846,3 +848,19 @@ fn read_u16(blob: &[u8], offset: usize) -> Option { *blob.get(offset + 1)?, ])) } + +#[no_mangle] +pub extern "C" fn ui_upload_mesh(ptr: *const u8, len: usize) -> i32 { + ui().upload_mesh(unsafe { bytes(ptr, len) }) +} +#[no_mangle] +pub extern "C" fn ui_free_mesh(handle: i32) { ui().free_mesh(handle); } +#[no_mangle] +pub extern "C" fn ui_set_mesh(id: i32, handle: i32) { ui().set_mesh(id, handle); } + +#[no_mangle] +pub extern "C" fn ui_mesh_vertices(handle:i32) -> *const u16 { ui().mesh(handle).map_or(core::ptr::null(), |m| m.vertices.as_ptr() as *const u16) } +#[no_mangle] +pub extern "C" fn ui_mesh_triangles(handle:i32) -> *const pocketjs_core::mesh::Triangle { ui().mesh(handle).map_or(core::ptr::null(), |m| m.triangles.as_ptr()) } +#[no_mangle] +pub extern "C" fn ui_mesh_triangle_count(handle:i32) -> u32 { ui().mesh(handle).map_or(0, |m| m.triangles.len() as u32) } diff --git a/hosts/3ds/include/pocket_core.h b/hosts/3ds/include/pocket_core.h index 601dfdf58..fdd3fe812 100644 --- a/hosts/3ds/include/pocket_core.h +++ b/hosts/3ds/include/pocket_core.h @@ -67,6 +67,13 @@ int32_t ui_upload_texture( uint32_t height, uint32_t pixel_storage ); +typedef struct { uint16_t indices[3]; uint32_t color; } PocketMeshTriangle; +const uint16_t *ui_mesh_vertices(int32_t handle); +const PocketMeshTriangle *ui_mesh_triangles(int32_t handle); +uint32_t ui_mesh_triangle_count(int32_t handle); +int32_t ui_upload_mesh(const uint8_t *bytes, size_t length); +void ui_free_mesh(int32_t handle); +void ui_set_mesh(int32_t id, int32_t handle); int32_t ui_upload_img_entry(const uint8_t *bytes, size_t length); int32_t ui_upload_tileset_tile(const uint8_t *bytes, size_t length, uint32_t index); void ui_free_texture(int32_t handle); diff --git a/hosts/3ds/src/gfx.c b/hosts/3ds/src/gfx.c index 25e9687fb..3c4c4011a 100644 --- a/hosts/3ds/src/gfx.c +++ b/hosts/3ds/src/gfx.c @@ -7,8 +7,8 @@ * font-atlas caches, and batching by texture and scissor are the same shape, * only the state they turn into is citro3d instead of GLES. * - * No clipping happens here. The core's CPU clip stage guarantees every - * coordinate is inside the viewport and i16-safe before the list is emitted. + * Screen-space primitives arrive CPU-clipped. Retained meshes carry an affine + * transform and clip rectangle; PICA transforms and clips their immutable VBOs. * * PICA200 constraints that shape the code: * - Textures are power-of-two, 8..1024 per dimension, and must already be @@ -46,6 +46,7 @@ #define DRAW_SCISSOR_POP 6u #define DRAW_TRI 7u #define DRAW_TEX_TRI 8u +#define DRAW_MESH 11u /* contracts/spec/spec.ts GradDir. ToTop is 0 and ToBottom is 1 — swapping the * two inverts every vertical gradient, which the other backends cannot hit @@ -69,10 +70,12 @@ * batches before submitting either one: the GPU reads this arena * asynchronously, so resetting it between screens would corrupt the first. */ -#define MAX_VERTICES 32768u +#define MAX_VERTICES 65536u #define MAX_COMMANDS 2048u #define MAX_CLIP_DEPTH 64u #define MAX_SURFACES 2u +#define MAX_MESHES 128u +#define MAX_MESH_BYTES (8u * 1024u * 1024u) typedef struct { float x, y; @@ -89,8 +92,18 @@ typedef struct { uint32_t first; uint32_t count; Clip clip; + Vertex *mesh; + float affine[6]; } Command; +typedef struct { + Vertex *vertices; + int32_t handle; + uint32_t count; + size_t bytes; + bool live; +} MeshBuffer; + typedef struct { uint32_t command_first; uint32_t command_count; @@ -145,6 +158,10 @@ static FontTexture *fonts; static size_t font_capacity; static bool initialized; +static MeshBuffer meshes[MAX_MESHES]; +static MeshBuffer retired_meshes[MAX_MESHES]; +static size_t retired_mesh_count; +static size_t mesh_bytes; // --------------------------------------------------------------------------- // word decoding @@ -180,6 +197,56 @@ static inline void unpack_color(uint32_t color, float *out) { out[3] = (float)((color >> 24) & 0xffu) / 255.0f; } +/* Upload happens inside the resource completion budget, before FrameBegin. + * Old buffers can still be read by the preceding GPU frame, so replacements + * are retired only after that frame's fence in gfx_begin_frame. */ +bool gfx_upload_mesh(int32_t handle) { + if (!initialized || handle < 0) return false; + const uint16_t *points = ui_mesh_vertices(handle); + const PocketMeshTriangle *triangles = ui_mesh_triangles(handle); + if (points == NULL || triangles == NULL) return false; + MeshBuffer *slot = &meshes[(uint32_t)handle & (MAX_MESHES - 1)]; + if (slot->live && slot->handle == handle) return true; + uint32_t count = ui_mesh_triangle_count(handle) * 3u; + if (count > 2048u * 3u) return false; + size_t bytes = (size_t)count * sizeof(Vertex); + if (bytes > MAX_MESH_BYTES - mesh_bytes || + (slot->live && retired_mesh_count == MAX_MESHES)) return false; + Vertex *buffer = count ? linearAlloc(bytes) : NULL; + if (count && buffer == NULL) return false; + for (uint32_t i = 0; i < count / 3u; i += 1) { + float color[4]; + unpack_color(triangles[i].color, color); + for (uint32_t j = 0; j < 3; j += 1) { + uint32_t p = (uint32_t)triangles[i].indices[j] * 2u; + buffer[i * 3u + j] = (Vertex){ + points[p] / 16.0f, points[p + 1] / 16.0f, 0, 0, + color[0], color[1], color[2], color[3], + }; + } + } + if (count) GSPGPU_FlushDataCache(buffer, bytes); + if (slot->live) retired_meshes[retired_mesh_count++] = *slot; + *slot = (MeshBuffer){buffer, handle, count, bytes, true}; + mesh_bytes += bytes; + return true; +} + +static void release_mesh(MeshBuffer *mesh) { + if (!mesh->live) return; + if (mesh->vertices) linearFree(mesh->vertices); + mesh_bytes -= mesh->bytes; + memset(mesh, 0, sizeof *mesh); +} + +static void collect_meshes(void) { + for (size_t i = 0; i < retired_mesh_count; i += 1) release_mesh(&retired_meshes[i]); + retired_mesh_count = 0; + for (size_t i = 0; i < MAX_MESHES; i += 1) { + if (meshes[i].live && ui_mesh_vertices(meshes[i].handle) == NULL) release_mesh(&meshes[i]); + } +} + // --------------------------------------------------------------------------- // texture upload // --------------------------------------------------------------------------- @@ -555,6 +622,7 @@ static void flush(C3D_Tex *texture, Clip clip, uint32_t *start) { command->first = *start; command->count = vertex_count - *start; command->clip = clip; + command->mesh = NULL; } else { dropped_commands += 1; } @@ -739,6 +807,26 @@ static void build( index += 12; break; } + case DRAW_MESH: { + if (index + 10 > length) return; + flush(texture, clip, &start); + int32_t handle = (int32_t)words[index + 1]; + MeshBuffer *mesh = &meshes[(uint32_t)handle & (MAX_MESHES - 1)]; + if (mesh->live && mesh->handle == handle && mesh->count) { + if (command_count < MAX_COMMANDS) { + Command *command = &commands[command_count++]; + command->texture = &white; + command->first = 0; + command->count = mesh->count; + command->mesh = mesh->vertices; + command->clip = (Clip){word_x(words[index + 8]), word_y(words[index + 8]), + word_w(words[index + 9]), word_h(words[index + 9])}; + for (size_t i = 0; i < 6; i += 1) command->affine[i] = word_float(words[index + 2 + i]); + } else dropped_commands += 1; + } + index += 10; + break; + } case DRAW_TRI: { if (index + 7 > length) return; if (texture != &white) { @@ -830,6 +918,7 @@ static bool apply_clip(Clip clip, uint32_t viewport_width, uint32_t viewport_hei void gfx_begin_frame(void) { if (!initialized) return; + collect_meshes(); sync_resources(); vertex_count = 0; command_count = 0; @@ -908,6 +997,8 @@ void gfx_draw_surface(uint32_t surface) { for (int stage = 1; stage < 6; stage += 1) C3D_TexEnvInit(C3D_GetTexEnv(stage)); C3D_Tex *bound = NULL; + Vertex *bound_buffer = NULL; + bool mesh_projection = false; bool scissored = false; uint32_t end = batch->command_first + batch->command_count; for (uint32_t index = batch->command_first; index < end; index += 1) { @@ -930,6 +1021,26 @@ void gfx_draw_surface(uint32_t surface) { if (!apply_clip(command->clip, batch->width, batch->height)) continue; scissored = true; } + Vertex *wanted_buffer = command->mesh ? command->mesh : vertices; + if (bound_buffer != wanted_buffer) { + C3D_BufInfo *buffer = C3D_GetBufInfo(); + BufInfo_Init(buffer); + if (BufInfo_Add(buffer, wanted_buffer, sizeof(Vertex), 3, 0x210) < 0) continue; + bound_buffer = wanted_buffer; + } + if (command->mesh) { + const float *a = command->affine; + C3D_Mtx model, projection; + Mtx_Identity(&model); + model.r[0] = FVec4_New(a[0], a[2], 0, a[4]); + model.r[1] = FVec4_New(a[1], a[3], 0, a[5]); + Mtx_Multiply(&projection, &batch->projection, &model); + C3D_FVUnifMtx4x4(GPU_VERTEX_SHADER, projection_uniform, &projection); + mesh_projection = true; + } else if (mesh_projection) { + C3D_FVUnifMtx4x4(GPU_VERTEX_SHADER, projection_uniform, &batch->projection); + mesh_projection = false; + } C3D_DrawArrays(GPU_TRIANGLES, (int)command->first, (int)command->count); } if (scissored) C3D_SetScissor(GPU_SCISSOR_DISABLE, 0, 0, 0, 0); @@ -988,6 +1099,9 @@ bool gfx_init(uint32_t logical_width, uint32_t logical_height) { void gfx_reset_resources(void) { if (!initialized) return; + for (size_t slot = 0; slot < MAX_MESHES; slot += 1) release_mesh(&meshes[slot]); + for (size_t slot = 0; slot < retired_mesh_count; slot += 1) release_mesh(&retired_meshes[slot]); + retired_mesh_count = 0; for (size_t slot = 0; slot < image_capacity; slot += 1) release_image(&images[slot]); for (size_t slot = 0; slot < font_capacity; slot += 1) release_font(&fonts[slot]); free(images); diff --git a/hosts/3ds/src/gfx.h b/hosts/3ds/src/gfx.h index 1314e64c8..3be3c0210 100644 --- a/hosts/3ds/src/gfx.h +++ b/hosts/3ds/src/gfx.h @@ -15,6 +15,9 @@ * target and before C3D_FrameEnd. */ bool gfx_init(uint32_t logical_width, uint32_t logical_height); +/* Materialize one immutable mesh VBO. Safe before FrameBegin: superseded + * buffers remain alive until the next GPU fence. Never allocates while drawing. */ +bool gfx_upload_mesh(int32_t handle); void gfx_begin_frame(void); bool gfx_prepare_surface( uint32_t surface, diff --git a/hosts/3ds/src/main.c b/hosts/3ds/src/main.c index b1fbed3ba..c90408143 100644 --- a/hosts/3ds/src/main.c +++ b/hosts/3ds/src/main.c @@ -873,6 +873,16 @@ int main(void) { #endif u64 offload_cpu_start = svcGetSystemTick(); + uint32_t input_elapsed_us = 0; +#ifndef POCKETJS_CAPTURE + static u64 previous_input_tick; + if (previous_input_tick) { + u64 elapsed = offload_cpu_start - previous_input_tick; + if (elapsed > SYSCLOCK_ARM11 / 15) elapsed = SYSCLOCK_ARM11 / 15; + input_elapsed_us = (uint32_t)(elapsed * 1000000 / SYSCLOCK_ARM11); + } + previous_input_tick = offload_cpu_start; +#endif int32_t touch_hit = 0; size_t hit_count = ui_touch_hits_auxiliary( touch_count > 0 ? &touch : NULL, @@ -881,7 +891,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, right_analog)) { + if (!qjs_frame(buttons, analog, &touch, &touch_hit, touch_count, right_analog, input_elapsed_us)) { #if defined(POCKETJS_CAPTURE) || defined(POCKETJS_OFFLOAD) fail(qjs_last_error()); #else @@ -953,6 +963,7 @@ int main(void) { #endif } gfx_finish_frame(); + u64 offload_prepared_at = svcGetSystemTick(); C3D_RenderTargetClear(primary_target, C3D_CLEAR_ALL, 0x000000ff, 0); C3D_FrameDrawOn(primary_target); @@ -965,7 +976,10 @@ int main(void) { C3D_SetViewport(0, 0, AUX_VIEW_H, AUX_VIEW_W); gfx_draw_surface(1); C3D_FrameEnd(0); - offload_measure((unsigned)((offload_ui_ticks + svcGetSystemTick() - offload_cpu_start) * 1000000 / SYSCLOCK_ARM11)); + offload_measure_parts( + (unsigned)(offload_ui_ticks * 1000000 / SYSCLOCK_ARM11), + (unsigned)((offload_prepared_at - offload_cpu_start) * 1000000 / SYSCLOCK_ARM11), + (unsigned)((svcGetSystemTick() - offload_prepared_at) * 1000000 / SYSCLOCK_ARM11)); #if !defined(POCKETJS_CAPTURE) && !defined(POCKETJS_OFFLOAD) guest.submitted_frames += 1; devserver_set_frame_stats( diff --git a/hosts/3ds/src/offload.c b/hosts/3ds/src/offload.c index fe736560a..8a385982f 100644 --- a/hosts/3ds/src/offload.c +++ b/hosts/3ds/src/offload.c @@ -3,6 +3,7 @@ #include "offload.h" #include "soc.h" #include "offload_queue.h" +#include "offload_image.h" #include <3ds.h> #include #include @@ -14,23 +15,40 @@ #ifndef POCKETJS_OFFLOAD_KEY #define POCKETJS_OFFLOAD_KEY "sdmc:/pocketjs/offload/unpaired.key" #endif +#ifndef POCKETJS_OFFLOAD_PORT +#define POCKETJS_OFFLOAD_PORT 8741 +#endif _Static_assert(ATOMIC_INT_LOCK_FREE == 2, "Offload requires lock-free 32-bit atomics"); static OffloadQueue outgoing, incoming; +static OffloadImages images; static _Atomic int connection; static _Atomic bool running; +static _Atomic bool reset_requested; static Thread worker; static unsigned sends, takes; static _Atomic unsigned measured_frames, max_us, over_budget; +static _Atomic unsigned ui_us, prepare_us, submit_us; +static void measure_max(_Atomic unsigned *counter, unsigned value) { + unsigned previous = atomic_load_explicit(counter, memory_order_relaxed); + while (value > previous && !atomic_compare_exchange_weak_explicit(counter, + &previous, value, memory_order_relaxed, memory_order_relaxed)) {} +} 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); } +void offload_measure_parts(unsigned ui, unsigned prepare, unsigned submit) { + measure_max(&ui_us, ui); + measure_max(&prepare_us, prepare); + measure_max(&submit_us, submit); + offload_measure(ui + prepare + submit); +} static OffloadRecord ui_record; void offload_frame(void) { sends = takes = 0; } -int offload_session(void) { return atomic_load_explicit(&connection, memory_order_acquire); } +int offload_session(void) { return atomic_load(&reset_requested) ? 0 : 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; @@ -39,10 +57,35 @@ bool offload_submit(const char *bytes, size_t length) { } 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; + if ((int)ui_record.generation != offload_session()) { + image_release(&images, ui_record.image_token); return 0; + } memcpy(out, ui_record.bytes, ui_record.length); return ui_record.length; } +const uint8_t *offload_image(uint32_t token, unsigned *width, unsigned *height) { + OffloadImageSlot *slot = image_borrow(&images, token); + if (!slot || slot->mesh) return NULL; + *width = slot->width; *height = slot->height; + /* The last eight bytes of the wire header are also an IMG entry header. + * Set its linear filter bit only after worker validation/publication. */ + slot->wire[13] = 2; + return slot->wire + OFFLOAD_IMAGE_HEADER; +} +const uint8_t *offload_mesh(uint32_t token, unsigned *length) { + OffloadImageSlot *slot=image_borrow(&images,token); if(!slot || !slot->mesh) return NULL; + *length=slot->length; return slot->wire+8; +} +void offload_release_image(uint32_t token) { image_release(&images, token); } +void offload_reset(void) { + /* A new JS realm starts request IDs at one. Drop the old connection before + * exposing transport credit, and release tickets whose JS owner was freed. */ + atomic_store(&reset_requested, true); + for (unsigned n = 0; n < OFFLOAD_IMAGE_SLOTS; n++) { + OffloadImageSlot *slot = &images.slots[n]; + if (atomic_load_explicit(&slot->state, memory_order_acquire) == IMAGE_READY) image_release(&images, slot->token); + } +} static bool transfer(int fd, char *p, size_t n, bool send_data) { u64 deadline = osGetTime() + 10000; while (n && atomic_load(&running)) { @@ -68,11 +111,12 @@ static void serve(void *unused) { if (listener < 0) return; 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 }; + struct sockaddr_in address = { .sin_family = AF_INET, .sin_port = htons(POCKETJS_OFFLOAD_PORT), .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)) { + if (atomic_exchange(&reset_requested, false)) atomic_store(&connection, 0); int fd = accept(listener, NULL, NULL); if (fd < 0) { svcSleepThread(10000000); continue; } fcntl(fd, F_SETFL, O_NONBLOCK); @@ -86,14 +130,18 @@ static void serve(void *unused) { char rx[OFFLOAD_BYTES + 4]; size_t have = 0, want = 4; u64 last_progress = osGetTime(); bool alive = true, ready = false; + OffloadImageSlot *image = NULL; + bool binary = false; + uint32_t image_length = 0, image_token = 0; u64 metrics_at = osGetTime(); - while (alive && atomic_load(&running)) { + while (alive && atomic_load(&running) && !atomic_load(&reset_requested)) { 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)); + "{\"v\":1,\"id\":0,\"method\":\"offload.metrics\",\"payload\":\"frames=%u maxCpuUs=%u over16ms=%u uiUs=%u prepareUs=%u submitUs=%u\"}", + atomic_load(&measured_frames), atomic_load(&max_us), atomic_load(&over_budget), + atomic_exchange(&ui_us, 0), atomic_exchange(&prepare_us, 0), atomic_exchange(&submit_us, 0)); uint32_t length = htonl((uint32_t)size); alive = transfer(fd, (char *)&length, 4, true) && transfer(fd, metrics, size, true); if (!alive) break; @@ -103,21 +151,48 @@ static void serve(void *unused) { 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; } + if (offload_push_ticket(&incoming, rx + 4, (uint32_t)(want - 4), (uint32_t)generation, image_token)) { + ready = false; have = 0; want = 4; image_token = 0; image = NULL; binary = false; + } } else { - int n = recv(fd, rx + have, want - have, 0); + if (binary && !image) { + image = image_reserve(&images); + if (!image) { svcSleepThread(1000000); continue; } + } + int n = recv(fd, binary ? (char *)image->wire + have : rx + have, want - have, 0); if (n > 0) { have += (size_t)n; last_progress = osGetTime(); - if (have == 4 && want == 4) { + if (!binary && 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; + binary = (length & 0x80000000u) != 0; + if (binary) { + image_length = length & 0x7fffffffu; + if (image_length < OFFLOAD_IMAGE_HEADER || image_length > OFFLOAD_IMAGE_HEADER + OFFLOAD_IMAGE_BYTES) { alive = false; continue; } + have = 0; want = image_length; + } else { + if (!length || length > OFFLOAD_BYTES) { alive = false; continue; } + want = 4 + length; + } + } else if (have == want) { + if (binary) { + if (!image_publish(&images, image, image_length, (uint32_t)generation)) { alive = false; continue; } + image_token = image->token; + int size = image->mesh ? snprintf(rx+4,OFFLOAD_BYTES, + "{\"id\":%u,\"mesh\":{\"token\":%u,\"width\":%u,\"height\":%u,\"bytes\":%u}}", + image->request,image_token,image->width,image->height,image->length) : snprintf(rx + 4, OFFLOAD_BYTES, + "{\"id\":%u,\"image\":{\"token\":%u,\"width\":%u,\"height\":%u}}", + image->request, image_token, image->width, image->height); + want = (size_t)size + 4; + } + ready = true; + } } else if (n == 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) alive = false; if (have && osGetTime() - last_progress > 10000) alive = false; } svcSleepThread(1000000); } + /* This allocation was not published to the UI queue; no consumer knows it. */ + if (image) atomic_store_explicit(&image->state, IMAGE_FREE, memory_order_release); atomic_store_explicit(&connection, -generation, memory_order_release); close(fd); } diff --git a/hosts/3ds/src/offload.h b/hosts/3ds/src/offload.h index a7125c8c2..63acdb66b 100644 --- a/hosts/3ds/src/offload.h +++ b/hosts/3ds/src/offload.h @@ -2,11 +2,17 @@ #define POCKET_OFFLOAD_H #include #include +#include bool offload_start(void); void offload_stop(void); void offload_frame(void); void offload_measure(unsigned microseconds); +void offload_measure_parts(unsigned ui, unsigned prepare, unsigned submit); int offload_session(void); bool offload_submit(const char *bytes, size_t length); size_t offload_take(char *out); +const uint8_t *offload_image(uint32_t token, unsigned *width, unsigned *height); +const uint8_t *offload_mesh(uint32_t token, unsigned *length); +void offload_release_image(uint32_t token); +void offload_reset(void); #endif diff --git a/hosts/3ds/src/offload_image.h b/hosts/3ds/src/offload_image.h new file mode 100644 index 000000000..44f5f853f --- /dev/null +++ b/hosts/3ds/src/offload_image.h @@ -0,0 +1,72 @@ +#ifndef POCKET_OFFLOAD_IMAGE_H +#define POCKET_OFFLOAD_IMAGE_H +#include +#include +#include +#include +#include +#define OFFLOAD_IMAGE_BYTES (256 * 256 * 2) +#define OFFLOAD_IMAGE_SLOTS 8 +#define OFFLOAD_IMAGE_HEADER 16 +enum { IMAGE_FREE, IMAGE_WRITING, IMAGE_READY }; +typedef struct { + _Atomic unsigned state; + uint32_t token, request, generation, length; + unsigned width, height; + bool mesh; + uint8_t wire[OFFLOAD_IMAGE_HEADER + OFFLOAD_IMAGE_BYTES]; +} OffloadImageSlot; +typedef struct { OffloadImageSlot slots[OFFLOAD_IMAGE_SLOTS]; uint32_t next_token; } OffloadImages; +static inline uint32_t image_u32(const uint8_t *p) { + return (uint32_t)p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24; +} +/* Worker acquires FREE storage before reading the network. UI never waits. */ +static inline OffloadImageSlot *image_reserve(OffloadImages *images) { + for (unsigned n = 0; n < OFFLOAD_IMAGE_SLOTS; n++) { + OffloadImageSlot *slot = &images->slots[n]; + unsigned expected = IMAGE_FREE; + if (atomic_compare_exchange_strong_explicit(&slot->state, &expected, IMAGE_WRITING, memory_order_acquire, memory_order_relaxed)) return slot; + } + return NULL; +} +/* Worker validates every byte count before publishing. Tokens never reuse a + * live allocation, including across connections and uint32 counter wrap. */ +static inline bool image_publish(OffloadImages *images, OffloadImageSlot *slot, uint32_t length, uint32_t generation) { + if (length < OFFLOAD_IMAGE_HEADER || length > sizeof slot->wire) return false; + const bool mesh = !memcmp(slot->wire, "PMSH", 4); + unsigned w, h, payload_length; + uint32_t request = image_u32(slot->wire + 4); + if (!request || !generation) return false; + if (mesh) { + if (length < 24 || length > 8 + 36880 || memcmp(slot->wire + 8, "PMH1", 4) || image_u32(slot->wire + 20)) return false; + const uint8_t *p=slot->wire+8; + w=p[4] | (unsigned)p[5]<<8; h=p[6] | (unsigned)p[7]<<8; + unsigned nv=p[8] | (unsigned)p[9]<<8, nt=p[10] | (unsigned)p[11]<<8; + if (!w || !h || w>4095 || h>4095 || nv>4096 || nt>2048 || length!=24+nv*4+nt*10) return false; + for (unsigned i=0;iw*16 || y>h*16) return false; } + for (unsigned i=0;i=nv) return false; } + payload_length=length-8; + } else { + if (memcmp(slot->wire,"PIMG",4) || image_u32(slot->wire+12)) return false; + w=slot->wire[8] | (unsigned)slot->wire[9]<<8; h=slot->wire[10] | (unsigned)slot->wire[11]<<8; + if (w<16 || h<16 || w>256 || h>256 || (w&(w-1)) || (h&(h-1)) || length!=OFFLOAD_IMAGE_HEADER+w*h*2) return false; + payload_length=w*h*2; + } + /* Tokens combine a 29-bit sequence with the slot index. Older tokens cannot + * release another slot; wrap requires 536 million uploads to the same slot. */ + images->next_token = (images->next_token % 0x1fffffff) + 1; + slot->token = (images->next_token << 3) | (uint32_t)(slot - images->slots); + slot->request = request; slot->generation = generation; slot->length = payload_length; slot->mesh = mesh; slot->width = w; slot->height = h; + atomic_store_explicit(&slot->state, IMAGE_READY, memory_order_release); return true; +} +/* Only the UI calls borrow/release after publication in the incoming queue. + * The worker must not reset READY slots on disconnect. */ +static inline OffloadImageSlot *image_borrow(OffloadImages *images, uint32_t token) { + OffloadImageSlot *slot = &images->slots[token % OFFLOAD_IMAGE_SLOTS]; + return token && atomic_load_explicit(&slot->state, memory_order_acquire) == IMAGE_READY && slot->token == token ? slot : NULL; +} +static inline void image_release(OffloadImages *images, uint32_t token) { + OffloadImageSlot *slot = image_borrow(images, token); + if (slot) atomic_store_explicit(&slot->state, IMAGE_FREE, memory_order_release); +} +#endif diff --git a/hosts/3ds/src/offload_queue.h b/hosts/3ds/src/offload_queue.h index 33742b972..4d5f5321e 100644 --- a/hosts/3ds/src/offload_queue.h +++ b/hosts/3ds/src/offload_queue.h @@ -6,22 +6,25 @@ #include #define OFFLOAD_BYTES 4096 #define OFFLOAD_SLOTS 8 -typedef struct { uint32_t generation, length; char bytes[OFFLOAD_BYTES]; } OffloadRecord; +typedef struct { uint32_t generation, length, image_token; 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) { +static inline bool offload_push_ticket(OffloadQueue *q, const char *p, uint32_t n, uint32_t generation, uint32_t image_token) { 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); + s->length = n; s->generation = generation; s->image_token = image_token; memcpy(s->bytes, p, n); atomic_store_explicit(&q->write, w + 1, memory_order_release); return true; } +static inline bool offload_push(OffloadQueue *q, const char *p, uint32_t n, uint32_t generation) { + return offload_push_ticket(q, p, n, generation, 0); +} 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); diff --git a/hosts/3ds/src/qjs.c b/hosts/3ds/src/qjs.c index 0a4df46d0..2d795466e 100644 --- a/hosts/3ds/src/qjs.c +++ b/hosts/3ds/src/qjs.c @@ -19,6 +19,7 @@ */ #include "qjs.h" +#include "gfx.h" #include "offload.h" #include "offload_coverage.h" @@ -50,7 +51,7 @@ #define POCKETJS_JS_STACK_SIZE (384 * 1024) typedef enum { - HostOffloadSession, HostOffloadSubmit, HostOffloadTake, HostOffloadCoverage, + HostOffloadSession, HostOffloadSubmit, HostOffloadTake, HostOffloadCoverage, HostOffloadImage, HostOffloadReleaseImage, HostOffloadMesh, HostCreateNode, HostDestroyNode, HostInsertBefore, @@ -61,7 +62,7 @@ typedef enum { HostSetText, HostReplaceText, HostUploadTexture, - HostSetImage, + HostSetImage, HostSetMesh, HostFreeMesh, HostSetSprite, HostAnimate, HostCancelAnim, @@ -107,6 +108,7 @@ static char debug_poll_buffer[32 * 1024]; static char svc_poll_buffer[8192 + 1]; static uint8_t coverage_pixels[512 * 16 * 4]; static bool coverage_used; +static bool image_used; static void set_error(const char *message) { size_t length = message == NULL ? 0 : strlen(message); @@ -296,6 +298,8 @@ static JSValue host_operation( (uint32_t)argument_int(ctx, argc, argv, 3) ) ); + case HostSetMesh: ui_set_mesh(argument_int(ctx,argc,argv,0),argument_int(ctx,argc,argv,1)); return JS_UNDEFINED; + case HostFreeMesh: ui_free_mesh(argument_int(ctx,argc,argv,0)); return JS_UNDEFINED; case HostSetImage: ui_set_image(argument_int(ctx, argc, argv, 0), argument_int(ctx, argc, argv, 1)); return JS_UNDEFINED; @@ -493,6 +497,27 @@ static JSValue host_operation( 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 HostOffloadMesh: { + if(image_used) return JS_NewInt32(ctx,-1); + unsigned length; const uint8_t *bytes=offload_mesh((uint32_t)argument_int(ctx,argc,argv,0),&length); + if(!bytes) return JS_NewInt32(ctx,-1); image_used=true; + int32_t handle = ui_upload_mesh(bytes, length); + if (handle >= 0 && !gfx_upload_mesh(handle)) { + ui_free_mesh(handle); + handle = -1; + } + return JS_NewInt32(ctx, handle); + } + case HostOffloadImage: { + if (image_used) return JS_NewInt32(ctx, -1); + unsigned width, height; + const uint8_t *pixels = offload_image((uint32_t)argument_int(ctx, argc, argv, 0), &width, &height); + if (!pixels) return JS_NewInt32(ctx, -1); + image_used = true; + return JS_NewInt32(ctx, ui_upload_img_entry(pixels - 8, width * height * 2 + 8)); + } + case HostOffloadReleaseImage: + offload_release_image((uint32_t)argument_int(ctx, argc, argv, 0)); return JS_UNDEFINED; case HostOffloadSession: return JS_NewInt32(ctx, offload_session()); case HostOffloadSubmit: { if (argc < 1 || !JS_IsString(argv[0])) return JS_FALSE; @@ -574,6 +599,10 @@ static void install_host(void) { #ifdef POCKETJS_OFFLOAD JSValue offload = JS_NewObject(context); add_operation(offload, "uploadCoverage", 6, HostOffloadCoverage); + add_operation(offload, "uploadMesh", 1, HostOffloadMesh); + add_operation(offload, "releaseMesh", 1, HostOffloadReleaseImage); + add_operation(offload, "uploadImage", 1, HostOffloadImage); + add_operation(offload, "releaseImage", 1, HostOffloadReleaseImage); add_operation(offload, "session", 0, HostOffloadSession); add_operation(offload, "submit", 1, HostOffloadSubmit); add_operation(offload, "take", 0, HostOffloadTake); @@ -591,6 +620,8 @@ static void install_host(void) { add_operation(ui, "setText", 2, HostSetText); add_operation(ui, "replaceText", 2, HostReplaceText); add_operation(ui, "uploadTexture", 4, HostUploadTexture); + add_operation(ui, "setMesh", 2, HostSetMesh); + add_operation(ui, "freeMesh", 1, HostFreeMesh); add_operation(ui, "setImage", 2, HostSetImage); add_operation(ui, "setSprite", 5, HostSetSprite); add_operation(ui, "animate", 6, HostAnimate); @@ -788,18 +819,21 @@ bool qjs_frame( const uint32_t *touches, const int32_t *hits, size_t touch_count, - int32_t right_analog + int32_t right_analog, + uint32_t input_elapsed_us ) { if (context == NULL) return false; offload_frame(); coverage_used = false; - JSValue arguments[6] = { + image_used = false; + JSValue arguments[7] = { JS_NewInt32(context, buttons), JS_NewInt32(context, analog), JS_NewArray(context), JS_NewArray(context), JS_NewArray(context), JS_NewInt32(context, right_analog), + JS_NewUint32(context, input_elapsed_us), }; for (size_t index = 0; index < touch_count && index < 8; index += 1) { JS_SetPropertyUint32( @@ -817,8 +851,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, 6, arguments); - for (size_t index = 0; index < 6; index += 1) JS_FreeValue(context, arguments[index]); + JSValue result = JS_Call(context, frame_function, global, 7, arguments); + for (size_t index = 0; index < 7; index += 1) JS_FreeValue(context, arguments[index]); if (JS_IsException(result)) { take_exception(); JS_FreeValue(context, result); @@ -834,6 +868,7 @@ const char *qjs_last_error(void) { } void qjs_shutdown(void) { + offload_reset(); if (context != NULL) { JS_FreeValue(context, frame_function); JS_FreeValue(context, global); diff --git a/hosts/3ds/src/qjs.h b/hosts/3ds/src/qjs.h index e6fc9d123..3b45a2d90 100644 --- a/hosts/3ds/src/qjs.h +++ b/hosts/3ds/src/qjs.h @@ -27,7 +27,8 @@ bool qjs_frame( const uint32_t *touches, const int32_t *hits, size_t touch_count, - int32_t right_analog + int32_t right_analog, + uint32_t input_elapsed_us ); const char *qjs_last_error(void); void qjs_shutdown(void); diff --git a/hosts/psp/build.rs b/hosts/psp/build.rs index 64fcbd4e8..4f8c45b59 100644 --- a/hosts/psp/build.rs +++ b/hosts/psp/build.rs @@ -28,6 +28,8 @@ fn dimension(name: &str, fallback: u32) -> u32 { } fn main() { + println!("cargo:rerun-if-env-changed=POCKETJS_OFFLOAD_SLOT"); + println!("cargo:rustc-env=POCKETJS_OFFLOAD_SLOT={}", env::var("POCKETJS_OFFLOAD_SLOT").unwrap_or_default()); let legacy_app = env::var("POCKETJS_APP").unwrap_or_default(); let app = env::var("POCKETJS_APP_OUTPUT").unwrap_or_else(|_| legacy_app.clone()); let embed_app = match env::var("POCKETJS_EMBED_APP") { diff --git a/hosts/psp/src/analog.rs b/hosts/psp/src/analog.rs new file mode 100644 index 000000000..c7eab120f --- /dev/null +++ b/hosts/psp/src/analog.rs @@ -0,0 +1,101 @@ +//! Normalize the physical nub before applying the shared runtime deadzone. +//! Rest the nub at launch, or hold SELECT to repeat the bounded calibration. +pub struct Analog { + center: [u8; 2], + low: [u8; 2], + high: [u8; 2], + count: u8, + attempts: u8, + done: bool, + reset_held: bool, +} +impl Analog { + pub const fn new() -> Self { + Self { + center: [128; 2], + low: [255; 2], + high: [0; 2], + count: 0, + attempts: 0, + done: false, + reset_held: false, + } + } + pub fn sample(&mut self, x: u8, y: u8, recenter: bool, buttons: u32) -> u16 { + if recenter && !self.reset_held { + *self = Self::new(); + } + self.reset_held = recenter; + if !self.done { + self.attempts = self.attempts.saturating_add(1); + let values = [x, y]; + if buttons == 0 && values.iter().all(|&v| (80..=176).contains(&v)) { + for i in 0..2 { + self.low[i] = self.low[i].min(values[i]); + self.high[i] = self.high[i].max(values[i]); + } + if (0..2).any(|i| self.high[i] - self.low[i] > 6) { + self.low = values; + self.high = values; + self.count = 0; + } + self.count += 1; + if self.count >= 16 { + for i in 0..2 { + self.center[i] = ((self.low[i] as u16 + self.high[i] as u16) / 2) as u8; + } + self.done = true; + } + } else { + self.count = 0; + self.low = [255; 2]; + self.high = [0; 2]; + } + if self.attempts >= 40 { + self.done = true; + } + return 0x8080; + } + let axis = |v: u8, c: u8| -> u16 { + if v < c { + v as u16 * 128 / c as u16 + } else { + 128 + (v - c) as u16 * 127 / (255 - c) as u16 + } + }; + (axis(x, self.center[0]) << 8) | axis(y, self.center[1]) + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn biased_rest_and_full_travel() { + let mut a = Analog::new(); + for _ in 0..16 { + assert_eq!(a.sample(162, 133, false, 0), 0x8080); + } + assert_eq!(a.sample(162, 133, false, 0), 0x8080); + assert_eq!(a.sample(255, 0, false, 0), 0xff00); + assert_eq!(a.sample(0, 255, false, 0), 0x00ff); + } + #[test] + fn held_stick_does_not_calibrate_to_an_endpoint() { + let mut a = Analog::new(); + for _ in 0..40 { + a.sample(255, 128, false, 0); + } + assert_eq!(a.sample(255, 128, false, 0), 0xff80); + } + #[test] + fn explicit_recenter_is_edge_triggered() { + let mut a = Analog::new(); + for _ in 0..40 { + a.sample(128, 128, false, 0); + } + for _ in 0..20 { + a.sample(151, 130, true, 0); + } + assert_eq!(a.sample(151, 130, false, 0), 0x8080); + } +} diff --git a/hosts/psp/src/ffi.rs b/hosts/psp/src/ffi.rs index 1ba5f93d7..d1c165104 100644 --- a/hosts/psp/src/ffi.rs +++ b/hosts/psp/src/ffi.rs @@ -305,7 +305,8 @@ unsafe extern "C" fn js_free_texture( argc: i32, argv: *mut JSValue, ) -> JSValue { - ui().free_texture(arg_i32(ctx, argc, argv, 0)); + let handle=arg_i32(ctx,argc,argv,0); + crate::ge::free_texture(ui(),handle); JS_UNDEFINED } @@ -915,6 +916,98 @@ unsafe extern "C" fn js_audio_poll( } } + +unsafe extern "C" fn js_mesh_set( + ctx: *mut JSContext, + _: JSValue, + n: i32, + a: *mut JSValue, +) -> JSValue { + ui().set_mesh(arg_i32(ctx, n, a, 0), arg_i32(ctx, n, a, 1)); + JS_UNDEFINED +} +unsafe extern "C" fn js_mesh_free( + ctx: *mut JSContext, + _: JSValue, + n: i32, + a: *mut JSValue, +) -> JSValue { + crate::mesh::free(ui(), arg_i32(ctx, n, a, 0)); + JS_UNDEFINED +} +unsafe extern "C" fn js_offload_session( + ctx: *mut JSContext, + _: JSValue, + _: i32, + _: *mut JSValue, +) -> JSValue { + JS_NewInt32(ctx, crate::offload::session()) +} +unsafe extern "C" fn js_offload_submit( + ctx: *mut JSContext, + _: JSValue, + n: i32, + a: *mut JSValue, +) -> JSValue { + if n < 1 { + return JS_NewBool(ctx, false); + } + let mut len = 0; + let p = JS_ToCStringLen2(ctx, &mut len, *a, 0); + if p.is_null() { + return JS_NewBool(ctx, false); + } + let ok = crate::offload::submit(core::slice::from_raw_parts(p as *const u8, len)); + JS_FreeCString(ctx, p); + if ok { + JS_NewBool(ctx, true) + } else { + JS_NewBool(ctx, false) + } +} +unsafe extern "C" fn js_offload_take( + ctx: *mut JSContext, + _: JSValue, + _: i32, + _: *mut JSValue, +) -> JSValue { + match crate::offload::take() { + Some(s) => JS_NewStringLen(ctx, s.as_ptr(), s.len()), + None => JS_UNDEFINED, + } +} +unsafe extern "C" fn js_offload_mesh( + ctx: *mut JSContext, + _: JSValue, + n: i32, + a: *mut JSValue, +) -> JSValue { + JS_NewInt32( + ctx, + crate::offload::upload(arg_i32(ctx, n, a, 0) as u32, true, ui()), + ) +} +unsafe extern "C" fn js_offload_image( + ctx: *mut JSContext, + _: JSValue, + n: i32, + a: *mut JSValue, +) -> JSValue { + JS_NewInt32( + ctx, + crate::offload::upload(arg_i32(ctx, n, a, 0) as u32, false, ui()), + ) +} +unsafe extern "C" fn js_offload_release( + ctx: *mut JSContext, + _: JSValue, + n: i32, + a: *mut JSValue, +) -> JSValue { + crate::offload::release(arg_i32(ctx, n, a, 0) as u32); + JS_UNDEFINED +} + // --------------------------------------------------------------------------- // registration // --------------------------------------------------------------------------- @@ -942,6 +1035,21 @@ pub unsafe fn register( sprites: &[crate::pak::SpriteReg], ) { let ui_obj = JS_NewObject(ctx); + add_fn(ctx, ui_obj, b"setMesh\0", js_mesh_set, 2); + add_fn(ctx, ui_obj, b"freeMesh\0", js_mesh_free, 1); + if crate::offload::enabled() { + ui().set_mesh_commands(true); + crate::offload::start(); + let io = JS_NewObject(ctx); + add_fn(ctx, io, b"session\0", js_offload_session, 0); + add_fn(ctx, io, b"submit\0", js_offload_submit, 1); + add_fn(ctx, io, b"take\0", js_offload_take, 0); + add_fn(ctx, io, b"uploadMesh\0", js_offload_mesh, 1); + add_fn(ctx, io, b"uploadImage\0", js_offload_image, 1); + add_fn(ctx, io, b"releaseMesh\0", js_offload_release, 1); + add_fn(ctx, io, b"releaseImage\0", js_offload_release, 1); + JS_SetPropertyStr(ctx, global, b"offload\0".as_ptr() as *const _, io); + } add_fn(ctx, ui_obj, b"createNode\0", js_create_node, 1); add_fn(ctx, ui_obj, b"destroyNode\0", js_destroy_node, 1); diff --git a/hosts/psp/src/ge.rs b/hosts/psp/src/ge.rs index 548f1bdc4..5ad04c822 100644 --- a/hosts/psp/src/ge.rs +++ b/hosts/psp/src/ge.rs @@ -481,6 +481,13 @@ pub unsafe fn render_over(ui: &Ui, words: &[u32]) { let mut i = 0usize; while i < n { match words[i] { + spec::draw_op::MESH => { + if i + 10 > n { break; } + crate::mesh::draw(&words[i..i + 10]); + if let Some(&(x, y, w, h)) = scissors.last() { sys::sceGuScissor(x, y, x + w, y + h); } + i += 10; + } + // The `i + N <= n` guards make truncated tails fall through to the // default `break` arm instead of spinning forever with count = 0. spec::draw_op::RECT if i + 4 <= n => { @@ -820,3 +827,11 @@ pub unsafe fn render_over(ui: &Ui, words: &[u32]) { sys::sceGuDisable(GuState::Texture2D); sys::sceGuScissor(0, 0, SCREEN_WIDTH as i32, SCREEN_HEIGHT as i32); } + +// Previous-frame GE commands may still sample a texture while JS disposes it. +// Defer the core allocation's release to the display-list retirement point. +static mut RETIRED_TEXTURES: Vec = Vec::new(); +pub unsafe fn free_texture(ui: &mut Ui, handle: i32){ + if let Some(texture) = ui.take_texture(handle) { RETIRED_TEXTURES.push(texture); } +} +pub unsafe fn retire_textures() { RETIRED_TEXTURES.clear(); } diff --git a/hosts/psp/src/lib.rs b/hosts/psp/src/lib.rs index cf9595852..452b7839b 100644 --- a/hosts/psp/src/lib.rs +++ b/hosts/psp/src/lib.rs @@ -36,3 +36,9 @@ pub mod svc; pub mod switch; pub mod veil; pub mod vid; + +pub mod offload; +pub mod offload_packet; +pub mod mesh; + +pub mod analog; diff --git a/hosts/psp/src/main.rs b/hosts/psp/src/main.rs index c3181e313..6308c5a17 100644 --- a/hosts/psp/src/main.rs +++ b/hosts/psp/src/main.rs @@ -531,7 +531,7 @@ unsafe fn run_guest( static mut DBG_PROBED: bool = false; if !DBG_PROBED { DBG_PROBED = true; - if dbg::init() { + if !pocketjs_psp::offload::enabled() && dbg::init() { trace("run: devtools mailbox active"); } } @@ -613,6 +613,9 @@ unsafe fn run_guest( // frame — and it spans guest swaps (see its doc). `guest_frame` is only // this guest's boot-trace / bench / present-skip counter. let mut guest_frame: u32 = 0; + let mut input_tick = 0u32; + #[cfg(not(feature = "capture"))] + let mut nub = pocketjs_psp::analog::Analog::new(); loop { #[cfg(feature = "bench")] let bench_frame_start = bench_now_us(); @@ -649,14 +652,23 @@ unsafe fn run_guest( // Analog nub packed (x << 8) | y, each axis 0..255 with 128 = center // (spec.ts "frame(buttons, analog)"; SceCtrlData names the axes lx/ly). #[cfg(not(feature = "capture"))] - let analog = (((pad.lx as u32) << 8) | pad.ly as u32) as i32; + let analog = if pocketjs_psp::offload::enabled() { + nub.sample(pad.lx, pad.ly, mask & spec::btn::SELECT as i32 != 0, + mask as u32 & !spec::btn::SELECT) as i32 + } else { (((pad.lx as u32) << 8) | pad.ly as u32) as i32 }; // The baked input script has no analog track: pin the nub to center // so scripted PPSSPPHeadless captures stay deterministic. #[cfg(feature = "capture")] let analog = pocketjs_core::spec::ANALOG_CENTER as i32; - let mut args = [JS_NewInt32(ctx, mask), JS_NewInt32(ctx, analog)]; - let r = JS_Call(ctx, frame_fn, global, 2, args.as_mut_ptr()); + let now = sys::sceKernelGetSystemTimeLow(); + let elapsed = if input_tick == 0 || cfg!(feature = "capture") { 0 } + else { now.wrapping_sub(input_tick).min(66666) }; + input_tick = now; + pocketjs_psp::offload::frame(mask as u32,analog as u32); + let mut args = [JS_NewInt32(ctx, mask), JS_NewInt32(ctx, analog), + JS_UNDEFINED, JS_UNDEFINED, JS_UNDEFINED, JS_UNDEFINED, JS_NewInt32(ctx, elapsed as i32)]; + let r = JS_Call(ctx, frame_fn, global, 7, args.as_mut_ptr()); #[cfg(feature = "bench")] let bench_after_js = bench_now_us(); if guest_frame == 0 { @@ -670,6 +682,7 @@ unsafe fn run_guest( trace("frame 0: JS return freed"); } + let after_js = sys::sceKernelGetSystemTimeLow(); host::drain_jobs(rt); // Arena-pressure GC (post-profiler-stub this WORKS: the guest's // per-frame cycles are collectable once no WeakMap pins them, and the @@ -738,11 +751,14 @@ unsafe fn run_guest( // guest's eval instead of flashing the two-frames-stale draw buffer. // Cold boot (GLOBAL_FRAME == 0) keeps the original present-first // behavior so single-app builds are bit-identical to before. + let cpu_before_present = sys::sceKernelGetSystemTimeLow().wrapping_sub(now); let present = GLOBAL_FRAME == 0 || guest_frame > 0; #[cfg(feature = "bench")] let bench_before_sync = bench_now_us(); if present { + let sync_start = sys::sceKernelGetSystemTimeLow(); sys::sceGuSync(GuSyncMode::Finish, GuSyncBehavior::Wait); + pocketjs_psp::offload::gpu_wait(sys::sceKernelGetSystemTimeLow().wrapping_sub(sync_start)); #[cfg(feature = "bench")] bench_record_gpu(guest_frame, bench_now_us().saturating_sub(bench_before_sync)); if guest_frame == 0 { @@ -782,6 +798,9 @@ unsafe fn run_guest( vid::close(ffi::ui()); // stops audio, frees the plane (Ui alive) audio_mod::reset(); // guest-scoped streams die with their guest svc::reset(); + pocketjs_psp::offload::reset(); + pocketjs_psp::mesh::reset(); + ge::retire_textures(); JS_FreeValue(ctx, frame_fn); JS_FreeValue(ctx, global); JS_FreeContext(ctx); @@ -795,7 +814,10 @@ unsafe fn run_guest( // arena [R] and open frame N's list. The video plane commits its // staged frame here too — the ONLY window where the GE is not // sampling the texture it overwrites in place (vid.rs). + let render_start = sys::sceKernelGetSystemTimeLow(); ge::reset_pool(); + pocketjs_psp::mesh::retire(); + ge::retire_textures(); vid::present(ffi::ui()); if guest_frame == 0 { trace("frame 0: pool reset ok"); @@ -821,6 +843,10 @@ unsafe fn run_guest( if guest_frame == 0 { trace("frame 0: rendered"); } + pocketjs_psp::offload::stages(after_js.wrapping_sub(now), + cpu_before_present.saturating_sub(after_js.wrapping_sub(now)), + sys::sceKernelGetSystemTimeLow().wrapping_sub(render_start)); + pocketjs_psp::offload::timing(cpu_before_present + sys::sceKernelGetSystemTimeLow().wrapping_sub(render_start)); sys::sceGuFinish(); // kick list N — the GE draws while frame N+1's CPU runs if guest_frame == 0 { trace("frame 0: gu finish (kicked) ok"); diff --git a/hosts/psp/src/mesh.rs b/hosts/psp/src/mesh.rs new file mode 100644 index 000000000..d0749902d --- /dev/null +++ b/hosts/psp/src/mesh.rs @@ -0,0 +1,172 @@ +//! Immutable GE vertex buffers; native mesh handles own residency. Retired +//! vertices survive until the previous display list completes. +use alloc::vec::Vec; +use core::ffi::c_void; +use pocketjs_core::Ui; +use psp::sys::{ + self, GuPrimitive, GuState, MatrixMode, ScePspFMatrix4, ScePspFVector4, VertexType, +}; +#[repr(C, align(16))] +#[derive(Clone, Copy)] +struct Vertex { + color: u32, + x: f32, + y: f32, + z: f32, +} +struct Buffer { + handle: i32, + vertices: Vec, +} +static mut LIVE: [Option; 128] = [const { None }; 128]; +static mut RETIRED: Vec = Vec::new(); +static mut BYTES: usize = 0; +fn allocation_bytes(count: usize) -> usize { + if count == 0 { + 0 + } else { + (count * 16).max(16).next_power_of_two() + } +} +const MAX_BYTES: usize = 4 * 1024 * 1024; +pub unsafe fn reset() { + for slot in &mut LIVE { + *slot = None; + } + RETIRED.clear(); + BYTES = 0; +} +pub unsafe fn retire() { + for b in RETIRED.drain(..) { + BYTES -= allocation_bytes(b.vertices.capacity()); + } +} +pub unsafe fn free(ui: &mut Ui, handle: i32) { + if handle < 0 { + return; + } + let i = (handle as usize) & 127; + if LIVE[i].as_ref().map(|b| b.handle) == Some(handle) { + RETIRED.push(LIVE[i].take().unwrap()); + } + ui.free_mesh(handle); +} +pub unsafe fn upload(ui: &mut Ui, bytes: &[u8]) -> i32 { + let handle = ui.upload_mesh(bytes); + if handle < 0 { + return -1; + } + let m = ui.mesh(handle).unwrap(); + let count = m.triangles.len() * 3; + if BYTES + allocation_bytes(count) > MAX_BYTES { + ui.free_mesh(handle); + return -1; + } + let mut vertices = Vec::with_capacity(count); + for t in &m.triangles { + for &id in &t.indices { + let p = m.vertices[id as usize]; + vertices.push(Vertex { + color: t.color, + x: p[0] as f32 / 16.0, + y: p[1] as f32 / 16.0, + z: 0.0, + }); + } + } + sys::sceKernelDcacheWritebackRange( + vertices.as_ptr() as *const c_void, + (vertices.len() * 16) as u32, + ); + BYTES += allocation_bytes(vertices.capacity()); + LIVE[(handle as usize) & 127] = Some(Buffer { handle, vertices }); + handle +} +fn matrix(a: [[f32; 4]; 4]) -> ScePspFMatrix4 { + ScePspFMatrix4 { + x: ScePspFVector4 { + x: a[0][0], + y: a[0][1], + z: a[0][2], + w: a[0][3], + }, + y: ScePspFVector4 { + x: a[1][0], + y: a[1][1], + z: a[1][2], + w: a[1][3], + }, + z: ScePspFVector4 { + x: a[2][0], + y: a[2][1], + z: a[2][2], + w: a[2][3], + }, + w: ScePspFVector4 { + x: a[3][0], + y: a[3][1], + z: a[3][2], + w: a[3][3], + }, + } +} +pub unsafe fn draw(words: &[u32]) { + let handle = words[1] as i32; + if handle < 0 { + return; + } + let Some(b) = &LIVE[(handle as usize) & 127] else { + return; + }; + if b.handle != handle || b.vertices.is_empty() { + return; + } + let a = f32::from_bits(words[2]); + let by = f32::from_bits(words[3]); + let c = f32::from_bits(words[4]); + let d = f32::from_bits(words[5]); + let tx = f32::from_bits(words[6]); + let ty = f32::from_bits(words[7]); + let x = (words[8] as u16) as i16 as i32; + let y = ((words[8] >> 16) as u16) as i16 as i32; + let w = (words[9] & 65535) as i32; + let h = (words[9] >> 16) as i32; + sys::sceGuScissor(x.max(0), y.max(0), (x + w).min(480), (y + h).min(272)); + sys::sceGuDisable(GuState::Texture2D); + sys::sceGuDisable(GuState::DepthTest); + sys::sceGuDisable(GuState::CullFace); + let projection = matrix([ + [2.0 / 480.0, 0.0, 0.0, 0.0], + [0.0, -2.0 / 272.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [-1.0, 1.0, 0.0, 1.0], + ]); + let identity = matrix([ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]); + let model = matrix([ + [a, by, 0.0, 0.0], + [c, d, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [tx, ty, 0.0, 1.0], + ]); + sys::sceGuSetMatrix(MatrixMode::Projection, &projection); + sys::sceGuSetMatrix(MatrixMode::View, &identity); + sys::sceGuSetMatrix(MatrixMode::Model, &model); + let vtype = VertexType::from_bits_truncate( + VertexType::COLOR_8888.bits() + | VertexType::VERTEX_32BITF.bits() + | VertexType::TRANSFORM_3D.bits(), + ); + sys::sceGuDrawArray( + GuPrimitive::Triangles, + vtype, + b.vertices.len() as i32, + core::ptr::null(), + b.vertices.as_ptr() as *const c_void, + ); + sys::sceGuScissor(0, 0, 480, 272); +} diff --git a/hosts/psp/src/offload.rs b/hosts/psp/src/offload.rs new file mode 100644 index 000000000..256c6889d --- /dev/null +++ b/hosts/psp/src/offload.rs @@ -0,0 +1,430 @@ +//! Bounded offload over PSPLINK host0. The lower-priority native worker owns +//! every file operation; UI entry points only exchange fixed slots. The +//! worker never touches QuickJS, Ui, GE, or the single-thread global allocator. +use crate::offload_packet as pkt; +use core::{ + cell::UnsafeCell, + ffi::c_void, + sync::atomic::{ + AtomicU32, + Ordering::{Acquire, Relaxed, Release}, + }, +}; +use psp::sys::{self, IoOpenFlags, SceUid}; +const N: usize = 8; +// FREE -> QUEUED -> SENT -> READY -> BORROWED -> FREE. The UI publishes +// requests as QUEUED; the worker owns QUEUED/SENT even across USB stalls. +const FREE: u32 = 0; +const QUEUED: u32 = 1; +const SENT: u32 = 2; +const READY: u32 = 3; +const BORROWED: u32 = 4; +#[repr(C, align(16))] +struct SlotData { + generation: u32, + seq: u32, + len: usize, + req: [u8; 4096], + header: [u8; 64], + data: [u8; 131072], +} +struct Slot { + state: AtomicU32, + data: UnsafeCell, +} +// A release/acquire state transition transfers exclusive ownership of data. +// UI accesses FREE/READY/BORROWED, the worker accesses QUEUED/SENT only. +unsafe impl Sync for Slot {} +impl Slot { + const fn new() -> Self { + Self { + state: AtomicU32::new(FREE), + data: UnsafeCell::new(SlotData { + generation: 0, + seq: 0, + len: 0, + req: [0; 4096], + header: [0; 64], + data: [0; 131072], + }), + } + } +} +static SLOTS: [Slot; N] = [const { Slot::new() }; N]; +static LAST_CONTACT: AtomicU32 = AtomicU32::new(0); +static SESSION: AtomicU32 = AtomicU32::new(0); +static RESET: AtomicU32 = AtomicU32::new(0); +static FRAMES: AtomicU32 = AtomicU32::new(0); +static CPU: AtomicU32 = AtomicU32::new(0); +static MAX_CPU: AtomicU32 = AtomicU32::new(0); +static LATE: AtomicU32 = AtomicU32::new(0); +static STAGES: [AtomicU32; 3] = [const { AtomicU32::new(0) }; 3]; +pub fn stages(js: u32, core: u32, ge: u32) { + for (s, v) in STAGES.iter().zip([js, core, ge]) { + s.store(v, Relaxed); + } +} +static GPU_WAIT: AtomicU32 = AtomicU32::new(0); +pub fn gpu_wait(us: u32) { + GPU_WAIT.store(us, Relaxed); +} +static BUTTONS: AtomicU32 = AtomicU32::new(0); +static ANALOG: AtomicU32 = AtomicU32::new(0); +static mut ROOT: [u8; 128] = [0; 128]; +static mut ROOT_LEN: usize = 0; +static mut BOOT: u32 = 0; +static mut SEQUENCE: u32 = 0; +static mut STARTED: bool = false; +static mut SENT_FRAME: u32 = 0; +static mut TAKEN_FRAME: u32 = u32::MAX; +static mut UPLOADED_FRAME: u32 = u32::MAX; +pub fn enabled() -> bool { + !env!("POCKETJS_OFFLOAD_SLOT").is_empty() +} +pub unsafe fn start() { + if STARTED || !enabled() { + return; + } + STARTED = true; + BOOT = sys::sceKernelGetSystemTimeLow().max(1); + let root = alloc::format!("host0:/pocket-offload/{}/", env!("POCKETJS_OFFLOAD_SLOT")); + ROOT_LEN = root.len(); + ROOT[..ROOT_LEN].copy_from_slice(root.as_bytes()); + let th = sys::sceKernelCreateThread( + b"pocket-offload\0".as_ptr(), + worker, + 0x30, + 32768, + sys::ThreadAttributes::USER, + core::ptr::null_mut(), + ); + if th.0 >= 0 { + sys::sceKernelStartThread(th, 0, core::ptr::null_mut()); + } +} +pub fn session() -> i32 { + if unsafe { sys::sceKernelGetSystemTimeLow() }.wrapping_sub(LAST_CONTACT.load(Acquire)) + > 3000000 + { + 0 + } else { + SESSION.load(Acquire) as i32 + } +} +pub fn frame(buttons: u32, analog: u32) { + FRAMES.fetch_add(1, Relaxed); + BUTTONS.store(buttons, Relaxed); + ANALOG.store(analog, Relaxed); +} +pub fn timing(us: u32) { + CPU.store(us, Relaxed); + MAX_CPU.fetch_max(us, Relaxed); + if us > 16667 { + LATE.fetch_add(1, Relaxed); + } +} +pub unsafe fn reset() { + SESSION.store(0, Release); + for slot in &SLOTS { + let state = slot.state.load(Acquire); + if state == READY || state == BORROWED { + slot.state.store(FREE, Release); + } + } + RESET.fetch_add(1, Release); +} +pub unsafe fn submit(bytes: &[u8]) -> bool { + if session() <= 0 || bytes.is_empty() || bytes.len() > 4096 { + return false; + } + let f = FRAMES.load(Relaxed); + if SENT_FRAME == f { + return false; + } + for slot in &SLOTS { + if slot.state.load(Acquire) == FREE { + let s = &mut *slot.data.get(); + s.generation = SESSION.load(Acquire); + SEQUENCE = SEQUENCE.wrapping_add(1).max(1); + s.seq = SEQUENCE; + s.len = bytes.len(); + s.req[..bytes.len()].copy_from_slice(bytes); + slot.state.store(QUEUED, Release); + SENT_FRAME = f; + return true; + } + } + false +} +pub unsafe fn take() -> Option { + let f = FRAMES.load(Relaxed); + if TAKEN_FRAME == f { + return None; + } + for slot in &SLOTS { + if slot.state.load(Acquire) == READY { + let s = &*slot.data.get(); + if s.generation != SESSION.load(Acquire) { + slot.state.store(FREE, Release); + continue; + } + TAKEN_FRAME = f; + let kind = pkt::word(&s.header, 16); + let len = pkt::word(&s.header, 32) as usize; + if kind == 0 { + let value = core::str::from_utf8(&s.data[..len]) + .ok() + .map(alloc::string::String::from); + slot.state.store(FREE, Release); + return value; + } + slot.state.store(BORROWED, Release); + return Some(alloc::format!( + "{{\"id\":{},\"{}\":{{\"token\":{},\"width\":{},\"height\":{},\"bytes\":{}}}}}", + pkt::word(&s.header, 20), + if kind == 1 { "mesh" } else { "image" }, + s.seq, + pkt::word(&s.header, 24), + pkt::word(&s.header, 28), + len + )); + } + } + None +} +pub unsafe fn release(token: u32) { + for slot in &SLOTS { + if slot.state.load(Acquire) == BORROWED && (*slot.data.get()).seq == token { + slot.state.store(FREE, Release); + return; + } + } +} +pub unsafe fn upload(token: u32, mesh: bool, ui: &mut pocketjs_core::Ui) -> i32 { + let f = FRAMES.load(Relaxed); + if UPLOADED_FRAME == f { + return -1; + } + UPLOADED_FRAME = f; + for slot in &SLOTS { + if slot.state.load(Acquire) == BORROWED && (*slot.data.get()).seq == token { + let s = &*slot.data.get(); + let kind = pkt::word(&s.header, 16); + let bytes = &s.data[..pkt::word(&s.header, 32) as usize]; + if mesh && kind == 1 { + return crate::mesh::upload(ui, bytes); + } + if !mesh && kind == 2 { + let w = pkt::word(&s.header, 24); + let h = pkt::word(&s.header, 28); + let handle = ui.upload_texture(bytes, w, h, pocketjs_core::spec::psm::PSM_5650); + if handle >= 0 { + crate::ge::writeback_texture(ui, handle); + } + return handle; + } + } + } + -1 +} +unsafe fn path(name: &[u8]) -> [u8; 160] { + let mut b = [0; 160]; + b[..ROOT_LEN].copy_from_slice(&ROOT[..ROOT_LEN]); + b[ROOT_LEN..ROOT_LEN + name.len()].copy_from_slice(name); + b +} +unsafe fn read(fd: SceUid, b: &mut [u8]) -> bool { + let mut n = 0; + while n < b.len() { + let k = sys::sceIoRead( + fd, + b.as_mut_ptr().add(n) as *mut c_void, + (b.len() - n).min(16384) as u32, + ); + if k <= 0 { + return false; + } + n += k as usize; + } + true +} +unsafe fn write(fd: SceUid, b: &[u8]) -> bool { + let mut n = 0; + while n < b.len() { + let k = sys::sceIoWrite( + fd, + b.as_ptr().add(n) as *const c_void, + (b.len() - n).min(16384), + ); + if k <= 0 { + return false; + } + n += k as usize; + } + true +} +unsafe fn open(name: &[u8], write: bool) -> SceUid { + let p = path(name); + sys::sceIoOpen( + p.as_ptr(), + if write { + IoOpenFlags::WR_ONLY | IoOpenFlags::CREAT | IoOpenFlags::TRUNC + } else { + IoOpenFlags::RD_ONLY + }, + 0o666, + ) +} +unsafe extern "C" fn worker(_: usize, _: *mut c_void) -> i32 { + let mut epoch = 0; + let mut beat = 0; + let mut last_beat = 0; + let mut last_poll = 0; + let mut reset = 0; + let mut generation = 0; + let mut stats_at = 0; + loop { + let now = sys::sceKernelGetSystemTimeLow(); + let wanted = RESET.load(Acquire); + if wanted != reset { + reset = wanted; + epoch = 0; + for slot in &SLOTS { + let state = slot.state.load(Acquire); + if state == QUEUED || state == SENT { + slot.state.store(FREE, Release); + } + } + } + if now.wrapping_sub(last_poll) >= 50000 { + last_poll = now; + let fd = open(b"ready", false); + let mut h = [0; 64]; + let ok = fd.0 >= 0 && read(fd, &mut h); + if fd.0 >= 0 { + sys::sceIoClose(fd); + } + if ok && pkt::word(&h, 0) == pkt::MAGIC { + if pkt::word(&h, 4) != epoch { + epoch = pkt::word(&h, 4); + SESSION.store(0, Release); + generation = (generation + 1) & 0x7fffffff; + if generation == 0 { + generation = 1; + } + for slot in &SLOTS { + let state = slot.state.load(Acquire); + if state == QUEUED || state == SENT { + slot.state.store(FREE, Release); + } + } + SESSION.store(generation, Release); + } + if pkt::word(&h, 8) != beat { + beat = pkt::word(&h, 8); + last_beat = now; + LAST_CONTACT.store(now, Release); + } + } + if !ok || now.wrapping_sub(last_beat) > 3000000 { + SESSION.store(0, Release); + epoch = 0; + } + } + if epoch != 0 { + for (i, slot) in SLOTS.iter().enumerate() { + let state = slot.state.load(Acquire); + if state != QUEUED && state != SENT { + continue; + } + let s = &mut *slot.data.get(); + if s.generation != generation { + slot.state.store(FREE, Release); + continue; + } + if state == QUEUED { + let mut h = [0; 64]; + for (p, v) in [ + (0, pkt::MAGIC), + (4, epoch), + (8, BOOT), + (12, s.seq), + (32, s.len as u32), + (36, pkt::hash(&s.req[..s.len])), + ] { + pkt::put(&mut h, p, v); + } + let fd = open(&[b'r', b'e', b'q', b'0' + i as u8], true); + let ok = fd.0 >= 0 && write(fd, &h) && write(fd, &s.req[..s.len]); + if fd.0 >= 0 { + sys::sceIoClose(fd); + } + if ok { + slot.state.store(SENT, Release); + } else { + SESSION.store(0, Release); + epoch = 0; + break; + } + } + if slot.state.load(Acquire) == SENT { + let fd = open(&[b'r', b'e', b's', b'0' + i as u8], false); + if fd.0 < 0 { + continue; + } + let mut h = [0; 64]; + let ok = read(fd, &mut h) && pkt::valid(&h, epoch, BOOT, s.seq); + if ok { + let len = pkt::word(&h, 32) as usize; + if read(fd, &mut s.data[..len]) + && pkt::hash(&s.data[..len]) == pkt::word(&h, 36) + { + s.header = h; + slot.state.store(READY, Release); + } + } + sys::sceIoClose(fd); + } + } + if now.wrapping_sub(stats_at) > 2000000 { + stats_at = now; + let mut b = [0u8; 256]; + let mut cursor = 0; + for (key, value) in [ + (b"frames=" as &[u8], FRAMES.load(Relaxed)), + (b" cpuUs=", CPU.load(Relaxed)), + (b" maxCpuUs=", MAX_CPU.load(Relaxed)), + (b" late=", LATE.load(Relaxed)), + (b" buttons=", BUTTONS.load(Relaxed)), + (b" analog=", ANALOG.load(Relaxed)), + (b" jsUs=", STAGES[0].load(Relaxed)), + (b" coreUs=", STAGES[1].load(Relaxed)), + (b" geUs=", STAGES[2].load(Relaxed)), + (b" gpuWaitUs=", GPU_WAIT.load(Relaxed)), + ] { + b[cursor..cursor + key.len()].copy_from_slice(key); + cursor += key.len(); + let mut digits = [0u8; 10]; + let mut v = value; + let mut n = 10; + loop { + n -= 1; + digits[n] = b'0' + (v % 10) as u8; + v /= 10; + if v == 0 { + break; + } + } + b[cursor..cursor + 10 - n].copy_from_slice(&digits[n..]); + cursor += 10 - n; + } + let fd = open(b"stats", true); + if fd.0 >= 0 { + write(fd, &b[..cursor]); + sys::sceIoClose(fd); + } + } + } + sys::sceKernelDelayThread(10000); + } +} diff --git a/hosts/psp/src/offload_packet.rs b/hosts/psp/src/offload_packet.rs new file mode 100644 index 000000000..2124418e7 --- /dev/null +++ b/hosts/psp/src/offload_packet.rs @@ -0,0 +1,76 @@ +//! Fixed USB mailbox envelopes. No allocator or platform calls. +pub const MAGIC: u32 = 0x31424f50; +pub const HEADER: usize = 64; +pub const JSON_MAX: usize = 4096; +pub const DATA_MAX: usize = 131072; +pub fn word(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) +} +pub fn put(b: &mut [u8], at: usize, value: u32) { + b[at..at + 4].copy_from_slice(&value.to_le_bytes()); +} +pub fn hash(b: &[u8]) -> u32 { + b.iter() + .fold(2166136261, |h, v| (h ^ *v as u32).wrapping_mul(16777619)) +} +pub fn valid(b: &[u8], epoch: u32, boot: u32, seq: u32) -> bool { + if b.len() != HEADER + || word(b, 0) != MAGIC + || word(b, 4) != epoch + || word(b, 8) != boot + || word(b, 12) != seq + { + return false; + } + let (kind, n, w, h) = (word(b, 16), word(b, 32) as usize, word(b, 24), word(b, 28)); + match kind { + 0 => n > 0 && n <= JSON_MAX, + 1 => n >= 16 && n <= 36880 && w > 0 && w <= 4095 && h > 0 && h <= 4095, + 2 => { + w >= 16 + && w <= 256 + && h >= 16 + && h <= 256 + && w.is_power_of_two() + && h.is_power_of_two() + && n == w as usize * h as usize * 2 + } + _ => false, + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn admission_and_generations() { + let mut h = [0; HEADER]; + for (p, v) in [ + (0, MAGIC), + (4, 1), + (8, 2), + (12, 3), + (16, 1), + (24, 256), + (28, 256), + (32, 36880), + ] { + put(&mut h, p, v); + } + assert!(valid(&h, 1, 2, 3)); + assert!(!valid(&h, 2, 2, 3)); + assert!(!valid(&h, 1, 2, 4)); + put(&mut h, 32, 36881); + assert!(!valid(&h, 1, 2, 3)); + put(&mut h, 16, 2); + put(&mut h, 24, 256); + put(&mut h, 28, 256); + put(&mut h, 32, 131072); + assert!(valid(&h, 1, 2, 3)); + put(&mut h, 24, 257); + assert!(!valid(&h, 1, 2, 3)); + put(&mut h, 16, 0); + put(&mut h, 32, 4097); + assert!(!valid(&h, 1, 2, 3)); + assert_eq!(hash(b"hello"), 0x4f9f2cab); + } +} diff --git a/hosts/web/wasm-ops.js b/hosts/web/wasm-ops.js index 5b0afecfd..05f2bbd06 100644 --- a/hosts/web/wasm-ops.js +++ b/hosts/web/wasm-ops.js @@ -91,6 +91,9 @@ export async function createWasmUi(wasm, options = {}) { setText: (id, str) => withStr(str, (p, l) => ex.ui_set_text(id, p, l)), replaceText: (id, str) => withStr(str, (p, l) => ex.ui_replace_text(id, p, l)), uploadTexture: (buf, w, h, psm) => withBytes(buf, (p, l) => ex.ui_upload_texture(p, l, w, h, psm)), + setMesh: (id, mesh) => ex.ui_set_mesh(id, mesh), + freeMesh: handle => ex.ui_free_mesh(handle), + uploadMesh: bytes => withBytes(bytes, (p,l) => ex.ui_upload_mesh(p,l)), setImage: (id, tex) => ex.ui_set_image(id, tex), setCompositorSurface: (id, surface, focused) => { if (surface < 0) compositorBindings.delete(id); diff --git a/package.json b/package.json index ab7d27594..ec17349e9 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "./resource-cache": "./framework/src/resource-cache.ts", "./resource-offload": "./framework/src/resource-offload.ts", "./resource-view": "./framework/src/resource-view.ts", + "./tile-viewport": "./framework/src/tile-viewport.ts", "./resource": "./framework/src/resource.ts", "./audio": "./framework/src/audio-api.ts", "./clock": "./framework/src/clock.ts", @@ -186,6 +187,7 @@ "./vue-vapor/resource-state": "./framework/src/resource-state.ts", "./vue-vapor/resource-cache": "./framework/src/resource-cache.ts", "./vue-vapor/resource-offload": "./framework/src/resource-offload.ts", + "./vue-vapor/tile-viewport": "./framework/src/tile-viewport.ts", "./vue-vapor/audio": "./framework/src/audio-api.ts", "./vue-vapor/clock": "./framework/src/clock.ts", "./vue-vapor/db": "./framework/src/db-api.ts", @@ -205,6 +207,7 @@ "./octane/resource-state": "./framework/src/resource-state.ts", "./octane/resource-cache": "./framework/src/resource-cache.ts", "./octane/resource-offload": "./framework/src/resource-offload.ts", + "./octane/tile-viewport": "./framework/src/tile-viewport.ts", "./octane/audio": "./framework/src/audio-api.ts", "./octane/clock": "./framework/src/clock.ts", "./octane/db": "./framework/src/db-api.ts", diff --git a/site/content/docs/components.md b/site/content/docs/components.md index 5743f8628..adb2ec7dd 100644 --- a/site/content/docs/components.md +++ b/site/content/docs/components.md @@ -498,6 +498,7 @@ mask. Its `active` property uses the blue header palette, and `headerHeight` defaults to 27 logical pixels. `ClassicSheet` accepts `open`, `title`, `message`, up to four `actions`, +`cancelDisabled` for a command awaiting confirmation, `cancelLabel` and `onCancel`. Each action supplies a label, tone and callback. **The host animates the panel translation and backdrop opacity.** The component keeps its fixed action subtree mounted and blocks other touch gestures until diff --git a/site/content/docs/native-contract.md b/site/content/docs/native-contract.md index d41fcf1fa..9bb58e27f 100644 --- a/site/content/docs/native-contract.md +++ b/site/content/docs/native-contract.md @@ -161,7 +161,7 @@ guest and forwards each op to a caller-owned core; see [ESP-IDF](/docs/esp-idf/). Every host drives frames through -`globalThis.frame(buttons, analog?, touches?, hits?, touchSurfaces?)`. Buttons use the shared PSP +`globalThis.frame(buttons, analog?, touches?, hits?, touchSurfaces?, rightAnalog?, inputElapsedUs?)`. Buttons use the shared PSP bitmask, analog is `(x << 8) | y` with centered bytes on stickless hosts, and touch contacts are packed snapshots in logical coordinates. `hits` carries parallel down-edge hit facts; `touchSurfaces` uses `0` for primary and `1` for @@ -170,6 +170,13 @@ these inputs before app hooks, then performs input edge detection and the end-of-frame sweep. See [Input & focus](/docs/input-focus/) and [Platform contracts](/docs/platform-contracts/). +`rightAnalog` is the packed optional second stick. **`inputElapsedUs` carries +a bounded input-sampling duration**, in microseconds, for velocity-driven +interaction through `@pocketjs/framework/clock`'s `inputDeltaSeconds()`. The 3DS +host supplies it from its monotonic counter; captures and hosts that omit it +use the nominal simulation step. It does not add ticks or resource pumps. +The flight recorder includes this input in v4 tapes. + ## Frame order Native hosts run one deterministic sequence per display frame. Web and Bun @@ -179,7 +186,7 @@ hosts perform the same logical steps under a fixed-step ``` read host input buttons + optional analog/touch snapshot ↓ -frame(buttons, analog?, touches?, hits?, touchSurfaces?) +frame(buttons, analog?, touches?, hits?, touchSurfaces?, rightAnalog?, inputElapsedUs?) ── JS ──► advance virtual time, latch input, run service pumps, deliver queued effects, resolve contact lifecycles (gestures), @@ -226,7 +233,7 @@ Key properties: In steady state — no reactive values changed — `frame()` emits **no** mutation ops, the sweep set is empty, and the only JS boundary crossing is the single -`frame(buttons, analog?, touches?, hits?, touchSurfaces?)` call itself. +`frame(buttons, analog?, touches?, hits?, touchSurfaces?, rightAnalog?, inputElapsedUs?)` call itself. Everything downstream (tick, layout, draw) is Rust. ## Node reclamation @@ -289,7 +296,7 @@ The whole design converges on a small steady-state cost: | budget | target | |---|---| -| FFI crossings per steady frame | **one** (`frame(buttons, analog?, touches?, hits?, touchSurfaces?)`; no mutation ops when nothing changed) | +| FFI crossings per steady frame | **one** (`frame(buttons, analog?, touches?, hits?, touchSurfaces?, rightAnalog?, inputElapsedUs?)`; no mutation ops when nothing changed) | | DrawList draw calls | **≤ ~40** `sceGuDrawArray` calls | | DrawList quads | **≤ ~2000** | | per-frame vertex bytes | **≈ 48 KB** from a per-frame bump pool (reset after `sceGuSync`) | diff --git a/site/content/docs/touch-gestures.md b/site/content/docs/touch-gestures.md index 957db549b..0a47856b9 100644 --- a/site/content/docs/touch-gestures.md +++ b/site/content/docs/touch-gestures.md @@ -352,3 +352,33 @@ tape tool in [DevTools](/docs/devtools/). - [Platform contracts](/docs/platform-contracts/) — declaring `input.touch` and guarding optional enhancements. - [DevTools](/docs/devtools/) — recording and replaying input tapes. + +## Filter quantized drag coordinates + +`createDragFilter` consumes **total travel since contact-down**, once per pan +frame. A one-pixel hysteresis rejects stationary quantization; an adaptive +response limits smoothing lag to three pixels beyond that hysteresis. Sampling +uses simulation time and constant-size state. The filter changes neither +recognition nor the raw touch snapshot. + +```ts +import { createGesture, createDragFilter } from "@pocketjs/framework/gesture"; +import { simulationHz } from "@pocketjs/framework/clock"; + +const filter = createDragFilter({ deadband: 1, maxLag: 3 }); +createGesture({ + region: { node: () => pad }, + onDown() { filter.reset(); camera.beginDrag(); }, + onPanMove(c) { + const d = filter.update(c.dx, c.dy, 1 / simulationHz()); + camera.drag(d.dx, d.dy); + }, + onPanEnd() { const v = filter.velocity(); camera.endDrag(v.x, v.y); }, + onTap() { camera.endDrag(0, 0); }, + onCancel() { camera.stop(); }, +}); +``` + +Call `update` on stationary pan frames too, so the release velocity decays +after a hold. The returned delta record is reused; consume it within the +callback. Do not pass per-frame `fdx` / `fdy` as total travel. diff --git a/tests/clock.test.ts b/tests/clock.test.ts index 8bb7630b9..183f6eb76 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -10,6 +10,7 @@ import { ticksPerFrame, virtualFrame, virtualNow, + inputDeltaSeconds, } from "../framework/src/clock.ts"; import { __drainEffects, @@ -33,6 +34,22 @@ describe("normalizeHz", () => { }); }); +test("sampled input duration is bounded frame data and does not alter virtual time", () => { + g.__simHz = 60; resetClock(); + expect(inputDeltaSeconds()).toBe(1 / 60); + __advanceClock(33333); + expect(inputDeltaSeconds()).toBe(0.033333); + __advanceClock(16667); + expect(inputDeltaSeconds()).toBe(0.016667); + expect(virtualNow()).toBe(1 / 60); + __advanceClock(5000000); + expect(inputDeltaSeconds()).toBe(0.066666); + for (const sample of [undefined, 0, -100, NaN, Infinity]) { + __advanceClock(sample); expect(inputDeltaSeconds()).toBe(1 / 60); + } + resetClock(); expect(inputDeltaSeconds()).toBe(1 / 60); +}); + describe("virtual clock", () => { beforeEach(() => { g.__simHz = 4; diff --git a/tests/devtools.test.ts b/tests/devtools.test.ts index d69576c80..3f0c2b17d 100644 --- a/tests/devtools.test.ts +++ b/tests/devtools.test.ts @@ -37,6 +37,7 @@ import { resetInput } from "../framework/src/input.ts"; import { resetPack } from "../framework/src/pak.ts"; import { AuxiliarySurface, Named, Text, View } from "../framework/src/components.ts"; import { BTN, ROOT_ID } from "../contracts/spec/spec.ts"; +import { inputDeltaSeconds } from "../framework/src/clock.ts"; // --------------------------------------------------------------------------- // Mock host with the DevTools ops + an in-process transport @@ -117,6 +118,23 @@ function frame(buttons = 0): void { const g = globalThis as Record; +test("elapsed input samples round-trip through recording; old tapes ignore live timing", () => { + const seen: number[] = []; + mountApp(() => { onFrame(() => seen.push(inputDeltaSeconds())); return View({}); }); + const frame = g.frame as (...args: unknown[]) => void; + for (const us of [0, 16667, 33333, 9000000]) frame(0, 0xff80, [], [], [], 0x8080, us); + const api = g.__pocketDevtools as { dumpTape(): Tape; replay(tape: Tape): void }; + const tape = api.dumpTape(), recorded = seen.slice(); + expect(tape.v).toBe(4); + expect(tape.inputElapsedUs).toEqual([[0, 1], [16667, 1], [33333, 1], [66666, 1]]); + api.replay(tape); + for (let i = 0; i < 4; i++) frame(0, 0, [], [], [], 0, 50000); + expect(seen.slice(4)).toEqual(recorded); + api.replay({ v: 1, frames: 2, masks: [[0, 2]] }); + frame(0, 0, [], [], [], 0, 50000); frame(0, 0, [], [], [], 0, 50000); + expect(seen.slice(8)).toEqual([1 / 60, 1 / 60]); +}); + beforeEach(() => { host = makeDevHost(); installHost(host); diff --git a/tests/fixtures/offload-images.c b/tests/fixtures/offload-images.c new file mode 100644 index 000000000..f8c5258da --- /dev/null +++ b/tests/fixtures/offload-images.c @@ -0,0 +1,50 @@ +#include "../../hosts/3ds/src/offload_image.h" +#include "../../hosts/3ds/src/offload_queue.h" +#include +#include +#include +static OffloadImages images; +static OffloadQueue queue; +static void prepare(OffloadImageSlot *s, uint32_t id) { + memcpy(s->wire, "PIMG", 4); + for (int i = 0; i < 4; i++) s->wire[4 + i] = id >> (i * 8); + s->wire[8] = s->wire[10] = 0; s->wire[9] = s->wire[11] = 1; + memset(s->wire + 12, 0, 4); memset(s->wire + 16, id & 255, OFFLOAD_IMAGE_BYTES); +} +static void *produce(void *unused) { + (void)unused; + for (uint32_t id = 1; id <= 4000; id++) { + OffloadImageSlot *s; while (!(s = image_reserve(&images))) {} + prepare(s, id); assert(image_publish(&images, s, sizeof s->wire, 7)); + uint32_t token = s->token; while (!offload_push_ticket(&queue, "x", 1, 7, token)) {} + } + return NULL; +} +int main(void) { + _Static_assert(ATOMIC_INT_LOCK_FREE == 2, "image credit requires lock-free atomics"); + OffloadImageSlot *s = image_reserve(&images); assert(s); prepare(s, 1); + assert(!image_publish(&images, s, sizeof s->wire - 1, 1)); + s->wire[9] = 2; assert(!image_publish(&images, s, sizeof s->wire, 1)); + prepare(s, 1); s->wire[12] = 1; assert(!image_publish(&images, s, sizeof s->wire, 1)); + prepare(s, 0); assert(!image_publish(&images, s, sizeof s->wire, 1)); + prepare(s, 1); assert(image_publish(&images, s, sizeof s->wire, 1)); + uint32_t old = s->token; image_release(&images, old); image_release(&images, old); + s = image_reserve(&images); prepare(s, 2); assert(image_publish(&images, s, sizeof s->wire, 2)); + image_release(&images, old); assert(image_borrow(&images, s->token) == s); assert(!image_borrow(&images, old)); + for (int n = 1; n < OFFLOAD_IMAGE_SLOTS; n++) { OffloadImageSlot *p = image_reserve(&images); assert(p); prepare(p, n + 2); assert(image_publish(&images, p, sizeof p->wire, 2)); } + assert(!image_reserve(&images)); + for (int n = 0; n < OFFLOAD_IMAGE_SLOTS; n++) image_release(&images, images.slots[n].token); + s=image_reserve(&images);memset(s->wire,0,64);memcpy(s->wire,"PMSH",4);s->wire[4]=1;memcpy(s->wire+8,"PMH1",4);s->wire[13]=s->wire[15]=1; + for(unsigned n=0;n<24;n++) assert(!image_publish(&images,s,n,1)); + s->wire[18]=1;assert(!image_publish(&images,s,34,1)); /* triangle with no vertices */ + s->wire[18]=0;assert(image_publish(&images,s,24,1));assert(s->mesh && s->length==16);image_release(&images,s->token); + images.next_token = 0x1ffffffe; /* cross the sequence wrap */ + pthread_t thread; assert(!pthread_create(&thread, NULL, produce, NULL)); + for (uint32_t id = 1; id <= 4000; id++) { + OffloadRecord r; while (!offload_pop(&queue, &r)) {} + OffloadImageSlot *p = image_borrow(&images, r.image_token); assert(p && p->request == id && p->generation == 7 && p->width == 256 && p->height == 256); + for (unsigned n = 0; n < OFFLOAD_IMAGE_BYTES; n++) assert(p->wire[16 + n] == (id & 255)); + image_release(&images, r.image_token); assert(!image_borrow(&images, r.image_token)); + } + pthread_join(thread, NULL); puts("4000 binary images verified with bounded credit and token ownership"); +} diff --git a/tests/fixtures/offload-native/3ds.h b/tests/fixtures/offload-native/3ds.h new file mode 100644 index 000000000..870970954 --- /dev/null +++ b/tests/fixtures/offload-native/3ds.h @@ -0,0 +1,20 @@ +#ifndef POCKET_TEST_3DS_H +#define POCKET_TEST_3DS_H +#include +#include +#include +#include +typedef uint64_t u64; +#define U64_MAX UINT64_MAX +typedef struct TestThread { pthread_t id; void (*run)(void *); void *data; } *Thread; +static inline uint64_t osGetTime(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return (uint64_t)t.tv_sec * 1000 + t.tv_nsec / 1000000; } +static inline void svcSleepThread(int64_t ns) { struct timespec t = { ns / 1000000000, ns % 1000000000 }; nanosleep(&t, NULL); } +static inline void *test_thread_start(void *p) { Thread t = p; t->run(t->data); return NULL; } +static inline Thread threadCreate(void (*run)(void *), void *data, size_t stack, int priority, int core, int detached) { + (void)stack; (void)priority; (void)core; (void)detached; + Thread t = calloc(1, sizeof *t); t->run = run; t->data = data; + if (pthread_create(&t->id, NULL, test_thread_start, t)) { free(t); return NULL; } return t; +} +static inline void threadJoin(Thread t, uint64_t timeout) { (void)timeout; pthread_join(t->id, NULL); } +static inline void threadFree(Thread t) { free(t); } +#endif diff --git a/tests/fixtures/offload-native/duplex-credit.ts b/tests/fixtures/offload-native/duplex-credit.ts new file mode 100644 index 000000000..87411f537 --- /dev/null +++ b/tests/fixtures/offload-native/duplex-credit.ts @@ -0,0 +1,74 @@ +// Run in its own process so the deterministic socket mock cannot affect the +// real TCP / native transport tests. The capability executor is a real process. +import { EventEmitter } from "node:events"; +import { strict as assert } from "node:assert"; +import { encodeOffloadRecord } from "../../../tools/offload-wire.ts"; + +class Socket extends EventEmitter { + destroyed = false; + writableLength = 0; + paused = false; + frames: number[] = []; + input: Buffer[] = []; + scheduled = false; + setNoDelay() { return this; } + setTimeout() { return this; } + pause() { this.paused = true; return this; } + resume() { this.paused = false; this.pump(); return this; } + pump() { + if (this.scheduled || this.paused || !this.input.length) return; + this.scheduled = true; + queueMicrotask(() => { + this.scheduled = false; + if (!this.paused && this.input.length) this.emit("data", this.input.shift()!); + }); + } + send(ids: number[]) { + this.input.push(Buffer.concat(ids.map(id => encodeOffloadRecord(JSON.stringify({ + v: 1, id, method: "test.image", payload: "private-payload", response: "image", + }))))); + this.pump(); + } + write(bytes: string | Buffer) { + if (typeof bytes === "string") return true; // Pairing key. + assert.equal(bytes.readUInt32BE(0) >>> 31, 1); + const id = bytes.readUInt32LE(8); + assert.equal(bytes.length, 131092); + assert(bytes.subarray(20).every(byte => byte === id)); + this.frames.push(id); this.writableLength = bytes.length; + return false; // No write credit until the test's receiver drains it. + } + destroy() { if (!this.destroyed) { this.destroyed = true; this.emit("close"); } return this; } +} + +const socket = new Socket(), logs: string[] = []; +require("node:net").connect = () => { queueMicrotask(() => socket.emit("connect")); return socket; }; +const { connectOffloadProvider } = await import("../../../tools/offload-provider.ts"); +const provider = connectOffloadProvider({ address: "127.0.0.1", key: "ab".repeat(32), + worker: new URL("./process-worker.ts", import.meta.url), isolation: "process", data: {}, + trace: true, log: line => logs.push(line) }); +async function until(done: () => boolean) { + const start = Date.now(); + while (!done()) { if (Date.now() - start > 2000) throw new Error("Fixture timed out"); await Bun.sleep(5); } +} +try { + await until(() => logs.some(line => line.includes("transport connected;"))); + socket.send([1]); await until(() => socket.frames.length === 1); + socket.send(Array.from({ length: 19 }, (_, n) => n + 2)); + // A blocked image must not prevent admitting other work within the shared + // eight-request/reply/write budget. Replies themselves remain queued. + await Bun.sleep(100); + assert.equal(logs.filter(line => line.includes(": request id=")).length, 8, + "blocked output should still admit seven jobs without exceeding total credit"); + assert.equal(socket.frames.length, 1); + for (let n = 1; n < 20; n++) { + socket.writableLength = 0; socket.emit("drain"); + await until(() => socket.frames.length > n); + } + socket.emit("drain"); + assert.equal(new Set(socket.frames).size, 20); + assert.equal(logs.some(line => line.includes("private-payload")), false); + assert.equal(logs.some(line => line.includes("disconnected:")), false); + console.log("duplex credit: 20 intact images; eight total reservations; no disconnect"); +} catch (error) { console.error(logs.join("\n")); throw error; } +finally { provider.close(); } diff --git a/tests/fixtures/offload-native/main.c b/tests/fixtures/offload-native/main.c new file mode 100644 index 000000000..ee288bd2c --- /dev/null +++ b/tests/fixtures/offload-native/main.c @@ -0,0 +1,46 @@ +#include "../../../hosts/3ds/src/offload.h" +#include <3ds.h> +#include +#include +#include +bool soc_ensure(char *error, size_t size) { (void)error; (void)size; return true; } +int main(void) { + assert(offload_start()); + uint64_t deadline = osGetTime() + 10000; unsigned phase = 0; int first_session = 0; + while (osGetTime() < deadline && phase < 5) { + offload_frame(); int session = offload_session(); + if (phase == 0 && session > 0) { + first_session = session; + const char *request = "{\"v\":1,\"id\":1,\"method\":\"test.image\",\"payload\":\"{}\",\"response\":\"image\"}"; + if (offload_submit(request, strlen(request))) phase = 1; + } + if (phase == 3 && session > first_session) { + const char *request = "{\"v\":1,\"id\":1,\"method\":\"test.text\",\"payload\":\"{}\"}"; + if (offload_submit(request, strlen(request))) phase = 4; + } + char record[4097]; size_t length = offload_take(record); record[length] = 0; + if (length && phase == 1) { + unsigned id, token, w, h; + assert(sscanf(record, "{\"id\":%u,\"image\":{\"token\":%u,\"width\":%u,\"height\":%u}}", &id, &token, &w, &h) == 4); + assert(id == 1 && w == 256 && h == 256); + const uint8_t *pixels = offload_image(token, &w, &h); assert(pixels); + for (unsigned n = 0; n < 256 * 256 * 2; n++) assert(pixels[n] == (n & 255)); + assert(pixels[-3] == 2); /* native IMG envelope uses linear filtering */ + offload_release_image(token); assert(!offload_image(token, &w, &h)); + const char *request="{\"v\":1,\"id\":2,\"method\":\"test.mesh\",\"payload\":\"{}\",\"response\":\"mesh\"}"; + assert(offload_submit(request,strlen(request))); phase=2; + } else if (length && phase==2) { + unsigned id,token,w,h,bytes; + assert(sscanf(record,"{\"id\":%u,\"mesh\":{\"token\":%u,\"width\":%u,\"height\":%u,\"bytes\":%u}}",&id,&token,&w,&h,&bytes)==5); + assert(id==2 && w==256 && h==256 && bytes==36880); unsigned n; + const uint8_t *mesh=offload_mesh(token,&n); assert(mesh && n==36880 && !memcmp(mesh,"PMH1",4)); + for(unsigned i=0;i<4096;i++){assert((mesh[16+i*4] | (unsigned)mesh[17+i*4]<<8)==i);assert((mesh[18+i*4] | (unsigned)mesh[19+i*4]<<8)==4096-i);} + for(unsigned i=0;i<2048;i++){unsigned at=16+4096*4+i*10;for(unsigned j=0;j<3;j++)assert((mesh[at+j*2] | (unsigned)mesh[at+j*2+1]<<8)==i+j);assert(mesh[at+6]==0x56 && mesh[at+9]==0xff);} + assert(!offload_image(token,&w,&h)); offload_release_image(token); assert(!offload_mesh(token,&n)); + offload_reset(); assert(offload_session() == 0); phase = 3; + } else if (length && phase == 4) { assert(strstr(record, "network-ok")); phase = 5; } + svcSleepThread(1000000); + } + offload_stop(); assert(phase == 5); + puts("native socket image transfer and realm-reset reconnect passed"); +} diff --git a/tests/fixtures/offload-native/process-worker.ts b/tests/fixtures/offload-native/process-worker.ts new file mode 100644 index 000000000..6ec754f79 --- /dev/null +++ b/tests/fixtures/offload-native/process-worker.ts @@ -0,0 +1,16 @@ +import { dispatchOffload } from "../../../tools/offload-provider.ts"; +declare const self: { onmessage: (event: MessageEvent) => void; postMessage(value: unknown): void }; +let url = ""; +self.onmessage = async event => { + if (event.data.init) { url = event.data.init.url; return; } + self.postMessage(await dispatchOffload({ + "test.pid": () => String(process.pid), + "test.image": () => ({ width: 256, height: 256, format: "r5g6b5", pixels: new Uint8Array(131072).fill(event.data.id & 255) }), + "test.fetch": async () => (await fetch(url, { signal: AbortSignal.timeout(8000) })).text(), + "test.hang": () => new Promise(() => {}), + "test.crash": () => { + if (!process.send) throw new Error("Crash fixture requires process isolation"); + process.kill(process.pid, "SIGKILL"); return "unreachable"; + }, + }, event.data)); +}; diff --git a/tests/fixtures/offload-native/worker.ts b/tests/fixtures/offload-native/worker.ts new file mode 100644 index 000000000..0e4e2d8da --- /dev/null +++ b/tests/fixtures/offload-native/worker.ts @@ -0,0 +1,15 @@ +import { dispatchOffload } from "../../../tools/offload-provider.ts"; +declare const self: { onmessage: (event: MessageEvent) => void; postMessage(value: unknown): void }; +self.onmessage = async event => { + if (event.data.init) return; + self.postMessage(await dispatchOffload({ + "test.image": () => ({ width: 256, height: 256, pixels: Uint8Array.from({ length: 256 * 256 * 2 }, (_, n) => n & 255), format: "r5g6b5" }), + "test.mesh": () => { + const bytes=new Uint8Array(36880),v=new DataView(bytes.buffer);bytes.set([80,77,72,49,0,1,0,1]);v.setUint16(8,4096,true);v.setUint16(10,2048,true); + for(let i=0;i<4096;i++){v.setUint16(16+i*4,i,true);v.setUint16(18+i*4,4096-i,true);} + for(let i=0;i<2048;i++){const p=16+4096*4+i*10;for(let j=0;j<3;j++)v.setUint16(p+j*2,i+j,true);v.setUint32(p+6,0xff123456,true);} + return {format:"mesh2d-v1",bytes}; + }, + "test.text": () => "network-ok", + }, event.data)); +}; diff --git a/tests/fixtures/offload-usb/worker.ts b/tests/fixtures/offload-usb/worker.ts new file mode 100644 index 000000000..0262fc847 --- /dev/null +++ b/tests/fixtures/offload-usb/worker.ts @@ -0,0 +1,29 @@ +import { dispatchOffload } from "../../../tools/offload-provider.ts"; +declare const self: { + onmessage(event: { data: any }): void; + postMessage(data: unknown): void; +}; +self.onmessage = async ({ data }) => { + if (data.init) return; + self.postMessage( + await dispatchOffload( + { + "test.delay": async (payload: string) => { + await Bun.sleep(180); + return payload; + }, + "test.echo": (payload: string) => payload, + "test.image": () => ({ + width: 16, + height: 16, + format: "r5g6b5", + pixels: new Uint8Array(512).fill(42), + }), + "test.crash": () => { + process.exit(1); + }, + }, + data, + ), + ); +}; diff --git a/tests/offload-images.test.ts b/tests/offload-images.test.ts new file mode 100644 index 000000000..1ef8d2082 --- /dev/null +++ b/tests/offload-images.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test"; +import { createRoot } from "solid-js"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { encodeOffloadImage } from "../tools/offload-wire.ts"; +import { dispatchOffload, connectOffloadProvider } from "../tools/offload-provider.ts"; +import { createOffloadClient } from "../framework/src/offload.ts"; +import { createResourceScheduler } from "../framework/src/resource-cache.ts"; +import { createResourceRuntime, createResourceView } from "../framework/src/resource-view.ts"; +import { createOffloadImageCollection } from "../framework/src/resource-offload.ts"; + +for (const isolation of ["thread", "process"] as const) test(`actual native worker receives binary images from the ${isolation} provider transport and reconnects after realm reset`, async () => { + const directory = mkdtempSync(join(tmpdir(), "pocket-native-")); + const server = Bun.serve({ port: 0, fetch: () => new Response("") }); const port = server.port!; server.stop(true); + const key = randomBytes(32).toString("hex"), keyPath = join(directory, "pair.key"); writeFileSync(keyPath, key, { mode: 0o600 }); + let provider: ReturnType | undefined; + let child: ReturnType | undefined; + try { + const binary = join(directory, "native"); + const compile = Bun.spawnSync(["cc", "-std=c11", "-O2", "-pthread", "-fsanitize=address,undefined", "-Itests/fixtures/offload-native", `-DPOCKETJS_OFFLOAD_KEY="${keyPath}"`, `-DPOCKETJS_OFFLOAD_PORT=${port}`, + "tests/fixtures/offload-native/main.c", "hosts/3ds/src/offload.c", "-o", binary]); + if (compile.exitCode) throw new Error(compile.stderr.toString()); + const process = Bun.spawn([binary], { stdout: "pipe", stderr: "pipe" }); child = process; + provider = connectOffloadProvider({ address: "127.0.0.1", port, key, worker: new URL("./fixtures/offload-native/worker.ts", import.meta.url), data: {}, isolation }); + const [status, output, error] = await Promise.all([process.exited, new Response(process.stdout).text(), new Response(process.stderr).text()]); + if (status) throw new Error(error); expect(output).toContain("realm-reset reconnect passed"); + } finally { provider?.close(); child?.kill(); rmSync(directory, { recursive: true }); } +}, 15000); + +test("native image staging survives malformed envelopes, full credit, token reuse and concurrent transfer", () => { + const directory = mkdtempSync(join(tmpdir(), "pocket-images-")); + try { + const binary = join(directory, "images"); + const compile = Bun.spawnSync(["cc", "-std=c11", "-O2", "-pthread", "-fsanitize=address,undefined", "tests/fixtures/offload-images.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("4000 binary images verified"); + } finally { rmSync(directory, { recursive: true }); } +}); +test("binary response extension requires opt-in and exact bounded pixels", async () => { + const image = { width: 16, height: 32, pixels: new Uint8Array(16 * 32 * 2), format: "r5g6b5" as const }; + const bytes = encodeOffloadImage(42, image); + expect(bytes.readUInt32BE()).toBe(0x80000000 + 16 + image.pixels.length); + expect(bytes.toString("ascii", 4, 8)).toBe("PIMG"); expect(bytes.readUInt32LE(8)).toBe(42); + expect(bytes.readUInt16LE(12)).toBe(16); expect(bytes.readUInt16LE(14)).toBe(32); + for (const width of [0, 15, 17, 512, Infinity]) expect(() => encodeOffloadImage(1, { ...image, width })).toThrow(); + expect(() => encodeOffloadImage(0, image)).toThrow(); + expect(() => encodeOffloadImage(1, { ...image, pixels: new Uint8Array(1) })).toThrow(); + const request = { v: 1 as const, id: 1, method: "tile", payload: "{}" }; + expect(await dispatchOffload({ tile: () => image }, request)).toHaveProperty("error"); + expect(await dispatchOffload({ tile: () => image }, { ...request, response: "image" })).toHaveProperty("image", image); +}); +function rig() { + let session = 1, uploaded = 0; + const sent: string[] = [], replies: string[] = [], released: number[] = []; + const client = createOffloadClient({ session: () => session, submit: raw => { sent.push(raw); return true; }, take: () => replies.shift(), + uploadImage: () => ++uploaded, releaseImage: token => released.push(token) }); + return { client, sent, replies, released, uploaded: () => uploaded, disconnect: () => session = -1, + reply(id: number, token: number) { replies.push(JSON.stringify({ id, image: { token, width: 16, height: 16 } })); } }; +} +test("cancelled, stale and unrequested images return staging without uploading pixels", () => { + const r = rig(); let delivered = 0; + const id = r.client.requestImage("tile", "{}", () => delivered++); + r.client.step(); expect(JSON.parse(r.sent[0]).response).toBe("image"); + r.client.cancel(id); r.reply(id, 8); r.client.step(); + const next = r.client.requestImage("tile", "{}", () => delivered++); r.client.step(); + r.disconnect(); r.reply(next, 16); r.client.step(); + expect(r.released).toEqual([8, 16]); expect(r.uploaded()).toBe(0); expect(delivered).toBe(1); +}); +test("image collection releases raw staging on withdrawn demand before materialization", () => { + createRoot(dispose => { + const r = rig(), runtime = createResourceRuntime({ maxConcurrent: 1, maxCollections: 1, startsPerFrame: 1, completionsPerFrame: 1 }); + const images = createOffloadImageCollection(runtime, r.client, { key: (i: string) => i, method: "tile", payload: i => i, width: 16, height: 16, maxEntries: 2, maxViews: 1 }); + let wanted = true; + createResourceView(images, { demand: () => wanted ? [{ input: "one", priority: 0 }] : [] }); + runtime.step(); r.client.step(); r.reply(JSON.parse(r.sent[0]).id, 8); r.client.step(); + wanted = false; runtime.step(); expect(r.released).toEqual([8]); expect(r.uploaded()).toBe(0); + dispose(); expect(r.released).toEqual([8]); + }); +}); +test("response cleanup covers success, materialize failure, oversized and late response", () => { + const released: string[] = [], callbacks: ((value: any) => void)[] = []; + const scheduler = createResourceScheduler({ maxConcurrent: 1, maxCollections: 1, startsPerFrame: 1, completionsPerFrame: 1 }); + const cache = scheduler.createCache({ key: (s: string) => s, maxEntries: 2, maxCost: 2, cost: () => 1, maxResponseBytes: 16, + load: (_, done) => { callbacks.push(done); return { cancel() {} }; }, materialize(raw: string) { if (raw === "fail") throw new Error("upload"); return raw; }, releaseResponse: raw => released.push(raw) }); + const demand = (input: string) => [{ input, priority: 0 }]; + cache.reconcile(demand("a")); scheduler.step(); callbacks[0]({ ok: true, value: "ok" }); scheduler.step(); + cache.reconcile(demand("b")); scheduler.step(); callbacks[1]({ ok: true, value: "fail" }); scheduler.step(); + cache.reconcile(demand("c")); scheduler.step(); callbacks[2]({ ok: true, value: "oversized" }); scheduler.step(); + cache.reconcile(demand("d")); scheduler.step(); cache.clear(); callbacks[3]({ ok: true, value: "late" }); + expect(released).toEqual(["ok", "fail", "oversized", "late"]); scheduler.dispose(); +}); diff --git a/tests/offload-meshes.test.ts b/tests/offload-meshes.test.ts new file mode 100644 index 000000000..801088cfb --- /dev/null +++ b/tests/offload-meshes.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from "bun:test"; +import { createRoot } from "solid-js"; +import { createOffloadClient } from "../framework/src/offload.ts"; +import { createOffloadMeshCollection } from "../framework/src/resource-offload.ts"; +import { createResourceRuntime, createResourceView } from "../framework/src/resource-view.ts"; +import { encodeOffloadMesh, validateMesh, prepareMesh } from "../tools/offload-wire.ts"; +import { dispatchOffload } from "../tools/offload-provider.ts"; +const mesh = () => { + const bytes = new Uint8Array(16); + bytes.set([80, 77, 72, 49, 0, 1, 0, 1]); + return { format: "mesh2d-v1" as const, bytes }; +}; +test("prepared mesh protocol requires explicit kind and validates every truncated entry", async () => { + const m = mesh(), + wire = encodeOffloadMesh(3, m); + expect(wire.readUInt32BE()).toBe(0x80000018); + expect(wire.toString("ascii", 4, 8)).toBe("PMSH"); + expect(validateMesh(m.bytes)).toEqual({ width: 256, height: 256, bytes: 16 }); + for (let n = 0; n < 16; n++) expect(() => validateMesh(m.bytes.slice(0, n))).toThrow(); + for (const at of [0, 9, 10, 12]) { + const b = m.bytes.slice(); + b[at] = 255; + expect(() => validateMesh(b)).toThrow(); + } + const request = { v: 1 as const, id: 1, method: "map.mesh", payload: "{}" }; + expect(await dispatchOffload({ "map.mesh": () => m }, request)).toHaveProperty("error"); + expect(await dispatchOffload({ "map.mesh": () => m }, { ...request, response: "image" })).toHaveProperty("error"); + expect(await dispatchOffload({ "map.mesh": () => m }, { ...request, response: "mesh" })).toHaveProperty("mesh", m); +}); +test("mesh cancellation, stale sessions, mismatched kinds and duplicate binary tickets release staging", () => { + let session = 1; + const replies: string[] = [], + released: number[] = []; + let deliveries = 0; + const client = createOffloadClient({ + session: () => session, + submit: () => true, + take: () => replies.shift(), + uploadMesh: () => 1, + releaseMesh: (t) => released.push(t), + uploadImage: () => 2, + releaseImage: (t) => released.push(t), + }); + const ticket = (token: number) => ({ token, width: 256, height: 256, bytes: 16 }); + const id = client.requestMesh("tile", "{}", () => deliveries++); + client.step(); + client.cancel(id); + replies.push(JSON.stringify({ id, mesh: ticket(8) })); + client.step(); + expect(deliveries).toBe(0); + const wrong = client.requestImage("tile", "{}", (r) => { + expect(r.ok).toBe(false); + deliveries++; + }); + client.step(); + replies.push(JSON.stringify({ id: wrong, mesh: ticket(16) })); + client.step(); + const stale = client.requestMesh("tile", "{}", () => deliveries++); + client.step(); + session = 2; + replies.push(JSON.stringify({ id: stale, mesh: ticket(24) })); + client.step(); + const both = client.requestMesh("tile", "{}", (r) => expect(r.ok).toBe(false)); + client.step(); + replies.push(JSON.stringify({ id: both, mesh: ticket(32), image: ticket(40) })); + client.step(); + expect(released).toEqual([8, 16, 24, 40, 32]); + expect(client.pending()).toBe(0); + expect(deliveries).toBe(2); +}); +test("mesh collection owns staging through failed materialization and withdrawn demand", () => + createRoot((dispose) => { + const replies: string[] = [], + sent: string[] = [], + released: number[] = []; + let uploads = 0, + wanted = true; + const client = createOffloadClient({ + session: () => 1, + submit: (r) => { + sent.push(r); + return true; + }, + take: () => replies.shift(), + uploadMesh: () => { + uploads++; + return -1; + }, + releaseMesh: (t) => released.push(t), + }); + const runtime = createResourceRuntime({ + maxCollections: 1, + maxConcurrent: 1, + startsPerFrame: 1, + completionsPerFrame: 1, + }); + const collection = createOffloadMeshCollection(runtime, client, { + key: (s: string) => s, + method: "mesh", + payload: (s) => s, + maxEntries: 2, + maxViews: 1, + retry: { attempts: 1, delayFrames: 1, maxDelayFrames: 1 }, + }); + createResourceView(collection, { demand: () => (wanted ? [{ input: "a", priority: 0 }] : []) }); + runtime.step(); + client.step(); + replies.push( + JSON.stringify({ id: JSON.parse(sent[0]).id, mesh: { token: 8, width: 256, height: 256, bytes: 16 } }), + ); + client.step(); + runtime.step(); + expect(uploads).toBe(1); + expect(released).toEqual([8]); + collection.invalidate(); + runtime.step(); + client.step(); + replies.push( + JSON.stringify({ id: JSON.parse(sent[1]).id, mesh: { token: 16, width: 256, height: 256, bytes: 16 } }), + ); + client.step(); + wanted = false; + runtime.step(); + expect(uploads).toBe(1); + expect(released).toEqual([8, 16]); + dispose(); + client.dispose(); + })); + +test("throwing response callbacks cannot strand native mesh staging", () => { + const replies: string[] = [], released: number[] = []; + const client = createOffloadClient({ session: () => 1, submit: () => true, take: () => replies.shift(), uploadMesh: () => 1, releaseMesh: token => released.push(token) }); + const id = client.requestMesh("mesh", "{}", () => { throw new Error("consumer failed"); }); + client.step(); replies.push(JSON.stringify({ id, mesh: { token: 8, width: 256, height: 256, bytes: 16 } })); client.step(); + expect(released).toEqual([8]); expect(client.pending()).toBe(0); +}); + +test("provider mesh packing quantizes logical units and rejects narrowing overflow", () => { + const input={width:256,height:256,vertices:[[0,0],[256,0],[.1,256]] as const,triangles:[[0,1,2,0xff123456]] as const}; + const bytes=prepareMesh(input).bytes;expect(validateMesh(bytes).bytes).toBe(38);expect(new DataView(bytes.buffer).getUint16(24,true)).toBe(2); + for(const index of [-1,.5,3,65536,NaN])expect(()=>prepareMesh({...input,triangles:[[0,1,index,0xff123456]]})).toThrow(); + for(const color of [-1,.5,0x100000000,Infinity])expect(()=>prepareMesh({...input,triangles:[[0,1,2,color]]})).toThrow(); + for(const x of [-1,257,Infinity,NaN])expect(()=>prepareMesh({...input,vertices:[[x,0]]})).toThrow(); +}); diff --git a/tests/offload-provider.test.ts b/tests/offload-provider.test.ts new file mode 100644 index 000000000..71e6c276f --- /dev/null +++ b/tests/offload-provider.test.ts @@ -0,0 +1,106 @@ +import { test, expect } from "bun:test"; +import { createServer, type Socket } from "node:net"; +import { connectOffloadProvider } from "../tools/offload-provider.ts"; +import { OffloadDecoder, encodeOffloadRecord } from "../tools/offload-wire.ts"; +import type { OffloadReply } from "../contracts/spec/offload.ts"; + +test("slow writes preserve bounded duplex admission instead of stopping healthy work", async () => { + const child = Bun.spawn([process.execPath, new URL("./fixtures/offload-native/duplex-credit.ts", import.meta.url).pathname], + { stdout: "pipe", stderr: "pipe" }); + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect({ code, error: err }).toEqual({ code: 0, error: "" }); + expect(out).toContain("20 intact images; eight total reservations; no disconnect"); +}, 10000); + +function queue() { + const values: T[] = [], pending: ((value: T) => void)[] = []; + return { push(value: T) { const take = pending.shift(); if (take) take(value); else values.push(value); }, + take(): Promise { if (values.length) return Promise.resolve(values.shift()!); return new Promise(resolve => pending.push(resolve)); } }; +} +async function rig(url = "", binaryReplies = false) { + const key = "ab".repeat(32), peers = queue<{ socket: Socket; read(): Promise; send(method: string, id?: number): void }>(); + const sockets = new Set(), logs: string[] = []; + const server = createServer(socket => { + sockets.add(socket); socket.on("close", () => sockets.delete(socket)); socket.on("error", () => {}); + let prefix = Buffer.alloc(0), paired = false, imageBuffer = Buffer.alloc(0); + const decoder = new OffloadDecoder(), replies = queue(); + socket.on("data", data => { + let bytes = Buffer.from(data); + if (!paired) { + prefix = Buffer.concat([prefix, bytes]); if (prefix.length < 64) return; + expect(prefix.subarray(0, 64).toString()).toBe(key); paired = true; bytes = prefix.subarray(64); + peers.push({ socket, read: replies.take, send(method, id = 1) { socket.write(encodeOffloadRecord(JSON.stringify({ v: 1, id, method, payload: "{}" }))); } }); + } + if (binaryReplies) { + imageBuffer = Buffer.concat([imageBuffer, bytes]); + while (imageBuffer.length >= 4) { + const size = imageBuffer.readUInt32BE() & 0x7fffffff; expect(size).toBeLessThanOrEqual(131088); + if (imageBuffer.length < size + 4) break; + const frame = imageBuffer.subarray(4, size + 4); + const id = frame.readUInt32LE(4); expect(frame.length).toBe(131088); + for (let n = 16; n < frame.length; n++) if (frame[n] !== (id & 255)) throw new Error("Corrupt image under backpressure"); + replies.push({ id }); imageBuffer = imageBuffer.subarray(size + 4); + } + } else decoder.push(bytes, raw => { replies.push(JSON.parse(raw)); }); + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as { port: number }).port; + const provider = connectOffloadProvider({ address: "127.0.0.1", port, key, isolation: "process", data: { url }, + worker: new URL("./fixtures/offload-native/process-worker.ts", import.meta.url), log: line => logs.push(line) }); + return { next: peers.take, logs, async close() { + provider.close(); for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } }; +} + +test("a provider process crash cannot kill the transport; request IDs can restart without replay", async () => { + const r = await rig(); + try { + const first = await r.next(); first.send("test.pid"); const pid = Number((await first.read()).payload); + expect(pid).not.toBe(process.pid); + first.send("test.crash", 2); + const second = await r.next(); second.send("test.pid"); const replacement = await second.read(); + expect(replacement.id).toBe(1); expect(Number(replacement.payload)).not.toBe(pid); + expect(r.logs.some(line => line.includes("provider process exited"))).toBe(true); + } finally { await r.close(); } +}, 10000); + +test("repeated disconnects during in-flight fetches reap providers and fence old replies", async () => { + const starts = queue(); + const http = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch() { starts.push(); return new Promise(() => {}); } }); + const r = await rig(String(http.url)); const pids = new Set(); + try { + for (let n = 0; n < 8; n++) { + const peer = await r.next(); peer.send("test.pid"); const pid = Number((await peer.read()).payload); pids.add(pid); + peer.send("test.fetch", 2); await starts.take(); + const closed = new Promise(resolve => peer.socket.once("close", resolve)); peer.socket.destroy(); await closed; + } + const final = await r.next(); final.send("test.pid"); expect((await final.read()).id).toBe(1); + expect(pids.size).toBe(8); + for (const pid of pids) expect(() => process.kill(pid, 0)).toThrow(); + } finally { await r.close(); http.stop(true); } +}, 25000); + +test("a wedged provider has a bounded deadline and its work is not replayed", async () => { + const r = await rig(); + try { + const peer = await r.next(); peer.send("test.hang"); + const next = await r.next(); next.send("test.pid"); expect((await next.read()).id).toBe(1); + expect(r.logs.some(line => line.includes("request deadline: test.hang id=1"))).toBe(true); + } finally { await r.close(); } +}, 15000); + +test("a slow device drains a burst of images without backlog disconnects or lost requests", async () => { + const r = await rig("", true); + try { + const peer = await r.next(); peer.socket.pause(); + const records = Array.from({ length: 64 }, (_, n) => encodeOffloadRecord(JSON.stringify({ v: 1, id: n + 1, method: "test.image", payload: "{}", response: "image" }))); + peer.socket.write(Buffer.concat(records)); + await Bun.sleep(500); peer.socket.resume(); + const seen = new Set(); + for (let n = 0; n < 64; n++) seen.add((await peer.read()).id); + expect(seen.size).toBe(64); expect(r.logs.filter(line => line.includes("transport connected;")).length).toBe(1); + expect(r.logs.some(line => line.includes("disconnected:"))).toBe(false); + } finally { await r.close(); } +}, 10000); diff --git a/tests/offload-usb.test.ts b/tests/offload-usb.test.ts new file mode 100644 index 000000000..99be4ebc4 --- /dev/null +++ b/tests/offload-usb.test.ts @@ -0,0 +1,123 @@ +import { test, expect } from "bun:test"; +import { + mkdtempSync, + rmSync, + writeFileSync, + readFileSync, + existsSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + connectOffloadUsbProvider, + usbPacket, + usbHash, +} from "../tools/offload-usb-provider.ts"; +async function until(f: () => T | undefined): Promise { + const end = Date.now() + 4000; + while (Date.now() < end) { + const value = f(); + if (value !== undefined) return value; + await Bun.sleep(10); + } + throw Error("USB test deadline"); +} +test("USB mailbox preserves identities, bounds records and binary payloads, and rotates a crashed executor", async () => { + const directory = mkdtempSync(join(tmpdir(), "pocket-usb-")); + const provider = connectOffloadUsbProvider({ + directory, + app: "test.usb", + worker: new URL("./fixtures/offload-usb/worker.ts", import.meta.url), + data: {}, + }); + const read = (file: string) => { + try { + return readFileSync(join(provider.root, file)); + } catch { + return undefined; + } + }; + const send = ( + slot: number, + sequence: number, + method: string, + payload: string, + response?: "image", + ) => + writeFileSync( + join(provider.root, `req${slot}`), + usbPacket( + provider.epoch, + 12, + sequence, + 0, + 0, + 0, + 0, + Buffer.from( + JSON.stringify({ + v: 1, + id: sequence, + method, + payload, + ...(response ? { response } : {}), + }), + ), + ), + ); + const reply = (slot: number, seq: number) => + until(() => { + const b = read(`res${slot}`); + return b && b.readUInt32LE(12) === seq ? b : undefined; + }); + try { + await until(() => read("ready")); + send(0, 1, "test.echo", "hello"); + let b = await reply(0, 1); + expect(JSON.parse(b.toString("utf8", 64))).toEqual({ + id: 1, + payload: "hello", + }); + expect(b.readUInt32LE(8)).toBe(12); + expect(b.readUInt32LE(36)).toBe(usbHash(b.subarray(64))); + send(0, 2, "test.image", "", "image"); + b = await reply(0, 2); + expect(b.readUInt32LE(16)).toBe(2); + expect(b.length).toBe(576); + expect(b.subarray(64).every((n) => n === 42)).toBe(true); + writeFileSync(join(provider.root, "req1"), Buffer.alloc(5000)); + await Bun.sleep(50); + expect(existsSync(join(provider.root, "res1"))).toBe(false); + send(2, 10, "test.delay", "obsolete"); + await Bun.sleep(40); + send(2, 11, "test.echo", "current"); + await reply(2, 11); + await Bun.sleep(240); + expect(read("res2")!.readUInt32LE(12)).toBe(11); + writeFileSync( + join(provider.root, "req3"), + usbPacket(provider.epoch, 12, 20, 0, 0, 0, 0, Buffer.from("null")), + ); + await Bun.sleep(30); + expect(read("res3")).toBeUndefined(); + const epoch = provider.epoch; + send(1, 3, "test.crash", ""); + await until(() => { + const ready = read("ready"); + // until() treats only undefined as pending. A boolean false here + // would send the recovery request before the crashed worker exits. + return provider.epoch !== epoch && + ready?.readUInt32LE(4) === provider.epoch + ? ready + : undefined; + }); + expect(provider.epoch).not.toBe(epoch); + send(0, 4, "test.echo", "recovered"); + b = await reply(0, 4); + expect(b.readUInt32LE(4)).toBe(provider.epoch); + expect(JSON.parse(b.toString("utf8", 64)).payload).toBe("recovered"); + } finally { + provider.close(); + rmSync(directory, { recursive: true, force: true }); + } +}, 10000); diff --git a/tests/offload.test.ts b/tests/offload.test.ts index 352b3938f..95b211468 100644 --- a/tests/offload.test.ts +++ b/tests/offload.test.ts @@ -18,13 +18,16 @@ describe("offload budgets and failure delivery", () => { 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()); + // Sanitizer compilation can exceed the runner's default five-second + // test budget. Bound compilation separately; keep the executable's + // run limit at five seconds and reject interrupted compiler output. + const compile = Bun.spawnSync(["cc", "-std=c11", "-O2", "-pthread", "-fsanitize=address,undefined", resolve(import.meta.dir, "fixtures/offload-queue.c"), "-o", binary], { timeout: 20_000, killSignal: "SIGKILL" }); + if (compile.exitCode !== 0) throw new Error(`Compiler exited ${compile.exitCode} (${compile.signalCode}): ${compile.stderr.toString()}`); + const run = Bun.spawnSync([binary], { timeout: 5_000, killSignal: "SIGKILL" }); + if (run.exitCode !== 0) throw new Error(`Native test exited ${run.exitCode} (${run.signalCode}): ${run.stderr.toString()}`); expect(run.stdout.toString()).toContain("100000 SPSC records verified"); } finally { rmSync(scratch, { recursive: true }); } - }); + }, 30_000); 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); @@ -50,7 +53,8 @@ describe("offload budgets and failure delivery", () => { 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(delivered).toBe(1); expect(r.client.pending()).toBe(1); + r.disconnect(); r.client.step(); 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", () => { @@ -62,6 +66,35 @@ describe("offload budgets and failure delivery", () => { } expect(() => new OffloadDecoder().push(Buffer.from([0, 0, 16, 1]), () => {})).toThrow(); }); + test("cancelled sent reads retain transport credit until replies arrive", () => { + const r = rig(); let delivered = 0; + for (let n = 0; n < 8; n++) { + const id = r.client.request("tile", "{}", () => delivered++); r.client.step(); r.client.cancel(id); + } + expect(r.client.pending()).toBe(8); expect(r.sent).toHaveLength(8); + expect(r.client.request("tile", "{}", () => {})).toBe(0); + for (let n = 0; n < 700; n++) r.client.step(); + expect(r.client.pending()).toBe(8); expect(r.sent).toHaveLength(8); + r.replies.push(JSON.stringify({ id: JSON.parse(r.sent[0]).id, payload: "late" })); r.client.step(); + expect(delivered).toBe(0); expect(r.client.pending()).toBe(7); + expect(r.client.request("tile", "{}", () => {})).toBeGreaterThan(0); + r.disconnect(); r.client.step(); expect(r.client.pending()).toBe(1); + }); + test("timed-out sent work notifies once without opening more wire credit", () => { + const r = rig(); let delivered = 0; + const id = r.client.request("slow", "{}", () => delivered++); r.client.step(); + for (let n = 0; n < 700; n++) r.client.step(); + expect(delivered).toBe(1); expect(r.client.pending()).toBe(1); + r.replies.push(JSON.stringify({ id, payload: "late" })); r.client.step(); + expect(delivered).toBe(1); expect(r.client.pending()).toBe(0); + }); + test("frame decoder can pause mid-chunk at a record boundary without losing the suffix", () => { + const bytes = Buffer.concat([encodeOffloadRecord("one"), encodeOffloadRecord("two"), encodeOffloadRecord("three")]); + const decoder = new OffloadDecoder(), rows: string[] = []; + const first = decoder.push(bytes, value => { rows.push(value); }, () => false); + expect(rows).toEqual(["one"]); expect(first).toBe(7); + decoder.push(bytes.subarray(first), value => { rows.push(value); }); expect(rows).toEqual(["one", "two", "three"]); + }); 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"); diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 1c5914a45..1233d217b 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -217,6 +217,7 @@ describe("platform registry", () => { ]); expect(validatePlatformContractRegistry(POCKET_PLATFORM_CONTRACTS)).toEqual([]); expect(POCKET_TARGETS.psp.capabilities).toEqual([ + "io.offload", "input.analog.left", "input.buttons", "input.cursor", diff --git a/tests/resource-cache.test.ts b/tests/resource-cache.test.ts index 32206deeb..9947e9cfc 100644 --- a/tests/resource-cache.test.ts +++ b/tests/resource-cache.test.ts @@ -110,6 +110,30 @@ test("visible demand preempts speculative requests without consuming retry attem x.requests[1].done({ ok: true, value: "V" }); x.scheduler.step(); expect(x.requests[2].key).toBe("prefetch"); }); +test("transport saturation preserves desired prefetch work until replacement can start", () => { + let raw: string | undefined; + const sent: { id: number; payload: string }[] = []; + const io = createOffloadClient({ session: () => 1, + submit(record) { sent.push(JSON.parse(record)); return true; }, + take() { const result = raw; raw = undefined; return result; } }); + const scheduler = createResourceScheduler({ maxConcurrent: 1, startsPerFrame: 1, + completionsPerFrame: 1, maxCollections: 1, available: () => io.pending() < 1 }); + const cache = scheduler.createCache({ key: (s: string) => s, maxEntries: 2, + maxResponseBytes: 100, maxCost: 2, cost: () => 1, + load: offloadResource(io, "test.read", s => s), materialize: (s: string) => s }); + cache.reconcile([{ input: "edge", priority: 1000 }]); scheduler.step(); io.step(); + cache.reconcile([{ input: "visible", priority: 0, pin: true }, { input: "edge", priority: 1000 }]); + for (let n = 0; n < 30; n++) { scheduler.step(); io.step(); } + raw = JSON.stringify({ id: sent[0].id, payload: "EDGE" }); io.step(); scheduler.step(); io.step(); + expect(cache.state("edge")).toEqual({ status: "ready", value: "EDGE" }); + expect(sent.map(r => r.payload)).toEqual(["edge", "visible"]); + raw = JSON.stringify({ id: sent[1].id, payload: "VISIBLE" }); io.step(); scheduler.step(); + cache.reconcile([{ input: "edge", priority: 0, pin: true }]); + scheduler.step(); io.step(); + expect(sent).toHaveLength(2); // Re-entering the edge needs no duplicate wire image. + scheduler.dispose(); io.dispose(); +}); + test("frame expiry revalidates only desired entries and keeps the old value visible", () => { const x = setup(); let loads = 0; const cache = x.scheduler.createCache({ key: (s: string) => s, maxEntries: 1, maxCost: 4, maxResponseBytes: 4, cost: () => 4, maxAgeFrames: 2, diff --git a/tests/resource-view.test.ts b/tests/resource-view.test.ts index 5619f765b..02238fcfa 100644 --- a/tests/resource-view.test.ts +++ b/tests/resource-view.test.ts @@ -139,3 +139,15 @@ test("a reusable disposer does not erase the decoded value's type", () => { expect(tile?.row).toBe(42); dispose(); expect(freed).toEqual([7]); }); }); + +test("unchanged demand survives clear, invalidation and mutation of reused arrays",()=>{ + createRoot(dispose=>{ + const x=setup(),wanted=demand("a");const v=createResourceView(x.collection,{demand:()=>wanted}); + x.runtime.step();x.requests[0].done({ok:true,value:"A"});x.runtime.step(); + for(let i=0;i<10;i++)x.runtime.step();expect(x.requests.length).toBe(1);expect(v.value("a")).toBe("A"); + x.collection.invalidate();x.runtime.step();expect(x.requests.length).toBe(2); + x.collection.clear();x.runtime.step();expect(x.requests.length).toBe(3); + wanted[0].input="b";x.runtime.step();expect(x.requests.at(-1)?.key).toBe("b");expect(v.state("a").status).toBe("pending"); + dispose(); + }); +}); diff --git a/tests/tile-viewport.test.ts b/tests/tile-viewport.test.ts new file mode 100644 index 000000000..f52322131 --- /dev/null +++ b/tests/tile-viewport.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; +import { createTileCamera, visibleTiles, planTileWindow, createTileIntent } from "../framework/src/tile-viewport.ts"; +import { createDragFilter } from "../framework/src/drag-filter.ts"; +const options = { width: 400, height: 240, x: 128, y: 128, zoom: 10, minZoom: 1, maxZoom: 18 }; +test("opening a finite atlas replaces wrapping and zoom limits without replacing the camera", () => { + const c = createTileCamera({ ...options, bounds: { width: 256, height: 256, wrapX: true } }); + c.zoomBy(2); + c.setWorld({ minZoom: 0, maxZoom: 7, bounds: { width: 256, height: 256 } }); + c.jump(300, -10, 10); + expect(c.view().zoom).toBe(7); expect(c.view().x).toBeCloseTo(256 - 200 / 128); + expect(c.view().y).toBeCloseTo(120 / 128); expect(c.view().moving).toBe(false); + const before = c.view(); + expect(() => c.setWorld({ minZoom: 8, maxZoom: 7 })).toThrow(); + expect(c.view()).toEqual(before); + c.zoomBy(-20); for (let n = 0; n < 20; n++) c.step(1 / 60); + expect(c.view().zoom).toBe(0); expect(c.view().x).toBe(128); + const p = { x: 128, y: 128, zoom: 5, level: 5, width: 400, height: 240, maxTiles: 12, margin: 256, leadX: 384, maxExtra: 12 }; + const window = planTileWindow(p); + expect(window.visible).toEqual(visibleTiles(p)); expect(window.lookAhead).toHaveLength(12); + expect(() => planTileWindow({ ...p, margin: 513 })).toThrow(); +}); +test("anchored zoom preserves the world point beneath the chosen viewport pixel", () => { + const camera = createTileCamera(options), before = camera.view(); + const at = (v: typeof before) => ({ x: v.x + (50 - 200) / v.scale, y: v.y + (80 - 120) / v.scale }); + camera.zoomBy(1, 50, 80); for (let i = 0; i < 20; i++) camera.step(1 / 60); + expect(camera.view().zoom).toBe(11); expect(at(camera.view()).x).toBeCloseTo(at(before).x, 10); expect(at(camera.view()).y).toBeCloseTo(at(before).y, 10); +}); +test("pan and inertia are sampled consistently at 30 and 60 Hz and react before IO", () => { + const replay = (hz: number) => { const c = createTileCamera(options); for (let i = 0; i < hz; i++) c.step(1 / hz, 300, -180); for (let i = 0; i < hz; i++) c.step(1 / hz); return c.view(); }; + expect(replay(30).x).toBeCloseTo(replay(60).x, 9); expect(replay(30).y).toBeCloseTo(replay(60).y, 9); + const c = createTileCamera(options); c.beginDrag(); c.drag(20, 30); expect(c.view().x).toBeLessThan(options.x); + c.endDrag(200, 0); const x = c.view().x; c.step(1 / 60); expect(c.view().x).toBeLessThan(x); +}); +test("bounded tile windows cover exact edges without hidden adjacent fetches", () => { + expect(visibleTiles({ x: 128, y: 128, zoom: 0, level: 0, width: 256, height: 256, maxTiles: 4 })).toEqual([{ column: 0, row: 0, priority: 0 }]); + expect(() => visibleTiles({ x: 0, y: 0, zoom: 0, level: 20, width: 400, height: 240, maxTiles: 12 })).toThrow("budget"); + const t = visibleTiles({ ...options, level: 10, maxTiles: 12 }); expect(t.length).toBeLessThanOrEqual(6); + expect(t.map(t => t.priority)).toEqual(t.map(t => t.priority).sort((a, b) => a - b)); +}); +test("world wrap, pole clamping and malformed input remain bounded", () => { + const c = createTileCamera({ ...options, bounds: { width: 256, height: 256, wrapX: true } }); + c.jump(257, -100, 1); expect(c.view().x).toBe(1); expect(c.view().y).toBe(60); + c.jump(-1, 1000, 99); expect(c.view().x).toBe(255); expect(c.view().zoom).toBe(18); expect(c.view().y).toBeLessThan(256); + expect(() => c.step(1)).toThrow(); expect(() => c.drag(NaN, 0)).toThrow(); +}); +test("look-ahead has a separate cap and never replaces visible demand", () => { + const p = { x: 256, y: 256, zoom: 0, level: 0, width: 400, height: 240, maxTiles: 12, margin: 64, leadX: 128, maxExtra: 4 }; + const plan = planTileWindow(p); + expect(plan.visible).toEqual(visibleTiles(p)); expect(plan.lookAhead.length).toBeLessThanOrEqual(4); + expect(plan.lookAhead.some(t => t.column === 2)).toBe(true); + expect(plan.lookAhead.every(t => !plan.visible.some(v => v.column === t.column && v.row === t.row))).toBe(true); + expect(planTileWindow({ ...p, maxExtra: 0 }).lookAhead).toEqual([]); + expect(() => planTileWindow({ ...p, leadX: Infinity })).toThrow(); + expect(() => planTileWindow({ ...p, level: 24 })).toThrow("budget"); + expect(() => planTileWindow({ ...p, x: Number.MAX_VALUE })).toThrow("integer range"); + expect(() => visibleTiles({ ...p, y: 1e100 })).toThrow("integer range"); +}); +test("drag filter rejects stationary quantization without accumulating drift", () => { + const filter = createDragFilter(); let x = 0, y = 0; + for (let i = 0; i < 600; i++) { const d = filter.update(i % 2 ? 1 : -1, 0, 1 / 60); x += d.dx; y += d.dy; } + expect(x).toBe(0); expect(y).toBe(0); expect(filter.velocity()).toEqual({ x: 0, y: 0 }); + expect(() => filter.update(NaN, 0, 1 / 60)).toThrow(); + expect(() => filter.update(0, 0, 1)).toThrow(); +}); +test("drag tracks total travel within a pixel budget at both sample rates and stops flinging after a hold", () => { + for (const hz of [30, 60]) { + const filter = createDragFilter(); let x = 0; + for (let i = 1; i <= hz; i++) { const d = filter.update(i * 300 / hz, 0, 1 / hz); x += d.dx; expect(i * 300 / hz - x).toBeLessThanOrEqual(4.0001); } + expect(filter.velocity().x).toBeGreaterThan(280); + for (let i = 0; i < hz; i++) x += filter.update(300, 0, 1 / hz).dx; + expect(x).toBeCloseTo(299, 4); expect(filter.velocity().x).toBe(0); + filter.reset(); expect(filter.update(0, 0, 1 / hz).dx).toBe(0); + } +}); + +test("direction history spans repeated strokes, decays at rest and turns without a reverse tail", () => { + for (const hz of [30, 60]) { + const intent = createTileIntent(); + for (let stroke = 0; stroke < 5; stroke++) { + for (let i = 0; i < hz / 2; i++) intent.sample(120 / hz, -120 / hz, 1 / hz); + for (let i = 0; i < hz / 2; i++) intent.sample(0, 0, 1 / hz); + } + const lead = intent.predict(512); expect(lead.x).toBeGreaterThan(350); expect(lead.y).toBeLessThan(-350); + const p = { x: 128, y: 128, zoom: 5, level: 5, width: 400, height: 240, maxTiles: 12, margin: 256, leadX: lead.x, leadY: lead.y, directional: true, maxExtra: 12 }; + const w = planTileWindow(p); expect(w.visible).toEqual(visibleTiles(p)); expect(w.lookAhead.length).toBeGreaterThan(3); + expect(w.lookAhead.every(t => (t.column + .5 - 16) * lead.x + (t.row + .5 - 16) * lead.y > 0)).toBe(true); + for (let i = 0; i < hz; i++) intent.sample(-180 / hz, 0, 1 / hz); + expect(intent.predict(512).x).toBeLessThan(-490); + for (let i = 0; i < hz * 9; i++) intent.sample(0, 0, 1 / hz); + expect(intent.predict(512).confidence).toBe(0); + intent.sample(200, 0, 1 / hz); expect(intent.predict(512).confidence).toBe(0); + } +}); +test("zoom intent exposes the final level throughout repeated animated inputs", () => { + const c = createTileCamera(options); c.zoomBy(1); c.step(1 / 60); c.zoomBy(1); + expect(c.view().zoom).toBeLessThan(12); expect(c.view().targetZoom).toBe(12); + c.stop(); expect(c.view().targetZoom).toBe(c.view().zoom); +}); diff --git a/tools/offload-process.ts b/tools/offload-process.ts new file mode 100644 index 000000000..0cbdfff2b --- /dev/null +++ b/tools/offload-process.ts @@ -0,0 +1,28 @@ +/** Bun subprocess adapter for existing provider modules using self.onmessage. + * OS process termination isolates fetch/native-addon teardown from the LAN + * transport. IPC uses structured cloning, including bounded image planes. */ +import { OFFLOAD } from "../contracts/spec/offload.ts"; +const waiting: unknown[] = []; +let loaded = false; +const scope = { + onmessage: undefined as ((event: { data: unknown }) => unknown) | undefined, + postMessage(value: unknown) { process.send?.(value); }, +}; +(globalThis as unknown as { self: typeof scope }).self = scope; +function deliver(data: unknown) { + try { + if (!scope.onmessage) throw new Error("Provider module has no message handler"); + Promise.resolve(scope.onmessage({ data })).catch(() => process.exit(1)); + } catch { process.exit(1); } +} +process.on("message", data => { + if (loaded) deliver(data); + else if (waiting.length < OFFLOAD.pending + 1) waiting.push(data); + else process.exit(1); +}); +// Do not leave a provider behind when its transport process goes away. +process.on("disconnect", () => process.exit(0)); +await import(process.argv[2]!); +loaded = true; +for (const data of waiting) deliver(data); +waiting.length = 0; diff --git a/tools/offload-provider.ts b/tools/offload-provider.ts index 1512dc4b9..c88784187 100644 --- a/tools/offload-provider.ts +++ b/tools/offload-provider.ts @@ -1,68 +1,150 @@ /** 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"; +import { resolve } from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { OFFLOAD, type OffloadRequest, type OffloadProviderReply, type OffloadImage, type OffloadMesh } from "../contracts/spec/offload.ts"; +import { OffloadDecoder, encodeOffloadRecord, encodeOffloadImage, encodeOffloadMesh } from "./offload-wire.ts"; +export { prepareMesh } from "./offload-wire.ts"; +export type { OffloadImage, OffloadMesh } from "../contracts/spec/offload.ts"; export function connectOffloadProvider(options: { address: string; key: string; worker: string | URL; data?: unknown; port?: number; log?: (message: string) => void; + /** Opt-in request timing and socket backpressure diagnostics; no payloads. */ + trace?: boolean; + /** Process mode isolates native codecs and fetch teardown from the transport. + * Both modes use the same self.onmessage/postMessage provider module API. */ + isolation?: "thread" | "process"; }) { 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; + let generation = 0; + let lastConnectFailure = ""; 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 session = ++generation; + const log = (message: string) => options.log?.(`Session ${session}: ${message}`); + let worker: { postMessage(value: unknown): void; terminate(): void | Promise } | undefined; + let closed = false, reason = "device closed connection"; + const pending = new Map(); const deadlines = new Map>(); - closeCurrent = () => socket.destroy(); + const replies: Buffer[] = []; + let writing = false, held: Buffer | undefined, consumed = 0; + let blockedAt = 0; + // Sending an image and computing the next resource can overlap. The same + // eight credits cover active work, queued replies and the blocked write. + const canRead = () => pending.size + replies.length + Number(writing) < OFFLOAD.pending; + const fail = (why: string) => { if (closed) return; reason = why; socket.destroy(); }; + const connecting = setTimeout(() => fail("connect timeout"), 5000); + closeCurrent = () => fail("provider stopped"); socket.setNoDelay(true); - socket.setTimeout(15000, () => socket.destroy()); + socket.setTimeout(15000, () => fail("device idle timeout")); socket.on("connect", () => { + clearTimeout(connecting); lastConnectFailure = ""; 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(); + const replyToDevice = (reply: OffloadProviderReply) => { + if (closed || socket.destroyed) return; + if (!reply || !Number.isSafeInteger(reply.id) || reply.id < 1 || reply.id > 0xffffffff) return fail("invalid provider reply ID"); + const request = pending.get(reply.id); + if (!pending.delete(reply.id)) return fail("unexpected provider reply ID"); 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(); } + if (reply.image && request?.response !== "image") throw new Error("Unrequested image response"); + if (reply.mesh && request?.response !== "mesh") throw new Error("Unrequested mesh response"); + const record = reply.mesh ? encodeOffloadMesh(reply.id, reply.mesh) : reply.image ? encodeOffloadImage(reply.id, reply.image) : encodeOffloadRecord(JSON.stringify(reply)); + if (options.trace) log(`reply id=${reply.id} method=${request!.method} providerMs=${Date.now() - request!.started} bytes=${record.length} error=${!!reply.error} pending=${pending.size} queued=${replies.length}`); + // Each queued reply replaces one admitted request. Slow LAN writes + // consume credit; they are not an invalid connection. + if (replies.length >= OFFLOAD.pending) return fail("provider reply credit exceeded"); + replies.push(record); flush(); + } catch { fail("invalid provider response"); } }; - options.log?.("Transport connected; waiting for paired device requests"); + try { + if (options.isolation === "process") { + const entry = options.worker instanceof URL ? options.worker.href : options.worker.startsWith("file:") ? options.worker : pathToFileURL(resolve(options.worker)).href; + const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./offload-process.ts", import.meta.url)), entry], { + stdin: "ignore", stdout: "inherit", stderr: "inherit", serialization: "advanced", + ipc: replyToDevice, + onExit(_child, code, signal) { if (!closed) fail(`provider process exited (code=${code}, signal=${signal ?? "none"})`); }, + }); + worker = { postMessage: value => child.send(value), terminate() { + if (child.exitCode === null && !child.signalCode) child.kill("SIGKILL"); + return child.exited; + } }; + log(`provider process pid=${child.pid}`); + } else { + const thread = new Worker(options.worker, { type: "module" }); + thread.onerror = () => fail("provider worker error"); + thread.onmessage = event => replyToDevice(event.data); + worker = thread; + } + worker.postMessage({ init: options.data }); + log("transport connected; waiting for paired device requests"); + } catch { fail("provider executor could not start"); } }); - socket.on("data", chunk => { + const requestFromDevice = (raw: string) => { + 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 canRead(); + } + if (request.v !== 1 || !Number.isSafeInteger(request.id) || request.id < 1 || request.id > 0xffffffff || + typeof request.method !== "string" || !/^[a-z][a-z0-9_.-]{0,63}$/.test(request.method) || + typeof request.payload !== "string" || request.payload.length > OFFLOAD.payloadChars || + request.response !== undefined && request.response !== "image" && request.response !== "mesh" || + pending.size >= OFFLOAD.pending || pending.has(request.id)) throw new Error("Invalid request"); + pending.set(request.id, { response: request.response, method: request.method, started: Date.now() }); + if (options.trace) log(`request id=${request.id} method=${request.method} pending=${pending.size}`); + deadlines.set(request.id, setTimeout(() => fail(`request deadline: ${request.method} id=${request.id}`), 9000)); + worker!.postMessage(request); + return canRead(); + }; + function read() { + if (closed || socket.destroyed) return; 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(); } + if (held && canRead()) { + consumed += decoder.push(held.subarray(consumed), requestFromDevice, canRead); + if (consumed === held.length) { held = undefined; consumed = 0; } + } + if (canRead() && !held) socket.resume(); else socket.pause(); + } catch { fail("invalid device request or provider unavailable"); } + } + function flush() { + if (closed || socket.destroyed) return; + while (!writing && replies.length) { + writing = !socket.write(replies.shift()!); + if (writing) { blockedAt = Date.now(); if (options.trace) log(`write blocked bytes=${socket.writableLength} queued=${replies.length}`); } + } + read(); + } + socket.on("drain", () => { + if (options.trace && writing) log(`write drained waitMs=${Date.now() - blockedAt} queued=${replies.length}`); + writing = false; flush(); + }); + socket.on("data", chunk => { + socket.pause(); + if (held) return fail("device input credit exceeded"); + held = typeof chunk === "string" ? Buffer.from(chunk) : chunk; consumed = 0; read(); }); - socket.on("error", () => {}); + socket.on("error", (error: NodeJS.ErrnoException) => { reason = `socket ${error.code ?? "error"}`; }); socket.on("close", () => { - worker?.terminate(); + closed = true; + clearTimeout(connecting); for (const timer of deadlines.values()) clearTimeout(timer); - if (!stopped) retry = setTimeout(attach, 1500); + if (worker) log(`disconnected: ${reason}; pending=${pending.size}`); + else if (!stopped && reason !== lastConnectFailure) { log(`waiting for device: ${reason}`); lastConnectFailure = reason; } + held = undefined; replies.length = 0; pending.clear(); deadlines.clear(); + // Reap the old process before starting another; its late replies cannot + // enter a replacement session, including after request IDs restart. + const reconnect = () => { + if (!stopped) retry = setTimeout(attach, 1500); + }; + Promise.resolve().then(() => worker?.terminate()).then(reconnect, reconnect); }); }; attach(); @@ -71,13 +153,22 @@ export function connectOffloadProvider(options: { /** Worker-side allowlist. A missing method cannot open arbitrary resources. */ export async function dispatchOffload( - methods: Readonly string | Promise>>, + methods: Readonly string | OffloadImage | OffloadMesh | Promise>>, request: OffloadRequest, -): Promise { +): 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 (typeof payload !== "string") { + if (payload.format === "mesh2d-v1") { + if (request.response !== "mesh") throw new Error("Mesh response was not requested"); + encodeOffloadMesh(request.id,payload); return {id:request.id,mesh:payload}; + } + if (request.response !== "image") throw new Error("Image response was not requested"); + encodeOffloadImage(request.id, payload); + return { id: request.id, image: payload }; + } if (payload.length > OFFLOAD.payloadChars) throw new Error("Result budget exceeded"); const reply = { id: request.id, payload }; encodeOffloadRecord(JSON.stringify(reply)); @@ -86,3 +177,5 @@ export async function dispatchOffload( return { id: request.id, error: error instanceof Error ? error.message.slice(0, 160) : "Provider failed" }; } } + +export { connectOffloadUsbProvider } from "./offload-usb-provider.ts"; diff --git a/tools/offload-usb-provider.ts b/tools/offload-usb-provider.ts new file mode 100644 index 000000000..064672941 --- /dev/null +++ b/tools/offload-usb-provider.ts @@ -0,0 +1,315 @@ +/** PSPLINK host0 adapter. Immutable response files are atomically replaced; + * eight reusable request slots bound disk and worker backlog. Codecs stay in + * the same process-isolated executor used by the LAN provider. */ +import { + mkdirSync, + readFileSync, + writeFileSync, + renameSync, + unlinkSync, + statSync, +} from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createHash, randomBytes } from "node:crypto"; +import { + OFFLOAD, + type OffloadRequest, + type OffloadProviderReply, +} from "../contracts/spec/offload.ts"; +import { validateMesh } from "./offload-wire.ts"; +export const USB_HEADER = 64, + USB_MAGIC = 0x31424f50; +export const usbSlot = (app: string) => + createHash("sha256").update(app).digest("hex").slice(0, 16); +export function usbHash(bytes: Uint8Array) { + let h = 2166136261; + for (const b of bytes) h = Math.imul(h ^ b, 16777619) >>> 0; + return h; +} +export function usbPacket( + epoch: number, + boot: number, + sequence: number, + kind: number, + id: number, + width: number, + height: number, + payload: Uint8Array, +) { + const b = Buffer.alloc(USB_HEADER + payload.length); + for (const [at, value] of [ + [0, USB_MAGIC], + [4, epoch], + [8, boot], + [12, sequence], + [16, kind], + [20, id], + [24, width], + [28, height], + [32, payload.length], + [36, usbHash(payload)], + ]) + b.writeUInt32LE(value >>> 0, at); + b.set(payload, USB_HEADER); + return b; +} +export function connectOffloadUsbProvider(options: { + directory: string; + app: string; + worker: string | URL; + data?: unknown; + log?(message: string): void; +}) { + const root = resolve( + options.directory, + "pocket-offload", + usbSlot(options.app), + ); + mkdirSync(root, { recursive: true }); + let epoch = randomBytes(4).readUInt32LE() || 1; + const pending = new Map< + number, + { + slot: number; + boot: number; + sequence: number; + request: OffloadRequest; + at: number; + } + >(); + const seen = Array(8).fill(""); + let stopped = false, + heartbeat = 0, + serial = 0, + loaded = false, + lastStats = ""; + function publish(name: string, bytes: Uint8Array) { + const path = resolve(root, name); + writeFileSync(path + ".tmp", bytes); + renameSync(path + ".tmp", path); + } + function offline() { + try { + unlinkSync(resolve(root, "ready")); + } catch {} + } + const entry = + options.worker instanceof URL + ? options.worker.href + : pathToFileURL(resolve(options.worker)).href; + let child: ReturnType; + let restartTimer: ReturnType | undefined; + function spawn() { + child = Bun.spawn( + [ + process.execPath, + fileURLToPath(new URL("./offload-process.ts", import.meta.url)), + entry, + ], + { + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + serialization: "advanced", + ipc(reply: OffloadProviderReply) { + if ( + !reply || + typeof reply !== "object" || + !Number.isSafeInteger(reply.id) + ) + return; + const p = pending.get(reply.id); + if (!p || stopped || !loaded) return; + pending.delete(reply.id); + if (seen[p.slot] !== `${p.boot}/${p.sequence}`) return; + try { + let kind = 0, + width = 0, + height = 0, + payload: Uint8Array; + if (reply.mesh) { + if (p.request.response !== "mesh") + throw Error("Unrequested mesh"); + ({ width, height } = validateMesh(reply.mesh.bytes)); + kind = 1; + payload = reply.mesh.bytes; + } else if (reply.image) { + const im = reply.image; + if ( + p.request.response !== "image" || + im.format !== "r5g6b5" || + ![im.width, im.height].every( + (n) => + Number.isInteger(n) && + n >= 16 && + n <= 256 && + !(n & (n - 1)), + ) || + im.pixels.length !== im.width * im.height * 2 + ) + throw Error("Invalid image"); + kind = 2; + width = im.width; + height = im.height; + payload = im.pixels; + } else { + payload = Buffer.from( + JSON.stringify({ ...reply, id: p.request.id }), + ); + if (payload.length > OFFLOAD.recordBytes) + throw Error("JSON budget exceeded"); + } + publish( + `res${p.slot}`, + usbPacket( + epoch, + p.boot, + p.sequence, + kind, + p.request.id, + width, + height, + payload, + ), + ); + options.log?.( + `USB reply ${p.request.method} id=${p.request.id} bytes=${payload.length} ms=${Date.now() - p.at}${reply.error ? ` error=${reply.error}` : ""}`, + ); + } catch (error) { + publish( + `res${p.slot}`, + usbPacket( + epoch, + p.boot, + p.sequence, + 0, + p.request.id, + 0, + 0, + Buffer.from( + JSON.stringify({ + id: p.request.id, + error: String(error).slice(0, 160), + }), + ), + ), + ); + } + }, + onExit() { + loaded = false; + offline(); + pending.clear(); + seen.fill(""); + options.log?.("USB provider executor stopped"); + if (!stopped) + restartTimer = setTimeout(() => { + epoch = randomBytes(4).readUInt32LE() || 1; + spawn(); + }, 500); + }, + }, + ); + child.send({ init: options.data }); + loaded = true; + } + spawn(); + const pulse = setInterval(() => { + if (!loaded || stopped) return; + const b = Buffer.alloc(USB_HEADER); + b.writeUInt32LE(USB_MAGIC, 0); + b.writeUInt32LE(epoch, 4); + b.writeUInt32LE(++heartbeat, 8); + publish("ready", b); + try { + const b = readFileSync(resolve(root, "stats")); + const s = b.toString(); + if (s.length > 0 && s !== lastStats) { + lastStats = s; + options.log?.(`PSP ${s.trim()}`); + } + } catch {} + }, 250); + const pump = setInterval(() => { + if (stopped || !loaded) return; + for (let slot = 0; slot < 8 && pending.size < 8; slot++) { + let b: Buffer; + try { + const path = resolve(root, `req${slot}`); + if (statSync(path).size > USB_HEADER + OFFLOAD.recordBytes) continue; + b = readFileSync(path); + } catch { + continue; + } + if ( + b.length < USB_HEADER || + b.length > USB_HEADER + OFFLOAD.recordBytes || + b.readUInt32LE(0) !== USB_MAGIC || + b.readUInt32LE(4) !== epoch + ) + continue; + const boot = b.readUInt32LE(8), + sequence = b.readUInt32LE(12), + key = `${boot}/${sequence}`; + if ( + !boot || + !sequence || + seen[slot] === key || + b.readUInt32LE(32) !== b.length - USB_HEADER || + usbHash(b.subarray(USB_HEADER)) !== b.readUInt32LE(36) + ) + continue; + let request: OffloadRequest; + try { + request = JSON.parse(b.toString("utf8", USB_HEADER)); + } catch { + continue; + } + if ( + !request || + typeof request !== "object" || + request.v !== 1 || + !Number.isSafeInteger(request.id) || + request.id < 1 || + request.id > 0xffffffff || + typeof request.method !== "string" || + !/^[a-z][a-z0-9_.-]{0,63}$/.test(request.method) || + typeof request.payload !== "string" || + request.payload.length > OFFLOAD.payloadChars || + (request.response !== undefined && + request.response !== "image" && + request.response !== "mesh") + ) + continue; + seen[slot] = key; + const id = ++serial; + pending.set(id, { slot, boot, sequence, request, at: Date.now() }); + child.send({ ...request, id }); + } + // Execution credit is released only after the child exits. A timeout + // rotates the entire session; late replies cannot attach to a new slot. + for (const p of pending.values()) + if (Date.now() - p.at > 12000) { + loaded = false; + offline(); + child.kill(); + break; + } + }, 10); + options.log?.(`USB provider ${root}`); + return { + root, + get epoch() { + return epoch; + }, + close() { + stopped = true; + if (restartTimer) clearTimeout(restartTimer); + clearInterval(pulse); + clearInterval(pump); + offline(); + if (child.exitCode === null) child.kill(); + }, + }; +} diff --git a/tools/offload-wire.ts b/tools/offload-wire.ts index f2a185326..c341a5b67 100644 --- a/tools/offload-wire.ts +++ b/tools/offload-wire.ts @@ -1,10 +1,41 @@ -import { OFFLOAD } from "../contracts/spec/offload.ts"; +import { + OFFLOAD, + OFFLOAD_IMAGE, + OFFLOAD_MESH, + type OffloadMesh, + type OffloadImage, +} from "../contracts/spec/offload.ts"; + +export function encodeOffloadImage(id: number, image: OffloadImage): Buffer { + const valid = (n: number) => Number.isInteger(n) && n >= 16 && n <= OFFLOAD_IMAGE.maxSide && (n & (n - 1)) === 0; + if ( + !Number.isSafeInteger(id) || + id < 1 || + id > 0xffffffff || + !valid(image.width) || + !valid(image.height) || + image.format !== "r5g6b5" || + !(image.pixels instanceof Uint8Array) || + image.pixels.byteLength !== image.width * image.height * 2 + ) + throw new Error("Invalid offload image envelope"); + const bytes = Buffer.allocUnsafe(4 + OFFLOAD_IMAGE.headerBytes + image.pixels.byteLength); + bytes.writeUInt32BE((OFFLOAD_IMAGE.flag + bytes.length - 4) >>> 0); + bytes.write("PIMG", 4); + bytes.writeUInt32LE(id, 8); + bytes.writeUInt16LE(image.width, 12); + bytes.writeUInt16LE(image.height, 14); + bytes.writeUInt32LE(0, 16); + bytes.set(image.pixels, 20); + return bytes; +} 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); + out.writeUInt32BE(payload.length); + payload.copy(out, 4); return out; } /** Fixed allocation, including partial headers and arbitrarily split UTF-8. */ @@ -12,12 +43,15 @@ export class OffloadDecoder { private bytes = Buffer.alloc(OFFLOAD.recordBytes + 4); private have = 0; private want = 4; - push(chunk: Uint8Array, deliver: (record: string) => void) { + /** A false continueReading pauses after a complete record. The caller retains + * the unconsumed suffix and resumes it when downstream credit returns. */ + push(chunk: Uint8Array, deliver: (record: string) => void, continueReading?: () => boolean): number { 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; + this.have += n; + offset += n; if (this.have !== this.want) continue; if (this.want === 4) { const size = this.bytes.readUInt32BE(); @@ -25,8 +59,86 @@ export class OffloadDecoder { this.want = size + 4; } else { const record = this.bytes.toString("utf8", 4, this.want); - this.have = 0; this.want = 4; deliver(record); + this.have = 0; + this.want = 4; + deliver(record); + if (continueReading && !continueReading()) break; } } + return offset; + } +} + +export function validateMesh(bytes: Uint8Array) { + if (!(bytes instanceof Uint8Array) || bytes.length < 16 || bytes.length > OFFLOAD_MESH.maxBytes) + throw new Error("Invalid mesh size"); + const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = v.getUint16(4, true), + height = v.getUint16(6, true), + nv = v.getUint16(8, true), + nt = v.getUint16(10, true); + if ( + v.getUint32(0, true) !== 0x31484d50 || + v.getUint32(12, true) || + !width || + !height || + width > 4095 || + height > 4095 || + nv > OFFLOAD_MESH.maxVertices || + nt > OFFLOAD_MESH.maxTriangles || + bytes.length !== 16 + nv * 4 + nt * 10 + ) + throw new Error("Invalid mesh envelope"); + for (let i = 0; i < nv; i++) + if (v.getUint16(16 + i * 4, true) > width * 16 || v.getUint16(18 + i * 4, true) > height * 16) + throw new Error("Invalid mesh coordinate"); + for (let i = 0; i < nt; i++) + for (let j = 0; j < 3; j++) + if (v.getUint16(16 + nv * 4 + i * 10 + j * 2, true) >= nv) throw new Error("Invalid mesh index"); + return { width, height, bytes: bytes.length }; +} +export function encodeOffloadMesh(id: number, mesh: OffloadMesh): Buffer { + if (!Number.isSafeInteger(id) || id < 1 || id > 0xffffffff || mesh.format !== "mesh2d-v1") + throw new Error("Invalid mesh response"); + validateMesh(mesh.bytes); + const bytes = Buffer.allocUnsafe(12 + mesh.bytes.length); + bytes.writeUInt32BE((OFFLOAD_IMAGE.flag + bytes.length - 4) >>> 0); + bytes.write("PMSH", 4); + bytes.writeUInt32LE(id, 8); + bytes.set(mesh.bytes, 12); + return bytes; +} + +/** Host-only packing of already clipped/tessellated geometry in logical units. + * Topology and style stay with the capability; the wire layout stays here. */ +export function prepareMesh(input: { + width: number; + height: number; + vertices: readonly (readonly [number, number])[]; + triangles: readonly (readonly [number, number, number, number])[]; +}): OffloadMesh { + const { width, height, vertices, triangles } = input; + if (![width, height].every(n => Number.isInteger(n) && n > 0 && n <= 4095) || + vertices.length > OFFLOAD_MESH.maxVertices || triangles.length > OFFLOAD_MESH.maxTriangles) + throw new Error("Invalid prepared mesh dimensions or counts"); + const bytes = new Uint8Array(16 + vertices.length * 4 + triangles.length * 10); + const view = new DataView(bytes.buffer); + view.setUint32(0, 0x31484d50, true); + for (const [at, n] of [[4, width], [6, height], [8, vertices.length], [10, triangles.length]]) view.setUint16(at, n, true); + for (let i = 0; i < vertices.length; i++) { + const [x,y] = vertices[i]; + if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x > width || y > height) throw new Error("Invalid prepared mesh coordinate"); + view.setUint16(16 + i * 4, Math.round(x * 16), true); view.setUint16(18 + i * 4, Math.round(y * 16), true); + } + for (let i = 0; i < triangles.length; i++) { + const triangle = triangles[i], at = 16 + vertices.length * 4 + i * 10; + if (!Number.isInteger(triangle[3]) || triangle[3] < 0 || triangle[3] > 0xffffffff) throw new Error("Invalid prepared mesh color"); + for (let j = 0; j < 3; j++) { + const index = triangle[j]; + if (!Number.isInteger(index) || index < 0 || index >= vertices.length) throw new Error("Invalid prepared mesh index"); + view.setUint16(at + j * 2, index, true); + } + view.setUint32(at + 6, triangle[3], true); } + return { format: "mesh2d-v1", bytes }; } diff --git a/tools/psp.ts b/tools/psp.ts index e265579f7..3440b9790 100644 --- a/tools/psp.ts +++ b/tools/psp.ts @@ -17,6 +17,7 @@ // ms0:/PocketJS-bench.jsonl and implies --capture. import { $ } from "bun"; +import { createHash } from "node:crypto"; import { existsSync, statSync, unlinkSync } from "node:fs"; import { resolve as resolvePath } from "node:path"; import { pathToFileURL } from "node:url"; @@ -307,6 +308,7 @@ const hostEnvironment = buildPlan }; const env = { + POCKETJS_OFFLOAD_SLOT: buildPlan?.features["io.offload"] ? createHash("sha256").update(buildPlan.app.id).digest("hex").slice(0, 16) : "", ...toolchain.environment, RUSTFLAGS: rustflags, CRATE_CC_NO_DEFAULTS: "1", diff --git a/tools/tape.ts b/tools/tape.ts index 7ee663571..9f42b67fd 100644 --- a/tools/tape.ts +++ b/tools/tape.ts @@ -23,6 +23,7 @@ import { expandTape, expandTapeAnalog, expandTapeRightAnalog, + expandTapeInputElapsed, expandTapeTouch, expandTapeTouchSurfaces, type Tape, @@ -85,6 +86,7 @@ interface BootResult { hits?: readonly number[], touchSurfaces?: readonly number[], rightAnalog?: number, + inputElapsedUs?: number, ) => void; tick: () => void; render: () => Uint8Array; @@ -156,6 +158,7 @@ async function cmdReplay(): Promise { const masks = expandTape(tape); const analogs = expandTapeAnalog(tape); const rightAnalogs = expandTapeRightAnalog(tape); + const inputElapsed = expandTapeInputElapsed(tape); const touches = expandTapeTouch(tape); const touchSurfaces = expandTapeTouchSurfaces(tape); const hashesOut = argValue("--hashes"); @@ -172,7 +175,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], rightAnalogs[f]); + b.frame(masks[f], analogs[f], touches[f], undefined, touchSurfaces[f], rightAnalogs[f], inputElapsed[f]); b.tick(); const fb = b.render(); const h = fnv1a(fb); @@ -210,13 +213,14 @@ async function cmdTree(): Promise { const masks = expandTape(tape); const analogs = expandTapeAnalog(tape); const rightAnalogs = expandTapeRightAnalog(tape); + const inputElapsed = expandTapeInputElapsed(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], rightAnalogs[f]); + b.frame(masks[f], analogs[f], touches[f], undefined, touchSurfaces[f], rightAnalogs[f], inputElapsed[f]); b.tick(); } b.outbox.length = 0; diff --git a/tools/test.ts b/tools/test.ts index aa1c98469..a2fd54cd1 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -85,6 +85,10 @@ const SUITE: readonly Stage[] = [ "tests/fs.test.ts", "tests/net.test.ts", "tests/offload.test.ts", + "tests/offload-images.test.ts", + "tests/offload-meshes.test.ts", + "tests/offload-provider.test.ts", + "tests/tile-viewport.test.ts", "tests/resource-cache.test.ts", "tests/net-web.test.js", "tests/vita-package.test.ts",