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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/3ds-runtime.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
name: 3DS runtime contracts
on:
pull_request:
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']
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/resource-pack.ts', 'tests/fixtures/resource-pack/**', '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/**', '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']
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/resource-pack.ts', 'tests/fixtures/resource-pack/**', '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:
Expand All @@ -18,4 +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
- run: bun test --conditions=browser tests/offload.test.ts tests/offload-images.test.ts tests/offload-meshes.test.ts tests/offload-provider.test.ts tests/tile-viewport.test.ts tests/resource-cache.test.ts tests/resource-view.test.ts tests/resource-pack.test.ts tests/resource-pack-view.test.ts
1 change: 1 addition & 0 deletions contracts/spec/platforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export const POCKET_CAPABILITIES = defineCapabilityRegistry([
// remain host-owned; the browser dev host, deterministic sim and reference
// core exercise the contract without granting network access to every host.
"io.offload",
"io.resource-pack",
"net.http",
// SQLite behind the db module's own namespace (`globalThis.db`,
// contracts/spec/db.ts): five synchronous ops, rows as one JSON line per
Expand Down
76 changes: 76 additions & 0 deletions docs/RESOURCE_PACKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Prepared resources on 3DS storage

An app can install immutable images on its SD card and load them through
`@pocketjs/framework/resource-pack`. **File access and zlib decoding run on a
native worker.** Guest JavaScript submits an indexed address and receives a
bounded image ticket. It never opens a file, parses a directory or copies pixels.

```ts
const tiles = createPackedImageCollection(runtime, {
key: (tile: Tile) => `${tile.version}/${tile.index}`,
pack: tile => ({ name: `atlas-${tile.version}`, entry: tile.index }),
width: 256, height: 256,
maxEntries: 40, maxViews: 2, maxDemandsPerView: 24,
fallback: { client: offload(), method: "map.tile", payload: JSON.stringify },
});
const visible = createResourceView(tiles, { demand: visibleTiles });
```

The collection owns one cache regardless of storage. Views merge demand,
priority and pins; withdrawal cancels delivery, completion releases staging,
and eviction frees the native texture. A missing or corrupt local record can
use the optional paired-desktop fallback. A host without `io.resource-pack`
uses that fallback directly. Loaders that report a disconnected client refuse
admission so they do not consume the shared scheduler's active slots.

Declare `io.resource-pack` in the app manifest's `enhances` list. The 3DS build
includes the worker only when the resolved profile enables this capability.
PSP and other hosts currently have no local pack implementation.

## Installation and image preparation

`tools/resource-pack.ts` exports `prepareTiledRGB565` and
`createResourcePack(path, count)`. The desktop converts core RGB565 pixels to
PICA's channel order, vertical origin and 8×8 Morton ordering, then compresses
each entry with zlib. The writer completes a temporary file before renaming it.
Install the resulting file at
`sdmc:/pocketjs/assets/<runtime-slot>/<name>.prp`. The slot is the first 16 hex
characters of SHA-256 of the app ID. Names contain 1–48 lowercase ASCII letters,
digits or hyphens. Upload to a temporary path and verify before activation;
installed names identify immutable contents and must change when contents change.

**A 256×256 prepared image occupies 128 KiB of GPU texture storage.** The host
copies its tiled RGB565 bytes without the normal RGBA8 expansion or pixel
conversion. The core retains a generation-tagged descriptor with dimensions,
without a second CPU pixel copy. The renderer adopts or retires storage only
after its previous GPU work completes. It performs at most one image/mesh
materialization per frame across the local and desktop paths.

The worker owns eight 128 KiB result slots, one 128 KiB + 128 byte compressed
scratch buffer, four open files and a 32 KiB thread stack. Zlib uses bounded
per-record working storage. **No whole atlas or whole index is loaded into
RAM.** A request reads one 24-byte index entry and its bounded payload.
Submission and delivery each allow one record per frame. Saturation rejects
admission; it does not wait. Realm reset fences obsolete jobs by generation.
Canceled reads may finish on the worker, but cannot reach the old view.

## Format and diagnostics

PRP1 version 1 uses a 64-byte little-endian header and up to 65,536 indexed
24-byte entries. Header fields are magic, version, count and total file length;
remaining bytes are zero. Entry fields are offset, compressed length, raw length,
CRC32, kind, then 16-bit width and height. Files are limited to 2 GiB minus one
byte for the 3DS stdio seek range. Every entry is independently zlib compressed.

Kind 1 carries at most 2,500 UTF-8 bytes of application metadata, with zero
dimensions. `resourcePacks()?.request("pack.read", "atlas/0", callback)` reads
it through the same bounded async service. The serialized response must fit
the 4,096-byte offload record envelope; excessive escaping produces an error.
Kind 2 carries tiled RGB565 images with power-of-two dimensions from 16 to 256.
CRC32 detects damage; it is not an authentication mechanism.

`resourcePackStats()` reports completed reads, failures, cumulative IO/decode/
upload microseconds and maxima. These counters separate SD and codec time from
GPU upload time; they do not measure full visible-tile latency or frame rate.
Compare identical camera routes and cache budgets on hardware before claiming
a latency or frame-rate improvement.
17 changes: 16 additions & 1 deletion engine/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ pub struct Texture {
byte_len: usize,
pub w: u32,
pub h: u32,
/// spec::psm::* pixel format.
/// spec::psm::* pixel format, or u32::MAX for backend-owned storage.
pub psm: u32,
/// CLUT (PSM_T8 only): exactly TEX_PALETTE_BYTES bytes (256 x u32 ABGR)
/// in a 16-byte-aligned backing like `data`, so the PSP GE can point
Expand Down Expand Up @@ -641,6 +641,21 @@ impl Ui {
self.upload_texture_flags(data, w, h, psm, 0)
}

/// Register an image whose pixels are owned by the native GPU backend.
/// The backend must attach storage before drawing this handle and retire
/// it after its final GPU use. No CPU pixel buffer is allocated or sampled.
pub fn register_external_texture(&mut self, w: u32, h: u32) -> i32 {
let valid = |n: u32| n > 0 && n <= spec::TEX_MAX_DIM && n.is_power_of_two();
if !valid(w) || !valid(h) { return -1; }
let texture = Texture {
data: alloc::vec::Vec::new(), byte_len: 0, w, h, psm: u32::MAX,
palette: None, linear: true, revision: 0,
};
let handle = tex_alloc(&mut self.textures, &mut self.tex_free, texture);
if handle >= 0 { self.bump_raster_revision(); }
handle
}

/// `upload_texture` honoring spec::img flags: FLAG_RLE marks the pixel
/// stream (for PSM_T8: the index bytes AFTER the palette — the palette
/// itself is never compressed) as PackBits-RLE, which must decode to
Expand Down
15 changes: 15 additions & 0 deletions engine/core/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3919,3 +3919,18 @@ fn retiring_texture_invalidates_handle_before_gpu_owner_drops() {
assert_eq!(ui.texture(next).unwrap().pixels[0], 9);
assert!(ui.take_texture(old).is_none());
}

#[test]
fn external_texture_has_dimensions_and_generations_without_cpu_pixels() {
let mut ui = Ui::new();
assert_eq!(ui.register_external_texture(0, 256), -1);
assert_eq!(ui.register_external_texture(255, 256), -1);
let handle = ui.register_external_texture(256, 256);
let view = ui.texture(handle).unwrap();
assert_eq!((view.w, view.h), (256, 256));
assert!(view.pixels.is_empty());
assert_eq!(view.psm, u32::MAX);
ui.free_texture(handle);
assert!(ui.texture(handle).is_none());
assert_ne!(handle, ui.register_external_texture(256, 256));
}
1 change: 1 addition & 0 deletions framework/compiler/subpaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const SUBPATHS: Record<string, SubpathDecl> = {
"resource-state": { file: "framework/src/resource-state.ts", aliases: TWINS },
"resource-cache": { file: "framework/src/resource-cache.ts", aliases: TWINS },
"resource-offload": { file: "framework/src/resource-offload.ts", aliases: TWINS },
"resource-pack": { file: "framework/src/resource-pack.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" } },
Expand Down
9 changes: 6 additions & 3 deletions framework/src/resource-offload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ 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<I>(
client: Pick<ReturnType<typeof createOffloadClient>, "request" | "cancel">,
client: Pick<ReturnType<typeof createOffloadClient>, "request" | "cancel"> & { connected?(): boolean },
method: string,
payload: (input: I) => string,
): ResourceLoad<I, string> {
return (input, complete) => {
if (client.connected?.() === false) return false;
const id = client.request(method, payload(input), (result) => complete(result));
return id ? { cancel: () => client.cancel(id) } : false;
};
Expand All @@ -22,7 +23,7 @@ export function offloadResource<I>(
* owned by the collection. Configure one image materialization per frame. */
export function createOffloadImageCollection<I>(
runtime: ReturnType<typeof createResourceRuntime>,
client: Pick<ReturnType<typeof createOffloadClient>, "requestImage" | "cancel" | "uploadImage" | "releaseImage">,
client: Pick<ReturnType<typeof createOffloadClient>, "requestImage" | "cancel" | "uploadImage" | "releaseImage"> & { connected?(): boolean },
options: Omit<
ResourceCollectionOptions<I, string, TextureResource>,
"load" | "materialize" | "dispose" | "releaseResponse" | "maxResponseBytes" | "cost" | "maxCost"
Expand All @@ -46,6 +47,7 @@ export function createOffloadImageCollection<I>(
cost: () => cost,
maxCost: options.maxEntries * cost,
load: (input, complete) => {
if (client.connected?.() === false) return false;
const id = client.requestImage(options.method, options.payload(input), complete);
return id ? { cancel: () => client.cancel(id) } : false;
},
Expand All @@ -64,7 +66,7 @@ export function createOffloadImageCollection<I>(
* One completion per frame covers the bounded native validation/copy. */
export function createOffloadMeshCollection<I>(
runtime: ReturnType<typeof createResourceRuntime>,
client: Pick<ReturnType<typeof createOffloadClient>, "requestMesh" | "cancel" | "uploadMesh" | "releaseMesh">,
client: Pick<ReturnType<typeof createOffloadClient>, "requestMesh" | "cancel" | "uploadMesh" | "releaseMesh"> & { connected?(): boolean },
options: Omit<
ResourceCollectionOptions<I, string, MeshResource>,
"load" | "materialize" | "dispose" | "releaseResponse" | "maxResponseBytes" | "cost" | "maxCost"
Expand All @@ -82,6 +84,7 @@ export function createOffloadMeshCollection<I>(
cost: () => cost,
maxCost: options.maxEntries * cost,
load: (input, complete) => {
if (client.connected?.() === false) return false;
const id = client.requestMesh(options.method, options.payload(input), complete);
return id ? { cancel: () => client.cancel(id) } : false;
},
Expand Down
Loading