diff --git a/README.md b/README.md
index e93b33c..4b5de55 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,22 @@ A map browser for Nintendo 3DS and PSP, built with [PocketJS](https://github.com
The Mac fetches OSM vector tiles, prepares bounded geometry and streams it to the 3DS GPU drawing path. Four real San Francisco tiles used **79.5% fewer terrain bytes** than the previous raw bitmap path; z14 geometry is reused through display z18. Hyrule retains its complete local raster atlas and 2,576 searchable places, with no internet requests while browsing. See [vector architecture, measurements and limits](docs/VECTOR_MAP.md).
-

+### San Francisco on the 3DS native renderer
+
+Union Square, the Embarcadero near the Ferry Building, and Golden Gate Park:
+
+
+
+
+
+These are **native 3DS screenshots captured in Azahar**, using real OSM data and
+the production app's UI. The 400×240 upper and 320×240 lower framebuffers are
+stacked without scaling. They are emulator captures, not console photographs.
+[Capture method, coordinates and provenance](docs/images/CAPTURES.md).
OSM map data © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright), served as Shortbread vectors by VersaTiles.
-These are **compiled guest + Wasm captures**, at the 3DS's 400×240 / 320×240 logical resolutions, not console photographs. Map artwork belongs to Nintendo; the pinned atlas and marker source is [Zelda Dungeon's map repository](https://github.com/zeldadungeon/maps/tree/d32a85656031d861cef38e32eb927a7d08a983a9/public/botw). Downloaded assets and generated databases stay outside Git.
+Map artwork belongs to Nintendo; the pinned atlas and marker source is [Zelda Dungeon's map repository](https://github.com/zeldadungeon/maps/tree/d32a85656031d861cef38e32eb927a7d08a983a9/public/botw). Downloaded assets and generated databases stay outside Git.
## PSP over USB
@@ -72,7 +83,17 @@ queue; it is not a device-render receipt.
The Mac keeps connection management separate from native decoding and network work. If the capability process exits, it is reaped and replaced on reconnect. Socket backpressure pauses traffic; cancelling a sent device request retains its wire credit until a reply or disconnection.
-Typing, dragging, inertia and zoom transitions update locally. Missing tiles show a fallback; an already loaded previous zoom level stays visible during replacement. A disconnected Mac leaves resident tiles navigable. Search, new tiles and uncached labels require the Mac, including in Hyrule mode. Offline here means independent of internet map services, not independent of the paired Mac.
+Typing, dragging, inertia and zoom transitions update locally. Missing tiles show a fallback; an already loaded previous zoom level stays visible during replacement. The optional [Hyrule SD pack](docs/SD_MAP.md) lets the 3DS open and load the entire terrain atlas without a Mac. Search, saved places, OSM and dynamic labels still use the paired host. Without the pack, a disconnected Mac leaves only resident tiles navigable.
+
+## Hyrule on the 3DS SD card
+
+After preparing the Mac atlas, run `bun run prepare:sd` and
+`bun run deploy:sd <3ds-ip>` to install a **354 MiB** prepared texture pack.
+The console reads and inflates individual records on a native worker; terrain
+misses no longer require Wi-Fi. In one physical 3DS comparison, mean time to
+ready terrain across four views fell from **1,178 ms over Wi-Fi to 293 ms from
+SD**. This does not establish sustained 60 FPS. See [installation, storage costs,
+measurements and limits](docs/SD_MAP.md).
## Complete local Hyrule atlas
@@ -80,9 +101,10 @@ Typing, dragging, inertia and zoom transitions update locally. Missing tiles sho
1,365 JPEG source tiles across six levels, about 49 MB. It rebakes the full
24,000px square source into **21,845 RGB565 textures across levels 0–7**, using
256px texture envelopes. Original source detail is preserved by subdividing
-the 750px tiles; the top level is a 32,768px grid. Deflate compression is used
-only for Mac storage. The 3DS receives raw pixels and performs no JPEG or
-Deflate decoding.
+the 750px tiles; the top level is a 32,768px grid. In the desktop streaming path,
+Deflate is used for Mac storage and the 3DS receives raw pixels. With the optional
+SD pack, the Mac prepares PICA texture layout and the native SD worker inflates
+individual records. The 3DS performs no JPEG decoding.
The completed `.local/hyrule/atlas.sqlite` is about **592 MB** and includes an
FTS5 index of regions, landmarks, towers, shrines, villages, Korok seeds and
@@ -176,7 +198,7 @@ const view = createResourceView(tiles, { demand: visibleTileDemand });
// view.state(tile)} fallback={() => } />
```
-Hyrule declares `createOffloadImageCollection` and renders `ResourceImage`.
+Hyrule declares `createPackedImageCollection` with a desktop fallback and renders `ResourceImage`.
Rendering starts no IO. The native worker owns reception; the scheduler admits
one materialization per frame; the collection returns staging and eventually
frees its native handles.
diff --git a/app/model.ts b/app/model.ts
index 1edd77d..0dd65e1 100644
--- a/app/model.ts
+++ b/app/model.ts
@@ -1,8 +1,9 @@
-import { createSignal, createMemo } from "solid-js";
+import { createSignal, createMemo, onCleanup } from "solid-js";
import { createResourceRuntime, createResourceView } from "@pocketjs/framework/resource-view";
import { createOffloadImageCollection, createOffloadMeshCollection, offloadResource } from "@pocketjs/framework/resource-offload";
import { createTileCamera } from "@pocketjs/framework/tile-viewport";
import { offload } from "@pocketjs/framework/offload";
+import { createPackedImageCollection, resourcePacks, resourcePackStats } from "@pocketjs/framework/resource-pack";
import { analogX, analogY, onFrame, onButtonPress } from "@pocketjs/framework/lifecycle";
import { BTN } from "@pocketjs/framework/input";
import { inputDeltaSeconds, simulationHz, virtualNow } from "@pocketjs/framework/clock";
@@ -20,6 +21,13 @@ export const MENU = {
map: ["Zoom in", "Zoom out", "Map labels", "Switch map", "Clear pin", "Retry tiles", "About & controls"],
};
export function createMap(io = offload(), viewport = { width: 400, height: 240 }, tileEntries = 40) {
+ // Cancelled wire requests retain offload credit until their response. Keep
+ // that queue bounded independently of active resource jobs and the SD queue.
+ const reads = { ...io,
+ request: (...args: Parameters) => io.pending() < 3 ? io.request(...args) : 0,
+ requestImage: (...args: Parameters) => io.pending() < 3 ? io.requestImage(...args) : 0,
+ requestMesh: (...args: Parameters) => io.pending() < 3 ? io.requestMesh(...args) : 0,
+ };
const [info, setInfo] = createSignal();
const [online, setOnline] = createSignal(false), [status, setStatus] = createSignal("Waiting for paired Mac");
const [mode, setMode] = createSignal("map");
@@ -34,15 +42,22 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
const remembered = new Map();
const planar = () => info()?.space === "planar";
const p = project(HOME.lat, HOME.lon);
+ const packs = resourcePacks();
+ const [useLocalTiles, setUseLocalTiles] = createSignal(true);
+ const [localSource, setLocalSource] = createSignal();
+ const localMapAvailable = () => !!packs?.connected() && planar() && useLocalTiles() && localSource() === info()?.source;
+ const [tileStorage, setTileStorage] = createSignal<"local" | "desktop">("desktop");
const camera = createTileCamera({ ...viewport, x: p.x, y: p.y, zoom: HOME.zoom, minZoom: 1, maxZoom: 18, bounds: { width: 256, height: 256, wrapX: true } });
- const runtime = createResourceRuntime({ maxConcurrent: 3, startsPerFrame: 1, completionsPerFrame: 1, maxCollections: 6, available: () => !switching() && io.connected() && !!info() && io.pending() < 3 });
- const rasterTiles = createOffloadImageCollection(runtime, io, { key: (i: TileInput) => `${i.source}/${i.z}/${i.x}/${i.y}`, method: "map.tile", payload: JSON.stringify,
+ const runtime = createResourceRuntime({ maxConcurrent: 3, startsPerFrame: 1, completionsPerFrame: 1, maxCollections: 6, available: () => !switching() && !!info() && (io.connected() || !!packs && planar()) });
+ const rasterTiles = createPackedImageCollection(runtime, { key: i => `${i.source}/${i.z}/${i.x}/${i.y}`,
+ pack: i => useLocalTiles() && planar() ? { name: `hyrule-${i.source}-v1`, entry: 1 + (4 ** i.z - 1) / 3 + i.y * 2 ** i.z + i.x } : undefined,
+ fallback: { client: reads, method: "map.tile", payload: JSON.stringify }, materialized: storage => { setTileStorage(storage); if (storage === "local") setLocalSource(info()?.source); },
width: 256, height: 256, maxEntries: tileEntries, maxViews: 2, maxDemandsPerView: 24, retry: { attempts: 3, delayFrames: 90, maxDelayFrames: 360 } });
const vector = () => info()?.render === "mesh";
- const meshTiles = createOffloadMeshCollection(runtime, io, {key:(i:TileInput)=>`${i.source}/${i.z}/${i.x}/${i.y}`,method:"map.mesh",payload:JSON.stringify,
+ const meshTiles = createOffloadMeshCollection(runtime, reads, {key:(i:TileInput)=>`${i.source}/${i.z}/${i.x}/${i.y}`,method:"map.mesh",payload:JSON.stringify,
maxEntries:tileEntries,maxViews:2,maxDemandsPerView:24,retry:{attempts:3,delayFrames:90,maxDelayFrames:360}});
const tiles = { invalidate(){rasterTiles.invalidate();meshTiles.invalidate();},clear(){rasterTiles.clear();meshTiles.clear();},stats:()=>vector()?meshTiles.stats():rasterTiles.stats() };
- const labels = createOffloadImageCollection(runtime, io, { key: (i: Place) => `${i.name}/${i.detail}`, method: "map.label", payload: i => JSON.stringify({ name: i.name, detail: i.detail }),
+ const labels = createOffloadImageCollection(runtime, reads, { key: (i: Place) => `${i.name}/${i.detail}`, method: "map.label", payload: i => JSON.stringify({ name: i.name, detail: i.detail }),
width: 256, height: 32, maxEntries: 24, maxViews: 29, maxDemandsPerView: 1 });
const [lookAhead, setLookAhead] = createSignal([]);
const frontDemand=()=>[...(front()?.tiles.map(t=>({input:t.input,priority:t.priority,pin:true}))??[]),...lookAhead().map(t=>({input:t.input,priority:t.priority,pin:false}))];
@@ -55,7 +70,7 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
const backView={state:(i:TileInput)=>vector()?meshBack.state(i):rasterBack.state(i)};
const searches = runtime.createCollection({ key: (i: SearchInput) => JSON.stringify(i), maxEntries: 4, maxViews: 1, maxDemandsPerView: 1,
maxCost: 4 * 8192, cost: () => 8192, maxResponseBytes: 5000, retry: { attempts: 1, delayFrames: 60, maxDelayFrames: 60 },
- load: offloadResource(io, "map.search", JSON.stringify), materialize(raw: string): Place[] {
+ load: offloadResource(reads, "map.search", JSON.stringify), materialize(raw: string): Place[] {
const rows = JSON.parse(raw);
if (!validPlaces(rows)) throw new Error("Invalid places response");
return rows;
@@ -63,8 +78,8 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
});
const results = createResourceView(searches, { demand: () => submitted() ? [{ input: submitted()!, priority: -10, pin: true }] : [] });
const places = createMemo(() => submitted() ? results.value(submitted()!) ?? [] : []);
- const saved = createSavedPlaces(io, runtime, mode, setMode, () => info()?.source);
- const annotations = createAnnotations(io, runtime, viewport), prediction = createMapPrediction(viewport);
+ const saved = createSavedPlaces(io, runtime, mode, setMode, () => info()?.source, reads);
+ const annotations = createAnnotations(reads, runtime, viewport), prediction = createMapPrediction(viewport);
const typing = () => mode() === "search" || mode() === "name";
const listing = () => mode() === "results" || mode() === "saved";
const rows = () => mode() === "saved" ? saved.page()?.items ?? [] : places();
@@ -89,6 +104,29 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
}
let frame = 0, previousSession = 0, infoRequest = 0, retryAt = 0, shiftAt = -10, levelAge = 0, candidateLevel = HOME.zoom;
let confirmed = false;
+ let bootstrap = packs ? 0 : -1;
+ onCleanup(() => { if (bootstrap > 0) packs?.cancel(bootstrap); });
+ function installInfo(value: MapInfo) {
+ if (typeof value.source !== "string" || !/^[a-f0-9]{16}$/.test(value.source) || typeof value.name !== "string" || typeof value.attribution !== "string" || !Number.isInteger(value.maxZoom) || value.maxZoom < 1 || value.maxZoom > 18
+ || !Number.isInteger(value.minZoom ?? 1) || (value.minZoom ?? 1) < 0 || (value.minZoom ?? 1) > value.maxZoom
+ || value.render !== undefined && value.render !== "mesh"
+ || value.render === "mesh" && (!Number.isInteger(value.dataZoom) || value.dataZoom! < 0 || value.dataZoom! > value.maxZoom)
+ || value.space !== undefined && value.space !== "mercator" && value.space !== "planar"
+ || value.home !== undefined && (!validPlaces([value.home]) || (value.home.space === "planar") !== (value.space === "planar"))) throw new Error("Invalid map provider");
+ if (value.maps !== undefined && (!Array.isArray(value.maps) || value.maps.length > 2 || !value.maps.every(m => (m.kind === "hyrule" || m.kind === "osm") && typeof m.name === "string" && m.name.length <= 40))) throw new Error("Invalid map catalog");
+ if (info()?.source !== value.source) {
+ runtime.cancel(); searches.clear(); labels.clear(); annotations.reset(); saved.reset(); setSubmitted(undefined); setQuery("");
+ tiles.clear(); setFront(undefined); setBack(undefined); setLookAhead([]); setPin(undefined);
+ camera.setWorld({ minZoom: value.minZoom ?? 1, maxZoom: value.maxZoom, bounds: { width: 256, height: 256, wrapX: value.space !== "planar" } });
+ const resume = value.kind && remembered.get(value.kind);
+ if (resume) { camera.jump(resume.x, resume.y, resume.zoom); setPin(resume.pin); }
+ else if (value.home) { const home = worldPosition(value.home); camera.jump(home.x, home.y, value.home.zoom); }
+ if (!resume && !value.home) { const p = project(HOME.lat, HOME.lon); camera.jump(p.x, p.y, HOME.zoom); }
+ prediction.reset(camera.view()); candidateLevel = Math.round(camera.view().zoom); levelAge = 0;
+ }
+ setInfo(value); setRequestedKind(value.kind); setSwitching(false); setStatus("Map ready");
+ }
+
function search() {
const text = query().trim(); if (!text) return;
const v = camera.view(), pos = positionAt(v.x, v.y, planar());
@@ -157,34 +195,29 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
if (session !== previousSession) {
if (infoRequest) { io.cancel(infoRequest); infoRequest = 0; }
runtime.cancel(); retryAt = 0;
- if (session > 0) { tiles.invalidate(); searches.invalidate(); labels.invalidate(); saved.refresh(); setStatus("Connecting map service"); }
- else setStatus("Mac disconnected - cached map");
+ if (session > 0) { if (!packs || !planar() || !useLocalTiles()) tiles.invalidate(); searches.invalidate(); labels.invalidate(); saved.refresh(); setStatus("Connecting map service"); }
+ else setStatus(localMapAvailable() ? "Hyrule from SD card" : "Mac disconnected - cached map");
previousSession = session;
}
- if (session > 0 && !infoRequest && frame >= retryAt) {
+ if (bootstrap === 0 && packs?.connected()) {
+ bootstrap = packs.request("pack.read", "hyrule/0", result => {
+ bootstrap = -1;
+ if (!result.ok || info() || switching()) return;
+ try {
+ const atlas = JSON.parse(result.value);
+ if (atlas.format !== "pocket-map-atlas-rgb565-v1" || atlas.tiles !== 21845 || atlas.info?.space !== "planar") return;
+ installInfo({ ...atlas.info, kind: "hyrule", markers: false });
+ setLocalSource(atlas.info.source); setStatus("Hyrule from SD card");
+ } catch { /* Optional installation; the paired provider remains available. */ }
+ }) || 0;
+ }
+ if (session > 0 && (bootstrap < 0 || frame >= 30 || !packs?.connected()) && !infoRequest && frame >= retryAt) {
infoRequest = io.request("map.info", JSON.stringify({ kind: requestedKind() }), result => {
infoRequest = 0; retryAt = frame + 3600;
if (!result.ok) { if (switching()) { setSourceError("Map unavailable. Choose again to retry."); setMode("sources"); } setStatus(result.error); setSwitching(false); setRequestedKind(info()?.kind); retryAt = frame + 120; return; }
try {
const value: MapInfo = JSON.parse(result.value);
- if (typeof value.source !== "string" || !/^[a-f0-9]{16}$/.test(value.source) || typeof value.name !== "string" || typeof value.attribution !== "string" || !Number.isInteger(value.maxZoom) || value.maxZoom < 1 || value.maxZoom > 18
- || !Number.isInteger(value.minZoom ?? 1) || (value.minZoom ?? 1) < 0 || (value.minZoom ?? 1) > value.maxZoom
- || value.render !== undefined && value.render !== "mesh"
- || value.render === "mesh" && (!Number.isInteger(value.dataZoom) || value.dataZoom! < 0 || value.dataZoom! > value.maxZoom)
- || value.space !== undefined && value.space !== "mercator" && value.space !== "planar"
- || value.home !== undefined && (!validPlaces([value.home]) || (value.home.space === "planar") !== (value.space === "planar"))) throw new Error("Invalid map provider");
- if (value.maps !== undefined && (!Array.isArray(value.maps) || value.maps.length > 2 || !value.maps.every(m => (m.kind === "hyrule" || m.kind === "osm") && typeof m.name === "string" && m.name.length <= 40))) throw new Error("Invalid map catalog");
- if (info()?.source !== value.source) {
- runtime.cancel(); searches.clear(); labels.clear(); annotations.reset(); saved.reset(); setSubmitted(undefined); setQuery("");
- tiles.clear(); setFront(undefined); setBack(undefined); setLookAhead([]); setPin(undefined);
- camera.setWorld({ minZoom: value.minZoom ?? 1, maxZoom: value.maxZoom, bounds: { width: 256, height: 256, wrapX: value.space !== "planar" } });
- const resume = value.kind && remembered.get(value.kind);
- if (resume) { camera.jump(resume.x, resume.y, resume.zoom); setPin(resume.pin); }
- else if (value.home) { const home = worldPosition(value.home); camera.jump(home.x, home.y, value.home.zoom); }
- if (!resume && !value.home) { const p = project(HOME.lat, HOME.lon); camera.jump(p.x, p.y, HOME.zoom); }
- prediction.reset(camera.view()); candidateLevel = Math.round(camera.view().zoom); levelAge = 0;
- }
- setInfo(value); setRequestedKind(value.kind); setSwitching(false); setStatus("Map ready");
+ installInfo(value);
} catch { setSwitching(false); setRequestedKind(info()?.kind); setStatus("Unsupported map provider"); retryAt = frame + 120; }
});
}
@@ -232,9 +265,10 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
if (back() && front()?.tiles.every(t => frontView.state(t.input).status === "ready")) setBack(undefined);
});
return { viewport, io, runtime, tiles, vector, labels, frontView, backView, info, planar, online, zoomHeld, switching, sourceError, maps, choices, choosing, choose, openSources, switchMap, annotations, status, mode, setMode, query, setQuery, submitted, results, places, selection, setSelection, pin, menu, menuIndex,
+ localMapAvailable, tileStorage, useLocalTiles, setLocalTiles(value: boolean) { runtime.cancel(); setUseLocalTiles(value); tiles.clear(); },
shift, symbols, front, back, camera, saved, typing, listing, rows, selectedIndex, select, saveCurrent, lookAhead, search, openSearch, go, zoom, key, dismiss, runMenu,
clearBack: () => setBack(undefined),
- diagnostics: () => ({ frame, pending: io.pending(), resources: runtime.stats(), tiles: tiles.stats(), camera: camera.view() }),
+ diagnostics: () => ({ frame, pending: io.pending(), resources: runtime.stats(), tiles: tiles.stats(), camera: camera.view(), pack: resourcePackStats(), storage: tileStorage() }),
};
}
export type MapModel = ReturnType;
diff --git a/app/saved.ts b/app/saved.ts
index e0c1882..23ab7e8 100644
--- a/app/saved.ts
+++ b/app/saved.ts
@@ -9,7 +9,7 @@ export type MapMode = "map" | "search" | "results" | "about" | "saved" | "name"
export function validPlaces(rows: unknown): rows is Place[] {
return Array.isArray(rows) && rows.length <= 5 && rows.every(p => p && typeof p.id === "string" && p.id.length <= 80 && typeof p.name === "string" && p.name.length <= 36 && typeof p.detail === "string" && p.detail.length <= 60 && Number.isInteger(p.zoom) && p.zoom >= 0 && p.zoom <= 18 && validPosition(p));
}
-export function createSavedPlaces(io: ReturnType, runtime: ReturnType, mode: () => MapMode, setMode: (mode: MapMode) => void, source: () => string | undefined = () => undefined) {
+export function createSavedPlaces(io: ReturnType, runtime: ReturnType, mode: () => MapMode, setMode: (mode: MapMode) => void, source: () => string | undefined = () => undefined, reads = io) {
const [offset, setOffset] = createSignal(0), [selection, setSelection] = createSignal(0);
const [name, setName] = createSignal(""), [editing, setEditing] = createSignal();
const [busy, setBusy] = createSignal(false), [error, setError] = createSignal("");
@@ -17,7 +17,7 @@ export function createSavedPlaces(io: ReturnType, runtime: Retur
let target: Place | undefined, returnMode: MapMode = "map", request = 0, last: BookmarkCommand | undefined;
const collection = runtime.createCollection({ key: (offset: number) => `${source()}/${offset}`, maxEntries: 4, maxViews: 1, maxDemandsPerView: 1,
maxCost: 4 * 8192, cost: () => 8192, maxResponseBytes: 5000, retry: { attempts: 2, delayFrames: 60, maxDelayFrames: 120 },
- load: offloadResource(io, "bookmarks.list", offset => JSON.stringify({ offset, source: source() })), materialize(raw: string): BookmarkPage {
+ load: offloadResource(reads, "bookmarks.list", offset => JSON.stringify({ offset, source: source() })), materialize(raw: string): BookmarkPage {
const page = JSON.parse(raw);
if (!page || !validPlaces(page.items) || !Number.isSafeInteger(page.offset) || page.offset < 0 || page.offset > 995 || page.offset % 5 || !Number.isSafeInteger(page.total) || page.total < 0 || page.total > 1000 || page.items.length !== Math.min(5, Math.max(0, page.total - page.offset))) throw new Error("Invalid saved places response");
return page;
diff --git a/app/ui.tsx b/app/ui.tsx
index c766a73..fb41680 100644
--- a/app/ui.tsx
+++ b/app/ui.tsx
@@ -132,7 +132,7 @@ function Deck(p: { s: MapModel }) {
{search}
- {typing() ? `${(naming() ? p.s.saved.name() : p.s.query()).slice(-31)}|` : saved() ? "Saved on your paired Mac" : results() ? p.s.query().slice(0, 32) : p.s.online() ? p.s.planar() ? "Hyrule - Breath of the Wild" : "Explore with your Nintendo 3DS" : "Waiting for paired Mac"}
+ {typing() ? `${(naming() ? p.s.saved.name() : p.s.query()).slice(-31)}|` : saved() ? "Saved on your paired Mac" : results() ? p.s.query().slice(0, 32) : p.s.localMapAvailable() && !p.s.online() ? "Hyrule on SD - Mac for search & saves" : p.s.online() ? p.s.planar() ? "Hyrule - Breath of the Wild" : "Explore with your Nintendo 3DS" : "Waiting for paired Mac"}
@@ -171,7 +171,7 @@ export default function MapApp() {
500 m
-
+
{s.status().slice(0, 35)}
diff --git a/docs/SD_MAP.md b/docs/SD_MAP.md
new file mode 100644
index 0000000..17c8633
--- /dev/null
+++ b/docs/SD_MAP.md
@@ -0,0 +1,141 @@
+# Hyrule on the 3DS SD card
+
+The optional SD pack stores the complete Hyrule terrain atlas on the console.
+**Panning and zooming can load new terrain without a paired Mac**, including
+at startup. OSM vectors, place search, bookmarks and dynamic marker labels
+retain their Mac providers. The PSP build retains its USB image path.
+
+```sh
+bun run prepare:hyrule
+bun run prepare:sd
+bun run 3ds
+# Keep ftpd open for this one-time large transfer and its full readback.
+bun run deploy:sd 192.168.8.102
+bun run deploy 192.168.8.102
+```
+
+Exit ftpd and open Pocket Map. The SD bootstrap opens Hyrule directly; the Mac
+connection adds search, saved places and the source chooser. The normal
+`bun run host` command remains unchanged. A missing local image can fall back
+to the paired Mac. Without an installed pack, the original host path remains
+available. No downloaded artwork, SQLite database or generated pack is committed.
+
+The installer uploads to a temporary filename, supports resuming after a
+disconnect, checks the complete file by SHA-256 readback, and only then activates
+it. A small bootstrap pack is activated after the terrain file. Keep ftpd
+running during both upload and verification; rerun the same command after an
+interruption. The default location is
+`/pocketjs/assets/c7771f0167312c63/`. Changed atlas revisions get distinct names.
+If verification reports a mismatch, the installer does not activate a replacement.
+`dist/qa/sd-verify.json` records the received size, hash and first differing byte.
+Use `bun run deploy:sd <3ds-ip> --restart` to replace the temporary copy from
+byte zero and repeat verification; an existing active pack stays in place until
+the replacement passes.
+
+## What changes
+
+| Stage | Paired Mac raster path | Prepared SD path |
+| --- | --- | --- |
+| Tile location | Mac SQLite and memory cache | One indexed file on the console |
+| Tile pixels reaching the console | 128 KiB raw RGB565 over Wi-Fi | Independently compressed prepared RGB565 from SD |
+| Image preparation | 3DS converts and reorders pixels for upload | Mac bakes PICA channel order and tiled layout |
+| Per-resident-tile pixel storage | 128 KiB core pixels + 256 KiB GPU RGBA8 | 128 KiB GPU RGB565, no retained core pixels |
+| Blocking operations | Network worker; conversion on render path | SD seek/read and zlib inflation on an SD worker |
+| UI materialization budget | One image or mesh per frame | Same shared one-per-frame budget |
+| Guest cache and prefetch | 40 tiles; directional prediction | Same policy and capacity |
+
+For the pinned atlas revision, the generated terrain file contains **21,845
+tiles across zoom levels 0–7**, plus metadata. It is **370,952,504 bytes
+(353.77 MiB)**. Compressed terrain records have a 14,000-byte median, a
+44,381-byte 95th percentile and an 80,077-byte maximum. Every record has been
+decoded and CRC-checked locally. RGB565 channel order and tiling were also
+compared with devkitPro tex3ds 2.3.0 using a color-grid fixture and the renderer's
+vertical origin; all 512 texture bytes matched.
+
+The device comparison below measures loading and frame callbacks separately.
+Removing the Wi-Fi transfer and render-path pixel conversion reduces loading
+time; it does not establish a 60 Hz presentation guarantee.
+
+The framework's optional `io.resource-pack` worker adds eight 128 KiB staging
+slots, one compressed scratch buffer and a 32 KiB stack. It caches four file
+handles and reads individual index records, without loading the whole atlas or
+directory. The map declares identity and demand through
+`createPackedImageCollection`; cancellation, fallback, native ownership and
+eviction remain in PocketJS. A stalled SD read cannot make a guest filesystem
+call wait, because the guest has no synchronous filesystem API.
+
+## Repeatable device comparison
+
+`bun scripts/benchmark-sd.ts` builds a separate **Pocket Map Storage Test**
+binary at `runtime/dist/3ds/pocketmap-sd-benchmark.3dsx`. It uses the normal
+app's asset slot but does not replace its production entry. Install that
+binary with `bun run deploy <3ds-ip> --benchmark` after installing the pack. Stop the ordinary 3DS map host,
+then run:
+
+```sh
+bun scripts/benchmark-sd.ts host 192.168.8.102
+```
+
+Launch the test entry and leave the controls untouched. It automatically runs
+four cold views, alternating SD and Mac at each view, then an eight-second
+diagonal pan for each storage path at 320 pixels/second. Both use the same
+40-entry cache and prediction policy; dynamic annotations are disabled for
+the comparison. Each cold view clears guest tiles. Each pan starts after its
+initial view has loaded. The Mac's normal cache remains enabled, so this is
+not a cold-disk comparison on the Mac.
+
+Ten receipts append to ignored `dist/qa/sd-benchmark.jsonl`. They record native
+wall-clock time to **resource-ready** terrain, frames, source actually used,
+SD IO/inflate/upload counters, and pan fallback frames and intervals over
+20 ms. GPU presentation follows resource readiness. A local leg that reports
+`storage: "desktop"`, a timeout, or a pan distance far below 2,560 pixels is
+not a successful SD comparison. Keep the Mac connected throughout the run;
+the native host's usual `offload.metrics` log supplies separate frame CPU data.
+The test returns to Hyrule after saving its receipts. Its automatic navigation
+is absent from the production app.
+
+## Hardware result: 2026-09-07
+
+One run on the user's physical 3DS completed all ten legs. Both binaries passed
+byte-exact FTP readback. The installed terrain pack passed full SHA-256 readback;
+restarting ftpd allowed its final rename after an earlier rename error. The
+[measurement record](benchmarks/3ds-sd-2026-09-07.json) includes build identities,
+artifact hashes, route coordinates, all ten receipts and the metric definitions.
+The console variant and SD card model were not recorded.
+
+| Cold view | Visible target tiles | SD ready | Mac ready | Mac / SD |
+| --- | ---: | ---: | ---: | ---: |
+| z4 | 3 | 150 ms | 895 ms | 5.97× |
+| z6 | 6 | 346 ms | 1,651 ms | 4.77× |
+| z7 | 4 | 435 ms | 1,174 ms | 2.70× |
+| z5 | 4 | 240 ms | 990 ms | 4.12× |
+| Mean | — | 292.75 ms | 1,177.50 ms | 4.02× |
+
+**Mean time to ready terrain fell by 75.1%** with the same 40-entry guest cache
+and prediction policy. The Mac cache was enabled. This is a guest-cache cold
+comparison on four views, with SD preceding Mac at each view; it is not a
+repeated-trial latency distribution or a cold-disk benchmark.
+
+| Eight-second diagonal pan | SD | Mac |
+| --- | ---: | ---: |
+| Distance | 2,560.32 px | 2,564.80 px |
+| Frame callbacks | 435 | 412 |
+| Frame callbacks per second | 54.35 | 51.40 |
+| Callbacks with target terrain not ready | 57 / 435 (13.10%) | 397 / 412 (96.36%) |
+| Callback intervals over 20 ms | 80 | 114 |
+
+"Not ready" means at least one visible tile at the target zoom level is not
+ready. It does not mean the whole viewport is blank, and does not measure how
+much a coarser fallback covers. Callback frequency is not a GPU presentation
+measurement. **This run does not demonstrate sustained 60 FPS.**
+
+The SD pan completed 63 worker reads and 61 texture uploads, with zero pack
+failures. Worker IO totalled 1,410 ms and inflation 640 ms across those reads;
+native texture registration/staging totalled 61.5 ms across the uploads. These
+are accumulated operation times, not time spent blocking UI frames. Mac pan
+performed no SD reads or uploads. A few cancelled SD reads finished during the
+first three Mac cold-view legs, but produced no uploads there.
+
+All SD legs reported local storage and all Mac legs reported desktop storage;
+native pack failures remained zero throughout. The comparison kept the Mac
+connected to collect results, so it does not verify an offline cold launch.
diff --git a/docs/benchmarks/3ds-sd-2026-09-07.json b/docs/benchmarks/3ds-sd-2026-09-07.json
new file mode 100644
index 0000000..dcce83a
--- /dev/null
+++ b/docs/benchmarks/3ds-sd-2026-09-07.json
@@ -0,0 +1,424 @@
+{
+ "date": "2026-09-07",
+ "device": "Physical Nintendo 3DS; console variant and SD card model not recorded",
+ "applicationCommit": "73ed46079d58c8e92752f04543a15afa0617a6e6",
+ "runtimeCommit": "e2226e8f69990dd8361d79aabf47bcbedc44786d",
+ "benchmarkBinarySha256": "d8ef7e51bab6716c72c380b6cd95eec82e70920b9dde5eba75ae058cbf1d3c57",
+ "terrainPack": {
+ "bytes": 370952504,
+ "sha256": "d088a630e7d2a0f4c87d78e700f10c04f3cceefaab89d477c7f9549b10fe8ff6"
+ },
+ "method": {
+ "completeRuns": 1,
+ "guestTileCapacity": 40,
+ "prediction": "Same directional policy in both paths",
+ "annotations": "off",
+ "desktopCache": "enabled",
+ "coldView": "Guest tiles cleared; elapsed time until all visible target-level resources are ready; SD precedes Mac at each view",
+ "pan": "Initial view warmed; 320 px/s diagonal at z6 for eight seconds per path",
+ "notReadyFrame": "At least one visible target-level tile is not ready; does not measure blank pixel area or whether a coarser fallback covers it",
+ "framesPerSecond": "Benchmark frame callback count divided by native elapsed time; not GPU presentation timing",
+ "limits": [
+ "One fixed-route run; not repeated-trial percentiles",
+ "SD worker requests already in flight may finish after a switch to Mac; they do not upload in the Mac legs",
+ "Offline cold startup was not exercised in this connected comparison"
+ ]
+ },
+ "summary": {
+ "views": [
+ {
+ "point": [
+ 107.733333,
+ 168,
+ 4
+ ],
+ "sdMs": 150,
+ "macMs": 895,
+ "speedup": 5.97
+ },
+ {
+ "point": [
+ 145,
+ 101,
+ 6
+ ],
+ "sdMs": 346,
+ "macMs": 1651,
+ "speedup": 4.77
+ },
+ {
+ "point": [
+ 128,
+ 128,
+ 7
+ ],
+ "sdMs": 435,
+ "macMs": 1174,
+ "speedup": 2.7
+ },
+ {
+ "point": [
+ 64,
+ 64,
+ 5
+ ],
+ "sdMs": 240,
+ "macMs": 990,
+ "speedup": 4.12
+ }
+ ],
+ "meanSDMs": 292.75,
+ "meanMacMs": 1177.5,
+ "meanSpeedup": 4.02,
+ "pan": [
+ {
+ "storage": "local",
+ "milliseconds": 8004,
+ "frames": 435,
+ "framesPerSecond": 54.35,
+ "fallbackFrames": 57,
+ "fallbackPercent": 13.1,
+ "over20ms": 80,
+ "distance": 2560.320000000004,
+ "packDelta": {
+ "reads": 63,
+ "ioUs": 1410023,
+ "decodeUs": 640240,
+ "uploads": 61,
+ "uploadUs": 61532,
+ "failures": 0
+ }
+ },
+ {
+ "storage": "desktop",
+ "milliseconds": 8015,
+ "frames": 412,
+ "framesPerSecond": 51.4,
+ "fallbackFrames": 397,
+ "fallbackPercent": 96.36,
+ "over20ms": 114,
+ "distance": 2564.8000000000034,
+ "packDelta": {
+ "reads": 0,
+ "ioUs": 0,
+ "decodeUs": 0,
+ "uploads": 0,
+ "uploadUs": 0,
+ "failures": 0
+ }
+ }
+ ]
+ },
+ "legs": [
+ {
+ "received": "2026-09-07T15:29:25.082Z",
+ "stage": 0,
+ "local": true,
+ "mode": "cold-view",
+ "point": [
+ 107.733333,
+ 168,
+ 4
+ ],
+ "milliseconds": 150,
+ "frames": 9,
+ "ready": 3,
+ "visible": 3,
+ "level": 4,
+ "storage": "local",
+ "complete": true,
+ "fallbackFrames": 0,
+ "over20ms": 0,
+ "distance": 0,
+ "packBefore": "reads=16 ioUs=349384 decodeUs=138216 maxIoUs=38140 maxDecodeUs=12241 failures=0 uploads=15 uploadUs=16274 maxUploadUs=1324",
+ "packAfter": "reads=19 ioUs=425620 decodeUs=162492 maxIoUs=38140 maxDecodeUs=12725 failures=0 uploads=18 uploadUs=19038 maxUploadUs=1324",
+ "packDelta": {
+ "reads": 3,
+ "ioUs": 76236,
+ "decodeUs": 24276,
+ "uploads": 3,
+ "uploadUs": 2764,
+ "failures": 0
+ }
+ },
+ {
+ "received": "2026-09-07T15:29:26.050Z",
+ "stage": 1,
+ "local": false,
+ "mode": "cold-view",
+ "point": [
+ 107.733333,
+ 168,
+ 4
+ ],
+ "milliseconds": 895,
+ "frames": 54,
+ "ready": 3,
+ "visible": 3,
+ "level": 4,
+ "storage": "desktop",
+ "complete": true,
+ "fallbackFrames": 0,
+ "over20ms": 0,
+ "distance": 0,
+ "packBefore": "reads=21 ioUs=482888 decodeUs=173891 maxIoUs=38140 maxDecodeUs=12725 failures=0 uploads=19 uploadUs=19990 maxUploadUs=1324",
+ "packAfter": "reads=23 ioUs=546750 decodeUs=190827 maxIoUs=45611 maxDecodeUs=12725 failures=0 uploads=19 uploadUs=19990 maxUploadUs=1324",
+ "packDelta": {
+ "reads": 2,
+ "ioUs": 63862,
+ "decodeUs": 16936,
+ "uploads": 0,
+ "uploadUs": 0,
+ "failures": 0
+ }
+ },
+ {
+ "received": "2026-09-07T15:29:27.310Z",
+ "stage": 2,
+ "local": true,
+ "mode": "cold-view",
+ "point": [
+ 145,
+ 101,
+ 6
+ ],
+ "milliseconds": 346,
+ "frames": 14,
+ "ready": 6,
+ "visible": 6,
+ "level": 6,
+ "storage": "local",
+ "complete": true,
+ "fallbackFrames": 0,
+ "over20ms": 0,
+ "distance": 0,
+ "packBefore": "reads=23 ioUs=546750 decodeUs=190827 maxIoUs=45611 maxDecodeUs=12725 failures=0 uploads=19 uploadUs=19990 maxUploadUs=1324",
+ "packAfter": "reads=31 ioUs=756181 decodeUs=251661 maxIoUs=45611 maxDecodeUs=14139 failures=0 uploads=25 uploadUs=28086 maxUploadUs=1801",
+ "packDelta": {
+ "reads": 8,
+ "ioUs": 209431,
+ "decodeUs": 60834,
+ "uploads": 6,
+ "uploadUs": 8096,
+ "failures": 0
+ }
+ },
+ {
+ "received": "2026-09-07T15:29:29.224Z",
+ "stage": 3,
+ "local": false,
+ "mode": "cold-view",
+ "point": [
+ 145,
+ 101,
+ 6
+ ],
+ "milliseconds": 1651,
+ "frames": 98,
+ "ready": 6,
+ "visible": 6,
+ "level": 6,
+ "storage": "desktop",
+ "complete": true,
+ "fallbackFrames": 0,
+ "over20ms": 0,
+ "distance": 0,
+ "packBefore": "reads=38 ioUs=922956 decodeUs=336096 maxIoUs=45611 maxDecodeUs=14139 failures=0 uploads=33 uploadUs=36186 maxUploadUs=1801",
+ "packAfter": "reads=41 ioUs=961930 decodeUs=412264 maxIoUs=45611 maxDecodeUs=49973 failures=0 uploads=33 uploadUs=36186 maxUploadUs=1801",
+ "packDelta": {
+ "reads": 3,
+ "ioUs": 38974,
+ "decodeUs": 76168,
+ "uploads": 0,
+ "uploadUs": 0,
+ "failures": 0
+ }
+ },
+ {
+ "received": "2026-09-07T15:29:30.522Z",
+ "stage": 4,
+ "local": true,
+ "mode": "cold-view",
+ "point": [
+ 128,
+ 128,
+ 7
+ ],
+ "milliseconds": 435,
+ "frames": 22,
+ "ready": 4,
+ "visible": 4,
+ "level": 7,
+ "storage": "local",
+ "complete": true,
+ "fallbackFrames": 0,
+ "over20ms": 0,
+ "distance": 0,
+ "packBefore": "reads=41 ioUs=961930 decodeUs=412264 maxIoUs=45611 maxDecodeUs=49973 failures=0 uploads=33 uploadUs=36186 maxUploadUs=1801",
+ "packAfter": "reads=50 ioUs=1240052 decodeUs=495505 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=39 uploadUs=42509 maxUploadUs=1801",
+ "packDelta": {
+ "reads": 9,
+ "ioUs": 278122,
+ "decodeUs": 83241,
+ "uploads": 6,
+ "uploadUs": 6323,
+ "failures": 0
+ }
+ },
+ {
+ "received": "2026-09-07T15:29:31.929Z",
+ "stage": 5,
+ "local": false,
+ "mode": "cold-view",
+ "point": [
+ 128,
+ 128,
+ 7
+ ],
+ "milliseconds": 1174,
+ "frames": 70,
+ "ready": 4,
+ "visible": 4,
+ "level": 7,
+ "storage": "desktop",
+ "complete": true,
+ "fallbackFrames": 0,
+ "over20ms": 0,
+ "distance": 0,
+ "packBefore": "reads=59 ioUs=1396321 decodeUs=572373 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=47 uploadUs=50236 maxUploadUs=1801",
+ "packAfter": "reads=61 ioUs=1467608 decodeUs=580975 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=47 uploadUs=50236 maxUploadUs=1801",
+ "packDelta": {
+ "reads": 2,
+ "ioUs": 71287,
+ "decodeUs": 8602,
+ "uploads": 0,
+ "uploadUs": 0,
+ "failures": 0
+ }
+ },
+ {
+ "received": "2026-09-07T15:29:33.068Z",
+ "stage": 6,
+ "local": true,
+ "mode": "cold-view",
+ "point": [
+ 64,
+ 64,
+ 5
+ ],
+ "milliseconds": 240,
+ "frames": 11,
+ "ready": 4,
+ "visible": 4,
+ "level": 5,
+ "storage": "local",
+ "complete": true,
+ "fallbackFrames": 0,
+ "over20ms": 0,
+ "distance": 0,
+ "packBefore": "reads=61 ioUs=1467608 decodeUs=580975 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=47 uploadUs=50236 maxUploadUs=1801",
+ "packAfter": "reads=66 ioUs=1590484 decodeUs=617771 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=51 uploadUs=56332 maxUploadUs=1975",
+ "packDelta": {
+ "reads": 5,
+ "ioUs": 122876,
+ "decodeUs": 36796,
+ "uploads": 4,
+ "uploadUs": 6096,
+ "failures": 0
+ }
+ },
+ {
+ "received": "2026-09-07T15:29:34.371Z",
+ "stage": 7,
+ "local": false,
+ "mode": "cold-view",
+ "point": [
+ 64,
+ 64,
+ 5
+ ],
+ "milliseconds": 990,
+ "frames": 59,
+ "ready": 4,
+ "visible": 4,
+ "level": 5,
+ "storage": "desktop",
+ "complete": true,
+ "fallbackFrames": 0,
+ "over20ms": 0,
+ "distance": 0,
+ "packBefore": "reads=78 ioUs=1854721 decodeUs=734985 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=62 uploadUs=67126 maxUploadUs=1975",
+ "packAfter": "reads=78 ioUs=1854721 decodeUs=734985 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=62 uploadUs=67126 maxUploadUs=1975",
+ "packDelta": {
+ "reads": 0,
+ "ioUs": 0,
+ "decodeUs": 0,
+ "uploads": 0,
+ "uploadUs": 0,
+ "failures": 0
+ }
+ },
+ {
+ "received": "2026-09-07T15:29:43.704Z",
+ "stage": 8,
+ "local": true,
+ "mode": "pan",
+ "point": [
+ 72,
+ 160,
+ 6
+ ],
+ "milliseconds": 8004,
+ "frames": 435,
+ "ready": 4,
+ "visible": 4,
+ "level": 6,
+ "storage": "local",
+ "complete": true,
+ "fallbackFrames": 57,
+ "over20ms": 80,
+ "distance": 2560.320000000004,
+ "packBefore": "reads=88 ioUs=2128783 decodeUs=817536 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=68 uploadUs=73767 maxUploadUs=1975",
+ "packAfter": "reads=151 ioUs=3538806 decodeUs=1457776 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=129 uploadUs=135299 maxUploadUs=1975",
+ "packDelta": {
+ "reads": 63,
+ "ioUs": 1410023,
+ "decodeUs": 640240,
+ "uploads": 61,
+ "uploadUs": 61532,
+ "failures": 0
+ }
+ },
+ {
+ "received": "2026-09-07T15:29:53.289Z",
+ "stage": 9,
+ "local": false,
+ "mode": "pan",
+ "point": [
+ 72,
+ 160,
+ 6
+ ],
+ "milliseconds": 8015,
+ "frames": 412,
+ "ready": 1,
+ "visible": 4,
+ "level": 6,
+ "storage": "desktop",
+ "complete": true,
+ "fallbackFrames": 397,
+ "over20ms": 114,
+ "distance": 2564.8000000000034,
+ "packBefore": "reads=151 ioUs=3538806 decodeUs=1457776 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=129 uploadUs=135299 maxUploadUs=1975",
+ "packAfter": "reads=151 ioUs=3538806 decodeUs=1457776 maxIoUs=55492 maxDecodeUs=49973 failures=0 uploads=129 uploadUs=135299 maxUploadUs=1975",
+ "packDelta": {
+ "reads": 0,
+ "ioUs": 0,
+ "decodeUs": 0,
+ "uploads": 0,
+ "uploadUs": 0,
+ "failures": 0
+ }
+ }
+ ]
+}
diff --git a/docs/images/CAPTURES.md b/docs/images/CAPTURES.md
new file mode 100644
index 0000000..4f9cafc
--- /dev/null
+++ b/docs/images/CAPTURES.md
@@ -0,0 +1,59 @@
+# Screenshot provenance
+
+## San Francisco, 2026-09-08
+
+The three `sf-*-3ds.png` images in the README come from the **native Nintendo
+3DS executable running in Azahar 2125.1.2**, with Vulkan and a 1× internal
+resolution. They are emulator captures, not photographs of a console and not
+Wasm screenshots. [sf-captures.json](sf-captures.json) records the app/runtime
+commits, native binary hash, camera coordinates, provider counts, raw framebuffer
+hashes and published PNG hashes.
+
+| Image | Camera latitude | Longitude | Display zoom |
+| --- | ---: | ---: | ---: |
+| Union Square | 37.7879 | -122.4075 | 15 |
+| Ferry Building / Embarcadero | 37.7955 | -122.3937 | 16 |
+| Golden Gate Park | 37.7694 | -122.4862 | 15 |
+
+The capture build uses the production `app/main.tsx` entry and its components.
+The Mac provider sets `map.info.home` to each listed camera position and disables
+predictive HTTP demand for the capture. It fetches real Shortbread MVTs from
+VersaTiles through the normal provider and SQLite cache. No terrain or labels
+are substituted with fixtures. The viewport is allowed to settle before frame
+600 is read back; all recorded provider responses succeeded.
+
+The native host's `POCKETJS_CAPTURE` path performs a GX display transfer from
+each PICA render target. Its raw records are decoded with the rotation and
+channel-order conversion used by `runtime/tests/e2e/azahar.ts`. **The published
+400×480 PNG stacks the unscaled 400×240 top screen and 320×240 bottom screen**,
+with the bottom screen centered between white margins. No UI or map pixels are
+painted over. Capture timing does not measure physical-device performance.
+
+The capture build can be prepared after the normal build has written
+`dist/plan.json`:
+
+```sh
+POCKETJS_CAP_START=600 POCKETJS_CAP_N=1 bun runtime/tools/3ds.ts \
+ --plan=dist/plan.json --project-root=. \
+ --outdir=.local/native-sf/guest --package-outdir=.local/native-sf/guest \
+ --capture
+```
+
+Use a separate emulator SD directory containing the app's pairing key at
+`pocketjs/offload/c7771f0167312c63.key`. Run the Mac provider against
+`127.0.0.1`, return the selected camera position in its map metadata, then launch
+the capture executable in Azahar. Read both `pocketjs-captures/*f0600.raw` files
+after its `done` marker appears. The capture session used a separate SD directory
+and restored the existing emulator configuration afterwards. Raw frames, keys,
+downloaded tiles and working caches stay ignored.
+
+## Other images
+
+- `psp-map.png`, `psp-keyboard.png` and `psp-hyrule.png` are earlier physical PSP
+ framebuffer captures. See [PSP validation](../PSP.md).
+- The earlier `vector-*.png`, Hyrule dual-screen images and interaction images
+ were produced by the compiled guest running against the Wasm core. They are
+ retained as historical visual references and are not console photographs.
+
+OSM data © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright).
+Hyrule artwork belongs to Nintendo; see the README for its pinned source.
diff --git a/docs/images/sf-captures.json b/docs/images/sf-captures.json
new file mode 100644
index 0000000..a16784f
--- /dev/null
+++ b/docs/images/sf-captures.json
@@ -0,0 +1,141 @@
+{
+ "capturedAt": "2026-09-08",
+ "applicationCommit": "958cb8dd72d3c911636530a76b2947aca4d30a72",
+ "runtimeCommit": "e2226e8f69990dd8361d79aabf47bcbedc44786d",
+ "emulator": "Azahar 2125.1.2",
+ "renderer": "Vulkan, resolution_factor=1",
+ "entry": "app/main.tsx (production guest and UI)",
+ "nativeBinarySha256": "a4ef45dcafd86f54983a6e37ec3c04a4007d924c131d75fbfab9493a247b71a9",
+ "capture": {
+ "method": "3DS native GX display-transfer readback of top and auxiliary PICA targets",
+ "frame": 600,
+ "framesPerRun": 1,
+ "screens": {
+ "top": [
+ 400,
+ 240
+ ],
+ "bottom": [
+ 320,
+ 240
+ ]
+ },
+ "presentation": "Top and bottom stacked without scaling, bottom centered with white side margins",
+ "sceneSetup": "map.info.home sets the listed camera coordinates; predictive HTTP demand disabled; real OSM provider and local cache otherwise unchanged",
+ "mapData": "OpenStreetMap contributors, Shortbread MVT via https://tiles.versatiles.org/tiles/osm/{z}/{x}/{y}",
+ "limits": "Emulator screenshots, not console photographs or hardware performance measurements"
+ },
+ "images": [
+ {
+ "file": "sf-union-square-3ds.png",
+ "camera": {
+ "id": "sf-union-square",
+ "name": "Union Square",
+ "detail": "San Francisco, California",
+ "lat": 37.7879,
+ "lon": -122.4075,
+ "zoom": 15
+ },
+ "size": [
+ 400,
+ 480
+ ],
+ "sha256": "d96ac6f5cb5e87ac03bd90ba40ac3a776c22546afbc8211a9d52229eb5932d86",
+ "rawReadbacks": [
+ {
+ "name": "aux-f0600.raw",
+ "bytes": 307200,
+ "sha256": "746257d31b2bd086fd8706007f9ed1ca4750a57152d2af9ab4cb88f5d941d68f"
+ },
+ {
+ "name": "f0600.raw",
+ "bytes": 384000,
+ "sha256": "541a48fc6e9e56e7a438ce2551dfda97e3bc1294070b02f5ed08a5ad9b5f971d"
+ }
+ ],
+ "meshReplies": 4,
+ "lastProviderDiagnostics": {
+ "osm": {
+ "httpHits": 0,
+ "downloads": 4,
+ "meshBytes": 91192,
+ "prepared": 4
+ }
+ }
+ },
+ {
+ "file": "sf-ferry-building-3ds.png",
+ "camera": {
+ "id": "sf-ferry-building",
+ "name": "Ferry Building",
+ "detail": "San Francisco, California",
+ "lat": 37.7955,
+ "lon": -122.3937,
+ "zoom": 16
+ },
+ "size": [
+ 400,
+ 480
+ ],
+ "sha256": "afca5b8a72412f9b2958ca44f78b87227c18cdb547844adf9e05a81dcb0aa739",
+ "rawReadbacks": [
+ {
+ "name": "aux-f0600.raw",
+ "bytes": 307200,
+ "sha256": "746257d31b2bd086fd8706007f9ed1ca4750a57152d2af9ab4cb88f5d941d68f"
+ },
+ {
+ "name": "f0600.raw",
+ "bytes": 384000,
+ "sha256": "af705e3c585ca076113d0cb6a777d1b095d55e983a5787021856dbf609ffd8e2"
+ }
+ ],
+ "meshReplies": 1,
+ "lastProviderDiagnostics": {
+ "osm": {
+ "httpHits": 1,
+ "downloads": 0,
+ "meshBytes": 16370,
+ "prepared": 1
+ }
+ }
+ },
+ {
+ "file": "sf-golden-gate-park-3ds.png",
+ "camera": {
+ "id": "sf-golden-gate-park",
+ "name": "Golden Gate Park",
+ "detail": "San Francisco, California",
+ "lat": 37.7694,
+ "lon": -122.4862,
+ "zoom": 15
+ },
+ "size": [
+ 400,
+ 480
+ ],
+ "sha256": "58ecc0917c077de74f3746de40c10d33c988f626d575d4a4e9f8b6f30e53de92",
+ "rawReadbacks": [
+ {
+ "name": "aux-f0600.raw",
+ "bytes": 307200,
+ "sha256": "746257d31b2bd086fd8706007f9ed1ca4750a57152d2af9ab4cb88f5d941d68f"
+ },
+ {
+ "name": "f0600.raw",
+ "bytes": 384000,
+ "sha256": "fba50df6dff02e84858cdd0e3c9996e1ba2312d5d31ee11608b2eb08a40f2f06"
+ }
+ ],
+ "meshReplies": 2,
+ "lastProviderDiagnostics": {
+ "osm": {
+ "httpHits": 0,
+ "downloads": 2,
+ "meshBytes": 31564,
+ "prepared": 2
+ }
+ }
+ }
+ ]
+}
diff --git a/docs/images/sf-ferry-building-3ds.png b/docs/images/sf-ferry-building-3ds.png
new file mode 100644
index 0000000..20b58d6
Binary files /dev/null and b/docs/images/sf-ferry-building-3ds.png differ
diff --git a/docs/images/sf-golden-gate-park-3ds.png b/docs/images/sf-golden-gate-park-3ds.png
new file mode 100644
index 0000000..72e3333
Binary files /dev/null and b/docs/images/sf-golden-gate-park-3ds.png differ
diff --git a/docs/images/sf-union-square-3ds.png b/docs/images/sf-union-square-3ds.png
new file mode 100644
index 0000000..83bb27b
Binary files /dev/null and b/docs/images/sf-union-square-3ds.png differ
diff --git a/package.json b/package.json
index cd36157..69feef4 100644
--- a/package.json
+++ b/package.json
@@ -11,11 +11,13 @@
"deploy": "bun scripts/deploy.ts",
"sim": "bun scripts/sim.ts",
"prepare:hyrule": "bun scripts/prepare-hyrule.ts",
- "test": "bun test --conditions=browser test/geo.test.ts test/provider.test.ts test/model.test.ts test/bookmarks.test.ts test/transport.test.ts test/atlas.test.ts test/navigation.test.ts test/vector.test.ts",
+ "test": "bun test --conditions=browser test/geo.test.ts test/provider.test.ts test/model.test.ts test/bookmarks.test.ts test/transport.test.ts test/atlas.test.ts test/navigation.test.ts test/vector.test.ts test/sd.test.ts test/storage-benchmark.test.ts",
"check": "bun run test && runtime/node_modules/.bin/tsc --noEmit",
"psp": "bun scripts/psp.ts",
"host:psp": "bun host/serve-usb.ts",
- "test:psp:watch": "bun scripts/watch-psp-smoke.ts"
+ "test:psp:watch": "bun scripts/watch-psp-smoke.ts",
+ "prepare:sd": "bun scripts/prepare-sd.ts",
+ "deploy:sd": "python3 scripts/deploy-sd.py"
},
"dependencies": {
"@mapbox/vector-tile": "3.0.0",
diff --git a/pocket.json b/pocket.json
index d8c622d..8424d19 100644
--- a/pocket.json
+++ b/pocket.json
@@ -1,6 +1,6 @@
{
"$schema": "https://pocketjs.dev/schema/pocket-2.json", "pocket": 2,
"id": "dev.pocket-stack.map", "name": "pocket-map", "title": "Pocket Map", "version": "0.1.0",
- "engine": { "capabilities": { "requires": ["io.offload", "text.glyphs.baked", "input.buttons", "display.auxiliary", "input.touch.auxiliary"], "enhances": ["input.analog.left", "input.analog.right"] } },
+ "engine": { "capabilities": { "requires": ["io.offload", "text.glyphs.baked", "input.buttons", "display.auxiliary", "input.touch.auxiliary"], "enhances": ["io.resource-pack", "input.analog.left", "input.analog.right"] } },
"app": { "entry": "app/main.tsx", "output": "pocketmap-main", "framework": "solid", "viewport": { "fixed": { "logical": [400, 240], "presentation": "native" } }, "surfaces": { "auxiliary": { "fixed": { "logical": [320, 240], "presentation": "native" } } } }
}
diff --git a/runtime b/runtime
index d5f0458..e2226e8 160000
--- a/runtime
+++ b/runtime
@@ -1 +1 @@
-Subproject commit d5f04587fadb153e7f523cf748eeea8d4431a403
+Subproject commit e2226e8f69990dd8361d79aabf47bcbedc44786d
diff --git a/scripts/benchmark-sd.ts b/scripts/benchmark-sd.ts
new file mode 100644
index 0000000..f2fe363
--- /dev/null
+++ b/scripts/benchmark-sd.ts
@@ -0,0 +1,39 @@
+import { mkdirSync } from "node:fs";
+import { resolve } from "node:path";
+import { connectOffloadProvider } from "@pocketjs/framework/offload/provider";
+import { resolve3dsBuildPlan } from "../runtime/tools/3ds-profile.ts";
+import { build3ds } from "../runtime/tools/3ds.ts";
+import { defaultConfig } from "../host/config.ts";
+const root = resolve(import.meta.dir, ".."),
+ qa = resolve(root, "dist/qa");
+mkdirSync(qa, { recursive: true });
+if (process.argv[2] === "host") {
+ const address = process.argv[3] ?? "192.168.8.102";
+ const key = (await Bun.file(resolve(root, ".local/pair.key")).text()).trim();
+ connectOffloadProvider({
+ address,
+ key,
+ worker: new URL("../test/device/sd-worker.ts", import.meta.url),
+ isolation: "process",
+ data: {
+ ...defaultConfig,
+ cache: resolve(root, ".local/cache.sqlite"),
+ kind: "hyrule",
+ atlas: resolve(root, ".local/hyrule"),
+ qaDirectory: qa,
+ },
+ log: (message) => console.log(new Date().toISOString(), message),
+ });
+} else {
+ const manifest = await Bun.file(
+ resolve(root, "test/device/pocket.json"),
+ ).json();
+ const plan = resolve3dsBuildPlan(manifest),
+ path = resolve(qa, "sd-benchmark-plan.json");
+ await Bun.write(path, JSON.stringify(plan, null, 2));
+ await build3ds([
+ `--plan=${path}`,
+ `--project-root=${root}`,
+ ...process.argv.slice(2),
+ ]);
+}
diff --git a/scripts/deploy-sd.py b/scripts/deploy-sd.py
new file mode 100644
index 0000000..43950f5
--- /dev/null
+++ b/scripts/deploy-sd.py
@@ -0,0 +1,106 @@
+"""Install an immutable, resumable atlas; activate only after full readback."""
+import argparse, ftplib, hashlib, json, pathlib, re, time
+
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument('host', nargs='?', default='192.168.8.102')
+parser.add_argument('--restart', action='store_true',
+ help='Upload a new temporary copy from byte zero after a readback mismatch')
+args = parser.parse_args()
+
+root = pathlib.Path(__file__).resolve().parent.parent
+meta = json.loads((root / '.local/3ds/manifest.json').read_text())
+if not isinstance(meta.get('name'), str) or not re.fullmatch(r'hyrule-[a-f0-9]{16}-v1', meta['name']):
+ raise ValueError('Invalid atlas identity')
+local = root / '.local/3ds' / (meta['name'] + '.prp')
+slot = hashlib.sha256(json.loads((root / 'pocket.json').read_text())['id'].encode()).hexdigest()[:16]
+base = '/pocketjs/assets/' + slot
+remote = base + '/' + local.name
+partial = remote + '.partial'
+host = args.host
+with local.open('rb') as source:
+ expected = hashlib.file_digest(source, 'sha256').hexdigest()
+started = time.monotonic()
+
+def connect():
+ ftp = ftplib.FTP()
+ ftp.connect(host, 5000, timeout=30)
+ ftp.login()
+ ftp.voidcmd('TYPE I')
+ return ftp
+
+ftp = connect()
+for path in ['/pocketjs', '/pocketjs/assets', base]:
+ try: ftp.mkd(path)
+ except ftplib.error_perm as e:
+ if not str(e).startswith('550'): raise
+try: installed = not args.restart and ftp.size(remote) == local.stat().st_size
+except ftplib.error_perm: installed = False
+if not installed:
+ for attempt in range(5):
+ try:
+ if ftp is None: ftp = connect()
+ try: offset = 0 if args.restart and attempt == 0 else ftp.size(partial) or 0
+ except ftplib.error_perm: offset = 0
+ if offset > local.stat().st_size: raise RuntimeError('Oversized partial atlas')
+ if offset == local.stat().st_size: break
+ progress = [offset, time.monotonic()]
+ def sent(block):
+ progress[0] += len(block)
+ if time.monotonic() - progress[1] > 10:
+ print(f'Upload {progress[0]}/{local.stat().st_size} bytes', flush=True)
+ progress[1] = time.monotonic()
+ with local.open('rb') as source:
+ source.seek(offset)
+ ftp.storbinary('STOR ' + partial, source, 65536, callback=sent,
+ rest=offset if offset else None)
+ break
+ except (OSError, EOFError, ftplib.error_temp):
+ if ftp: ftp.close()
+ ftp = None
+ if attempt == 4: raise
+ time.sleep(1)
+ target = partial
+else:
+ target = remote
+digest = hashlib.sha256()
+progress = [0, time.monotonic()]
+first_difference = None
+def received(block):
+ global first_difference
+ original = source.read(len(block))
+ if first_difference is None and block != original:
+ first_difference = progress[0] + next(
+ (i for i, (a, b) in enumerate(zip(block, original)) if a != b),
+ min(len(block), len(original)))
+ digest.update(block)
+ progress[0] += len(block)
+ if time.monotonic() - progress[1] > 10:
+ print(f'Verify {progress[0]}/{local.stat().st_size} bytes', flush=True)
+ progress[1] = time.monotonic()
+with local.open('rb') as source:
+ ftp.retrbinary('RETR ' + target, received, 65536, rest=0)
+verification = dict(path=target, bytes=progress[0], expectedBytes=local.stat().st_size,
+ sha256=digest.hexdigest(), expectedSha256=expected,
+ firstDifference=first_difference)
+(root / 'dist/qa').mkdir(parents=True, exist_ok=True)
+(root / 'dist/qa/sd-verify.json').write_text(json.dumps(verification, indent=2))
+if progress[0] != local.stat().st_size or digest.hexdigest() != expected:
+ ftp.close()
+ raise RuntimeError('Atlas readback differs; rerun with --restart: ' + json.dumps(verification))
+if not installed:
+ ftp.rename(partial, remote)
+# The small bootstrap pointer becomes visible only after its complete atlas.
+bootstrap = root / '.local/3ds/hyrule.prp'
+data = bootstrap.read_bytes()
+with bootstrap.open('rb') as source:
+ ftp.storbinary('STOR ' + base + '/hyrule.prp.partial', source)
+actual = bytearray()
+ftp.retrbinary('RETR ' + base + '/hyrule.prp.partial', actual.extend)
+assert actual == data, 'Bootstrap readback differs'
+ftp.rename(base + '/hyrule.prp.partial', base + '/hyrule.prp')
+ftp.quit()
+receipt = dict(path=remote, bytes=local.stat().st_size, sha256=expected,
+ verified=True, seconds=time.monotonic()-started)
+(root / 'dist/qa').mkdir(parents=True, exist_ok=True)
+(root / 'dist/qa/sd-install.json').write_text(json.dumps(receipt, indent=2))
+print(json.dumps(receipt), flush=True)
diff --git a/scripts/deploy.ts b/scripts/deploy.ts
index f4b71d0..f68f0ce 100644
--- a/scripts/deploy.ts
+++ b/scripts/deploy.ts
@@ -1,7 +1,10 @@
import { randomBytes, createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
const address = process.argv[2] ?? "192.168.8.102";
-const bytes = readFileSync("dist/pocketmap-main.3dsx");
+const benchmark = process.argv.includes("--benchmark");
+const artifact = benchmark ? "runtime/dist/3ds/pocketmap-sd-benchmark.3dsx" : "dist/pocketmap-main.3dsx";
+const filename = benchmark ? "pocketmap-sd-benchmark.3dsx" : "pocketmap-main.3dsx";
+const bytes = readFileSync(artifact);
if (bytes.includes(Buffer.from("pocketjs-captures"))) throw new Error("Rebuild without capture before device deployment");
mkdirSync(".local", { recursive: true });
if (!existsSync(".local/pair.key")) writeFileSync(".local/pair.key", randomBytes(32).toString("hex"), { mode: 0o600, flag: "wx" });
@@ -15,7 +18,7 @@ for path in ['/3DS','/pocketjs','/pocketjs/offload']:
except ftplib.error_perm as error:
if not str(error).startswith('550'): raise
receipt=[]
-for local,remote in [('.local/pair.key','/pocketjs/offload/'+sys.argv[2]+'.key'),('dist/pocketmap-main.3dsx','/3DS/pocketmap-main.3dsx')]:
+for local,remote in [('.local/pair.key','/pocketjs/offload/'+sys.argv[2]+'.key'),(sys.argv[3],'/3DS/'+sys.argv[4])]:
data=pathlib.Path(local).read_bytes()
ftp.storbinary('STOR '+remote,io.BytesIO(data),blocksize=65536)
read=io.BytesIO(); ftp.retrbinary('RETR '+remote,read.write)
@@ -23,6 +26,7 @@ for local,remote in [('.local/pair.key','/pocketjs/offload/'+sys.argv[2]+'.key')
receipt.append({'path':remote,'bytes':len(data),'verified':True,**({'sha256':hashlib.sha256(data).hexdigest()} if local.endswith('.3dsx') else {})})
ftp.quit(); print(json.dumps(receipt,indent=2))
`;
-const result = Bun.spawnSync(["python3", "-c", script, address, slot], { stdout: "pipe", stderr: "pipe" });
+const result = Bun.spawnSync(["python3", "-c", script, address, slot, artifact, filename], { stdout: "pipe", stderr: "pipe" });
if (result.exitCode) throw new Error(result.stderr.toString());
-await Bun.write("dist/qa/deploy.json", result.stdout); console.log(result.stdout.toString());
+mkdirSync("dist/qa", { recursive: true });
+await Bun.write(benchmark ? "dist/qa/deploy-benchmark.json" : "dist/qa/deploy.json", result.stdout); console.log(result.stdout.toString());
diff --git a/scripts/prepare-sd.ts b/scripts/prepare-sd.ts
new file mode 100644
index 0000000..1e0d88e
--- /dev/null
+++ b/scripts/prepare-sd.ts
@@ -0,0 +1,72 @@
+import { Database } from "bun:sqlite";
+import { inflateRawSync } from "node:zlib";
+import { mkdirSync } from "node:fs";
+import { resolve } from "node:path";
+const root = resolve(import.meta.dir, ".."),
+ runtime = resolve(process.env.POCKETJS_RUNTIME ?? resolve(root, "runtime"));
+const { createResourcePack, prepareTiledRGB565 } = await import(
+ `${runtime}/tools/resource-pack.ts`
+);
+const db = new Database(resolve(root, ".local/hyrule/atlas.sqlite"), {
+ readonly: true,
+});
+const manifest = JSON.parse(
+ (
+ db.query("SELECT value FROM metadata WHERE key='manifest'").get() as {
+ value: string;
+ }
+ ).value,
+);
+if (
+ manifest.format !== "pocket-map-atlas-rgb565-v1" ||
+ manifest.tiles !== 21845 ||
+ !/^[a-f0-9]{16}$/.test(manifest.info?.source ?? "") ||
+ manifest.info.space !== "planar" ||
+ manifest.info.minZoom !== 0 ||
+ manifest.info.maxZoom !== 7
+) {
+ db.close();
+ throw Error("Unsupported Hyrule atlas");
+}
+const out = resolve(root, ".local/3ds");
+mkdirSync(out, { recursive: true });
+const name = `hyrule-${manifest.info.source}-v1`,
+ pack = createResourcePack(resolve(out, `${name}.prp`), 21846);
+try {
+ pack.add(Buffer.from(JSON.stringify(manifest)));
+ const query = db.query("SELECT pixels FROM tiles WHERE z=? AND x=? AND y=?");
+ for (let z = 0; z <= 7; z++) {
+ for (let y = 0; y < 2 ** z; y++)
+ for (let x = 0; x < 2 ** z; x++) {
+ const row = query.get(z, x, y) as { pixels: Uint8Array } | null;
+ if (!row) throw Error("Missing atlas tile");
+ const pixels = inflateRawSync(row.pixels, { maxOutputLength: 131072 });
+ const index = pack.add(prepareTiledRGB565(pixels, 256, 256), {
+ width: 256,
+ height: 256,
+ });
+ if (index !== 1 + (4 ** z - 1) / 3 + y * 2 ** z + x)
+ throw Error("Invalid tile order");
+ }
+ console.log(`SD atlas level ${z} complete`);
+ }
+ const receipt = { ...pack.finish(), name, source: manifest.info.source };
+ const bootstrap = createResourcePack(resolve(out, "hyrule.prp"), 1);
+ try {
+ bootstrap.add(Buffer.from(JSON.stringify(manifest)));
+ bootstrap.finish();
+ } catch (error) {
+ bootstrap.abort();
+ throw error;
+ }
+ await Bun.write(
+ resolve(out, "manifest.json"),
+ JSON.stringify(receipt, null, 2),
+ );
+ console.log(receipt);
+} catch (e) {
+ pack.abort();
+ throw e;
+} finally {
+ db.close();
+}
diff --git a/test/device/pocket.json b/test/device/pocket.json
new file mode 100644
index 0000000..4fe2444
--- /dev/null
+++ b/test/device/pocket.json
@@ -0,0 +1,49 @@
+{
+ "$schema": "https://pocketjs.dev/schema/pocket-2.json",
+ "pocket": 2,
+ "id": "dev.pocket-stack.map",
+ "name": "pocket-map",
+ "title": "Pocket Map Storage Test",
+ "version": "0.1.0",
+ "engine": {
+ "capabilities": {
+ "requires": [
+ "io.offload",
+ "text.glyphs.baked",
+ "input.buttons",
+ "display.auxiliary",
+ "input.touch.auxiliary"
+ ],
+ "enhances": [
+ "io.resource-pack",
+ "input.analog.left",
+ "input.analog.right"
+ ]
+ }
+ },
+ "app": {
+ "entry": "test/device/sd-benchmark.tsx",
+ "output": "pocketmap-sd-benchmark",
+ "framework": "solid",
+ "viewport": {
+ "fixed": {
+ "logical": [
+ 400,
+ 240
+ ],
+ "presentation": "native"
+ }
+ },
+ "surfaces": {
+ "auxiliary": {
+ "fixed": {
+ "logical": [
+ 320,
+ 240
+ ],
+ "presentation": "native"
+ }
+ }
+ }
+ }
+}
diff --git a/test/device/sd-benchmark.tsx b/test/device/sd-benchmark.tsx
new file mode 100644
index 0000000..82131c5
--- /dev/null
+++ b/test/device/sd-benchmark.tsx
@@ -0,0 +1,27 @@
+// Separate diagnostic entry; never imported by the production app.
+import { createSignal } from "solid-js";
+import { mount } from "@pocketjs/framework/solid";
+import { Text } from "@pocketjs/framework/components";
+import { onFrame } from "@pocketjs/framework/lifecycle";
+import MapApp from "../../app/ui.tsx";
+import type { MapModel } from "../../app/model.ts";
+import { createStorageBenchmark } from "./storage-benchmark.ts";
+
+mount(() => {
+ const app = ;
+ const [label, setLabel] = createSignal("Storage test: waiting for Mac");
+ onFrame(
+ createStorageBenchmark(
+ () => (globalThis as unknown as { __map?: MapModel }).__map,
+ setLabel,
+ ),
+ );
+ return (
+ <>
+ {app}
+
+ {label()}
+
+ >
+ );
+});
diff --git a/test/device/sd-worker.ts b/test/device/sd-worker.ts
new file mode 100644
index 0000000..3f589f4
--- /dev/null
+++ b/test/device/sd-worker.ts
@@ -0,0 +1,42 @@
+import { dispatchOffload } from "@pocketjs/framework/offload/provider";
+import { mkdirSync, appendFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { MapService } from "../../host/service.ts";
+declare const self: {
+ onmessage: (event: MessageEvent) => void;
+ postMessage(value: unknown): void;
+};
+let provider: MapService, output: string;
+self.onmessage = async (event) => {
+ if (event.data.init) {
+ provider = new MapService(event.data.init);
+ output = resolve(event.data.init.qaDirectory, "sd-benchmark.jsonl");
+ mkdirSync(event.data.init.qaDirectory, { recursive: true });
+ return;
+ }
+ self.postMessage(
+ await dispatchOffload(
+ {
+ ...provider.methods(),
+ "map.benchmark": (raw: string) => {
+ const row = JSON.parse(raw);
+ if (
+ !Number.isInteger(row.stage) ||
+ row.stage < 0 ||
+ row.stage > 9 ||
+ !Number.isFinite(row.milliseconds) ||
+ row.milliseconds < 0
+ )
+ throw Error("Invalid measurement");
+ appendFileSync(
+ output,
+ JSON.stringify({ received: new Date().toISOString(), ...row }) +
+ "\n",
+ );
+ return "{}";
+ },
+ },
+ event.data,
+ ),
+ );
+};
diff --git a/test/device/storage-benchmark.ts b/test/device/storage-benchmark.ts
new file mode 100644
index 0000000..e149f26
--- /dev/null
+++ b/test/device/storage-benchmark.ts
@@ -0,0 +1,125 @@
+import type { MapModel } from "../../app/model.ts";
+
+const route = [
+ [107.733333, 168, 4],
+ [145, 101, 6],
+ [128, 128, 7],
+ [64, 64, 5],
+] as const;
+export function createStorageBenchmark(
+ map: () => MapModel | undefined,
+ setLabel: (label: string) => void,
+ nowMilliseconds = Date.now,
+) {
+ let initializedStage = -1;
+ let stage = 0,
+ frames = 0,
+ started = 0,
+ awaiting = false,
+ finished = false,
+ before = "";
+ let panning = false,
+ previous = 0,
+ fallbackFrames = 0,
+ over20ms = 0,
+ distance = 0;
+ return () => {
+ const s = map();
+ if (!s || finished) return;
+ if (initializedStage >= 0 && !s.online()) {
+ finished = true;
+ setLabel("Storage test: disconnected");
+ return;
+ }
+ if (!s.info() || !s.online() || s.switching() || awaiting) return;
+ if (!s.planar()) {
+ s.switchMap("hyrule");
+ return;
+ }
+ const local = stage % 2 === 0,
+ pan = stage >= 8,
+ point = pan ? ([72, 160, 6] as const) : route[Math.floor(stage / 2)]!;
+ if (initializedStage !== stage) {
+ initializedStage = stage;
+ s.annotations.setLayer("off");
+ s.setLocalTiles(local);
+ s.clearBack();
+ s.camera.jump(point[0], point[1], point[2]);
+ started = nowMilliseconds();
+ before = s.diagnostics().pack ?? "";
+ setLabel(
+ `${local ? "SD" : "Mac"} ${pan ? "pan" : "load"} test ${stage + 1}/10`,
+ );
+ }
+ frames++;
+ // Ignore the previous front layer during the first camera reconciliation.
+ const visible = s.front()?.tiles ?? [];
+ const ready = visible.filter(
+ (t) => s.frontView.state(t.input).status === "ready",
+ ).length;
+ const now = nowMilliseconds(),
+ readyNow =
+ !!ready && ready === visible.length && s.front()?.level === point[2];
+ if (pan && !panning && frames >= 3 && readyNow) {
+ panning = true;
+ s.camera.beginDrag();
+ started = previous = now;
+ frames = fallbackFrames = over20ms = 0;
+ distance = 0;
+ before = s.diagnostics().pack ?? "";
+ }
+ if (pan && panning) {
+ if (!readyNow) fallbackFrames++;
+ const elapsed = now - previous;
+ if (elapsed > 20) over20ms++;
+ const move = Math.min(100, elapsed) * 0.32;
+ distance += move;
+ s.camera.drag(-move / Math.SQRT2, move / Math.SQRT2);
+ previous = now;
+ if (now - started < 8000) return;
+ s.camera.endDrag(0, 0);
+ } else if (frames < 3 || (!readyNow && now - started < 20000)) return;
+ const row = {
+ stage,
+ local,
+ mode: pan ? "pan" : "cold-view",
+ point,
+ milliseconds: now - started,
+ frames,
+ ready,
+ visible: visible.length,
+ level: s.front()?.level,
+ storage: s.tileStorage(),
+ complete: pan ? panning : readyNow,
+ fallbackFrames,
+ over20ms,
+ distance,
+ packBefore: before,
+ packAfter: s.diagnostics().pack ?? "",
+ };
+ // One small receipt after each leg; its acknowledgement is outside the
+ // measured interval. Date.now is the native clock, not virtual frame time.
+ awaiting = true;
+ const id = s.io.request("map.benchmark", JSON.stringify(row), (result) => {
+ awaiting = false;
+ if (!result.ok) {
+ finished = true;
+ setLabel("Storage test: receipt failed");
+ return;
+ }
+ stage++;
+ frames = 0;
+ panning = false;
+ if (stage === 10) {
+ finished = true;
+ s.setLocalTiles(true);
+ s.camera.jump(...route[0]);
+ setLabel("Storage test saved on Mac");
+ }
+ });
+ if (!id) {
+ finished = true;
+ setLabel("Storage test: no receipt credit");
+ }
+ };
+}
diff --git a/test/sd.test.ts b/test/sd.test.ts
new file mode 100644
index 0000000..ee65f7f
--- /dev/null
+++ b/test/sd.test.ts
@@ -0,0 +1,123 @@
+import { expect, test } from "bun:test";
+import { createRoot } from "solid-js";
+import { createOffloadClient } from "@pocketjs/framework/offload";
+import { resourcePacks } from "@pocketjs/framework/resource-pack";
+import { installHost, type HostOps } from "../runtime/framework/src/host.ts";
+import {
+ runFrameHooks,
+ resetFrameHooks,
+} from "../runtime/framework/src/frame.ts";
+import { runServicePumps } from "../runtime/framework/src/services.ts";
+import { createMap } from "../app/model.ts";
+
+test("SD bootstrap opens and navigates new Hyrule tiles while the Mac is offline", () => {
+ resetFrameHooks();
+ const replies: string[] = [],
+ addresses: string[] = [],
+ released: number[] = [],
+ freed: number[] = [];
+ let token = 0,
+ uploaded = 0,
+ remote = 0;
+ const source = "1234567890abcdef";
+ const atlas = {
+ format: "pocket-map-atlas-rgb565-v1",
+ tiles: 21845,
+ info: {
+ source,
+ name: "Hyrule",
+ attribution: "Fixture",
+ minZoom: 0,
+ maxZoom: 7,
+ space: "planar",
+ home: {
+ id: "home",
+ name: "Home",
+ detail: "",
+ space: "planar",
+ x: 128,
+ y: 128,
+ zoom: 4,
+ },
+ },
+ };
+ (globalThis as any).resourcePacks = {
+ session: () => 1,
+ enqueue(id: number, name: string, entry: number) {
+ addresses.push(`${name}/${entry}`);
+ replies.push(
+ JSON.stringify(
+ name === "hyrule"
+ ? { id, payload: JSON.stringify(atlas) }
+ : { id, image: { token: ++token, width: 256, height: 256 } },
+ ),
+ );
+ return true;
+ },
+ take: () => replies.shift(),
+ uploadImage: () => ++uploaded,
+ releaseImage: (id: number) => released.push(id),
+ stats: () => "fixture",
+ };
+ installHost({
+ kind: "injected",
+ target: "fixture",
+ strict: true,
+ ops: { freeTexture: (id: number) => freed.push(id) } as unknown as HostOps,
+ });
+ const io = createOffloadClient({
+ session: () => 0,
+ submit: () => {
+ remote++;
+ return false;
+ },
+ take: () => undefined,
+ uploadImage: () => -1,
+ releaseImage() {},
+ });
+ try {
+ createRoot((dispose) => {
+ const s = createMap(io);
+ const frames = (n: number) => {
+ for (let i = 0; i < n; i++) {
+ runServicePumps();
+ runFrameHooks(0);
+ s.runtime.step();
+ io.step();
+ }
+ };
+ frames(90);
+ expect(s.info()?.source).toBe(source);
+ expect(s.online()).toBe(false);
+ expect(s.localMapAvailable()).toBe(true);
+ expect(
+ s
+ .front()!
+ .tiles.every((t) => s.frontView.state(t.input).status === "ready"),
+ ).toBe(true);
+ const before = addresses.length;
+ s.camera.jump(220, 80, 7);
+ frames(90);
+ expect(addresses.length).toBeGreaterThan(before);
+ expect(
+ s
+ .front()!
+ .tiles.every((t) => s.frontView.state(t.input).status === "ready"),
+ ).toBe(true);
+ expect(remote).toBe(0);
+ expect(s.tiles.stats().entries).toBeLessThanOrEqual(40);
+ expect(addresses[0]).toBe("hyrule/0");
+ expect(
+ addresses.slice(1).every((a) => a.startsWith(`hyrule-${source}-v1/`)),
+ ).toBe(true);
+ dispose();
+ expect(freed.length).toBe(uploaded);
+ expect(released.length).toBe(uploaded);
+ });
+ } finally {
+ resourcePacks()?.dispose();
+ io.dispose();
+ delete (globalThis as any).resourcePacks;
+ resetFrameHooks();
+ }
+});
diff --git a/test/storage-benchmark.test.ts b/test/storage-benchmark.test.ts
new file mode 100644
index 0000000..492980a
--- /dev/null
+++ b/test/storage-benchmark.test.ts
@@ -0,0 +1,131 @@
+import { expect, test } from "bun:test";
+import { createTileCamera } from "@pocketjs/framework/tile-viewport";
+import type { MapModel } from "../app/model.ts";
+import { createStorageBenchmark } from "./device/storage-benchmark.ts";
+
+function rig() {
+ let now = 0,
+ readyAt = 0,
+ level = 4,
+ changeAt = 0,
+ local = true,
+ online = true;
+ const camera = createTileCamera({
+ width: 400,
+ height: 240,
+ x: 128,
+ y: 128,
+ zoom: 4,
+ minZoom: 0,
+ maxZoom: 7,
+ bounds: { width: 256, height: 256 },
+ });
+ const jump = camera.jump;
+ camera.jump = (x, y, z) => {
+ jump(x, y, z);
+ changeAt = now + (8 * 1000) / 60;
+ };
+ const resets: boolean[] = [],
+ rows: any[] = [],
+ labels: string[] = [];
+ const acks: {
+ at: number;
+ callback: (result: { ok: true; value: string }) => void;
+ }[] = [];
+ const map = {
+ info: () => ({}),
+ online: () => online,
+ switching: () => false,
+ planar: () => true,
+ annotations: { setLayer() {} },
+ clearBack() {},
+ camera,
+ setLocalTiles(value: boolean) {
+ resets.push(value);
+ local = value;
+ readyAt = now + 50;
+ },
+ diagnostics: () => ({ pack: "fixture" }),
+ tileStorage: () => (local ? "local" : "desktop"),
+ front: () => ({ level, tiles: [{ input: {} }, { input: {} }] }),
+ frontView: {
+ state: () => ({ status: now >= readyAt ? "ready" : "pending" }),
+ },
+ io: {
+ request(
+ _method: string,
+ raw: string,
+ callback: (result: { ok: true; value: string }) => void,
+ ) {
+ rows.push(JSON.parse(raw));
+ acks.push({ at: now + 50, callback });
+ return rows.length;
+ },
+ },
+ } as unknown as MapModel;
+ const tick = createStorageBenchmark(
+ () => map,
+ (s) => labels.push(s),
+ () => now,
+ );
+ return {
+ rows,
+ resets,
+ labels,
+ camera,
+ disconnect() {
+ online = false;
+ },
+ step() {
+ now += 1000 / 60;
+ if (now >= changeAt) level = Math.round(camera.view().zoom);
+ if (acks[0]?.at <= now) acks.shift()!.callback({ ok: true, value: "{}" });
+ tick();
+ },
+ };
+}
+
+test("storage comparison waits for target LOD and keeps the warmed pan cache", () => {
+ const r = rig();
+ for (let i = 0; i < 1400; i++) r.step();
+ expect(r.rows).toHaveLength(10);
+ expect(r.rows.map((row) => row.stage)).toEqual([
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
+ ]);
+ for (const row of r.rows) {
+ expect(row.level).toBe(row.point[2]);
+ expect(row.complete).toBe(true);
+ expect(row.storage).toBe(row.local ? "local" : "desktop");
+ }
+ for (const row of r.rows.slice(8)) {
+ expect(row.milliseconds).toBeGreaterThanOrEqual(8000);
+ expect(row.distance).toBeGreaterThanOrEqual(2559);
+ expect(row.distance).toBeLessThan(2570);
+ expect(row.fallbackFrames).toBe(0);
+ }
+ // Ten leg starts plus the final return to normal local browsing. Resetting
+ // the pan frame counter must not clear the warmed cache a second time.
+ expect(r.resets).toEqual([
+ true,
+ false,
+ true,
+ false,
+ true,
+ false,
+ true,
+ false,
+ true,
+ false,
+ true,
+ ]);
+ expect(r.labels.at(-1)).toBe("Storage test saved on Mac");
+});
+
+test("connection loss aborts comparison instead of folding offline time into latency", () => {
+ const r = rig();
+ r.step();
+ r.disconnect();
+ for (let i = 0; i < 100; i++) r.step();
+ expect(r.rows).toHaveLength(0);
+ expect(r.labels.at(-1)).toBe("Storage test: disconnected");
+});