diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
new file mode 100644
index 0000000..353edbc
--- /dev/null
+++ b/.github/workflows/check.yml
@@ -0,0 +1,20 @@
+name: Map provider and model
+on: [push, pull_request]
+permissions:
+ contents: read
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ submodules: recursive
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.14
+ - run: bun install --frozen-lockfile
+ working-directory: runtime
+ - run: bun install --frozen-lockfile
+ - run: bun run setup
+ - run: bun run check
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..33a0b38
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+.local/
+.DS_Store
+*.log
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..33f6800
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "runtime"]
+ branch = main
+ path = runtime
+ url = https://github.com/pocket-stack/pocketjs.git
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..1839902
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,9 @@
+# Pocket Map
+
+- Framework transport, resource lifecycle and tile geometry belong in PocketJS.
+- Keep network requests, SQLite, image decoding and rasterization in the Mac worker.
+- Bound guest tile demand, pending responses, native image staging and GPU residency.
+- Import PocketJS APIs from `@pocketjs/framework/*`, Solid primitives from `solid-js`.
+- Keep pairing keys, cached tiles, user searches and raw replay captures out of Git. Curated public demo images belong in docs/images/.
+- Run the app's explicit tests; do not discover tests through the runtime submodule.
+- Publish validated changes as a Draft PR with a Conventional Commits title.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c025cd0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Pocket Stack contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 91b79b1..e93b33c 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,204 @@
# Pocket Map
-A Nintendo 3DS map app built with PocketJS, with a paired Mac providing map tiles and place search.
+A map browser for Nintendo 3DS and PSP, built with [PocketJS](https://github.com/pocket-stack/pocketjs) and SolidJS 1.9. Browse **OSM vector maps** or the complete Hyrule map from **The Legend of Zelda: Breath of the Wild**. Pan with the resistive touchpad, zoom, search and save places on the paired Mac.
+
+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).
+
+
+
+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.
+
+## PSP over USB
+
+The PSP port renders the same OSM vector geometry on its local GE, with a 480×272 single-screen UI, shoulder menus, a virtual keyboard and saved places on the Mac. The Mac performs network requests, vector preparation and image decoding through PocketJS's native USB offload worker. Hyrule retains its raster path. See [PSP setup, controls and measured limits](docs/PSP.md).
+
+
+
+## Try it
+
+Requirements: Bun 1.3.14, Docker with the PocketJS 3DS toolchain (or a supported native devkitPro install), Git, a Mac and a 3DS on the same LAN, Homebrew Launcher and ftpd. The runtime submodule pins the mesh and image resource support used by this app.
+
+```sh
+git clone --recurse-submodules https://github.com/pocket-stack/pocket-map.git
+cd pocket-map
+cd runtime && bun install --frozen-lockfile && cd ..
+bun install --frozen-lockfile
+bun run setup
+bun run prepare:hyrule
+bun run 3ds
+# Open ftpd on the console first. Substitute its LAN IP.
+bun run deploy 192.168.8.102
+bun run host 192.168.8.102
+```
+
+Exit ftpd and open **Pocket Map** in HBL. The deployment script installs only this app and its pairing key, then reads both back for verification. Keys, saved places and the provider's cache stay under ignored `.local/`. `L + R + START` returns to HBL.
+
+### Updating this build
+
+**The current `POCKETJS_OFFLOAD` native build excludes the development server
+and package storage path.** PocketJS's ordinary 3DS runtime supports guest
+updates on port 8131, but that connection is unavailable in this build, even
+with a valid development key. Rebuild with `bun run 3ds`, deploy through ftpd,
+and restart Pocket Map for guest or native changes. Mac-only provider changes
+need only a daemon restart. The offload UI path omits synchronous SD package
+work; enabling safe development updates here needs native host integration.
+
+For request timing and socket backpressure diagnostics, start the host with
+`POCKET_MAP_TRACE=1 bun run host <3ds-ip>`. It records request IDs, method names,
+provider duration, cumulative cache hits/downloads and socket drain waits.
+Request payloads are omitted. A drain receipt measures the Mac socket's write
+queue; it is not a device-render receipt.
+
+| Control | Action |
+| --- | --- |
+| Bottom touchpad | Drag to pan; flick for inertia |
+| Circle Pad / D-pad | Pan the upper map |
+| `+` / `-` buttons | Animated zoom around the map center |
+| Search / Y | Open the local keyboard; Find submits the query |
+| Search results touchpad | Slide to choose a result, tap to open |
+| D-pad up/down + A | Choose and open a search result or menu item |
+| X / Back to pin | Return to the selected place |
+| Hold L | Search, saved places, save map center, return to pin, or map home |
+| Hold R | Zoom, label categories, switch map, clear pin, retry, or controls |
+| Hold ZL + D-pad up/down | Open the vertical zoom rail; tap or hold to change levels |
+| Map name on the lower screen | Switch Hyrule / OSM without restarting |
+| Save view / Save place | Name and save the center or selected search result on the Mac |
+| Saved | Browse, rename, delete with confirmation, or return to a saved location |
+| Saved page: Prev / Next or D-pad left/right | Turn five-place pages |
+| B | Dismiss results/menu; backspace while typing |
+| Shift | Tap for one uppercase letter; double-tap for caps lock |
+
+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.
+
+## Complete local Hyrule atlas
+
+`bun run prepare:hyrule` sparsely checks out a pinned Zelda Dungeon revision:
+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 completed `.local/hyrule/atlas.sqlite` is about **592 MB** and includes an
+FTS5 index of regions, landmarks, towers, shrines, villages, Korok seeds and
+treasures. Preparation publishes the database only after all levels complete.
+It is reused on the next invocation; `--rebuild` explicitly replaces it.
+
+The Hyrule provider has no HTTP fallback or request throttle. It reads the
+prepared textures and keeps 128 decoded renditions on the Mac. The device
+retains 40 tiles and requests up to 12 neighboring tiles with a 256px margin
+and at most 512px of directional prediction. Repeated strokes accumulate camera
+travel: lifting the stylus retains the direction for three seconds, followed by
+expiry. Once direction is established, the extra tiles follow a forward corridor
+instead of filling a surrounding ring. A turn changes the prediction.
+
+After zooming in, a five-second prediction window prepares up to six tiles from
+the next level around the center. These share the 12-extra-tile budget with pan
+prediction and the same 40-entry cache. Visible tiles retain priority.
+The finite world clamps at its edges; Map home returns to the Great Plateau.
+Try searching `Kakariko`, `Hyrule Castle`, `Shrine` or `Great Plateau`.
+
+## Switching maps and game labels
+
+Tap the **Map** name on the lower screen, or use **R → Switch map**. Choose with
+touch or D-pad / A. Each map remembers its last camera position, zoom and pin
+for this guest session. The daemon keeps both providers available; all tile,
+search and bookmark requests carry the selected source identity. Switching
+clears old view demand and fences late responses. A pending bookmark mutation
+must finish or be resolved before switching databases.
+
+Hyrule's separate marker index contains 2,576 annotations. **R → Map labels**
+selects all labels, places/shrines/towers, collectibles, enemies, or hides labels.
+Regions appear at wide zooms; detailed items such as Koroks and treasures appear
+from level 6. The Mac performs spatial queries and density selection. The guest
+receives bounded point/name/category records and draws small baked icons plus
+text locally, so labels move with the map without another network round trip.
+OSM labels are independent of terrain geometry. Its menu offers Show / Hide labels; ASCII uses the shared local glyph atlas, with a cached image fallback for other scripts.
+
+`prepare:hyrule` refreshes the separate marker index without rebaking existing
+terrain textures. Marker assets and SQLite stay outside Git; the small UI icons
+are drawn for this app.
+
+## Saved places
+
+Use **Save view** to store the map center, or **Save place** on a search result.
+The local keyboard names it before Save sends a command to the Mac. Saved opens
+five-place pages, with Rename, Delete and Go to place actions. Deletion uses an
+animated confirmation sheet; B / Keep place cancels it.
+
+The Mac stores Hyrule bookmarks in `.local/hyrule/places.sqlite`. The OSM provider
+uses `.local/places.sqlite`, separate from `.local/cache.sqlite`. Geographic
+bookmarks migrate without losing existing entries or operation receipts. Coordinates, zoom and names survive daemon restarts.
+Mutations commit with an operation receipt: Retry after a missing acknowledgement
+uses the same operation, so it cannot create a second copy or resurrect a deleted
+place. Cancel after an unconfirmed command does not undo a Mac write; reopen
+Saved to check its outcome. A connected Mac is needed for writes. The guest
+retains up to four previously viewed pages during a disconnection.
+
+## OpenStreetMap vector provider
+
+Start `bun run host <3ds-ip> --osm` to open OSM first. Hyrule remains available
+from the source picker when its atlas is installed. For an OSM-only setup, skip
+`prepare:hyrule` and use `--osm`.
+
+The default uses [VersaTiles' public Shortbread MVTs](https://docs.versatiles.org/guides/use_tiles_versatiles_org.html)
+and [Photon place search](https://github.com/komoot/photon#demo-server), without
+API keys. The Mac caches responses in SQLite, preserves HTTP validators and
+prepares geometry in its isolated capability process. Search requires an
+explicit submit and has a 1.1-second minimum interval.
+
+Override the Shortbread endpoint in ignored `.local/provider.json`, then restart
+the daemon. [VECTOR_MAP.md](docs/VECTOR_MAP.md#provider-configuration) includes an
+OSM official endpoint example, protocol details, provider requirements and
+measurement limits. Source z14 geometry remains resident while zooming through
+z18; street-label windows query the same prepared source data.
+
+Custom 256px PNG endpoints remain supported with `"format": "raster"`. Hyrule's
+complete local JPEG-derived atlas stays on the R5G6B5 image path.
+
+## Architecture and resource pattern
+
+The app defines separate image and geometry collections. Both use the same
+scheduler, view-demand merging, retry, fallback and eviction lifecycle:
+
+```ts
+const tiles = createOffloadMeshCollection(runtime, io, {
+ key: (tile: TileInput) => `${tile.source}/${tile.z}/${tile.x}/${tile.y}`,
+ method: "map.mesh", payload: JSON.stringify,
+ maxEntries: 40, maxViews: 2, maxDemandsPerView: 24,
+});
+const view = createResourceView(tiles, { demand: visibleTileDemand });
+// view.state(tile)} fallback={() => } />
+```
+
+Hyrule declares `createOffloadImageCollection` 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.
+
+Map-specific policy remains in [app/model.ts](app/model.ts), [app/prediction.ts](app/prediction.ts)
+and [app/annotations.ts](app/annotations.ts). [host/vector-geometry.ts](host/vector-geometry.ts)
+prepares OSM geometry; [host/atlas.ts](host/atlas.ts) reads Hyrule textures.
+[ARCHITECTURE.md](docs/ARCHITECTURE.md) records budgets and lifecycle.
+
+## Validation
+
+```sh
+bun run check
+runtime/node_modules/.bin/tsc --noEmit
+bun run sim # compiled guest, synthetic OSM provider, interaction replay
+bun run sim --hyrule # complete local atlas, search and delayed-response navigation
+bun scripts/vector-sim.ts
+bun scripts/vector-sim.ts --live # a small real vector viewport, cached on Mac
+qjs --std --stack-size 131072 scripts/quickjs-smoke.js
+qjs --std --stack-size 131072 scripts/quickjs-smoke.js runtime/dist/3ds/guest/pocketmap-main.js hyrule
+```
+
+Generated captures and receipts go to `dist/qa/`. Replay covers keyboard input, selecting places, drag/inertia, zoom, sustained movement while replies wait, reconnect, date-line navigation and bounded resource ownership. Replay also covers saving, a lost acknowledgement and safe retry, renaming, paging, delete confirmation and entering a prefetched tile. Stress tests use synthetic tiles, not automated public-service downloads.
+
+Native build, native transport, visual replay and physical interaction are separate checks. **No measured 60 fps claim is made yet.** Per-frame IO and image limits prevent network waits on the UI thread; JS, allocation, GPU uploads and presentation still need hardware measurement. See [docs/VALIDATION.md](docs/VALIDATION.md).
diff --git a/app/annotations.ts b/app/annotations.ts
new file mode 100644
index 0000000..77151f6
--- /dev/null
+++ b/app/annotations.ts
@@ -0,0 +1,155 @@
+import { createMemo, createSignal } from "solid-js";
+import { createResourceView, type createResourceRuntime } from "@pocketjs/framework/resource-view";
+import { offloadResource } from "@pocketjs/framework/resource-offload";
+import { getOps } from "@pocketjs/framework/host";
+import { visibleTiles } from "@pocketjs/framework/tile-viewport";
+import type { offload } from "@pocketjs/framework/offload";
+import { MARKER_KINDS, type MapInfo, type MapMarker, type MarkerInput, type MarkerLayer } from "../shared/types.ts";
+export const LAYERS: { id: MarkerLayer; name: string }[] = [
+ { id: "all", name: "All map labels" },
+ { id: "travel", name: "Places, shrines & towers" },
+ { id: "collectibles", name: "Koroks & treasures" },
+ { id: "enemies", name: "Enemies" },
+ { id: "off", name: "Hide labels" },
+];
+const metrics = new Map();
+export function labelMetrics(name: string) {
+ const cached = metrics.get(name);
+ if (cached) return cached;
+ const unicode = /[^\x20-\x7e]/.test(name);
+ const width = (text: string) => (unicode ? text.length * 13 : getOps().measureText(text, 0));
+ let text = name;
+ while (text.length > 1 && width(text) > 166) text = text.slice(0, -1);
+ if (text !== name) {
+ text = text.slice(0, -3) + "...";
+ }
+ const result = { text, width: Math.min(174, Math.ceil(width(text)) + 8) };
+ metrics.set(name, result);
+ if (metrics.size > 256) metrics.delete(metrics.keys().next().value!);
+ return result;
+}
+export const labelWidth = (name: string) => labelMetrics(name).width;
+export function createAnnotations(io: ReturnType, runtime: ReturnType, viewport = { width: 400, height: 240 }) {
+ const [layer, setLayer] = createSignal("all"),
+ [demand, setDemand] = createSignal([]);
+ const collection = runtime.createCollection({
+ key: (i: MarkerInput) => `${i.source}/${i.layer}/${i.z}/${i.x}/${i.y}`,
+ maxEntries: 40,
+ maxViews: 1,
+ maxDemandsPerView: 12,
+ cost: () => 8192,
+ maxCost: 40 * 8192,
+ maxResponseBytes: 5000,
+ load: offloadResource(io, "map.markers", JSON.stringify),
+ materialize(raw: string): MapMarker[] {
+ const rows = JSON.parse(raw);
+ if (
+ !Array.isArray(rows) ||
+ rows.length > 12 ||
+ !rows.every(
+ (m) =>
+ Array.isArray(m) &&
+ m.length === 5 &&
+ Number.isSafeInteger(m[0]) &&
+ typeof m[1] === "string" &&
+ m[1].length <= 24 &&
+ MARKER_KINDS.includes(m[2]) &&
+ Number.isFinite(m[3]) &&
+ Number.isFinite(m[4]) &&
+ m[3] >= 0 &&
+ m[3] < 256 &&
+ m[4] >= 0 &&
+ m[4] < 256,
+ )
+ )
+ throw new Error("Invalid map annotations");
+ return rows;
+ },
+ });
+ const view = createResourceView(collection, {
+ demand: () => demand().map((input) => ({ input, priority: 25, pin: true })),
+ });
+ const rows = createMemo(() => demand().flatMap((i) => view.value(i) ?? []).sort((a, b) => a[0] - b[0]));
+ const [renderRows, setRenderRows] = createSignal([]);
+ let last: { x: number; y: number; zoom: number; rows: MapMarker[] } | undefined;
+ let settled = false;
+ let demandAt: { x: number; y: number; zoom: number; level: number; info: MapInfo; layer: MarkerLayer } | undefined;
+ let placements = new Map();
+ return {
+ layer,
+ setLayer,
+ rows,
+ renderRows,
+ placement: (id: number) => placements.get(id),
+ reset() {
+ setDemand([]); setRenderRows([]); placements.clear(); last = undefined; demandAt = undefined;
+ collection.clear();
+ },
+ update(camera: { x: number; y: number; zoom: number }, level: number, info: MapInfo) {
+ if (info.render === "mesh") level = Math.min(info.maxZoom, Math.max(0, Math.round(camera.zoom)));
+ const vector = info.render === "mesh",
+ size = vector ? 256 : 512;
+ const cells = Math.max(1, 2 ** (vector ? level : level - 1));
+ const demandChanged = !demandAt || demandAt.x !== camera.x || demandAt.y !== camera.y
+ || demandAt.zoom !== camera.zoom || demandAt.level !== level
+ || demandAt.info !== info || demandAt.layer !== layer();
+ if (demandChanged) {
+ demandAt = { ...camera, level, info, layer: layer() };
+ const next: MarkerInput[] =
+ info.markers && layer() !== "off"
+ ? visibleTiles({
+ ...camera,
+ zoom: vector ? camera.zoom : level,
+ level,
+ width: viewport.width,
+ height: viewport.height,
+ tileSize: size,
+ maxTiles: vector ? 12 : 4,
+ })
+ .filter((t) => t.row >= 0 && t.row < cells && (vector || (t.column >= 0 && t.column < cells)))
+ .map((t) => ({
+ source: info.source,
+ z: level,
+ x: vector ? ((t.column % cells) + cells) % cells : t.column,
+ y: t.row,
+ layer: layer(),
+ }))
+ : [];
+ const old = demand();
+ if (next.length !== old.length || next.some((v, n) => {
+ const o = old[n]; return !o || v.source !== o.source || v.layer !== o.layer || v.z !== o.z || v.x !== o.x || v.y !== o.y;
+ })) setDemand(next);
+ }
+ const candidates = rows();
+ if (settled && last?.x === camera.x && last.y === camera.y && last.zoom === camera.zoom && last.rows === candidates) return;
+ last = { ...camera, rows: candidates };
+ if (vector) {
+ const placed = new Map(), names = new Set();
+ const shown = renderRows(), retained = new Map(shown.map(m => [m[0], m])), selected: MapMarker[] = [];
+ const scale = 2 ** camera.zoom;
+ function place(marker: MapMarker) {
+ if (placed.size >= 12 || placed.has(marker[0]) || names.has(marker[1])) return;
+ let dx = marker[3] - camera.x; dx -= Math.round(dx / 256) * 256;
+ const width = labelWidth(marker[1]), x = viewport.width / 2 + dx * scale - width / 2, y = viewport.height / 2 + (marker[4] - camera.y) * scale - 7;
+ if (x < 2 || x + width > viewport.width - 2 || y < 29 || y > viewport.height - 32) return;
+ for (const p of placed.values()) if (x < p.x + p.width + 4 && x + width + 4 > p.x && Math.abs(y - p.y) < 19) return;
+ placed.set(marker[0], { x, y, width }); names.add(marker[1]); selected.push(retained.get(marker[0]) ?? marker);
+ }
+ // Keep visible identities first without sorting the whole candidate
+ // window each frame. Mount at most ONE newly visible label per frame;
+ // candidates outside the screen never create UI nodes/frame hooks.
+ for (const marker of candidates) if (retained.has(marker[0])) place(marker);
+ const retainedCount = selected.length;
+ for (const marker of candidates) place(marker);
+ settled = selected.length <= retainedCount + 1;
+ const mounted = selected.slice(0, retainedCount + 1);
+ for (let i = mounted.length; i < selected.length; i++) placed.delete(selected[i][0]);
+ placements = placed;
+ if (mounted.length !== shown.length || mounted.some((m, i) => m !== shown[i])) setRenderRows(mounted);
+ } else {
+ placements.clear(); settled = true;
+ setRenderRows(candidates); // Hyrule retains its bounded icon markers.
+ }
+ },
+ };
+}
diff --git a/app/geo.ts b/app/geo.ts
new file mode 100644
index 0000000..cd955aa
--- /dev/null
+++ b/app/geo.ts
@@ -0,0 +1,25 @@
+export const MAX_LATITUDE = 85.0511287798066;
+export function project(lat: number, lon: number) {
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) throw new Error("Invalid coordinate");
+ lat = Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, lat));
+ const sine = Math.sin(lat * Math.PI / 180);
+ return { x: (((lon + 180) % 360 + 360) % 360) / 360 * 256,
+ y: (0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)) * 256 };
+}
+export function unproject(x: number, y: number) {
+ return { lon: x / 256 * 360 - 180, lat: Math.atan(Math.sinh(Math.PI * (1 - 2 * y / 256))) * 180 / Math.PI };
+}
+export function wrapTile(x: number, z: number) { const n = 2 ** z; return ((x % n) + n) % n; }
+export function scaleBar(lat: number, zoom: number) {
+ const metersPerPixel = 156543.03392804097 * Math.cos(lat * Math.PI / 180) / 2 ** zoom;
+ const target = metersPerPixel * 70, base = 10 ** Math.floor(Math.log10(target));
+ const meters = [5, 2, 1].map(n => n * base).find(n => n <= target) ?? base / 2;
+ return { pixels: meters / metersPerPixel, label: meters >= 1000 ? `${meters / 1000} km` : `${meters} m` };
+}
+import type { Position } from "../shared/types.ts";
+export function worldPosition(position: Position) {
+ return position.space === "planar" ? { x: position.x, y: position.y } : project(position.lat, position.lon);
+}
+export function positionAt(x: number, y: number, planar: boolean): Position {
+ return planar ? { space: "planar", x, y } : unproject(x, y);
+}
diff --git a/app/main.tsx b/app/main.tsx
new file mode 100644
index 0000000..cd09b1d
--- /dev/null
+++ b/app/main.tsx
@@ -0,0 +1,3 @@
+import { mount } from "@pocketjs/framework/solid";
+import MapApp from "./ui.tsx";
+mount(() => );
diff --git a/app/map-pad.svg b/app/map-pad.svg
new file mode 100644
index 0000000..686a96e
--- /dev/null
+++ b/app/map-pad.svg
@@ -0,0 +1 @@
+
diff --git a/app/map-pin.svg b/app/map-pin.svg
new file mode 100644
index 0000000..9c6b712
--- /dev/null
+++ b/app/map-pin.svg
@@ -0,0 +1 @@
+
diff --git a/app/map-view.tsx b/app/map-view.tsx
new file mode 100644
index 0000000..b3f1f29
--- /dev/null
+++ b/app/map-view.tsx
@@ -0,0 +1,67 @@
+import { createSignal, For, Show } from "solid-js";
+import { Image, Text, View, type NodeMirror } from "@pocketjs/framework/components";
+import { ResourceImage, ResourceMesh } from "@pocketjs/framework/resource";
+import { createResourceView } from "@pocketjs/framework/resource-view";
+import { onFrame } from "@pocketjs/framework/lifecycle";
+import * as hot from "@pocketjs/framework/hot";
+import { labelWidth, labelMetrics } from "./annotations.ts";
+import type { MapModel, Layer } from "./model.ts";
+import type { Place, MapMarker } from "../shared/types.ts";
+const MARKER_ICONS = { landmark: "marker-landmark.svg", tower: "marker-tower.svg", shrine: "marker-shrine.svg", stable: "marker-stable.svg", village: "marker-village.svg", seed: "marker-seed.svg", treasure: "marker-treasure.svg", enemy: "marker-enemy.svg", special: "marker-special.svg" };
+const box = (x: number, y: number, w: number, h: number) => ({ posType: 1, insetL: x, insetT: y, width: w, height: h });
+
+export function TileLayer(p: { s: MapModel; back?: boolean }) {
+ let world: NodeMirror | undefined;
+ let last:{layer:Layer;x:number;y:number;zoom:number}|undefined;
+ const layer = () => (p.back ? p.s.back() : p.s.front());
+ const resources = p.back ? p.s.backView : p.s.frontView;
+ onFrame(() => {
+ const l = layer(); if (!l || !world) return;
+ const view = p.s.camera.view();if(last?.layer===l&&last.x===view.x&&last.y===view.y&&last.zoom===view.zoom)return;last={layer:l,...view};
+ const scale = 2 ** (view.zoom - l.level);
+ const worldWidth = 256 * 2 ** l.level;
+ let offsetX = l.originX - view.x * 2 ** l.level;
+ // Rebase at the viewport, including the nearest copy across the date line.
+ if (!p.s.planar()) offsetX -= Math.round(offsetX / worldWidth) * worldWidth;
+ hot.prop(world, "translateX", p.s.viewport.width / 2 + offsetX * scale);
+ hot.prop(world, "translateY", p.s.viewport.height / 2 + (l.originY - view.y * 2 ** l.level) * scale);
+ hot.prop(world, "scaleX", scale); hot.prop(world, "scaleY", scale);
+ });
+ return
+ {tile => { const Tile = p.s.vector() ? ResourceMesh : ResourceImage; return resources.state(tile.input)} fallback={() => p.back || p.s.back() ? :
+
+
+ } errorFallback={() => Tile unavailable } />; }}
+ ;
+}
+function UnicodeAnnotation(p:{s:MapModel;place:Place;width:number}) {
+ const image=createResourceView(p.s.labels,{demand:()=>[{input:p.place,priority:30,pin:true}]});
+ return image.state(p.place)} fallback={()=> } />;
+}
+export function Annotation(p: { s: MapModel; marker: MapMarker }) {
+ let root: NodeMirror | undefined;
+ const [visible,setVisible]=createSignal(false);
+ const vector=p.s.vector(),unicode=vector && /[^\x20-\x7e]/.test(p.marker[1]);
+ const place:Place={id:`label:${p.marker[0]}`,name:labelMetrics(p.marker[1]).text,detail:"",lat:0,lon:0,zoom:14};
+ let lastPlacement:ReturnType;
+ const width=vector?labelWidth(p.marker[1]):118;
+ onFrame(() => {
+ if(vector){const at=p.s.annotations.placement(p.marker[0]);if(at===lastPlacement)return;lastPlacement=at;setVisible(!!at);hot.prop(root,"display",at?0:1);if(at){hot.prop(root,"translateX",at.x);hot.prop(root,"translateY",at.y);}return;}
+ const v = p.s.camera.view(), x = p.s.viewport.width / 2 + (p.marker[3] - v.x) * v.scale, y = p.s.viewport.height / 2 + (p.marker[4] - v.y) * v.scale;
+ hot.prop(root, "translateX", x - 7); hot.prop(root, "translateY", y - 7);
+ hot.prop(root, "display", x < -100 || x > p.s.viewport.width + 10 || y < 18 || y > p.s.viewport.height - 5 ? 1 : 0);
+ });
+ return
+
+
+
+ {p.marker[1].length > 16 ? p.marker[1].slice(0, 13) + "..." : p.marker[1]}
+ >}>
+
+
+ {labelMetrics(p.marker[1]).text}
+ >}>
+
+ ;
+}
diff --git a/app/marker-enemy.svg b/app/marker-enemy.svg
new file mode 100644
index 0000000..3bdee3c
--- /dev/null
+++ b/app/marker-enemy.svg
@@ -0,0 +1 @@
+
diff --git a/app/marker-landmark.svg b/app/marker-landmark.svg
new file mode 100644
index 0000000..be92995
--- /dev/null
+++ b/app/marker-landmark.svg
@@ -0,0 +1 @@
+
diff --git a/app/marker-seed.svg b/app/marker-seed.svg
new file mode 100644
index 0000000..6f13562
--- /dev/null
+++ b/app/marker-seed.svg
@@ -0,0 +1 @@
+
diff --git a/app/marker-shrine.svg b/app/marker-shrine.svg
new file mode 100644
index 0000000..e244c34
--- /dev/null
+++ b/app/marker-shrine.svg
@@ -0,0 +1 @@
+
diff --git a/app/marker-special.svg b/app/marker-special.svg
new file mode 100644
index 0000000..40d51c5
--- /dev/null
+++ b/app/marker-special.svg
@@ -0,0 +1 @@
+
diff --git a/app/marker-stable.svg b/app/marker-stable.svg
new file mode 100644
index 0000000..13ce024
--- /dev/null
+++ b/app/marker-stable.svg
@@ -0,0 +1 @@
+
diff --git a/app/marker-tower.svg b/app/marker-tower.svg
new file mode 100644
index 0000000..7d422ad
--- /dev/null
+++ b/app/marker-tower.svg
@@ -0,0 +1 @@
+
diff --git a/app/marker-treasure.svg b/app/marker-treasure.svg
new file mode 100644
index 0000000..c3cc111
--- /dev/null
+++ b/app/marker-treasure.svg
@@ -0,0 +1 @@
+
diff --git a/app/marker-village.svg b/app/marker-village.svg
new file mode 100644
index 0000000..2838bdb
--- /dev/null
+++ b/app/marker-village.svg
@@ -0,0 +1 @@
+
diff --git a/app/model.ts b/app/model.ts
new file mode 100644
index 0000000..1edd77d
--- /dev/null
+++ b/app/model.ts
@@ -0,0 +1,240 @@
+import { createSignal, createMemo } 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 { analogX, analogY, onFrame, onButtonPress } from "@pocketjs/framework/lifecycle";
+import { BTN } from "@pocketjs/framework/input";
+import { inputDeltaSeconds, simulationHz, virtualNow } from "@pocketjs/framework/clock";
+import { project, worldPosition, positionAt } from "./geo.ts";
+import { createSavedPlaces, validPlaces, type MapMode } from "./saved.ts";
+import { HOME, type TileInput, type MapInfo, type SearchInput, type Place, type MapKind } from "../shared/types.ts";
+
+import { createMapPrediction } from "./prediction.ts";
+import { createAnnotations, LAYERS } from "./annotations.ts";
+
+export interface DrawTile { input: TileInput; column: number; row: number; priority: number }
+export interface Layer { level: number; tiles: DrawTile[]; originX: number; originY: number }
+export const MENU = {
+ places: ["Search places", "Saved places", "Save map center", "Back to pin", "Map home"],
+ 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) {
+ const [info, setInfo] = createSignal();
+ const [online, setOnline] = createSignal(false), [status, setStatus] = createSignal("Waiting for paired Mac");
+ const [mode, setMode] = createSignal("map");
+ const [query, setQuery] = createSignal(""), [submitted, setSubmitted] = createSignal();
+ const [selection, setSelection] = createSignal(0), [pin, setPin] = createSignal();
+ const [menu, setMenu] = createSignal<"places" | "map">(), [menuIndex, setMenuIndex] = createSignal(0);
+ const [shift, setShift] = createSignal<"off" | "once" | "locked">("off"), [symbols, setSymbols] = createSignal(false);
+ const [front, setFront] = createSignal(), [back, setBack] = createSignal();
+ const [zoomHeld, setZoomHeld] = createSignal(false), [switching, setSwitching] = createSignal(false);
+ const [sourceError, setSourceError] = createSignal("");
+ const [requestedKind, setRequestedKind] = createSignal();
+ const remembered = new Map();
+ const planar = () => info()?.space === "planar";
+ const p = project(HOME.lat, HOME.lon);
+ 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,
+ 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,
+ 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 }),
+ 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}))];
+ const backDemand=()=>back()?.tiles.map(t=>({input:t.input,priority:100,pin:true}))??[];
+ const rasterFront=createResourceView(rasterTiles,{demand:()=>vector()?[]:frontDemand()});
+ const meshFront=createResourceView(meshTiles,{demand:()=>vector()?frontDemand():[]});
+ const rasterBack=createResourceView(rasterTiles,{demand:()=>vector()?[]:backDemand()});
+ const meshBack=createResourceView(meshTiles,{demand:()=>vector()?backDemand():[]});
+ const frontView={state:(i:TileInput)=>vector()?meshFront.state(i):rasterFront.state(i)};
+ 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[] {
+ const rows = JSON.parse(raw);
+ if (!validPlaces(rows)) throw new Error("Invalid places response");
+ return rows;
+ },
+ });
+ 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 typing = () => mode() === "search" || mode() === "name";
+ const listing = () => mode() === "results" || mode() === "saved";
+ const rows = () => mode() === "saved" ? saved.page()?.items ?? [] : places();
+ const selectedIndex = () => mode() === "saved" ? saved.selection() : selection();
+ function select(index: number) { const n = Math.max(0, Math.min(rows().length - 1, index)); if (mode() === "saved") saved.setSelection(n); else setSelection(n); }
+ prediction.reset(camera.view());
+ const maps = () => info()?.maps ?? [];
+ const labelLayers = () => vector() ? [LAYERS[0],LAYERS[4]] : LAYERS;
+ const choices = () => mode() === "sources" ? maps().map(m => m.name) : labelLayers().map(l => l.name);
+ const choosing = () => mode() === "sources" || mode() === "layers";
+ function openSources() { if (saved.busy() || saved.modal() || typing() || switching()) return; camera.stop(); setSourceError(""); setMode("sources"); setSelection(Math.max(0, maps().findIndex(m => m.kind === info()?.kind))); setMenu(undefined); }
+ function choose(index = selection()) {
+ if (mode() === "sources") { const kind = maps()[index]?.kind; if (kind) switchMap(kind); }
+ else if (mode() === "layers") { annotations.setLayer(labelLayers()[index].id); dismiss(); }
+ }
+ function switchMap(kind: MapKind) {
+ if (saved.busy() || saved.modal() || typing() || switching()) return;
+ if (kind === info()?.kind) { dismiss(); return; }
+ const old = info(); if (old?.kind) remembered.set(old.kind, { ...camera.view(), pin: pin() });
+ camera.stop(); if (infoRequest) io.cancel(infoRequest); infoRequest = 0;
+ setSourceError(""); setRequestedKind(kind); setSwitching(true); retryAt = 0; setMode("map"); setStatus(`Opening ${kind === "hyrule" ? "Hyrule" : "OpenStreetMap"}...`);
+ }
+ let frame = 0, previousSession = 0, infoRequest = 0, retryAt = 0, shiftAt = -10, levelAge = 0, candidateLevel = HOME.zoom;
+ let confirmed = false;
+ function search() {
+ const text = query().trim(); if (!text) return;
+ const v = camera.view(), pos = positionAt(v.x, v.y, planar());
+ const next: SearchInput = { query: text, source: info()?.source, ...(pos.space === "planar" ? pos : { lat: Math.round(pos.lat * 10) / 10, lon: Math.round(pos.lon * 10) / 10 }) };
+ searches.invalidate(i => JSON.stringify(i) === JSON.stringify(next));
+ setSubmitted(next); setSelection(0); setMode("results"); camera.stop();
+ }
+ function openSearch() { if (saved.busy() || saved.modal() || mode() === "name" || switching() || zoomHeld()) return; camera.stop(); setMode("search"); setMenu(undefined); }
+ function go(place = rows()[selectedIndex()]) {
+ if (!place) return;
+ if ((place.space === "planar") !== planar()) return;
+ const pos = worldPosition(place); camera.jump(pos.x, pos.y, Math.min(info()?.maxZoom ?? 18, place.zoom));
+ prediction.reset(camera.view());
+ setPin(place); setMode("map"); setMenu(undefined);
+ }
+ function home(name: string, lat: number, lon: number) { go({ id: name, name, detail: "", lat, lon, zoom: 14 }); }
+ function zoom(delta: number) {
+ if (delta > 0 && camera.view().zoom >= (info()?.maxZoom ?? 18)) return;
+ if (switching()) return;
+ prediction.zoom(delta); camera.zoomBy(delta);
+ }
+ function key(value: string) {
+ if (!typing() || saved.busy()) return;
+ const change = (fn: (value: string) => string) => mode() === "name" ? saved.changeName(fn(saved.name())) : setQuery(fn);
+ if (value === "DEL") change(q => q.slice(0, -1));
+ else if (value === "SPACE") change(q => q.length < 80 ? q + " " : q);
+ else if (value === "GO") mode() === "name" ? saved.submit() : search();
+ else if (value === "SHIFT") {
+ const now = virtualNow(); setShift(s => s === "locked" ? "off" : now - shiftAt < 0.35 ? "locked" : s === "off" ? "once" : "off"); shiftAt = now;
+ } else if (value === "123") setSymbols(s => !s);
+ else if (value.length === 1) { change(q => q.length < 80 ? q + (shift() !== "off" ? value.toUpperCase() : value) : q); if (shift() === "once") setShift("off"); }
+ }
+ function saveCurrent() {
+ camera.stop();
+ if (mode() === "results") { const selected = places()[selection()]; if (selected) { const p = worldPosition(selected); camera.jump(p.x, p.y, selected.zoom); setPin(selected); saved.begin(selected); } return; }
+ const view = camera.view(), pos = positionAt(view.x, view.y, planar()), selected = pin();
+ const target = selected && worldPosition(selected);
+ let dx = target ? target.x - view.x : Infinity; if (!planar()) dx -= Math.round(dx / 256) * 256;
+ const detail = pos.space === "planar" ? `Hyrule ${pos.x.toFixed(2)}, ${pos.y.toFixed(2)}` : `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}`;
+ saved.begin(target && Math.hypot(dx, target.y - view.y) * view.scale < 12 ? selected! : { id: "center", name: "Map center", detail, ...pos, zoom: Math.round(view.zoom) });
+ }
+ function dismiss() { if (saved.busy()) return; if (mode() === "name") { saved.cancelName(); return; } setMenu(undefined); setMode("map"); camera.stop(); }
+ function runMenu() {
+ const i = menuIndex(), bank = menu(); setMenu(undefined); confirmed = true;
+ if (bank === "places") {
+ if (i === 0) openSearch(); else if (i === 1) saved.open(); else if (i === 2) saveCurrent(); else if (i === 3) go(pin()); else if (info()?.home) go(info()!.home); else home("San Francisco", HOME.lat, HOME.lon);
+ } else if (bank === "map") {
+ if (i < 2) zoom(i === 0 ? 1 : -1); else if (i === 2) { setMode("layers"); setSelection(labelLayers().findIndex(l => l.id === annotations.layer())); } else if (i === 3) openSources(); else if (i === 4) setPin(undefined); else if (i === 5) tiles.invalidate(); else setMode("about");
+ }
+ }
+ onButtonPress(BTN.CIRCLE, (_pressed, buttons) => { if (buttons & BTN.ZL || saved.modal() || saved.busy() || switching() || zoomHeld()) return; if (choosing()) choose(); else if (menu()) runMenu(); else if (typing()) key("GO"); else if (listing()) go(); });
+ onButtonPress(BTN.CROSS, () => { if (saved.modal()) { saved.cancelDelete(); return; } if (saved.busy()) return; if (menu()) { confirmed = true; setMenu(undefined); } else if (typing()) key("DEL"); else dismiss(); });
+ onButtonPress(BTN.TRIANGLE, (_pressed, buttons) => { if (!(buttons & BTN.ZL)) openSearch(); });
+ onButtonPress(BTN.SQUARE, (_pressed, buttons) => { if (!(buttons & BTN.ZL) && !switching() && mode() === "map") go(pin()); });
+ for (const [button, delta] of [[BTN.UP, -1], [BTN.DOWN, 1]] as const) onButtonPress(button, (_pressed, buttons) => {
+ if (saved.modal() || saved.busy() || switching() || buttons & BTN.ZL) return;
+ if (menu()) setMenuIndex(i => Math.max(0, Math.min(MENU[menu()!].length - 1, i + delta)));
+ else if (choosing()) setSelection(i => Math.max(0, Math.min(choices().length - 1, i + delta)));
+ else if (listing()) select(selectedIndex() + delta);
+ });
+ onButtonPress(BTN.LEFT, () => { if (mode() === "saved" && !menu() && !saved.modal()) saved.turnPage(-1); });
+ onButtonPress(BTN.RIGHT, () => { if (mode() === "saved" && !menu() && !saved.modal()) saved.turnPage(1); });
+ let previousButtons = 0, zoomRepeat = 0, zoomDirection = 0;
+ onFrame(buttons => {
+ frame++; const session = io.session(); setOnline(session > 0);
+ 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");
+ previousSession = session;
+ }
+ if (session > 0 && !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");
+ } catch { setSwitching(false); setRequestedKind(info()?.kind); setStatus("Unsupported map provider"); retryAt = frame + 120; }
+ });
+ }
+ const zl = !!(buttons & BTN.ZL) && mode() === "map" && !saved.modal() && !saved.busy() && !switching();
+ setZoomHeld(zl);
+ const zd = zl ? (buttons & BTN.UP ? 1 : 0) - (buttons & BTN.DOWN ? 1 : 0) : 0;
+ if (zl && !(previousButtons & BTN.ZL)) { camera.endDrag(0, 0); setMenu(undefined); }
+ if (zd && (zd !== zoomDirection || frame >= zoomRepeat)) { zoom(zd); zoomRepeat = frame + Math.round(simulationHz() * (zd === zoomDirection ? .28 : .4)); }
+ zoomDirection = zd;
+ const shoulders = buttons & (BTN.LTRIGGER | BTN.RTRIGGER);
+ if (!shoulders) { confirmed = false; if (menu()) setMenu(undefined); }
+ else if (!zl && !switching() && !saved.modal() && !saved.busy() && !typing() && !confirmed && !menu() && shoulders !== (previousButtons & (BTN.LTRIGGER | BTN.RTRIGGER))) {
+ setMenu(shoulders & BTN.LTRIGGER ? "places" : "map"); setMenuIndex(0); camera.stop();
+ }
+ const canPan = mode() === "map" && !shoulders && !zl && !switching() && !saved.modal();
+ const axisX = canPan ? analogX() : 0, axisY = canPan ? analogY() : 0;
+ const dx = canPan ? (buttons & BTN.LEFT ? 1 : 0) - (buttons & BTN.RIGHT ? 1 : 0) : 0;
+ const dy = canPan ? (buttons & BTN.UP ? 1 : 0) - (buttons & BTN.DOWN ? 1 : 0) : 0;
+ camera.step(inputDeltaSeconds(), dx ? dx * 180 : -axisX * 320, dy ? dy * 180 : -axisY * 320);
+ previousButtons = buttons;
+ if (!info()) return;
+ const view = camera.view(); prediction.sample(view, inputDeltaSeconds(), !planar());
+ const next = Math.min(info()!.dataZoom ?? info()!.maxZoom, Math.max(info()!.minZoom ?? 1, Math.round(view.zoom)));
+ levelAge = next === candidateLevel ? levelAge + 1 : 0; candidateLevel = next;
+ let level = front()?.level ?? next;
+ if (next !== level && (levelAge >= 8 || Math.abs(next - level) > 1)) {
+ const previous = front();
+ setBack(previous ? { ...previous, tiles: previous.tiles.filter(t => frontView.state(t.input).status === "ready") } : undefined);
+ level = next;
+ }
+ let plan;
+ try { plan = prediction.plan(view, level, info()!); }
+ catch { level = next; plan = prediction.plan(view, level, info()!); }
+ const draw = plan.visible, ahead = plan.extra;
+ annotations.update(view, next, info()!);
+ if (ahead.length !== lookAhead().length || ahead.some((t, i) => { const o = lookAhead()[i]; return !o || t.input.z !== o.input.z || t.input.x !== o.input.x || t.input.y !== o.input.y; })) setLookAhead(ahead);
+ const previous = front();
+ if (!previous || previous.level !== level || previous.tiles.length !== draw.length || draw.some(t => !previous.tiles.some(o => o.column === t.column && o.row === t.row))) {
+ // Solid's For keys by object identity. Keep overlapping tile instances;
+ // a one-column pan must not remount every resource/fallback on the screen.
+ const retained = draw.map(t => previous?.level === level
+ ? previous.tiles.find(o => o.column === t.column && o.row === t.row && o.input.source === t.input.source) ?? t : t);
+ setFront({ level, tiles: retained, originX: Math.min(...draw.map(t => t.column)) * 256, originY: Math.min(...draw.map(t => t.row)) * 256 });
+ }
+ 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,
+ 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() }),
+ };
+}
+export type MapModel = ReturnType;
diff --git a/app/prediction.ts b/app/prediction.ts
new file mode 100644
index 0000000..9b5077d
--- /dev/null
+++ b/app/prediction.ts
@@ -0,0 +1,53 @@
+import { createTileIntent, planTileWindow, visibleTiles, type createTileCamera } from "@pocketjs/framework/tile-viewport";
+import type { MapInfo, TileInput } from "../shared/types.ts";
+import { wrapTile } from "./geo.ts";
+type View = ReturnType["view"]>;
+export interface PlannedTile { input: TileInput; column: number; row: number; priority: number }
+interface Plan { visible: PlannedTile[]; extra: PlannedTile[]; intent: { x: number; y: number; confidence: number } }
+export function createMapPrediction(viewport = { width: 400, height: 240 }) {
+ const intent = createTileIntent(); let previous: View | undefined, now = 0, zoomUntil = 0;
+ let cached: { info: MapInfo; level: number; zoom: number; target: number; x0: number; x1: number; y0: number; y1: number; at: number; zoomPrediction: boolean; x: number; y: number; plan: Plan } | undefined;
+ function reset(view: View) { intent.reset(); previous = view; zoomUntil = 0; cached = undefined; }
+ return {
+ reset,
+ zoom(delta: number) { zoomUntil = delta > 0 ? now + 5 : 0; cached = undefined; },
+ sample(view: View, seconds: number, wraps: boolean) {
+ if (previous) {
+ let dx = view.x - previous.x; if (wraps) dx -= Math.round(dx / 256) * 256;
+ intent.sample(dx * view.scale, (view.y - previous.y) * view.scale, seconds);
+ }
+ previous = view; now += seconds;
+ },
+ plan(view: View, level: number, info: MapInfo): Plan {
+ // Re-sort predictive demand at most 12 times/second while the visible
+ // tile set is unchanged. A newly exposed row/column is never delayed.
+ const scale = 2 ** level / 256, screen = 2 ** view.zoom;
+ const x0 = Math.floor((view.x - viewport.width / 2 / screen) * scale), x1 = Math.ceil((view.x + viewport.width / 2 / screen) * scale) - 1;
+ const y0 = Math.floor((view.y - viewport.height / 2 / screen) * scale), y1 = Math.ceil((view.y + viewport.height / 2 / screen) * scale) - 1;
+ const stationary = cached?.plan.intent.confidence === 0
+ && cached.x === view.x && cached.y === view.y && !view.moving && now >= zoomUntil;
+ if (cached && cached.info === info && cached.level === level && cached.zoom === view.zoom && cached.target === view.targetZoom
+ && cached.x0 === x0 && cached.x1 === x1 && cached.y0 === y0 && cached.y1 === y1
+ && cached.zoomPrediction === (now < zoomUntil)
+ && (stationary || now - cached.at < 1 / 12 - 1e-8)) return cached.plan;
+ const local = info.local === true, lead = intent.predict(local ? 512 : 128), directional = lead.confidence > .45;
+ const options = { ...view, level, ...viewport, maxTiles: 12, margin: local ? 256 : 128,
+ leadX: lead.x, leadY: lead.y, directional, maxExtra: info.prefetch === false ? 0 : local ? 12 : 4 };
+ const window = planTileWindow(options);
+ const address = (list: typeof window.visible, z: number): PlannedTile[] => list
+ .filter(t => t.row >= 0 && t.row < 2 ** z && (info.space !== "planar" || t.column >= 0 && t.column < 2 ** z))
+ .map(t => ({ ...t, input: { source: info.source, z, x: wrapTile(t.column, z), y: t.row } }));
+ const next = Math.round(view.targetZoom) + 1;
+ // Only the complete local atlas predicts another zoom level. Public OSM
+ // retains modest same-level look-ahead rather than multi-level pre-seeding.
+ const zoomTiles = local && now < zoomUntil && next > level && next <= info.maxZoom
+ ? address(visibleTiles({ ...options, zoom: next, level: next }), next).slice(0, 6) : [];
+ const extra = [...zoomTiles.map(t => ({ ...t, priority: 300 + t.priority })),
+ ...address(window.lookAhead, level).map(t => ({ ...t, priority: 1000 + t.priority }))].slice(0, local ? 12 : 4);
+ const plan = { visible: address(window.visible, level), extra, intent: lead };
+ cached = { info, level, zoom: view.zoom, target: view.targetZoom, x0, x1, y0, y1, at: now,
+ zoomPrediction: now < zoomUntil, x: view.x, y: view.y, plan };
+ return plan;
+ },
+ };
+}
diff --git a/app/psp.tsx b/app/psp.tsx
new file mode 100644
index 0000000..8f405de
--- /dev/null
+++ b/app/psp.tsx
@@ -0,0 +1,276 @@
+import { createEffect, untrack, For, Show } from "solid-js";
+import { mount } from "@pocketjs/framework/solid";
+import {
+ Image,
+ Text,
+ View,
+ type NodeMirror,
+} from "@pocketjs/framework/components";
+import { ClassicFace, ClassicPanel } from "@pocketjs/framework/classic";
+import { ResourceImage } from "@pocketjs/framework/resource";
+import { createResourceView } from "@pocketjs/framework/resource-view";
+import type { Place } from "../shared/types.ts";
+import { createOsk, Osk } from "@pocketjs/framework/osk";
+import { onFrame, onButtonPress } from "@pocketjs/framework/lifecycle";
+import { BTN } from "@pocketjs/framework/input";
+import * as hot from "@pocketjs/framework/hot";
+import { createMap, MENU, type MapModel } from "./model.ts";
+import { TileLayer, Annotation } from "./map-view.tsx";
+import { worldPosition } from "./geo.ts";
+const box = (x: number, y: number, w: number, h: number) => ({
+ posType: 1,
+ insetL: x,
+ insetT: y,
+ width: w,
+ height: h,
+});
+function RowLabel(p: {
+ s: MapModel;
+ place: () => Place | undefined;
+ title: () => string;
+ selected: () => boolean;
+}) {
+ const unicode = () => !!p.place() && /[^\x20-\x7e]/.test(p.place()!.name);
+ const image = createResourceView(p.s.labels, {
+ demand: () =>
+ unicode() ? [{ input: p.place()!, priority: -5, pin: true }] : [],
+ });
+ return (
+
+ {p.title().slice(0, 45)}
+
+ }
+ >
+ image.state(p.place()!)}
+ fallback={() => (
+
+ )}
+ />
+
+ );
+}
+export function PspMapApp() {
+ const s = createMap(undefined, { width: 480, height: 272 }, 24);
+ (globalThis as unknown as { __map: typeof s }).__map = s;
+ const osk = createOsk({
+ value: () => (s.mode() === "name" ? s.saved.name() : s.query()),
+ setValue: (text) =>
+ s.mode() === "name" ? s.saved.changeName(text) : s.setQuery(text),
+ maxLength: 36,
+ closeOnCommit: false,
+ onCommit: () => s.key("GO"),
+ onClose: s.dismiss,
+ });
+ createEffect(() => {
+ if (s.typing()) untrack(osk.open);
+ else osk.close();
+ });
+ createEffect(() => s.saved.setModal(!!s.saved.deleting()));
+ onButtonPress(BTN.SQUARE, () => {
+ if (s.mode() === "saved" && !s.menu() && !s.saved.modal())
+ s.saved.askRemove();
+ });
+ onButtonPress(BTN.CIRCLE, () => {
+ if (s.saved.deleting() && !s.saved.busy()) s.saved.remove();
+ });
+ onButtonPress(BTN.START, () => {
+ if (s.mode() === "name") {
+ osk.open();
+ return;
+ }
+ if (s.mode() === "saved" && s.saved.selected())
+ s.saved.begin(s.saved.selected()!, true);
+ else if (s.listing() || s.mode() === "map") s.saveCurrent();
+ });
+ let marker: NodeMirror | undefined, zoom: NodeMirror | undefined;
+ onFrame(() => {
+ const v = s.camera.view();
+ hot.text(zoom, `z${v.zoom.toFixed(1)}`);
+ const pin = s.pin();
+ if (pin) {
+ const p = worldPosition(pin);
+ let dx = p.x - v.x;
+ if (!s.planar()) dx -= Math.round(dx / 256) * 256;
+ hot.prop(marker, "translateX", 240 + dx * v.scale - 8);
+ hot.prop(marker, "translateY", 136 + (p.y - v.y) * v.scale - 22);
+ }
+ });
+ const rows = () =>
+ s.menu()
+ ? MENU[s.menu()!]
+ : s.choosing()
+ ? s.choices()
+ : s.rows().map((p) => p.name);
+ const selected = () =>
+ s.menu() ? s.menuIndex() : s.choosing() ? s.selection() : s.selectedIndex();
+ const empty = () =>
+ s.mode() === "saved"
+ ? s.saved.state().status === "pending"
+ ? "Loading saved places..."
+ : s.saved.state().status === "error"
+ ? "Saved places unavailable. Reopen to retry."
+ : "No saved places. START saves this view."
+ : s.submitted() && s.results.state(s.submitted()!).status === "pending"
+ ? "Searching places on your Mac..."
+ : s.submitted() && s.results.state(s.submitted()!).status === "error"
+ ? "Search unavailable. Triangle edits the query."
+ : "No places found. Triangle tries another query.";
+ const title = () =>
+ s.menu() === "places"
+ ? "Places"
+ : s.menu() === "map"
+ ? "Map controls"
+ : s.choosing()
+ ? s.mode() === "sources"
+ ? "Choose map"
+ : "Map labels"
+ : s.mode() === "saved"
+ ? "Saved on your Mac"
+ : `Places: ${s.query()}`;
+ // Build each bounded subtree before mounting the outer screen on QuickJS.
+ const tiles = (
+
+
+
+
+ {(marker) => }
+
+
+
+ );
+ const panel = (
+
+
+
+ {title().slice(0, 44)}
+
+
+ {(row, i) => (
+
+ (s.listing() ? s.rows()[i()] : undefined)}
+ title={() => row}
+ selected={() => selected() === i()}
+ />
+
+ )}
+
+
+
+ {empty()}
+
+
+
+ {s.mode() === "saved"
+ ? "O: open START: rename Square: delete X: map"
+ : "D-pad: choose O: open X: map"}
+
+
+
+ );
+ const keyboard = (
+
+
+
+
+ {s.mode() === "name" ? "Save: " : "Search: "}
+ {osk.display().slice(-52)}
+
+
+
+
+ {s.saved.busy()
+ ? "Saving on your Mac..."
+ : s.saved.error() ||
+ "D-pad + O: type Square: delete Triangle: space START: submit"}
+
+
+
+ );
+ return (
+
+ {tiles}
+
+
+
+ Pocket Map
+
+
+ {s.online() ? "USB / PSPLINK" : "Mac disconnected"}
+
+
+ z14.0
+
+
+
+
+ Analog: pan L: places R: zoom / map Triangle: search
+
+
+ {s.info()?.attribution || s.status()}
+
+
+ {panel}
+
+
+
+ Pocket Map / PSP
+
+
+ Move with the analog stick or D-pad. Hold L for places, R for zoom
+ and map settings. Choose with D-pad and O. Triangle searches. START
+ saves a place; in Saved it renames. Left/right turns saved pages.
+ SELECT recalibrates the resting stick. X returns to the map.
+
+
+
+
+
+
+ Delete saved place?
+
+
+ {s.saved.deleting()?.name}
+
+
+ {s.saved.busy()
+ ? "Deleting on your Mac..."
+ : s.saved.error() || "O: delete X: keep place"}
+
+
+
+ {keyboard}
+
+ );
+}
+mount(() => );
diff --git a/app/psp/Psp.toml b/app/psp/Psp.toml
new file mode 100644
index 0000000..e2a98fc
--- /dev/null
+++ b/app/psp/Psp.toml
@@ -0,0 +1 @@
+title = "Pocket Map"
diff --git a/app/saved.ts b/app/saved.ts
new file mode 100644
index 0000000..e0c1882
--- /dev/null
+++ b/app/saved.ts
@@ -0,0 +1,72 @@
+import { createSignal, createMemo, onCleanup } from "solid-js";
+import { createResourceRuntime, createResourceView } from "@pocketjs/framework/resource-view";
+import { offloadResource } from "@pocketjs/framework/resource-offload";
+import type { offload } from "@pocketjs/framework/offload";
+import type { Place, BookmarkPage, BookmarkCommand } from "../shared/types.ts";
+import { validPosition } from "../shared/types.ts";
+
+export type MapMode = "map" | "search" | "results" | "about" | "saved" | "name" | "sources" | "layers";
+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) {
+ const [offset, setOffset] = createSignal(0), [selection, setSelection] = createSignal(0);
+ const [name, setName] = createSignal(""), [editing, setEditing] = createSignal();
+ const [busy, setBusy] = createSignal(false), [error, setError] = createSignal("");
+ const [deleting, setDeleting] = createSignal(), [modal, setModal] = createSignal(false);
+ 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 {
+ 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;
+ } });
+ const view = createResourceView(collection, { demand: () => mode() === "saved" || mode() === "name" ? [{ input: offset(), priority: -10, pin: true }] : [] });
+ const page = createMemo(() => view.value(offset()));
+ const selected = () => page()?.items[Math.min(selection(), (page()?.items.length ?? 1) - 1)];
+ function open() { if (busy()) return; collection.invalidate(); setSelection(0); setOffset(0); setMode("saved"); }
+ function begin(place: Place, rename = false) {
+ if (busy()) return;
+ target = place; returnMode = mode(); setEditing(rename ? place : undefined); setName(place.name); setError(""); last = undefined; setMode("name");
+ }
+ function changeName(value: string) { if (busy()) return; setName(value.slice(0, 36)); last = undefined; setError(""); }
+ function perform(command: BookmarkCommand) {
+ if (busy()) return;
+ command = { ...command, source: command.source ?? source() };
+ last = command; setBusy(true); setError("");
+ request = io.request("bookmarks.command", JSON.stringify(command), result => {
+ request = 0; setBusy(false);
+ if (!result.ok) { setError("No confirmation. Retry is safe."); return; }
+ try { const reply = JSON.parse(result.value); if (!/^b_[a-f0-9]{24}$/.test(reply.id)) throw new Error(); }
+ catch { setError("No confirmation. Retry is safe."); return; }
+ collection.invalidate(undefined, true); last = undefined; setDeleting(undefined);
+ if (command.kind === "save") { setOffset(0); setSelection(0); }
+ else if (command.kind === "remove") setSelection(i => Math.max(0, i - 1));
+ setMode("saved");
+ });
+ if (!request) { setBusy(false); setError("Mac unavailable. Tap Retry."); }
+ }
+ const operation = () => `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}_${Math.random().toString(36).slice(2)}`;
+ function submit() {
+ if (!target || !name().trim() || busy()) return;
+ if (last) { perform(last); return; }
+ perform(editing() ? { op: operation(), kind: "rename", id: editing()!.id, name: name().trim() }
+ : { op: operation(), kind: "save", place: { ...target, name: name().trim() } });
+ }
+ function remove() { const p = deleting(); if (p) perform(last?.kind === "remove" ? last : { op: operation(), kind: "remove", id: p.id }); }
+ function askRemove() { const p = selected(); if (p && !busy()) { setError(""); last = undefined; setDeleting(p); } }
+ function cancelDelete() { if (!busy()) { setDeleting(undefined); setError(""); last = undefined; collection.invalidate(); } }
+ function cancelName() { if (busy()) return; collection.invalidate(); setMode(returnMode); setError(""); last = undefined; }
+ function turnPage(delta: number) {
+ const p = page(); if (!p || busy()) return;
+ const next = p.offset + delta * 5;
+ if (next >= 0 && next < p.total) { setOffset(next); setSelection(0); }
+ }
+ onCleanup(() => { if (request) io.cancel(request); });
+ return { page, selected, selection, setSelection, name, changeName, editing, busy, error, deleting, modal, setModal,
+ open, begin, submit, askRemove, remove, cancelDelete, cancelName, turnPage, refresh: () => collection.invalidate(),
+ reset() { if (request) io.cancel(request); request = 0; target = last = undefined; collection.clear(); setOffset(0); setSelection(0); setError(""); setBusy(false); setDeleting(undefined); },
+ state: () => view.state(offset()),
+ };
+}
diff --git a/app/shift-lock.svg b/app/shift-lock.svg
new file mode 100644
index 0000000..fbc5c08
--- /dev/null
+++ b/app/shift-lock.svg
@@ -0,0 +1 @@
+
diff --git a/app/shift.svg b/app/shift.svg
new file mode 100644
index 0000000..cebacd2
--- /dev/null
+++ b/app/shift.svg
@@ -0,0 +1 @@
+
diff --git a/app/ui.tsx b/app/ui.tsx
new file mode 100644
index 0000000..c766a73
--- /dev/null
+++ b/app/ui.tsx
@@ -0,0 +1,188 @@
+import { createSignal, For, Show } from "solid-js";
+import { AuxiliarySurface, Image, Text, View, type NodeMirror } from "@pocketjs/framework/components";
+import { ClassicButton, ClassicFace, ClassicPanel, ClassicSheet } from "@pocketjs/framework/classic";
+import { ResourceImage, ResourceMesh } from "@pocketjs/framework/resource";
+import { createResourceView } from "@pocketjs/framework/resource-view";
+import { createGesture, createDragFilter } from "@pocketjs/framework/gesture";
+import { inputDeltaSeconds } from "@pocketjs/framework/clock";
+import { onFrame } from "@pocketjs/framework/lifecycle";
+import * as hot from "@pocketjs/framework/hot";
+import {labelWidth,labelMetrics} from "./annotations.ts";
+import { createMap, MENU, type MapModel, type Layer } from "./model.ts";
+import { worldPosition, unproject, scaleBar } from "./geo.ts";
+import type { Place, MapMarker } from "../shared/types.ts";
+import { TileLayer, Annotation } from "./map-view.tsx";
+const box = (x: number, y: number, w: number, h: number) => ({ posType: 1, insetL: x, insetT: y, width: w, height: h });
+
+function ZoomRail(p: { s: MapModel }) {
+ let thumb: NodeMirror | undefined, label: NodeMirror | undefined;
+ onFrame(() => {
+ const v = p.s.camera.view(), min = p.s.info()?.minZoom ?? 1, max = p.s.info()?.maxZoom ?? 18;
+ hot.prop(thumb, "translateY", 124 * (1 - (v.targetZoom - min) / Math.max(1, max - min)));
+ hot.text(label, `${Math.round(v.targetZoom)}`);
+ });
+ return
+ Zoom
+ {p.s.info()?.maxZoom ?? 18}
+
+ 4
+ {p.s.info()?.minZoom ?? 1}
+ ZL + D-pad
+ ;
+}
+function ChoicePanel(p: { s: MapModel }) {
+ return
+ {p.s.mode() === "sources" ? "Choose a map" : "Map labels"}
+ {(name, i) =>
+ {name}
+ }
+ {p.s.sourceError() || "D-pad: choose A: apply B: return"}
+ ;
+}
+function PlaceRow(p: { s: MapModel; place: Place; index: number }) {
+ const unicode = /[^\x20-\x7e]/.test(p.place.name + p.place.detail);
+ const image = createResourceView(p.s.labels, { demand: () => unicode && p.s.listing() ? [{ input: p.place, priority: -5, pin: true }] : [] });
+ return
+ {p.index + 1}
+
+ {p.place.name.slice(0, 36)}
+ {p.place.detail.slice(0, 43)}
+ >}> image.state(p.place)} fallback={() => } />
+ ;
+}
+function SearchResults(p: { s: MapModel }) {
+ return
+ {p.s.mode() === "saved" ? `Saved places${p.s.saved.page() ? " (" + p.s.saved.page()!.total + ")" : ""}` : `Places: ${p.s.query().slice(0, 32)}`}
+ {(place, i) => }
+
+ {p.s.mode() === "saved" ? p.s.saved.state().status === "pending" ? "Loading saved places from your Mac..." : p.s.saved.state().status === "error" ? "Saved places unavailable. Reopen to retry." : "No saved places yet. Use Save view." : p.s.submitted() && p.s.results.state(p.s.submitted()!).status === "pending" ? "Searching with your Mac..." : p.s.submitted() && p.s.results.state(p.s.submitted()!).status === "error" ? "Search unavailable. Edit and retry." : "No places found. Try another name."}
+
+ ;
+}
+function Context(p: { s: MapModel }) {
+ return {bank =>
+
+ {bank() === "places" ? "Places" : "Map controls"}
+ {(name, i) =>
+ {name}
+ }
+ D-pad: choose A: open B: cancel
+
+ } ;
+}
+const LETTERS = ["q w e r t y u i o p", "a s d f g h j k l DEL", "SHIFT z x c v b n m .", "123 - , SPACE"];
+const SYMBOLS = ["1 2 3 4 5 6 7 8 9 0", "@ # / ( ) : ; ' ? DEL", "SHIFT ! $ % & * + = .", "123 - , SPACE"];
+function Keyboard(p: { s: MapModel }) {
+ const [pressed, setPressed] = createSignal(""); let root: NodeMirror | undefined;
+ const keys = () => (p.s.symbols() ? SYMBOLS : LETTERS).flatMap((line, row) => {
+ let x = 6; return line.split(" ").map((value, col) => { const w = row === 3 ? [36, 28, 28, 216][col] : 30.8;
+ const key = { value, x, y: 41 + row * 22, w: w - 2 }; x += w; return key; });
+ });
+ createGesture({ surface: "auxiliary", region: { node: () => root }, onDown(c) {
+ if (!p.s.typing() || p.s.menu() || p.s.saved.busy()) return;
+ const k = keys().find(k => c.x >= k.x && c.x < k.x + k.w && c.y >= k.y && c.y < k.y + 20);
+ if (k) { setPressed(k.value); p.s.key(k.value); }
+ }, onUp: () => setPressed(""), onCancel: () => setPressed("") });
+ return
+ {k =>
+ {k.value === "123" && p.s.symbols() ? "ABC" : k.value.length === 1 && p.s.shift() !== "off" ? k.value.toUpperCase() : k.value === "SPACE" ? "space" : k.value}}>
+
+
+ }
+ ;
+}
+function Deck(p: { s: MapModel }) {
+ let pad: NodeMirror | undefined; let dy = 0;
+ const filter = createDragFilter();
+ const [touching, setTouching] = createSignal(false);
+ const typing = p.s.typing, results = p.s.listing, saved = () => p.s.mode() === "saved", naming = () => p.s.mode() === "name";
+ const blocked = () => !!p.s.menu() || p.s.saved.modal() || p.s.saved.busy() || p.s.switching() || p.s.zoomHeld();
+ const top = () => typing() ? 136 : 40, height = () => 194 - top();
+ createGesture({ surface: "auxiliary", region: { node: () => pad }, panSlop: 2,
+ onDown() { if (blocked() || naming() || p.s.choosing()) return; setTouching(true); dy = 0; filter.reset(); p.s.camera.beginDrag(); },
+ onPanMove(c) {
+ if (blocked() || naming() || p.s.choosing()) return;
+ if (results()) { dy += c.fdy; const step = Math.trunc(dy / 20); if (step) { p.s.select(p.s.selectedIndex() + step); dy -= step * 20; } }
+ else { const delta = filter.update(c.dx, c.dy, inputDeltaSeconds()); p.s.camera.drag(delta.dx * 1.45, delta.dy * 1.45); }
+ },
+ onPanEnd() { setTouching(false); if (blocked() || naming() || p.s.choosing()) return; const v = filter.velocity(); p.s.camera.endDrag(results() ? 0 : v.x * 1.45, results() ? 0 : v.y * 1.45); },
+ onTap() { setTouching(false); if (blocked() || naming() || p.s.choosing()) return; p.s.camera.endDrag(0, 0); if (results()) p.s.go(); },
+ onCancel() { setTouching(false); p.s.camera.stop(); },
+ });
+ // Build fixed controls outside the outer wrapper's mount stack on QuickJS.
+ const keyboard = ;
+ const search = typing() || saved() || p.s.mode() === "about" || p.s.choosing() ? p.s.dismiss() : p.s.openSearch()} />;
+ const padView =
+ {naming() ? p.s.saved.editing() ? "Rename saved place" : "Save place to your Mac" : results() ? "Choose a place" : touching() ? "Moving map" : "Map touchpad"}
+
+
+ {results() ? "Slide to choose; tap to open" : "Drag to pan. Flick to glide."}
+ {saved() ? `Page ${Math.floor((p.s.saved.page()?.offset ?? 0) / 5) + 1} of ${Math.max(1, Math.ceil((p.s.saved.page()?.total ?? 0) / 5))} Left / Right: turn page` : results() ? "Or use D-pad and A" : "The upper screen follows your stylus"}
+
+ {naming() ? p.s.saved.busy() ? "Saving on your Mac..." : p.s.saved.error() || "Name this location, then tap Save" : "Drag here to move the map"}
+ ;
+ const plus = saved() ? p.s.saved.turnPage(-1) : p.s.zoom(1)} />;
+ const minus = = p.s.saved.page()!.total : results())} onPress={() => saved() ? p.s.saved.turnPage(1) : p.s.zoom(-1)} />;
+ const left = naming() ? p.s.saved.changeName("") : typing() ? p.s.setQuery("") : saved() ? p.s.saved.begin(p.s.saved.selected()!, true) : results() ? p.s.dismiss() : p.s.go(p.s.pin())} />;
+ const middle = saved() ? p.s.saved.askRemove() : p.s.saveCurrent()} />;
+ const right = typing() ? p.s.key("GO") : results() ? p.s.go() : p.s.saved.open()} />;
+ const sheet = ;
+ return
+
+ {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"}
+
+
+
+ {(name, i) => p.s.choose(i())} />}
+
+
+ {keyboard}{padView}{plus}{minus}{left}{middle}{right}{sheet}
+ ;
+}
+
+export default function MapApp() {
+ const s = createMap(); (globalThis as unknown as { __map: MapModel }).__map = s;
+ let marker: NodeMirror | undefined, bar: NodeMirror | undefined, scaleText: NodeMirror | undefined, zoomText: NodeMirror | undefined;
+ onFrame(() => {
+ const v = s.camera.view(), pin = s.pin();
+ hot.text(zoomText, `z${v.zoom.toFixed(1)}`);
+ if (pin) { const pos = worldPosition(pin); let dx = pos.x - v.x; if (!s.planar()) dx -= Math.round(dx / 256) * 256;
+ hot.prop(marker, "translateX", 200 + dx * v.scale - 8); hot.prop(marker, "translateY", 120 + (pos.y - v.y) * v.scale - 22); }
+ const scale = s.planar() ? { pixels: 64, label: `${Math.round(64 / v.scale * 24000 / 256)} u` } : scaleBar(unproject(v.x, v.y).lat, v.zoom);
+ hot.prop(bar, "scaleX", scale.pixels / 70); hot.text(scaleText, scale.label);
+ });
+ const deck = ;
+ const map =
+
+ {marker => }
+
+ ;
+ return <>
+ {map}
+
+ Pocket Map
+
+ z14.0
+
+
+ 500 m
+
+
+
+ {s.status().slice(0, 35)}
+
+
+
+
+
+
+ Pocket Map
+ {`Stylus / Circle Pad / D-pad: pan\n+ and -: zoom around the map center\nY: search X: return to selected place\nHold L: places Hold R: map controls\nHold ZL + Up/Down: zoom level\n\n${s.planar() ? "Hyrule: Breath of the Wild\nMap art: Nintendo | Data: Zelda Dungeon\nMap and place search stored on your Mac" : "Maps: OpenStreetMap contributors\nosm.org/copyright | Tiles: OSM DE\nPlace search: Photon by komoot"}`}
+
+
+ {s.info()?.attribution ?? "Map data"}
+ {deck}>;
+}
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..d11e2aa
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,29 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "pocket-map",
+ "dependencies": {
+ "@mapbox/vector-tile": "3.0.0",
+ "earcut": "3.2.3",
+ "pbf": "5.1.2",
+ },
+ },
+ },
+ "packages": {
+ "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
+
+ "@mapbox/vector-tile": ["@mapbox/vector-tile@3.0.0", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.0.0" } }, "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q=="],
+
+ "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
+
+ "earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="],
+
+ "pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="],
+
+ "protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="],
+
+ "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
+ }
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..00c2aee
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,212 @@
+# Pocket Map architecture
+
+## Ownership
+
+**The 3DS guest has one JavaScript thread and no network or filesystem calls.**
+Its state consists of a camera, two tile-layer descriptions, a bounded search
+page, local keyboard text, saved-place pages and a selected place. Solid owns components and
+subscriptions. Continuous camera movement writes paint transforms through
+PocketJS `hot` APIs; a tile window change updates the component tree.
+
+The native offload worker owns sockets, the SD pairing-key read and binary
+reception. The paired Mac's capability process owns SQLite atlas reads, texture
+decompression, HTTPS/MVT parsing, styling, simplification, polygon triangulation and Unicode label rasterization. Custom raster sources retain the PNG decoder.
+Atlas preparation performs JPEG decoding and R5G6B5 packing before browsing.
+Provider socket callbacks pass
+bounded requests and replies; they do not execute these capabilities.
+
+## Working set
+
+| Budget | Limit |
+| --- | --- |
+| Active resource requests | 3 |
+| Resource starts / materializations | 1 / 1 per frame |
+| Tile collection | 40 entries, two view owners |
+| Tile demand per view | 24 addresses (up to 12 visible + 12 local look-ahead; OSM extras capped at four) |
+| Shared image / mesh staging | 8 × 131,088 bytes plus metadata |
+| Native image envelope | 16–256px power-of-two sides, two bytes per pixel |
+| Native image or mesh materializations | 1 per frame |
+| Places per search | 5 |
+| Saved places | 1,000 on Mac; four five-place pages cached on device |
+| Saved-place name | 36 UTF-16 code units |
+| Label resource cache | 40 windows; at most 12 demanded, 12 records per window (Hyrule demands at most four) |
+| Query text | 80 UTF-16 code units |
+| Unicode label collection | 24 × 256×32 images; at most 12 map labels visible |
+| Mac concurrent tile decodes | 3 |
+| Mac prepared geometry cache | 128 source tiles, honoring expiry |
+| Native mesh entry | 4,096 vertices; 2,048 triangles; 36,880 wire bytes |
+| Retained GPU geometry | 8 MiB including pending retirement; at most 196,608 bytes per mesh |
+| Mac decoded-image cache | 128 immutable atlas textures; 64 HTTP renditions honoring expiry |
+| Mac HTTP cache | 2,048 bounded responses in SQLite |
+| Mac transport credit | Eight total executing requests, queued replies and blocked writes |
+
+The image collection reserves 18 bytes per pixel plus 512 bytes per entry:
+native staging, old-plus-new core/GPU storage, the tiled upload scratch buffer
+and the serialized ticket. Forty steady resident 256px tiles account for
+about 15 MiB of core R5G6B5 plus GPU RGBA8 pixels; native staging, upload scratch,
+fonts, UI nodes and JavaScript use separate memory. This is an admission upper bound, not a claim that
+every tile allocates that amount. JSON responses retain the 4,096-byte record
+and 2,500-code-unit payload limits.
+
+## One tile's lifecycle
+
+1. The camera's viewport produces near-first tile coordinates. Hyrule adds up
+ to 12 neighbors using a 256px margin and at most 512px directional lead; OSM
+ adds at most four, with a 128px margin and lead cap. Prediction accumulates camera travel across gestures in constant space. A
+ settled direction chooses a forward corridor. Three idle seconds preserve
+ direction, followed by confidence decay. Sustained turns replace old intent.
+ Hyrule also prepares at most six next-level tiles for five seconds after a
+ zoom-in input, sharing the 12-extra-tile budget. Visible
+ entries are pinned; extras have lower priority and are unpinned. Hyrule clips
+ both axes; OSM wraps longitude and clips polar rows. Addresses include the
+ provider source identity.
+2. `createResourceView` declares desired keys. The collection merges both zoom
+ layers' demand and reserves entry cost before starting an offload read.
+3. The Mac looks up an already baked Hyrule texture in SQLite and decompresses
+ it to exactly 131,072 bytes, or returns a decoded-cache hit. This provider has
+ no HTTP path. The OSM provider reads or fetches an MVT using HTTP validators,
+ decodes PBF, applies a bounded detail policy and prepares indexed geometry.
+ Geometry at source z14 serves display levels through z18.
+4. The 3DS worker receives binary pixels or geometry into a free native slot and publishes
+ a small ticket. Socket reads pause when staging credit is exhausted.
+5. The scheduler materializes one completed resource per frame. The native
+ upload consumes no JS pixel or vertex array; `ResourceImage` or `ResourceMesh`
+ replaces its fallback. Mesh materialization also uploads an immutable GPU
+ buffer. Drawing submits a handle, transform and clip rectangle; eviction
+ frees the buffer after the preceding GPU frame retires.
+6. `releaseResponse` returns staging credit. Texture / mesh disposal waits until
+ cache eviction or invalidation with value removal. A cancelled or late
+ response also returns staging credit without an upload.
+
+**Rendering a resource does not request it.** Demand, subscriptions and
+ownership have different roles. An old zoom layer retains loaded resources
+while the new layer demands current tiles. It never requests an unseen parent
+pyramid or downloads a city in advance.
+
+## Transport recovery
+
+**The Mac connection manager does not import the native canvas module.** It
+starts the existing capability module with `isolation: "process"`, using Bun IPC
+instead of a Web Worker. Socket loss, a process crash or a nine-second task
+deadline ends that session. The old process is reaped before the next starts;
+its replies cannot cross connection generations. SQLite mutations retain their
+operation receipts across process replacement.
+
+The device retains transmission credit after a sent request is cancelled or
+times out, until its reply arrives or the session ends. The Mac pauses reads at
+eight total executing requests, queued replies and blocked writes. While a
+write waits for the socket to drain, other work can start within that shared
+budget. The scheduler preserves a desired in-flight prefetch when transport
+credit cannot admit its replacement. Slow image
+transfer therefore cannot build a queue proportional to cancelled viewports.
+Connection logs name socket failures, process exits and timed-out methods;
+they omit query text and command payloads.
+
+## Motion and recovery
+
+`createTileCamera` stores level-zero coordinates and log2 zoom. Dragging applies
+screen-space deltas at the current scale. `createDragFilter` filters cumulative
+touch travel with a 1px hysteresis and up to 3px additional lag. Fast motion
+reduces filtering; stationary frames settle release velocity. The 1.45 touchpad
+gain maps that 4px input-space bound to 5.8px on the map. Circle Pad movement approaches a
+velocity; release follows exponential inertia. Zoom runs an anchored 180ms
+transition. The integrator gives matching held-input/fling distances at 30 and
+60 Hz. Mercator projection is application code.
+
+`camera.setWorld` validates and replaces zoom limits and world bounds when the
+provider metadata arrives. It stops previous motion and clamps the camera
+without replacing the camera object or resource owners. Hyrule uses a finite
+256-unit square and zoom levels 0–7. Its source coordinates map through
+`x = (east + 12000) / 24000 * 256` and
+`y = (12000 - north) / 24000 * 256`. Search results and bookmarks use an explicit
+planar position instead of treating game coordinates as latitude/longitude.
+
+Tile positions are rebased near the viewport before reaching native float
+transforms, including the nearest world copy at the date line. Tile enumeration
+has a hard maximum; a mismatched old level cannot create an unbounded loop.
+
+On connection-generation changes, pending reads are cancelled and current
+demand is revalidated. Resident tiles remain visible. An explicit source change
+clears tiles, because the old source is a different identity. Exhausted tile
+retries have a visible error fallback and an R-menu retry action. Search errors
+and empty results have distinct messages; editing and submitting retries a
+query. Local camera and query changes never wait for responses.
+
+## Framework additions
+
+PocketJS receives binary image responses, native staging ownership, the
+`releaseResponse` cache hook, `createOffloadImageCollection`, and the camera /
+visible-window / look-ahead functions in `tile-viewport`, and `createDragFilter`
+in `gesture`. There is no OSM, Photon, Mercator,
+search-history or map-service policy inside the framework. These APIs can
+support image pages, photo renditions or diagram tiles using different keys
+and demand plans.
+
+The image extension retains offload's authenticated LAN connection and JSON
+control frames. It does not turn the older companion SVC socket API into a
+worker API. Other native hosts still need to implement optional image staging
+operations; an in-process iPhone provider is not part of this implementation.
+
+## Saved-place commands
+
+A fourth resource collection owns four five-place SQLite pages. Selection,
+name editing, pagination and delete confirmation belong to the app controller.
+**Writes bypass the resource cache.** `bookmarks.command` carries a bounded
+operation ID and typed save / rename / remove payload. The Mac commits the
+change and its receipt in one SQLite transaction. Repeating that operation ID
+returns the stored result; using it with another payload is rejected. Receipts
+remain after deletion, so delayed save retries cannot recreate deleted data.
+
+The UI waits for confirmation before changing its saved list. Missing replies
+leave a retry action which retains the operation ID. Cancellation of that UI
+cannot roll back a completed Mac write. Successful writes invalidate saved
+pages; deleting the last row of a page makes the provider return the preceding
+populated page. User data lives in `places.sqlite`; HTTP-cache eviction never
+touches it. Hyrule uses `.local/hyrule/places.sqlite`; the geographic provider
+uses `.local/places.sqlite`. Existing geographic records migrate transactionally
+to typed positions while preserving IDs and operation receipts. The Mac performs
+all SQL work in its provider process.
+
+## Atlas preparation and scope
+
+The pinned source contains 1,365 JPEG tiles in six levels. Preparation subdivides
+750px source tiles into 256px RGB565 renditions, producing 21,845 tiles in eight
+levels and a 2,576-place SQLite FTS5 index. Deflate compression applies only to
+Mac storage. The completed atlas replaces its destination atomically after all
+levels and metadata have been written. Downloaded art and generated databases
+remain outside Git; source attribution is retained in the app and README.
+
+This app implements map browsing, place search and saved places. Hyrule is fully
+installed on the paired Mac; new tiles still require the LAN connection. There
+is no GPS, turn-by-turn routing or satellite layer. The optional geographic
+provider has no area downloader, and public services have no availability
+guarantee. Network isolation and bounded work do not establish a hardware
+frame-time guarantee.
+
+## Source selection and annotation resources
+
+**Each request identifies its source.** `map.info({ kind })` returns the selected
+metadata and the installed-source catalog. The worker routes tile/search and
+bookmark calls by source identity rather than mutating a connection-wide active
+provider. Delayed commands and retries therefore retain their original SQLite
+database. Old guests that omit a source use the configured startup provider.
+
+The guest suspends new resource starts while source metadata is requested. It
+keeps old pixels visible until metadata succeeds, then cancels old demand,
+clears source-dependent collections and restores that map's remembered camera.
+Source changes are unavailable during bookmark naming or mutation confirmation.
+
+The marker index uses SQLite R-tree queries on the Mac. Requests address fixed
+512px annotation windows at an integer level and a category filter. Each reply
+has at most 12 records; four windows cover the viewport. The host bounds query
+candidates to 256 and chooses one marker per density grid slot. Region, travel,
+enemy and collectible visibility use different zoom ranges. This reduces
+clutter; it is not a full cross-window label-collision solver.
+
+Annotation responses share resource scheduling with terrain but have separate
+cache costs and view ownership. Names and category icons are drawn locally at
+constant screen size; only their transforms change during motion. No bitmap is
+requested for every label or every frame. Icon assets are compiled into the
+package. An annotation cache failure leaves terrain navigation available.
+
+The [vector map design](VECTOR_MAP.md) documents source selection, geometry format, label windows, GPU/CPU responsibilities and measured bandwidth.
diff --git a/docs/PSP.md b/docs/PSP.md
new file mode 100644
index 0000000..7d992b9
--- /dev/null
+++ b/docs/PSP.md
@@ -0,0 +1,69 @@
+# Pocket Map on PSP
+
+The PSP version uses **PSPLINK USB for capabilities and the local GE for rendering**. The Mac fetches and parses OSM vector tiles, simplifies and triangulates them into PMH1 meshes, and decodes Hyrule images. The PSP pans and zooms its cached map without requesting a new screen image from the Mac. Search, saved places, labels and cache ownership share the 3DS model and provider.
+
+## Build and run
+
+Use the pinned PocketJS runtime, Bun dependencies and PSP toolchain. Install `usbhostfs_pc` and `pspsh` and start PSPLINK on the USB-connected console.
+
+```sh
+bun run psp
+# Keep these running in separate terminals. Only one usbhostfs process
+# should own this PSP; its mounted root must match host:psp.
+usbhostfs_pc -b 10000 dist/psplink
+bun run host:psp
+pspsh -e 'ldstart host0:/pocket-map.prx'
+```
+
+`dist/psp/EBOOT.PBP` is also generated, with the XMB title **Pocket Map**. To install it to a Memory Stick through an active PSPLINK session:
+
+```sh
+cp dist/psp/EBOOT.PBP dist/psplink/pocket-map.pbp
+pspsh -e 'mkdir ms0:/PSP/GAME/PocketMap'
+pspsh -e 'cp host0:/pocket-map.pbp ms0:/PSP/GAME/PocketMap/EBOOT.PBP'
+pspsh -e 'cp ms0:/PSP/GAME/PocketMap/EBOOT.PBP host0:/pocket-map-readback.pbp'
+cmp dist/psp/EBOOT.PBP dist/psplink/pocket-map-readback.pbp
+```
+
+Rebuilds require a module restart: `pspsh -e reset`, wait for the USB connection, then load the PRX again. **The EBOOT does not install or start the PSPLINK USB driver.** Uncached content requires a running PSPLINK host0 connection and Mac provider. Hardware runtime validation used the PRX under PSPLINK; Memory Stick installation was checked by file readback. Standalone XMB USB startup has not been implemented or validated. `ldstart` loads the PRX, not the packaged PBP.
+
+Rest the analog nub at launch. The PSP host calibrates a stable center from 16 samples within a bounded startup window; full travel is rescaled around that center. Hold **SELECT with the nub at rest** to repeat calibration. The shared runtime then applies its normal deadzone. This addresses the test PSP's untouched raw reading of approximately `(162,133)` instead of `(128,128)`.
+
+| Control | Action |
+| --- | --- |
+| Analog nub / D-pad | Pan locally |
+| Hold L, D-pad, Circle | Search, saved places, save center, return to pin or home |
+| Hold R, D-pad, Circle | Zoom, labels, source selection, retry or help |
+| Triangle | Open the keyboard |
+| Keyboard: D-pad + Circle | Choose and type a key |
+| Keyboard: Square / Triangle | Backspace / space |
+| Keyboard: R / L | Uppercase / symbols |
+| START | Submit text; save the map center or a search result |
+| Saved places: START / Square | Rename / delete with confirmation |
+| Saved places: left/right | Turn pages |
+| Cross | Close keyboard, return to map, or cancel deletion |
+| SELECT at rest | Recalibrate the analog nub |
+
+The keyboard uses the framework's modal input ownership, so its shoulder shortcuts cannot also open the map menus. Hyrule remains available through R → Switch map after `bun run prepare:hyrule` has downloaded the local atlas.
+
+## Resource limits
+
+The viewport is **480×272**. The PSP selects up to 24 terrain resources rather than the 3DS configuration's 40. Native transport has eight fixed staging slots; the shared resource runtime admits three concurrent reads and materializes one completion per frame. Retained GE vertex allocations, including retired buffers, have a 4 MiB limit. The Mac's SQLite HTTP cache and saved-place store remain under ignored `.local/` and can be reused by the LAN provider.
+
+Four sampled San Francisco PMH1 meshes total 107,464 terrain bytes (26,866 bytes/tile on average), compared with 524,288 bytes for four raw RGB565 tiles. A horizontal, integer-zoom pan exposing 3.75 tiles/second would require approximately 0.10 MB/s of terrain payload for those samples. **This is a workload estimate, not a measured USB ceiling**; it excludes labels, envelope/file RPC overhead, other movement directions and denser tiles.
+
+Pocket YouTube established the USB host0 transport route. Map tiles require independent request identities and reliable completion ownership, while video can discard superseded whole frames. PocketJS now places the blocking host0 RPCs on a lower-priority native worker; no file reads, image codecs or MVT parsing run inside the map's JavaScript frame callback. See the runtime's [PSP offload contract](../runtime/docs/PSP_OFFLOAD.md).
+
+## Hardware evidence
+
+The real PSP completed the scripted checkpoints: OSM map, keyboard typing/backspace, San Francisco search, continuous pan, zoom, save, saved-place list, confirmed deletion, Hyrule, and return to OSM. The test removes the bookmark it creates. Captures below are **real PSP framebuffers obtained through PSPLINK**, with the UI thread briefly suspended after a checkpoint so the framebuffer is complete. They are not physical-button acceptance receipts.
+
+
+
+
+
+On the same settled SF view at 333 MHz, elapsed CPU-side frame work changed from approximately **33.5 ms → 18.1 ms → 14.3 ms** after skipping unchanged resource unions and repeated stationary-map calculations. Stable samples then advanced by 120 frames per approximately 2.00 seconds. Those settled frames fit 60 Hz; moving into new resources and mounting the keyboard/menu still produce longer frames. GE submission measured roughly 0.4–0.5 ms; submission duration alone is not GPU execution time.
+
+The USB host process was suspended for more than five seconds while the app ran. The PSP UI thread's run counter continued advancing and its cached map survived; communication recovered when the process resumed. This exercises a blocked transport, not just a provider error response. Performance logs taken during debugger screenshots include debugger preemption and should not be used as an uncontaminated frame-time percentile benchmark.
+
+To repeat the scripted hardware run, build with `bun run psp --smoke`, start `bun run test:psp:watch`, reset/load the PRX, and inspect `dist/qa/psp/smoke.json`. `--smoke` overrides controller input and disables automatic framebuffer dumps. Rebuild without that option and reload before handing the PSP back for manual use.
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
new file mode 100644
index 0000000..a203fc0
--- /dev/null
+++ b/docs/VALIDATION.md
@@ -0,0 +1,293 @@
+# Validation
+
+## Local execution
+
+- Eleven app tests cover Mercator wrapping, immediate controller motion while IO
+ waits, keyboard submission, menu ownership, HTTP validators, cache limits,
+ duplicate requests, pixel order, invalid image dimensions and bounded search
+ results, including a real Bun HTTP 304 cache revalidation. Saved-place tests reopen SQLite, replay committed writes, reject
+ operation-ID reuse, paginate, rename and delete. Atlas tests check pixel bytes,
+ cache reuse, FTS search, planar bookmarks, invalid addresses and corrupt data.
+ Legacy bookmark migration preserves entries and operation receipts. TypeScript checks pass.
+- The compiled guest ran 1,591 simulated frames against a synthetic provider.
+ Replay covers real auxiliary hit testing, keyboard input, result selection,
+ placing a pin, touch inertia, animated zoom, 180 frames with replies withheld,
+ disconnect/reconnect, date-line navigation and menu presentation.
+- Observed replay maxima: three pending guest requests, 40 resident tile
+ entries and three native-image staging tickets (the pool limit is eight). Staging drains to zero after
+ navigation settles. Generated receipts are in `dist/qa/replay.json`.
+- Live OSM DE tiles and Photon search passed through the same compiled guest
+ and resource API in the earlier geographic-provider check. That live check
+ requests one viewport and one submitted query; it performs no pan scan. The
+ README now shows the compiled guest using the complete local Hyrule atlas.
+- QuickJS passed 1,084 frames in each of the geographic and Hyrule modes with a 128 KiB stack, including offline mount,
+ resource reveal, sustained panning, both zoom directions, local typing,
+ search, place navigation and reconnect. Texture tickets drained to zero.
+
+## Native boundaries
+
+The 3DS package builds with the pinned PocketJS ARM toolchain. Framework tests
+exercise production `offload.c` through POSIX socket/thread shims, compare all
+bytes of a 256×256 image, verify staging release and reconnect after realm
+reset. ASan/UBSan cover 4,000 concurrent images with bounded credit, malformed
+envelopes and token reuse. This verifies transport code; it does not emulate
+libctru or PICA200 timing.
+
+The production `.3dsx` and an app-specific pairing key were uploaded through
+ftpd and read back for equality. The install receipt, package digest and target
+path are recorded in ignored `dist/qa/deploy.json`. The slot is
+`c7771f0167312c63`, derived from `dev.pocket-stack.map`.
+
+**The physical device has connected and is returning frame telemetry.** A
+stable interval reported 119–120 frames per two-second sample. The earlier
+session recorded CPU frames above 16,667 microseconds and a lifetime maximum
+of 263,387 microseconds. These counters do not identify which input caused a
+frame or prove continuous 60 fps. The first device build remounted overlapping
+tile components at window changes; the revised model retains their identities.
+
+The user physically confirmed the first build was usable, then requested
+more caching, drag smoothing, layout changes and saved places. The revised
+replay covers those flows, including lost-acknowledgement retries and a resident
+prefetched tile becoming visible. Physical gesture and frame-time validation
+of this revision remain pending. Build, native transport, Wasm visuals, device liveness and physical
+input remain separate evidence.
+
+## Device disconnect investigation
+
+The uploaded revision lost its Mac daemon after a Bun 1.3.14 native crash on a
+Worker thread. Session diagnostics added during recovery then recorded
+`device response backlog exceeded` and request-credit exhaustion during tile
+churn. The previous transport closed the socket at those limits, terminated
+the active Worker and exposed the daemon to a native teardown crash.
+
+The fix uses a separate capability process, pauses transport input/output when
+credit is occupied and preserves device reservations for cancelled sent work.
+A native transport test covers both execution modes. Fault tests kill a child
+process, disconnect eight times during fetch, trigger a wedged-task deadline,
+and drain a paused receiver's burst of 64 full-size images without losing its
+connection. An app test runs the actual PNG provider across three interrupted
+fetches, then verifies every pixel of a returned 256px image. UI replay and
+QuickJS checks cover the revised guest cancellation semantics.
+
+The revised Mac daemon is running with process isolation and socket
+backpressure and has reconnected to the physical 3DS. The guest patch preserving
+transmission credit passes replay / QuickJS checks and was uploaded through
+ftpd with byte-for-byte readback: 1,686,296 bytes, SHA-256
+`cafcdcc0112c8bde54935f3105852c1d3eb582a5feab264cbeda143b515d1e13`.
+The Hyrule deployment below supersedes this binary.
+
+A subsequent source inspection found that `POCKETJS_OFFLOAD` excludes both
+native development-server initialization and package storage. The existing
+pairing key alone does not enable guest updates in this build. Earlier claims
+that `bun run update` was ready for Pocket Map were incorrect; the unsupported
+command has been removed. Guest fixes still need an ftpd/native deployment.
+
+## Complete Hyrule atlas and latency correction
+
+Preparation downloaded the pinned Zelda Dungeon source and generated all
+21,845 textures (zoom 0–7) plus 2,576 searchable places. The completed SQLite file
+is 591,581,184 bytes. A full verification inflated every texture to exactly
+131,072 bytes, checked per-level counts and passed SQLite integrity checking.
+The receipt is `dist/qa/hyrule-atlas.json`; source revision is
+`d32a85656031d861cef38e32eb927a7d08a983a9`. Assets and databases are ignored.
+
+The Hyrule compiled-guest replay passes 11 checks across 1,001 frames using that
+complete database. It searches Kakariko, navigates to the result, fits the finite
+world without wrapping, holds image responses for half a second, enters a
+prefetched tile and settles after sustained panning. Maxima remain three active
+requests, 40 resident entries and three staging tickets. All tickets drain;
+HTTP downloads remain zero. The OSM replay also passes all 27 checks after the
+bookmark migration. Receipts are `dist/qa/hyrule.json` and `dist/qa/replay.json`.
+
+Two regressions demonstrated failures before the framework correction:
+
+- A blocked image write serialized new provider work. The revised connection
+ overlaps reads with writes inside eight total executing/queued/writing slots.
+ A deterministic socket fixture admits eight jobs while its first write waits,
+ then drains 20 images without disconnecting.
+- A still-desired prefetch was cancelled before transport availability was
+ checked. It now remains useful while a replacement cannot be admitted; its
+ completed texture is reusable without sending the same tile again.
+
+The 49 affected resource/offload tests and eight tile-geometry tests pass, as
+does framework TypeScript. Geometry coverage includes changing a live camera
+from wrapping geographic bounds to a finite atlas and rejecting invalid bounds
+without partially mutating camera state.
+
+The Hyrule native build was uploaded to `192.168.8.102:5000` and read back equal:
+1,689,576 bytes, SHA-256
+`31033e5d9d3ef06ff247fd2c85d42b8d64cfd656f5c77d328fd4ba1cea0eca1f`.
+The paired Mac is running the local atlas provider. Its connected-device trace
+sample contains 375 tile requests: atlas work median/p95 1/1 ms, provider
+round-trip median/p95 2/2 ms and zero HTTP downloads. Socket drain waits have
+median/p95 353/434 ms over 374 writes. These are host-side measurements, not
+render or input latency; each image still sends 128 KiB over the LAN.
+
+Earlier OSM traces had both cached 1–4 ms provider work and uncached work taking
+seconds. The source's documented per-IP speed allowance means absence of HTTP
+429 does not rule out throttling. The complete local atlas removes that external
+service dependency. It does not establish the 3DS bandwidth limit or continuous
+60 fps. The new device session has returned frame telemetry and image requests;
+physical gesture acceptance remains separate from liveness and replay results.
+
+## Directional navigation, live sources and labels
+
+The next revision passes 14 app tests and TypeScript. New cases exercise
+repeated diagonal strokes separated by pauses, reversal/expiry, local next-level
+prediction, OSM's four-tile same-level cap, the ZL chord without camera panning,
+source restoration and delayed bookmark receipts routed to their original map.
+Marker tests cover zoom/category filtering, reply limits and invalid windows.
+Ten framework tile tests and framework TypeScript pass, including equivalent
+30/60 Hz directional histories and camera zoom-target exposure.
+
+The compiled guest passes 20 Hyrule replay checks across 1,713 frames and all
+27 geographic checks across 1,591 frames. New replay coverage uses actual lower
+surface hit testing for repeated strokes and source switching, ZL zoom/rail
+mounting, next-level demand, real indexed labels and category selection. The
+OSM provider used during switching is synthetic: no public-service stress scan
+runs. Maxima remain three pending requests, 40 tile entries and three staging
+tickets. Hyrule made zero HTTP downloads; the synthetic OSM adapter served eight
+tile fetches. All staging tickets drained.
+
+QuickJS passes 1,272 frames per startup provider at a 128 KiB stack, including
+marker components, ZL, source/filter panels and switching both ways. The native
+package includes all nine annotation icons, with the correct power-of-two
+image envelopes. Wasm captures were visually checked for the labels, zoom rail,
+source picker and category picker. Native build and readback do not substitute
+for physical gesture or frame-time acceptance of this revision.
+
+The navigation build was uploaded and read back equal: 1,720,504 bytes; SHA-256
+`348e614c1016fceb175d701bd3ae14294f817367f326d13493473488e16a1e2a`.
+The Mac daemon now exposes both maps through source-identified requests.
+
+## Vector OSM reconstruction
+
+The default OSM provider now uses Shortbread MVTs. Hyrule's existing raster
+atlas, marker index, bookmarks and source switching remain available. See
+[VECTOR_MAP.md](VECTOR_MAP.md) for the execution split and measurement scope.
+
+- 18 application tests / 161 assertions pass, including holes, clipping,
+ dense geometry, HTTP deduplication, overzoom source reuse and provider policy.
+- Framework resource/offload regression: 64 tests / 682 assertions; the final
+ targeted image/mesh/offload suite also passes 22 tests / 140 assertions,
+ including the added consumer-exception cleanup case.
+- Rust core: 131 tests pass, including malformed geometry, stale handles and
+ transformed/clipped TRI output.
+- Synthetic MVT compiled-guest + Wasm replay: 1,403 frames, 13 checks; delayed
+ responses, disconnect, search, Hyrule raster switching and return to vector.
+- Real San Francisco replay: 453 frames. Four MVTs become 107,464 wire bytes of
+ prepared geometry, versus 524,368 bytes for four old raw image records.
+ Additional display zooms z15–18 send zero terrain bytes; label JSON and Unicode
+ images remain separate traffic. Receipts include those costs.
+- Existing complete-Hyrule compiled replay: 1,713 frames, 20 checks.
+- QuickJS at 128 KiB stack: both startup modes pass 1,272 frames and release all
+ staging tickets. Wasm screenshots were inspected for geometry, holes, fixed-size
+ labels, CJK text and source switching.
+
+Native `.3dsx` builds pass with the pinned nightly and devkitPro image. These
+checks establish build, protocol, resource and pixel behavior. Real 3DS input
+and frame-time acceptance of the vector revision remain pending; Wasm CPU
+numbers are not a hardware frame-rate claim.
+
+The vector build was deployed through ftpd and read back byte-for-byte equal:
+1,736,564 bytes, SHA-256
+`2f33588ba2b1081f8c86146300b5210f39b1239fa155688c20f478d04bb64130`.
+The Mac daemon starts OSM by default and keeps Hyrule available through the
+source picker. Transfer verification does not establish physical interaction
+or a measured frame rate.
+
+## Vector CPU baseline and retained GPU revision
+
+The first vector build was uploaded and connected to the physical device.
+Although terrain bandwidth decreased, warm vector views often advanced only
+40–60 frames per two-second telemetry interval. Many intervals recorded every
+frame above 16.67 ms, including periods without new tile requests. This fails
+the intended frame budget and is not evidence of continuous 60 fps.
+
+That build expanded and clipped every triangle on the CPU each frame. The
+revised 3DS backend materializes immutable GPU buffers once and emits bounded
+handle/transform/clip commands during navigation. Its telemetry splits UI/core,
+frame preparation and submission durations. Core tests cover backend opt-in,
+transform scaling, opacity fallback, off-screen culling and stale mesh handles.
+The native build and app checks pass. This revision was uploaded to
+`192.168.8.102:5000` and read back equal: 1,740,768 bytes, SHA-256
+`fd9f701d9159aedc8a2e45ffa0aa9e98f0e5fc4d888e6ccca816a2033dd8a135`.
+Device telemetry received after deployment includes stable intervals of 120
+frames per two-second sample. These are settled-view observations; continuous
+dragging and materialization remain separate measurements.
+
+## Dense vector tile availability
+
+The device's `Tile unavailable` reports corresponded to Mac preparation errors:
+`Vector tile cannot fit the device detail budget`. Many failed requests were
+HTTP-cache hits. Replaying all 362 cached MVTs without network access reproduced
+66 failures across source zooms 3–14. In one failing z12 tile, the old last detail
+pass still contained 4,413 triangles against the 2,048-triangle limit.
+
+The correction adds small-area filtering, coarse line joining/deduplication,
+removal of secondary line detail and complete-feature budget admission. It
+keeps the same native wire, vertex, triangle and residency limits. All 362 cached
+tiles now prepare successfully; 18 require the final feature-admission pass.
+The source blobs and raw comparison renders remain in ignored local QA paths.
+Mac reference / bounded-mesh comparisons include urban, coastal and regional
+tiles. Shape simplification stops at two logical pixels; dense scenes can omit
+smaller geographic features and roads.
+
+22 app tests / 180 assertions and TypeScript pass. Generated fixtures cover
+fragmented major roads, dense land parcels, junctions, loops, overlapping links,
+last-pass admission and rejection without partial mutation. The compiled guest
+replay passes its 13 checks across 1,403 frames, including delayed responses,
+reconnect and the unchanged Hyrule raster path. This is a Mac provider change;
+the installed 3DS binary does not require replacement.
+
+After the Mac daemon restarted with this correction, the running 3DS reconnected
+and requested nine meshes. All nine replies succeeded from the HTTP cache,
+with zero downloads or provider errors. Subsequent settled samples reported
+120 frames per two seconds, UI/core maxima around 11 ms, preparation around
+1 ms and submission below 0.6 ms. The recovery upload interval also recorded
+seven additional CPU frames above 16.67 ms, with a window UI maximum of 27.6 ms;
+settled timing must not be presented as uninterrupted 60 fps during loading.
+
+## Sustained controller motion
+
+The earlier guest advanced the camera by `1 / simulationHz()` per presented
+frame. Holding full Circle Pad input therefore moved 320 pixels in a second
+at 60 presented frames, but 160 pixels at 30 frames. The same failure reproduces
+with the actual previous compiled guest in QuickJS; the camera's exponential
+integrator was correct when supplied with the elapsed interval.
+
+The 3DS host now supplies input-sampling microseconds as recorded frame data.
+Pocket Map passes `inputDeltaSeconds()` to camera integration, drag filtering
+and directional intent. Virtual timers, core ticks and resource work budgets
+remain unchanged. The elapsed step is bounded to 66.666 ms: a long pause cannot
+cause a large catch-up jump, but rates below 15 fps cannot retain real-time
+travel under that bound.
+
+Vector labels mount at most 12 visible components and admit at most one new
+identity per frame. Hidden candidates no longer create UI nodes or frame hooks.
+Unchanged placements reuse their previous result. Predictive tile sorting runs
+at 12 Hz while a viewport's tile set and zoom remain unchanged; crossing a tile
+boundary or changing source/zoom recomputes demand in that frame.
+
+- 25 application tests / 981 assertions and TypeScript pass. Four directions
+ retain 320 pixels/second under 60/45/30 Hz and alternating 16.667/33.333/50 ms
+ samples. Tests also cover the resume bound, visible-tile admission and label
+ mount limits.
+- The actual new compiled guest in QuickJS reports 320.006, 319.997 and 320.000
+ pixels per second under 60-frame, 30-frame and mixed-cadence input; the previous
+ guest reports 320, 160 and 160. Both OSM and Hyrule pass 1,572 frames with a
+ 128 KiB stack and no outstanding staging tickets.
+- Compiled guest + Wasm replay passes 17 checks across 1,903 frames, including
+ varying input intervals with real resource loading. Complete-Hyrule replay
+ remains 1,713 frames / 20 checks with zero Hyrule HTTP downloads.
+- PocketJS passes 80 clock, tape, tile and 3DS transport/profile tests, and
+ TypeScript. Tape v4 reproduces input intervals; legacy tapes ignore live
+ timing and keep their nominal step.
+- The production binary builds: 1,745,104 bytes, SHA-256
+ `f452efd99ee29491279780c621f59961466444d6fc59ab5948cd9b1568644330`.
+ Uploaded to `192.168.8.102:5000` at `/3DS/pocketmap-main.3dsx` and read back
+ byte-for-byte equal, together with the existing app pairing key. Physical
+ held-stick acceptance of this revision remains pending.
+
+These replays establish distance per input time and bound label work. They do
+not establish uninterrupted 60 fps on hardware during resource materialization.
diff --git a/docs/VECTOR_MAP.md b/docs/VECTOR_MAP.md
new file mode 100644
index 0000000..20ad1b9
--- /dev/null
+++ b/docs/VECTOR_MAP.md
@@ -0,0 +1,206 @@
+# OSM vector maps on 3DS
+
+## Data and execution
+
+**OSM now uses Shortbread vector tiles. Hyrule retains its complete raster atlas.**
+The default endpoint is VersaTiles' public OSM service; the URL and source settings
+live in the Mac provider configuration. The previous OSM route fetched PNGs and
+sent a 256×256 R5G6B5 plane: 131,072 pixel bytes per tile. The new route fetches
+MVT/PBF, prepares indexed geometry on the Mac and sends a bounded binary mesh.
+
+```mermaid
+flowchart LR
+ OSM[Shortbread MVT] --> HTTP[Mac HTTP cache in SQLite]
+ HTTP --> Prep[Mac PBF decode, style, simplify, triangulate]
+ Prep --> Mesh[Prepared geometry cache]
+ Mesh --> Wire[Bounded offload staging]
+ Atlas[Local Hyrule raster atlas] --> Wire
+ Wire --> Resources[PocketJS resource collections]
+ Resources --> Core[Native immutable mesh buffers or textures]
+ Core --> GPU[3DS GPU transforms, clipping and rasterization]
+ Mesh --> Labels[Bounded label windows]
+ Labels --> Text[Local glyph atlas and Unicode image fallback]
+```
+
+The UI JavaScript thread updates the camera, resource demand and UI controls.
+The 3DS native worker handles socket reception and validates binary envelopes.
+The Mac capability process performs PBF decoding, geometry simplification,
+polygon tessellation, HTTP, SQLite and Unicode text rendering. This follows the
+worker/bucket separation in [Mapbox GL JS's architecture](https://github.com/mapbox/mapbox-gl-js/blob/main/ARCHITECTURE.md):
+GPU drawing consumes prepared geometry; the GPU does not parse protobuf.
+
+**No extra JavaScript thread or device-side PBF parser is required.** The current
+3DS worker uses lower priority and libctru affinity `-2`, rather than assuming
+all New 3DS cores are available to applications. Moving the Mac preparation
+stage into another native device worker is a future host implementation choice;
+it does not change the guest resource API.
+
+## What carries over from Google Maps 5.0
+
+Google's [December 2010 engineering article](https://googleblog.blogspot.com/2010/12/under-hood-of-google-maps-50-for.html)
+describes geometry reuse across zoom and viewing angles, independently placed
+labels, and offline caching. Its “100 times” comparison concerns viewing maps
+across all zoom levels. It is not a promised ratio for one MVT or one prepared
+mesh.
+
+Pocket Map implements these relevant parts:
+
+- Quantized coordinates and indexed triangles replace raster tile transmission.
+- The device applies local pan/zoom transforms to resident geometry.
+- **Source geometry at z14 is reused through display z18.** New high-zoom label
+ windows query the already prepared source tile and place road names on the
+ visible part of each line. They do not fetch another MVT.
+- Lower source levels remain separate resources; a previous ready level stays
+ behind incoming content while zooming.
+- Same-level directional look-ahead retains travel direction across gestures.
+ Public VersaTiles requests remain bounded to four extra tiles; the complete
+ Hyrule atlas permits twelve, including the next zoom level after zoom-in.
+- Geometry, label windows and Unicode fallbacks share the resource scheduler.
+
+This is a 2D street map renderer. It does not implement Google/Mapbox's full
+style engine, 3D buildings, routing, terrain, line-following rotated text or
+arbitrary zoom-dependent style expressions. Prepared road widths scale with the
+geometry. Dense tiles reduce minor detail to fit the device budget, so the
+result is not visually identical to the previous server-rendered PNG.
+
+## Text
+
+**ASCII map labels use the existing shared baked glyph atlas.** Their positions
+follow the map while their font size stays fixed. The guest chooses at most
+12 visible labels, deduplicates names, avoids overlapping rectangles and keeps
+already visible labels stable where possible.
+
+Names outside the baked font repertoire use a separately cached 256×32 label
+image with a skeleton fallback. The Mac explicitly selects a CJK font when
+needed. This preserves readable multilingual names without baking labels into
+every map tile. Dynamic Unicode glyph pages are not implemented in this change.
+
+Mapbox's [text rendering design](https://github.com/mapbox/mapbox-gl-native/wiki/Text-Rendering)
+uses a glyph atlas and signed distance fields. The current PICA200 backend uses
+fixed fragment combiners; its programmable vertex shader does not provide the
+fragment shader needed to copy Mapbox's SDF approach directly. Baked coverage
+fonts are the applicable atlas path here.
+
+## Bounds and ownership
+
+| Stage | Bound |
+| --- | --- |
+| MVT response body | 2 MiB, in the Mac process |
+| Considered source geometry | 200,000 points; 20,000 features per layer |
+| Prepared entry | 4,096 vertices, 2,048 triangles, 36,880 bytes |
+| Coordinates | Unsigned 1/16 logical pixels, inside the declared rectangle |
+| Mac prepared cache | 128 source tiles, honoring HTTP expiry |
+| Device geometry cache | 40 entries, two demand owners |
+| Shared resource requests | 3 active, one start and one completion per frame |
+| Shared native staging | Eight slots for images and meshes together |
+| Materialization | One image or mesh per frame |
+| Label windows | 40 cached; up to 12 demanded, at most 12 rows per reply |
+| Visible labels | 12, with bounded overlap checks |
+| Native mesh handles | 128 slots; generation-tagged, separate from textures |
+| Retained 3DS mesh buffers | 8 MiB, including buffers awaiting GPU retirement |
+| 3DS immediate vertex arena | 65,536 vertices shared by both screens |
+
+The provider triangulates complete polygons, preserving holes, then clips the
+result. Coarser detail passes simplify complete shapes and omit minor layers;
+they do not transmit an arbitrary triangle prefix. Dense tiles remove small
+parcels/holes, minor line layers and road casings. Coarse road preparation joins
+degree-two endpoints and deduplicates links on a tile-aligned grid; junctions
+remain endpoints. Shape simplification tolerance stops at two logical pixels,
+while feature selection can continue to reduce detail. A final admission pass
+selects complete geographic areas and long major roads within the existing
+vertex/triangle limits, then restores painter order. It can omit smaller roads
+or decorative features instead of failing an otherwise valid tile.
+
+Invalid indices, coordinates,
+versions or byte counts are rejected before acquiring native mesh residency.
+
+`createOffloadMeshCollection` owns materialization and eviction. `ResourceMesh`
+borrows the handle and declares its fallback. Cancelled sent work retains its
+wire credit until a response or disconnect; late responses release staging.
+Meshes and images have the same lifecycle but separate native handle tables.
+
+**3DS mesh materialization uploads an immutable GPU vertex buffer once.**
+Opaque mesh Views emit ten words per visible tile: its generation-tagged handle,
+affine transform and clip rectangle. PICA transforms and clips the resident
+vertices. Camera motion does not expand triangles into the immediate vertex
+arena. Uploads share the one-per-frame image/mesh completion budget and fail
+through the resource retry path if the buffer cannot be allocated.
+
+Replacing or evicting a resource cannot free memory still referenced by the
+previous GPU frame. The backend retires these buffers after `C3D_FrameBegin`
+confirms that frame has completed. Wasm and other existing backends retain the
+CPU-clipped TRI path; 3DS also uses it for translucent mesh Views. The limits
+bound work; device timing remains a separate validation gate.
+
+## Provider configuration
+
+The default [VersaTiles service](https://docs.versatiles.org/guides/use_tiles_versatiles_org.html)
+uses `https://tiles.versatiles.org/tiles/osm/{z}/{x}/{y}`. Set a different
+Shortbread endpoint in ignored `.local/provider.json` without rebuilding the
+3DS application:
+
+```json
+{
+ "format": "vector",
+ "tileURL": "https://vector.openstreetmap.org/shortbread_v1/{z}/{x}/{y}.mvt",
+ "dataZoom": 14,
+ "maxZoom": 18,
+ "name": "OpenStreetMap"
+}
+```
+
+Restart the daemon after changing its configuration. Source identity includes
+the endpoint and prepared style revision, preventing old resources from being
+mixed with a new source. HTTP requests identify Pocket Map, preserve validators
+and honor cache expiry. When using `vector.openstreetmap.org`, predictive HTTP
+downloads are disabled to follow its [vector tile policy](https://operations.osmfoundation.org/policies/vector/).
+For regional offline use, obtain a licensed regional tile archive through a
+provider's supported download workflow; do not scrape the public endpoint.
+
+## Measurements and reproduction
+
+A compiled guest + Wasm replay viewed four real San Francisco tiles at z14,
+then zoomed through z18 and returned. The MVTs and receipts stay under ignored
+`.local/` and `dist/qa/`; screenshots in `docs/images/` are curated captures.
+
+| Quantity | Measured |
+| --- | --- |
+| Four prepared mesh records, including wire headers | 107,464 bytes |
+| Four equivalent old raw raster records | 524,368 bytes |
+| Terrain byte reduction | 79.5% |
+| Additional terrain transfers for z15–18 | 0 |
+
+Label-window JSON and occasional Unicode images are additional traffic. See
+`dist/qa/vector-live.json` for these totals. Synthetic replay figures are not
+used as a real-world compression ratio. Wasm software raster times do not
+measure PICA200 performance or real 3DS frame rate.
+
+```sh
+bun run check
+bun run 3ds
+bun scripts/vector-sim.ts # generated MVTs, delayed replies, disconnects
+bun scripts/vector-sim.ts --live # a small real viewport, persistent HTTP cache
+bun scripts/sim.ts --hyrule # complete real local atlas regression
+qjs --std --stack-size 131072 scripts/quickjs-smoke.js runtime/dist/3ds/guest/pocketmap-main.js
+```
+
+Framework validation covers native socket transfer in both thread and process
+provider modes, maximum-size geometry, malformed/truncated data, stale handles,
+shared staging credit, cancellation, failed uploads, polygon clipping and
+resource cleanup. Pocket Map tests cover polygon holes, dense-tile detail
+reduction, source routing, label/geometry deduplication and source/display LOD.
+
+## Motion and label work per frame
+
+The host passes its elapsed input-sampling interval into the recorded frame.
+Pocket Map integrates controller velocity using `inputDeltaSeconds()` rather
+than counting presented frames; missing a vblank therefore does not halve
+travel speed. A 66.666 ms upper bound limits movement after a pause. Virtual
+animation ticks and resource admissions keep their fixed frame budgets.
+
+Only 12 collision-tested vector labels can own UI components. Candidate windows
+remain cached data; they do not each create a component or frame hook. Existing
+visible identities stay mounted and at most one new label mounts per frame.
+Predictive tile sorting is reused for up to 1/12 second while visible tile
+bounds and zoom stay fixed. A newly exposed tile invalidates that reuse in the
+same frame, preserving immediate visible demand.
diff --git a/docs/images/delete.png b/docs/images/delete.png
new file mode 100644
index 0000000..1160479
Binary files /dev/null and b/docs/images/delete.png differ
diff --git a/docs/images/hyrule-search.png b/docs/images/hyrule-search.png
new file mode 100644
index 0000000..27f341f
Binary files /dev/null and b/docs/images/hyrule-search.png differ
diff --git a/docs/images/hyrule.png b/docs/images/hyrule.png
new file mode 100644
index 0000000..8698e70
Binary files /dev/null and b/docs/images/hyrule.png differ
diff --git a/docs/images/keyboard.png b/docs/images/keyboard.png
new file mode 100644
index 0000000..d8cf726
Binary files /dev/null and b/docs/images/keyboard.png differ
diff --git a/docs/images/map.png b/docs/images/map.png
new file mode 100644
index 0000000..211f0fd
Binary files /dev/null and b/docs/images/map.png differ
diff --git a/docs/images/psp-hyrule.png b/docs/images/psp-hyrule.png
new file mode 100644
index 0000000..13edb15
Binary files /dev/null and b/docs/images/psp-hyrule.png differ
diff --git a/docs/images/psp-keyboard.png b/docs/images/psp-keyboard.png
new file mode 100644
index 0000000..0b9949b
Binary files /dev/null and b/docs/images/psp-keyboard.png differ
diff --git a/docs/images/psp-map.png b/docs/images/psp-map.png
new file mode 100644
index 0000000..1b80024
Binary files /dev/null and b/docs/images/psp-map.png differ
diff --git a/docs/images/saved.png b/docs/images/saved.png
new file mode 100644
index 0000000..06af7f3
Binary files /dev/null and b/docs/images/saved.png differ
diff --git a/docs/images/search.png b/docs/images/search.png
new file mode 100644
index 0000000..c06c732
Binary files /dev/null and b/docs/images/search.png differ
diff --git a/docs/images/sources.png b/docs/images/sources.png
new file mode 100644
index 0000000..023a126
Binary files /dev/null and b/docs/images/sources.png differ
diff --git a/docs/images/vector-osm.png b/docs/images/vector-osm.png
new file mode 100644
index 0000000..98f71cd
Binary files /dev/null and b/docs/images/vector-osm.png differ
diff --git a/docs/images/vector-zoom.png b/docs/images/vector-zoom.png
new file mode 100644
index 0000000..259a597
Binary files /dev/null and b/docs/images/vector-zoom.png differ
diff --git a/docs/images/zoom-rail.png b/docs/images/zoom-rail.png
new file mode 100644
index 0000000..38e41ed
Binary files /dev/null and b/docs/images/zoom-rail.png differ
diff --git a/host/atlas-format.ts b/host/atlas-format.ts
new file mode 100644
index 0000000..d29c34b
--- /dev/null
+++ b/host/atlas-format.ts
@@ -0,0 +1,2 @@
+export const HYRULE_REVISION = "d32a85656031d861cef38e32eb927a7d08a983a9";
+export const ATLAS_FORMAT = "pocket-map-atlas-rgb565-v1";
diff --git a/host/atlas.ts b/host/atlas.ts
new file mode 100644
index 0000000..52738c4
--- /dev/null
+++ b/host/atlas.ts
@@ -0,0 +1,71 @@
+import { Database } from "bun:sqlite";
+import { inflateRawSync } from "node:zlib";
+import { existsSync } from "node:fs";
+import { MarkerIndex } from "./markers.ts";
+import { join } from "node:path";
+import type { OffloadImage } from "@pocketjs/framework/offload/provider";
+import { Bookmarks } from "./bookmarks.ts";
+import { renderLabel } from "./provider.ts";
+import { validPosition, type MapInfo, type Place, type SearchInput, type TileInput, type MarkerInput } from "../shared/types.ts";
+
+import { ATLAS_FORMAT } from "./atlas-format.ts";
+export { HYRULE_REVISION, ATLAS_FORMAT } from "./atlas-format.ts";
+export interface AtlasManifest { format: string; revision: string; tiles: number; places: number; info: MapInfo }
+
+/** The installed atlas is complete and immutable. Reads never fall through to
+ * a network provider; user bookmarks live in a different SQLite database. */
+export class AtlasProvider {
+ readonly info: MapInfo;
+ readonly bookmarks: Bookmarks;
+ private db: Database;
+ private markers?: MarkerIndex;
+ private images = new Map();
+ private reads = 0;
+ private hits = 0;
+ constructor(directory: string) {
+ this.db = new Database(join(directory, "atlas.sqlite"), { readonly: true });
+ const row = this.db.query("SELECT value FROM metadata WHERE key='manifest'").get() as { value: string } | null;
+ const manifest: AtlasManifest | undefined = row ? JSON.parse(row.value) : undefined;
+ if (!manifest || manifest.format !== ATLAS_FORMAT || manifest.tiles !== 21845 || manifest.info.space !== "planar") {
+ this.db.close(); throw new Error("Incomplete Hyrule atlas; run bun run prepare:hyrule");
+ }
+ if (existsSync(join(directory, "markers.sqlite"))) this.markers = new MarkerIndex(join(directory, "markers.sqlite"));
+ this.info = { ...manifest.info, markers: !!this.markers };
+ this.bookmarks = new Bookmarks(join(directory, "places.sqlite"));
+ }
+ methods() { return {
+ "map.info": () => JSON.stringify(this.info),
+ "map.markers": (raw: string) => JSON.stringify(this.markerRows(JSON.parse(raw))),
+ "map.tile": (raw: string) => this.tile(JSON.parse(raw)),
+ "map.search": (raw: string) => JSON.stringify(this.search(JSON.parse(raw))),
+ "map.label": (raw: string) => renderLabel(JSON.parse(raw)),
+ "bookmarks.list": (raw: string) => JSON.stringify(this.bookmarks.list(JSON.parse(raw).offset)),
+ "bookmarks.command": (raw: string) => this.bookmarks.command(JSON.parse(raw)),
+ }; }
+ tile(input: TileInput): OffloadImage {
+ const { source, z, x, y } = input;
+ if (source !== this.info.source || ![z, x, y].every(Number.isInteger) || z < 0 || z > 7 || x < 0 || y < 0 || x >= 2 ** z || y >= 2 ** z) throw new Error("Invalid atlas tile");
+ const key = `${z}/${x}/${y}`, hit = this.images.get(key);
+ if (hit) { this.hits++; this.images.delete(key); this.images.set(key, hit); return hit; }
+ const row = this.db.query("SELECT pixels FROM tiles WHERE z=? AND x=? AND y=?").get(z, x, y) as { pixels: Uint8Array } | null;
+ if (!row) throw new Error("Missing installed atlas tile");
+ const pixels = new Uint8Array(inflateRawSync(row.pixels, { maxOutputLength: 131072 }));
+ if (pixels.length !== 131072) throw new Error("Invalid atlas pixels");
+ const image: OffloadImage = { width: 256, height: 256, format: "r5g6b5", pixels };
+ this.reads++; this.images.set(key, image);
+ if (this.images.size > 128) this.images.delete(this.images.keys().next().value!);
+ return image;
+ }
+ search(input: SearchInput): Place[] {
+ if (!input || typeof input.query !== "string" || input.query.length > 80 || !validPosition(input) || input.space !== "planar") throw new Error("Invalid atlas search");
+ const words = input.query.match(/[\p{L}\p{N}]+/gu)?.slice(0, 8);
+ if (!words?.length) return [];
+ const match = words.map(word => `"${word}"*`).join(" AND ");
+ const rows = this.db.query(`SELECT id,name,detail,x,y,zoom FROM place_search WHERE place_search MATCH ?
+ ORDER BY rank, ((x-?)*(x-?)+(y-?)*(y-?)) LIMIT 5`).all(match, input.x, input.x, input.y, input.y) as Omit, "space">[];
+ return rows.map(row => ({ ...row, space: "planar" }));
+ }
+ markerRows(input: MarkerInput) { if (input.source !== this.info.source) throw new Error("Unknown marker source"); return this.markers?.query(input) ?? []; }
+ diagnostics() { return { atlasReads: this.reads, memoryHits: this.hits, downloads: 0 }; }
+ close() { this.markers?.close(); this.db.close(); this.bookmarks.close(); }
+}
diff --git a/host/bookmarks.ts b/host/bookmarks.ts
new file mode 100644
index 0000000..5978c94
--- /dev/null
+++ b/host/bookmarks.ts
@@ -0,0 +1,63 @@
+import { Database } from "bun:sqlite";
+import { createHash } from "node:crypto";
+import { mkdirSync } from "node:fs";
+import { dirname } from "node:path";
+import type { BookmarkPage, BookmarkCommand, Place } from "../shared/types.ts";
+import { validPosition } from "../shared/types.ts";
+
+/** User data has its own database: evicting the HTTP cache cannot erase it.
+ * Mutation receipts and changes commit together, including deletion receipts. */
+export class Bookmarks {
+ private db: Database;
+ constructor(path: string) {
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
+ this.db = new Database(path);
+ this.db.exec("PRAGMA journal_mode=WAL");
+ this.db.transaction(() => {
+ const migrated = this.db.query("SELECT name FROM sqlite_master WHERE name='locations'").get();
+ this.db.exec(`CREATE TABLE IF NOT EXISTS locations (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT UNIQUE NOT NULL, name TEXT NOT NULL, detail TEXT NOT NULL, position TEXT NOT NULL, zoom INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS receipts (op TEXT PRIMARY KEY, body TEXT NOT NULL, result TEXT NOT NULL);`);
+ if (!migrated && this.db.query("SELECT name FROM sqlite_master WHERE name='places'").get()) {
+ this.db.exec("INSERT INTO locations SELECT seq,id,name,detail,json_object('lat',lat,'lon',lon),zoom FROM places");
+ }
+ })();
+ }
+ list(offset: number): BookmarkPage {
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > 995 || offset % 5) throw new Error("Invalid saved places page");
+ const total = (this.db.query("SELECT count(*) AS n FROM locations").get() as { n: number }).n;
+ offset = Math.min(offset, Math.max(0, Math.ceil(total / 5) - 1) * 5);
+ const rows = this.db.query("SELECT id,name,detail,position,zoom FROM locations ORDER BY seq DESC LIMIT 5 OFFSET ?").all(offset) as { id: string; name: string; detail: string; position: string; zoom: number }[];
+ const items: Place[] = rows.map(({ position, ...row }) => ({ ...row, ...JSON.parse(position) }));
+ return { items, offset, total };
+ }
+ command(command: BookmarkCommand): string {
+ if (!command || typeof command.op !== "string" || !/^[a-zA-Z0-9_-]{12,80}$/.test(command.op)) throw new Error("Invalid operation ID");
+ const body = JSON.stringify(command);
+ if (body.length > 1200) throw new Error("Saved place exceeds budget");
+ return this.db.transaction(() => {
+ const previous = this.db.query("SELECT body,result FROM receipts WHERE op=?").get(command.op) as { body: string; result: string } | null;
+ if (previous) { if (previous.body !== body) throw new Error("Operation ID reused with different data"); return previous.result; }
+ let id = command.id;
+ if (command.kind === "save") {
+ const p = command.place;
+ if (!p || typeof p.name !== "string" || !p.name.trim() || p.name.length > 36 || typeof p.detail !== "string" || p.detail.length > 60 || !validPosition(p) || !Number.isInteger(p.zoom) || p.zoom < 0 || p.zoom > 18) throw new Error("Invalid saved place");
+ if (this.list(0).total >= 1000) throw new Error("Saved places limit reached (1000)");
+ id = "b_" + createHash("sha256").update(command.op).digest("hex").slice(0, 24);
+ const position = p.space === "planar" ? { space: p.space, x: p.x, y: p.y } : { lat: p.lat, lon: p.lon };
+ this.db.query("INSERT INTO locations (id,name,detail,position,zoom) VALUES (?,?,?,?,?)").run(id, p.name.trim(), p.detail, JSON.stringify(position), p.zoom);
+ } else {
+ if (typeof id !== "string" || !/^b_[a-f0-9]{24}$/.test(id)) throw new Error("Invalid saved place ID");
+ if (!this.db.query("SELECT id FROM locations WHERE id=?").get(id)) throw new Error("Saved place no longer exists");
+ if (command.kind === "rename") {
+ if (typeof command.name !== "string" || !command.name.trim() || command.name.length > 36) throw new Error("Name must contain 1-36 characters");
+ this.db.query("UPDATE locations SET name=? WHERE id=?").run(command.name.trim(), id);
+ } else if (command.kind === "remove") this.db.query("DELETE FROM locations WHERE id=?").run(id);
+ else throw new Error("Unknown saved place command");
+ }
+ const result = JSON.stringify({ id });
+ this.db.query("INSERT INTO receipts VALUES (?,?,?)").run(command.op, body, result);
+ return result;
+ })();
+ }
+ close() { this.db.close(); }
+}
diff --git a/host/cache.ts b/host/cache.ts
new file mode 100644
index 0000000..b1f9ebe
--- /dev/null
+++ b/host/cache.ts
@@ -0,0 +1,69 @@
+import { Database } from "bun:sqlite";
+import { mkdirSync } from "node:fs";
+import { dirname } from "node:path";
+export type NetworkFetch = (url: string | URL | Request, options?: RequestInit) => Promise;
+
+/** Host-only HTTP cache. Completed tiles persist across provider reconnections;
+ * in-flight requests deduplicate, with bounded bodies and conditional expiry. */
+export class HttpCache {
+ private db: Database;
+ private pending = new Map>();
+ private running = 0;
+ hits = 0; downloads = 0;
+ constructor(path: string, private network: NetworkFetch = fetch, private maxEntries = 2048) {
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
+ this.db = new Database(path);
+ this.db.exec("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS http (url TEXT PRIMARY KEY, bytes BLOB NOT NULL, expires INTEGER NOT NULL, etag TEXT, modified TEXT, touched INTEGER NOT NULL)");
+ }
+ get(url: string, options: { maxBytes: number; ttl: number; signal?: AbortSignal }): Promise {
+ const existing = this.pending.get(url); if (existing) return existing;
+ if (this.running >= 4) return Promise.reject(new Error("Host request budget exhausted"));
+ this.running++;
+ const request = this.read(url, options).finally(() => { this.pending.delete(url); this.running--; });
+ this.pending.set(url, request); return request;
+ }
+ private async read(url: string, options: { maxBytes: number; ttl: number; signal?: AbortSignal }) {
+ const row = this.db.query("SELECT * FROM http WHERE url=?").get(url) as { bytes: Uint8Array; expires: number; etag: string; modified: string } | null;
+ const now = Date.now();
+ if (row && row.expires > now && row.bytes.byteLength <= options.maxBytes) {
+ this.hits++; this.db.query("UPDATE http SET touched=? WHERE url=?").run(now, url); return row.bytes;
+ }
+ const headers: Record = { "User-Agent": "PocketMap/0.1 (+https://github.com/pocket-stack/pocket-map)", "Accept": "application/vnd.mapbox-vector-tile,application/x-protobuf,image/png,application/json" };
+ if (row?.etag) headers["If-None-Match"] = row.etag;
+ if (row?.modified) headers["If-Modified-Since"] = row.modified;
+ // Bun's redirect:error rejects 304 as well. Manual mode preserves cache
+ // revalidation; the status checks below still reject actual redirects.
+ const response = await this.network(url, { headers, redirect: "manual", signal: options.signal ?? AbortSignal.timeout(6500) });
+ const cacheControl = response.headers.get("cache-control") ?? "";
+ const maxAge = /(?:^|,)\s*max-age=(\d+)/i.exec(cacheControl);
+ const expiresAt = Date.parse(response.headers.get("expires") ?? "");
+ const age = Number(response.headers.get("age") ?? 0) || 0;
+ const ttl = maxAge ? Math.max(0, Number(maxAge[1]) - age) * 1000 : Number.isFinite(expiresAt) ? Math.max(0, expiresAt - now) : options.ttl;
+ let bytes: Uint8Array;
+ if (response.status === 304 && row) bytes = row.bytes;
+ else {
+ if (!response.ok) throw new Error(`Map service returned HTTP ${response.status}`);
+ if (Number(response.headers.get("content-length") ?? 0) > options.maxBytes) { await response.body?.cancel(); throw new Error("Map response exceeds budget"); }
+ const reader = response.body?.getReader(); if (!reader) throw new Error("Empty map response");
+ const parts: Uint8Array[] = []; let size = 0;
+ try {
+ while (true) {
+ const part = await reader.read(); if (part.done) break;
+ size += part.value.byteLength;
+ if (size > options.maxBytes) { await reader.cancel(); throw new Error("Map response exceeds budget"); }
+ parts.push(part.value);
+ }
+ } finally { reader.releaseLock(); }
+ bytes = new Uint8Array(size); let at = 0; for (const part of parts) { bytes.set(part, at); at += part.length; }
+ this.downloads++;
+ }
+ if (/no-store/i.test(cacheControl)) this.db.query("DELETE FROM http WHERE url=?").run(url);
+ else {
+ this.db.query("INSERT OR REPLACE INTO http VALUES (?,?,?,?,?,?)").run(url, bytes, now + (/no-cache/i.test(cacheControl) ? 0 : ttl), response.headers.get("etag") ?? row?.etag ?? null, response.headers.get("last-modified") ?? row?.modified ?? null, now);
+ this.db.query("DELETE FROM http WHERE url IN (SELECT url FROM http ORDER BY touched DESC, url LIMIT -1 OFFSET ?)").run(this.maxEntries);
+ }
+ return bytes;
+ }
+ expires(url: string) { return (this.db.query("SELECT expires FROM http WHERE url=?").get(url) as { expires: number } | null)?.expires ?? 0; }
+ close() { this.db.close(); }
+}
diff --git a/host/config.ts b/host/config.ts
new file mode 100644
index 0000000..a867d07
--- /dev/null
+++ b/host/config.ts
@@ -0,0 +1,5 @@
+export interface ProviderConfig { tileURL: string; format?: "raster" | "vector"; dataZoom?: number; searchURL: string; name: string; attribution: string; maxZoom: number; cache: string; kind?: "osm" | "hyrule"; atlas?: string }
+export const defaultConfig: ProviderConfig = {
+ tileURL: "https://tiles.versatiles.org/tiles/osm/{z}/{x}/{y}", format: "vector", dataZoom: 14, searchURL: "https://photon.komoot.io/api/",
+ name: "OpenStreetMap", attribution: "OpenStreetMap contributors", maxZoom: 18, cache: ".local/cache.sqlite",
+};
diff --git a/host/markers.ts b/host/markers.ts
new file mode 100644
index 0000000..205ec91
--- /dev/null
+++ b/host/markers.ts
@@ -0,0 +1,74 @@
+import { Database } from "bun:sqlite";
+import { existsSync, renameSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { HYRULE_REVISION } from "./atlas-format.ts";
+import { MARKER_KINDS, type MarkerInput, type MapMarker, type MarkerKind } from "../shared/types.ts";
+
+const VERSION = `markers-v1/${HYRULE_REVISION}`;
+const classify = (file: string, group: string): MarkerKind => file === "seeds" ? "seed" : file === "treasures" ? "treasure"
+ : group === "Sheikah Tower" ? "tower" : group === "Shrine" ? "shrine" : group === "Stable" ? "stable"
+ : /Village|Settlement|Lab/.test(group) ? "village" : /Talus|Hinox|Lynel|Molduga|Guardian/.test(group) ? "enemy"
+ : /Memory|Fairy|Statue/.test(group) ? "special" : "landmark";
+
+/** A separate small index can be refreshed without rebaking 592 MB of pixels. */
+export async function prepareMarkers(directory: string, source: string) {
+ const output = join(directory, "markers.sqlite");
+ if (existsSync(output)) {
+ const old = new Database(output, { readonly: true });
+ const row = old.query("SELECT version,count FROM metadata").get() as { version: string; count: number } | null; old.close();
+ if (row?.version === VERSION) return row.count;
+ }
+ const pending = join(directory, "markers.build.sqlite"); if (existsSync(pending)) rmSync(pending);
+ const db = new Database(pending);
+ db.exec(`CREATE TABLE metadata(version TEXT,count INTEGER);
+ CREATE TABLE markers(id INTEGER PRIMARY KEY,name TEXT,kind TEXT,x REAL,y REAL,minZoom INTEGER,maxZoom INTEGER,priority INTEGER);
+ CREATE VIRTUAL TABLE bounds USING rtree(id,x0,x1,y0,y1);`);
+ const insert = db.query("INSERT INTO markers VALUES (?,?,?,?,?,?,?,?)"), bounds = db.query("INSERT INTO bounds VALUES (?,?,?,?,?)");
+ let count = 0; const seen = new Set();
+ try {
+ for (const file of ["locations", "pins", "seeds", "treasures"]) {
+ const groups = await Bun.file(join(source, `markers/${file}.json`)).json();
+ db.transaction(() => {
+ for (const group of groups) for (const layer of group.layers ?? []) for (const marker of layer.markers ?? []) {
+ if (!Array.isArray(marker.coords) || marker.coords.length !== 2 || !marker.coords.every(Number.isFinite)) continue;
+ const [north, east] = marker.coords, x = (east + 12000) / 24000 * 256, y = (12000 - north) / 24000 * 256;
+ if (x < 0 || x >= 256 || y < 0 || y >= 256) continue;
+ const kind = classify(file, group.name), label = marker.name ?? `${kind === "seed" ? "Korok" : group.name} ${marker.id ?? ""}`;
+ const name = String(label).replace(/<[^>]+>/g, "").replace(/[^\x20-\x7e]/g, "'").slice(0, 24);
+ const key = `${kind}/${x}/${y}/${name}`; if (seen.has(key)) continue; seen.add(key);
+ const min = group.name === "Region" ? 0 : group.name === "Subregion" ? 2 : kind === "tower" ? 2
+ : kind === "seed" || kind === "treasure" ? 6 : kind === "enemy" ? 5 : kind === "landmark" ? 4 : 3;
+ const max = group.name === "Region" ? 2 : group.name === "Subregion" ? 4 : 7;
+ const priority = kind === "tower" ? 0 : kind === "village" ? 1 : kind === "shrine" ? 2 : kind === "stable" ? 3 : kind === "landmark" ? 4 : 5;
+ insert.run(++count, name, kind, x, y, min, max, priority); bounds.run(count, x, x, y, y);
+ }
+ })();
+ }
+ db.query("INSERT INTO metadata VALUES (?,?)").run(VERSION, count); db.close(); renameSync(pending, output); return count;
+ } catch (error) { db.close(); throw error; }
+}
+export class MarkerIndex {
+ private db: Database;
+ constructor(path: string) { this.db = new Database(path, { readonly: true }); }
+ query(input: MarkerInput): MapMarker[] {
+ const { z, x, y, layer } = input, cells = Math.max(1, 2 ** (z - 1));
+ if (![z, x, y].every(Number.isInteger) || z < 0 || z > 7 || x < 0 || y < 0 || x >= cells || y >= cells || !["all", "travel", "collectibles", "enemies", "off"].includes(layer)) throw new Error("Invalid marker window");
+ if (layer === "off") return [];
+ const kinds = layer === "travel" ? ["tower", "shrine", "stable", "village", "landmark", "special"] : layer === "collectibles" ? ["seed", "treasure", "special"] : layer === "enemies" ? ["enemy"] : MARKER_KINDS;
+ const size = 512 / 2 ** z, x0 = x * size, y0 = y * size;
+ const rows = this.db.query(`SELECT m.id,m.name,m.kind,m.x,m.y FROM bounds b JOIN markers m ON m.id=b.id
+ WHERE b.x1>=? AND b.x0 AND b.y1>=? AND b.y0 AND m.x>=? AND m.x AND m.y>=? AND m.y AND minZoom<=? AND maxZoom>=?
+ AND kind IN (${kinds.map(() => "?").join(",")}) ORDER BY m.priority,m.id LIMIT 256`).all(x0, x0 + size, y0, y0 + size, x0, x0 + size, y0, y0 + size, z, z, ...kinds) as { id: number; name: string; kind: MarkerKind; x: number; y: number }[];
+ // Labels occupy stable global grid slots; adjacent windows use the same grid.
+ // This bounds density without relaying a viewport-sized layout every frame.
+ const occupied = new Set(), result: MapMarker[] = [];
+ for (const r of rows) {
+ const cell = `${Math.floor(r.x * 2 ** z / 128)}/${Math.floor(r.y * 2 ** z / 32)}`;
+ if (occupied.has(cell)) continue; occupied.add(cell);
+ result.push([r.id, r.name, r.kind, +r.x.toFixed(5), +r.y.toFixed(5)]);
+ if (result.length === 12) break;
+ }
+ return result;
+ }
+ close() { this.db.close(); }
+}
diff --git a/host/provider.ts b/host/provider.ts
new file mode 100644
index 0000000..cabf70b
--- /dev/null
+++ b/host/provider.ts
@@ -0,0 +1,317 @@
+import { createHash } from "node:crypto";
+import { createCanvas, loadImage } from "@napi-rs/canvas";
+import type { OffloadImage, OffloadMesh } from "@pocketjs/framework/offload/provider";
+import { prepareVector, labelsInWindow, type PreparedTile } from "./vector-geometry.ts";
+import { HttpCache, type NetworkFetch } from "./cache.ts";
+import { Bookmarks } from "./bookmarks.ts";
+import { join, dirname } from "node:path";
+import type { MapInfo, Place, SearchInput, TileInput } from "../shared/types.ts";
+
+import type { ProviderConfig } from "./config.ts";
+export { defaultConfig, type ProviderConfig } from "./config.ts";
+export function packRGB(rgba: Uint8ClampedArray | Uint8Array, width: number, height: number): OffloadImage {
+ if (rgba.byteLength !== width * height * 4) throw new Error("Invalid pixel plane");
+ const pixels = new Uint8Array(width * height * 2);
+ for (let i = 0; i < width * height; i++) {
+ const alpha = rgba[i * 4 + 3] / 255;
+ const r = Math.round(rgba[i * 4] * alpha + 255 * (1 - alpha));
+ const g = Math.round(rgba[i * 4 + 1] * alpha + 255 * (1 - alpha));
+ const b = Math.round(rgba[i * 4 + 2] * alpha + 255 * (1 - alpha));
+ const rgb = (r >> 3) | ((g >> 2) << 5) | ((b >> 3) << 11);
+ pixels[i * 2] = rgb & 255;
+ pixels[i * 2 + 1] = rgb >> 8;
+ }
+ return { width, height, pixels, format: "r5g6b5" };
+}
+export class MapProvider {
+ readonly info: MapInfo;
+ readonly cache: HttpCache;
+ readonly bookmarks: Bookmarks;
+ private decoded = new Map();
+ private loading = new Map>();
+ private vectors = new Map();
+ private preparing = new Map>();
+ private meshBytes = 0;
+ private searching = false;
+ private nextSearch = 0;
+ constructor(
+ readonly config: ProviderConfig,
+ network: NetworkFetch = fetch,
+ ) {
+ for (const url of [config.tileURL, config.searchURL])
+ if (new URL(url).protocol !== "https:" && !/^http:\/\/(localhost|127\.0\.0\.1)(:|\/)/.test(url))
+ throw new Error("Use HTTPS or a local provider");
+ if (
+ !["{z}", "{x}", "{y}"].every((part) => config.tileURL.includes(part)) ||
+ !Number.isInteger(config.maxZoom) ||
+ config.maxZoom < 1 ||
+ config.maxZoom > 18
+ )
+ throw new Error("Invalid tile source");
+ if (
+ config.format === "vector" &&
+ (!Number.isInteger(config.dataZoom ?? 14) || (config.dataZoom ?? 14) < 0 || (config.dataZoom ?? 14) > 14)
+ )
+ throw new Error("Invalid vector source zoom");
+ this.info = {
+ source: createHash("sha256")
+ .update(JSON.stringify([config.tileURL, config.format === "vector" ? "mesh-shortbread-v1" : "r5g6b5-v1"]))
+ .digest("hex")
+ .slice(0, 16),
+ name: config.name.slice(0, 40),
+ attribution: config.attribution.slice(0, 100),
+ maxZoom: config.maxZoom,
+ ...(config.format === "vector"
+ ? {
+ render: "mesh" as const,
+ dataZoom: config.dataZoom ?? 14,
+ markers: true,
+ prefetch: new URL(config.tileURL).hostname !== "vector.openstreetmap.org",
+ }
+ : {}),
+ };
+ this.cache = new HttpCache(config.cache, network);
+ this.bookmarks = new Bookmarks(
+ config.cache === ":memory:" ? ":memory:" : join(dirname(config.cache), "places.sqlite"),
+ );
+ }
+ methods() {
+ return {
+ "map.info": () => JSON.stringify(this.info),
+ "map.mesh": (raw: string) => this.mesh(JSON.parse(raw)),
+ "map.markers": async (raw: string) => JSON.stringify(await this.markerRows(JSON.parse(raw))),
+ "map.tile": (raw: string) => this.tile(JSON.parse(raw)),
+ "map.search": async (raw: string) => JSON.stringify(await this.search(JSON.parse(raw))),
+ "map.label": (raw: string) => this.label(JSON.parse(raw)),
+ "bookmarks.list": (raw: string) => JSON.stringify(this.bookmarks.list(JSON.parse(raw).offset)),
+ "bookmarks.command": (raw: string) => this.bookmarks.command(JSON.parse(raw)),
+ };
+ }
+ tile(input: TileInput): Promise {
+ if (this.config.format === "vector") return Promise.reject(new Error("Use map.mesh for this vector source"));
+ const { source, z, x, y } = input;
+ if (
+ source !== this.info.source ||
+ ![z, x, y].every(Number.isInteger) ||
+ z < 0 ||
+ z > this.config.maxZoom ||
+ x < 0 ||
+ x >= 2 ** z ||
+ y < 0 ||
+ y >= 2 ** z
+ )
+ return Promise.reject(new Error("Invalid tile address or source"));
+ const key = `${z}/${x}/${y}`,
+ hit = this.decoded.get(key);
+ if (hit && hit.expires > Date.now()) {
+ this.decoded.delete(key);
+ this.decoded.set(key, hit);
+ return Promise.resolve(hit.image);
+ }
+ const pending = this.loading.get(key);
+ if (pending) return pending;
+ if (this.loading.size >= 3) return Promise.reject(new Error("Tile decode budget exhausted"));
+ const url = this.config.tileURL.replace("{z}", String(z)).replace("{x}", String(x)).replace("{y}", String(y));
+ const work = this.decode(url)
+ .then((image) => {
+ this.decoded.set(key, { image, expires: this.cache.expires(url) });
+ if (this.decoded.size > 64) this.decoded.delete(this.decoded.keys().next().value!);
+ return image;
+ })
+ .finally(() => this.loading.delete(key));
+ this.loading.set(key, work);
+ return work;
+ }
+ private vector(input: TileInput): Promise {
+ const { source, z, x, y } = input;
+ if (
+ this.config.format !== "vector" ||
+ source !== this.info.source ||
+ ![z, x, y].every(Number.isInteger) ||
+ z < 0 ||
+ z > (this.config.dataZoom ?? 14) ||
+ x < 0 ||
+ y < 0 ||
+ x >= 2 ** z ||
+ y >= 2 ** z
+ )
+ return Promise.reject(new Error("Invalid vector tile address or source"));
+ const key = `${z}/${x}/${y}`,
+ hit = this.vectors.get(key);
+ if (hit && hit.expires > Date.now()) {
+ this.vectors.delete(key);
+ this.vectors.set(key, hit);
+ return Promise.resolve(hit.value);
+ }
+ const pending = this.preparing.get(key);
+ if (pending) return pending;
+ if (this.preparing.size >= 3) return Promise.reject(new Error("Vector preparation budget exhausted"));
+ const url = this.config.tileURL.replace("{z}", String(z)).replace("{x}", String(x)).replace("{y}", String(y));
+ const work = this.cache
+ .get(url, { maxBytes: 2 * 1024 * 1024, ttl: 7 * 86400_000 })
+ .then((bytes) => {
+ const value = prepareVector(bytes, z);
+ this.meshBytes += value.mesh.bytes.length;
+ this.vectors.set(key, { value, expires: this.cache.expires(url) });
+ if (this.vectors.size > 128) this.vectors.delete(this.vectors.keys().next().value!);
+ return value;
+ })
+ .finally(() => this.preparing.delete(key));
+ this.preparing.set(key, work);
+ return work;
+ }
+ async mesh(input: TileInput): Promise {
+ return (await this.vector(input)).mesh;
+ }
+ async markerRows(input: TileInput & { layer?: string }) {
+ if (this.config.format !== "vector" || input.layer === "off") return [];
+ if (
+ ![input.z, input.x, input.y].every(Number.isInteger) ||
+ input.z < 0 ||
+ input.z > this.info.maxZoom ||
+ input.x < 0 ||
+ input.y < 0 ||
+ input.x >= 2 ** input.z ||
+ input.y >= 2 ** input.z
+ )
+ throw new Error("Invalid label window");
+ const dataZoom = Math.min(input.z, this.config.dataZoom ?? 14),
+ factor = 2 ** (input.z - dataZoom),
+ x = Math.floor(input.x / factor),
+ y = Math.floor(input.y / factor);
+ const tile = await this.vector({ ...input, z: dataZoom, x, y }),
+ scale = 2 ** dataZoom,
+ size = 256 / factor;
+ const labels = labelsInWindow(tile, input.z, (input.x % factor) * size, (input.y % factor) * size, size);
+ return labels.map((l, i) => [
+ input.y * 2 ** input.z * 16 + input.x * 16 + i,
+ l.name,
+ "landmark",
+ (x * 256 + l.x) / scale,
+ (y * 256 + l.y) / scale,
+ ]);
+ }
+
+ private async decode(url: string) {
+ const bytes = await this.cache.get(url, { maxBytes: 512 * 1024, ttl: 7 * 86400_000 });
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ if (
+ bytes.length < 24 ||
+ view.getUint32(0) !== 0x89504e47 ||
+ view.getUint32(4) !== 0x0d0a1a0a ||
+ view.getUint32(12) !== 0x49484452 ||
+ view.getUint32(16) !== 256 ||
+ view.getUint32(20) !== 256
+ )
+ throw new Error("Expected a 256px PNG tile");
+ const image = await loadImage(Buffer.from(bytes));
+ if (image.width !== 256 || image.height !== 256) throw new Error("Invalid decoded tile size");
+ const canvas = createCanvas(256, 256),
+ ctx = canvas.getContext("2d");
+ ctx.drawImage(image, 0, 0);
+ return packRGB(ctx.getImageData(0, 0, 256, 256).data, 256, 256);
+ }
+ async search(input: SearchInput): Promise {
+ if (
+ input.space === "planar" ||
+ typeof input.query !== "string" ||
+ input.query.length > 80 ||
+ !Number.isFinite(input.lat) ||
+ Math.abs(input.lat) > 90 ||
+ !Number.isFinite(input.lon) ||
+ Math.abs(input.lon) > 180
+ )
+ throw new Error("Invalid place search");
+ const query = input.query.trim();
+ if (!query) return [];
+ if (this.searching) throw new Error("A search is already running");
+ this.searching = true;
+ try {
+ // Explicit submits only, at most one outgoing search per second.
+ await Bun.sleep(Math.max(0, this.nextSearch - Date.now()));
+ this.nextSearch = Date.now() + 1100;
+ const url = new URL(this.config.searchURL);
+ url.searchParams.set("q", query);
+ url.searchParams.set("limit", "5");
+ url.searchParams.set("lang", "en");
+ url.searchParams.set("lat", input.lat.toFixed(1));
+ url.searchParams.set("lon", input.lon.toFixed(1));
+ const bytes = await this.cache.get(url.toString(), { maxBytes: 64 * 1024, ttl: 86400_000 });
+ const result = JSON.parse(new TextDecoder().decode(bytes));
+ if (!Array.isArray(result.features)) throw new Error("Invalid search response");
+ const seen = new Set(),
+ places: Place[] = [];
+ for (const feature of result.features.slice(0, 5)) {
+ const p = feature.properties,
+ xy = feature.geometry?.coordinates;
+ if (
+ !p ||
+ typeof p.name !== "string" ||
+ !Array.isArray(xy) ||
+ !xy.slice(0, 2).every(Number.isFinite) ||
+ Math.abs(xy[0]) > 180 ||
+ Math.abs(xy[1]) > 90
+ )
+ continue;
+ const id = `${String(p.osm_type).slice(0, 1)}${String(p.osm_id).slice(0, 20)}`;
+ if (seen.has(id)) continue;
+ seen.add(id);
+ const detail = [
+ ...new Set([p.street, p.city, p.state, p.country].filter((n) => typeof n === "string" && n !== p.name)),
+ ]
+ .join(", ")
+ .slice(0, 60);
+ places.push({
+ id,
+ name: p.name.slice(0, 36),
+ detail,
+ lon: xy[0],
+ lat: xy[1],
+ zoom: ["city", "town", "village", "state", "country"].includes(p.type) ? (p.type === "country" ? 5 : 13) : 16,
+ });
+ }
+ return places;
+ } finally {
+ this.searching = false;
+ }
+ }
+ label(input: { name: string; detail: string }) {
+ return renderLabel(input);
+ }
+ diagnostics() {
+ return {
+ httpHits: this.cache.hits,
+ downloads: this.cache.downloads,
+ meshBytes: this.meshBytes,
+ prepared: this.vectors.size,
+ };
+ }
+ close() {
+ this.cache.close();
+ this.bookmarks.close();
+ }
+}
+
+export function renderLabel(input: { name: string; detail: string }) {
+ if (
+ typeof input.name !== "string" ||
+ input.name.length > 80 ||
+ typeof input.detail !== "string" ||
+ input.detail.length > 120
+ )
+ throw new Error("Invalid label");
+ const canvas = createCanvas(256, 32),
+ c = canvas.getContext("2d");
+ c.fillStyle = "#f7f9fc";
+ c.fillRect(0, 0, 256, 32);
+ const family = /[\u3000-\u9fff]/.test(input.name + input.detail)
+ ? '"Hiragino Sans GB", "Noto Sans CJK SC", sans-serif'
+ : "Arial, sans-serif";
+ c.fillStyle = "#263748";
+ c.font = `bold 13px ${family}`;
+ c.fillText(input.name, 5, 13, 246);
+ c.fillStyle = "#627387";
+ c.font = `11px ${family}`;
+ c.fillText(input.detail, 5, 28, 246);
+ return packRGB(c.getImageData(0, 0, 256, 32).data, 256, 32);
+}
diff --git a/host/serve-usb.ts b/host/serve-usb.ts
new file mode 100644
index 0000000..d98fcf8
--- /dev/null
+++ b/host/serve-usb.ts
@@ -0,0 +1,26 @@
+import { resolve } from "node:path";
+import { connectOffloadUsbProvider } from "@pocketjs/framework/offload/provider";
+import { defaultConfig } from "./config.ts";
+const root = resolve(import.meta.dir, "..");
+const overrides = await Bun.file(resolve(root, ".local/provider.json"))
+ .json()
+ .catch(() => ({}));
+const config = {
+ ...defaultConfig,
+ ...overrides,
+ cache: resolve(root, ".local/cache.sqlite"),
+ atlas: resolve(root, ".local/hyrule"),
+ kind: process.argv.includes("--hyrule") ? "hyrule" : "osm",
+};
+const provider = connectOffloadUsbProvider({
+ directory: resolve(root, "dist/psplink"),
+ app: "dev.pocket-stack.map",
+ worker: new URL("./worker.ts", import.meta.url),
+ data: config,
+ log: (message: string) => console.log(new Date().toISOString(), message),
+});
+for (const signal of ["SIGINT", "SIGTERM"] as const)
+ process.on(signal, () => {
+ provider.close();
+ process.exit(0);
+ });
diff --git a/host/serve.ts b/host/serve.ts
new file mode 100644
index 0000000..53f5c28
--- /dev/null
+++ b/host/serve.ts
@@ -0,0 +1,16 @@
+import { resolve } from "node:path";
+import { connectOffloadProvider } from "@pocketjs/framework/offload/provider";
+import { defaultConfig } from "./config.ts";
+const root = resolve(import.meta.dir, "..");
+const address = process.argv.slice(2).find(arg => !arg.startsWith("--")) ?? "192.168.8.102";
+const configPath = resolve(root, ".local/provider.json");
+const overrides = await Bun.file(configPath).exists() ? await Bun.file(configPath).json() : {};
+const config = { ...defaultConfig, ...overrides, format: overrides.format ?? (overrides.tileURL?.endsWith(".png") ? "raster" : defaultConfig.format), cache: resolve(root, ".local/cache.sqlite") };
+config.kind = process.argv.includes("--osm") ? "osm" : process.argv.includes("--hyrule") ? "hyrule" : config.kind ?? "hyrule";
+config.atlas = resolve(root, ".local/hyrule");
+if (config.kind === "hyrule" && !await Bun.file(resolve(config.atlas, "atlas.sqlite")).exists()) throw new Error("Run bun run prepare:hyrule before starting the Hyrule host");
+const key = (await Bun.file(resolve(root, ".local/pair.key")).text()).trim();
+connectOffloadProvider({ address, key, worker: new URL("./worker.ts", import.meta.url), isolation: "process", data: config,
+ trace: process.env.POCKET_MAP_TRACE === "1",
+ log: message => console.log(new Date().toISOString(), message) });
+console.log(`Pocket Map: ${config.kind === "hyrule" ? "Hyrule (local atlas)" : config.name} -> ${address}; ${config.kind === "hyrule" ? config.atlas : config.cache}`);
diff --git a/host/service.ts b/host/service.ts
new file mode 100644
index 0000000..5ed71b6
--- /dev/null
+++ b/host/service.ts
@@ -0,0 +1,41 @@
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { MapProvider } from "./provider.ts";
+import { AtlasProvider } from "./atlas.ts";
+import type { ProviderConfig } from "./config.ts";
+import type { NetworkFetch } from "./cache.ts";
+import type { MapKind } from "../shared/types.ts";
+
+/** Selection is in each request, never mutable connection-wide state. Old
+ * reads and lost command acknowledgements cannot cross databases on a switch. */
+export class MapService {
+ private providers = new Map();
+ private selected: MapKind;
+ constructor(config: ProviderConfig, network?: NetworkFetch) {
+ this.providers.set("osm", new MapProvider(config, network));
+ if (config.atlas && existsSync(join(config.atlas, "atlas.sqlite"))) this.providers.set("hyrule", new AtlasProvider(config.atlas));
+ this.selected = config.kind ?? "osm";
+ if (!this.providers.has(this.selected)) throw new Error("Prepare the Hyrule atlas first");
+ }
+ private resolve(source?: string) {
+ if (source === undefined) return this.providers.get(this.selected)!; // Existing guest compatibility.
+ for (const p of this.providers.values()) if (p.info.source === source) return p;
+ throw new Error("Unknown map source");
+ }
+ methods() { return {
+ "map.info": (raw: string) => {
+ const kind = JSON.parse(raw).kind ?? this.selected, p = this.providers.get(kind);
+ if (!p) throw new Error("Map is not installed on your Mac");
+ return JSON.stringify({ ...p.info, kind, maps: [...this.providers].map(([kind, p]) => ({ kind, name: p.info.name })) });
+ },
+ "map.mesh": (raw:string) => {const v=JSON.parse(raw),p=this.resolve(v.source);if(!(p instanceof MapProvider))throw new Error("This source uses raster tiles");return p.mesh(v);},
+ "map.tile": (raw: string) => { const v = JSON.parse(raw); return this.resolve(v.source).tile(v); },
+ "map.search": async (raw: string) => { const v = JSON.parse(raw); return JSON.stringify(await this.resolve(v.source).search(v)); },
+ "map.label": (raw: string) => this.resolve().methods()["map.label"](raw),
+ "map.markers": async (raw: string) => { const v = JSON.parse(raw), p = this.resolve(v.source); return JSON.stringify(await p.markerRows(v)); },
+ "bookmarks.list": (raw: string) => { const v = JSON.parse(raw); return JSON.stringify(this.resolve(v.source).bookmarks.list(v.offset)); },
+ "bookmarks.command": (raw: string) => { const v = JSON.parse(raw); return this.resolve(v.source).bookmarks.command(v); },
+ }; }
+ diagnostics() { return Object.fromEntries([...this.providers].map(([key, p]) => [key, p.diagnostics()])); }
+ close() { for (const p of this.providers.values()) p.close(); }
+}
diff --git a/host/vector-geometry.ts b/host/vector-geometry.ts
new file mode 100644
index 0000000..3a063e7
--- /dev/null
+++ b/host/vector-geometry.ts
@@ -0,0 +1,544 @@
+import { VectorTile, classifyRings, type VectorTileFeature } from "@mapbox/vector-tile";
+import { PbfReader } from "pbf";
+import earcut from "earcut";
+import { prepareMesh, type OffloadMesh } from "@pocketjs/framework/offload/provider";
+export type Point = { x: number; y: number };
+export interface VectorLabel {
+ name: string;
+ x: number;
+ y: number;
+ rank: number;
+ maxZoom: number;
+ path?: Point[];
+}
+export interface PreparedTile {
+ mesh: OffloadMesh;
+ labels: VectorLabel[];
+ triangles: number;
+ vertices: number;
+ simplified: number;
+}
+const color = (hex: number) => (0xff000000 | ((hex & 255) << 16) | (hex & 0xff00) | (hex >>> 16)) >>> 0;
+const LAND = color(0xf0ede3),
+ WATER = color(0xa6cddd),
+ PARK = color(0xc0d4ab),
+ BUILDING = color(0xd8d2c6),
+ ROAD = color(0xfffdf7),
+ EDGE = color(0xc7bfae),
+ MAIN = color(0xf5cf83);
+/** Quantize shared coordinates once; clip triangles AFTER tessellation so holes
+ * retain their topology, including polygons crossing the tile boundary. */
+export class MeshBuilder {
+ points: [number, number][] = [];
+ faces: [number, number, number, number][] = [];
+ priority = 0;
+ private indices = new Map();
+ triangle(a: Point, b: Point, c: Point, fill: number) {
+ let poly = [a, b, c];
+ for (const [axis, bound, le] of [
+ ["x", 0, false],
+ ["x", 256, true],
+ ["y", 0, false],
+ ["y", 256, true],
+ ] as const) {
+ const next: Point[] = [];
+ for (let i = 0; i < poly.length; i++) {
+ const p = poly[i],
+ q = poly[(i + 1) % poly.length],
+ d = p[axis] - bound,
+ e = q[axis] - bound,
+ ip = le ? d <= 0 : d >= 0,
+ iq = le ? e <= 0 : e >= 0;
+ if (ip) next.push(p);
+ if (ip !== iq) {
+ const t = d / (d - e);
+ next.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t });
+ }
+ }
+ poly = next;
+ if (poly.length < 3) return;
+ }
+ const ids = poly.map((p) => {
+ const x = Math.max(0, Math.min(4096, Math.round(p.x * 16))),
+ y = Math.max(0, Math.min(4096, Math.round(p.y * 16))),
+ key = `${x}/${y}`;
+ let id = this.indices.get(key);
+ if (id === undefined) {
+ id = this.points.length;
+ this.points.push([x, y]);
+ this.indices.set(key, id);
+ }
+ return id;
+ });
+ for (let i = 1; i < ids.length - 1; i++) {
+ const [p, q, r] = [this.points[ids[0]], this.points[ids[i]], this.points[ids[i + 1]]];
+ if ((q[0] - p[0]) * (r[1] - p[1]) !== (q[1] - p[1]) * (r[0] - p[0]))
+ this.faces.push([ids[0], ids[i], ids[i + 1], fill]);
+ }
+ }
+ polygon(rings: Point[][], fill: number) {
+ const coords: number[] = [],
+ holes: number[] = [];
+ for (const ring of rings) {
+ if (coords.length) holes.push(coords.length / 2);
+ for (const p of ring) coords.push(p.x, p.y);
+ }
+ const indices = earcut(coords, holes, 2);
+ for (let i = 0; i < indices.length; i += 3) {
+ const p = (n: number) => ({ x: coords[n * 2], y: coords[n * 2 + 1] });
+ this.triangle(p(indices[i]), p(indices[i + 1]), p(indices[i + 2]), fill);
+ }
+ }
+ line(points: Point[], width: number, fill: number) {
+ // Bounded miter joins share vertices; no gaps at segment junctions.
+ if (points.length < 2) return;
+ const left: Point[] = [],
+ right: Point[] = [];
+ const normal = (a: Point, b: Point) => {
+ const dx = b.x - a.x,
+ dy = b.y - a.y,
+ n = Math.hypot(dx, dy) || 1;
+ return { x: -dy / n, y: dx / n };
+ };
+ for (let i = 0; i < points.length; i++) {
+ const a = normal(points[Math.max(0, i - 1)], points[i]),
+ b = normal(points[i], points[Math.min(i + 1, points.length - 1)]);
+ let nx = a.x + b.x,
+ ny = a.y + b.y;
+ const len = Math.hypot(nx, ny) || 1;
+ nx /= len;
+ ny /= len;
+ const dot = Math.abs(nx * (i === 0 ? b.x : a.x) + ny * (i === 0 ? b.y : a.y));
+ const half = (width * 0.5) / Math.max(0.5, dot);
+ left.push({ x: points[i].x + nx * half, y: points[i].y + ny * half });
+ right.push({ x: points[i].x - nx * half, y: points[i].y - ny * half });
+ }
+ for (let i = 1; i < points.length; i++) {
+ this.triangle(left[i - 1], right[i - 1], left[i], fill);
+ this.triangle(right[i - 1], right[i], left[i], fill);
+ }
+ }
+ /** Admit a complete feature or leave the builder unchanged. */
+ append(other: MeshBuilder): boolean {
+ if (!other.faces.length) return true;
+ if (this.faces.length + other.faces.length > 2048) return false;
+ const fresh: [number, number][] = [], remap: number[] = [];
+ for (const point of other.points) {
+ const existing = this.indices.get(`${point[0]}/${point[1]}`);
+ remap.push(existing ?? this.points.length + fresh.length);
+ if (existing === undefined) fresh.push(point);
+ }
+ if (this.points.length + fresh.length > 4096) return false;
+ for (const point of fresh) {
+ this.indices.set(`${point[0]}/${point[1]}`, this.points.length);
+ this.points.push(point);
+ }
+ for (const [a, b, c, fill] of other.faces) this.faces.push([remap[a], remap[b], remap[c], fill]);
+ return true;
+ }
+ entry(): OffloadMesh {
+ return prepareMesh({
+ width: 256,
+ height: 256,
+ vertices: this.points.map(([x, y]) => [x / 16, y / 16]),
+ triangles: this.faces,
+ });
+ }
+}
+
+/** Last detail pass: choose complete features, then restore painter order.
+ * Geographic areas outrank decoration; long major roads outrank short links.
+ * This guarantees a bounded mesh even when a dense source survives simplification. */
+class BudgetMeshBuilder extends MeshBuilder {
+ private features: { mesh: MeshBuilder; priority: number; order: number }[] = [];
+ override polygon(rings: Point[][], fill: number) {
+ const mesh = new MeshBuilder();
+ mesh.polygon(rings, fill);
+ this.features.push({ mesh, priority: this.priority, order: this.features.length });
+ }
+ override line(points: Point[], width: number, fill: number) {
+ const mesh = new MeshBuilder();
+ mesh.line(points, width, fill);
+ let length = 0;
+ for (let i = 1; i < points.length; i++)
+ length += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
+ this.features.push({ mesh, priority: this.priority + length, order: this.features.length });
+ }
+ finish(): MeshBuilder {
+ const budget = new MeshBuilder();
+ const selected = this.features.sort((a, b) => b.priority - a.priority || a.order - b.order)
+ .filter((feature) => budget.append(feature.mesh));
+ const result = new MeshBuilder();
+ for (const feature of selected.sort((a, b) => a.order - b.order)) result.append(feature.mesh);
+ return result;
+ }
+}
+function simplify(points: Point[], epsilon: number): Point[] {
+ if (points.length <= 3) return points;
+ // Iterative Douglas-Peucker: host work, no recursion proportional to geometry.
+ const keep = new Uint8Array(points.length);
+ keep[0] = keep[points.length - 1] = 1;
+ const work: [number, number][] = [[0, points.length - 1]];
+ while (work.length) {
+ const [a, b] = work.pop()!,
+ p = points[a],
+ q = points[b],
+ dx = q.x - p.x,
+ dy = q.y - p.y,
+ len = dx * dx + dy * dy;
+ let best = epsilon * epsilon,
+ at = -1;
+ for (let i = a + 1; i < b; i++) {
+ const t = len ? Math.max(0, Math.min(1, ((points[i].x - p.x) * dx + (points[i].y - p.y) * dy) / len)) : 0;
+ const x = points[i].x - p.x - t * dx,
+ y = points[i].y - p.y - t * dy,
+ d = x * x + y * y;
+ if (d > best) {
+ best = d;
+ at = i;
+ }
+ }
+ if (at >= 0) {
+ keep[at] = 1;
+ work.push([a, at], [at, b]);
+ }
+ }
+ const result = points.filter((_, i) => keep[i]);
+ return result.length >= 3
+ ? result
+ : points.length > 3 && points[0].x === points.at(-1)!.x && points[0].y === points.at(-1)!.y
+ ? points
+ : result;
+}
+function ringArea(ring: Point[]): number {
+ let area = 0;
+ for (let i = 0; i < ring.length; i++) {
+ const a = ring[i], b = ring[(i + 1) % ring.length];
+ area += a.x * b.y - b.x * a.y;
+ }
+ return area / 2;
+}
+type Feature = {
+ layer: string;
+ kind: string;
+ type: number;
+ rings: Point[][];
+ polygons: Point[][][];
+ properties: VectorTileFeature["properties"];
+};
+const AREAS = [
+ "ocean",
+ "water_polygons",
+ "land",
+ "landcover",
+ "sites",
+ "buildings",
+ "street_polygons",
+ "pier_polygons",
+];
+const LINES = ["water_lines", "boundaries", "streets", "pier_lines"];
+
+/** Join degree-two endpoints before simplifying. MVT features often split one
+ * road into hundreds of short links; simplifying each link cannot reduce it.
+ * Junctions remain endpoints, so simplification cannot cut across a branch. */
+export function joinLinePaths(paths: Point[][]): Point[][] {
+ const edges = paths.filter((p) => p.length >= 2);
+ const nodes = new Map();
+ const key = (p: Point) => `${p.x}/${p.y}`;
+ for (const [edge, path] of edges.entries())
+ for (const end of [0, 1]) {
+ const k = key(path[end ? path.length - 1 : 0]);
+ const links = nodes.get(k) ?? [];
+ links.push({ edge, end });
+ nodes.set(k, links);
+ }
+ const used = new Uint8Array(edges.length), result: Point[][] = [];
+ const walk = (edge: number, reverse: boolean) => {
+ const joined: Point[] = [];
+ while (!used[edge]) {
+ used[edge] = 1;
+ const path = edges[edge];
+ for (let j = joined.length ? 1 : 0; j < path.length; j++)
+ joined.push(path[reverse ? path.length - 1 - j : j]);
+ const links = nodes.get(key(joined[joined.length - 1]))!;
+ if (links.length !== 2) break;
+ const next = links.find((link) => !used[link.edge]);
+ if (!next) break;
+ edge = next.edge;
+ reverse = next.end === 1;
+ }
+ result.push(joined);
+ };
+ for (const [edge, path] of edges.entries()) {
+ if (used[edge]) continue;
+ if (nodes.get(key(path[0]))!.length !== 2) walk(edge, false);
+ else if (nodes.get(key(path[path.length - 1]))!.length !== 2) walk(edge, true);
+ }
+ for (let edge = 0; edge < edges.length; edge++) if (!used[edge]) walk(edge, false);
+ return result;
+}
+
+/** At coarse detail, subpixel parallel links and short interchange edges have
+ * the same coverage. Snap to a tile-aligned grid and deduplicate before joining. */
+export function generalizeLinePaths(paths: Point[][], grid: number): Point[][] {
+ const edges = new Map();
+ const snap = (p: Point) => ({ x: Math.round(p.x / grid) * grid, y: Math.round(p.y / grid) * grid });
+ const key = (p: Point) => `${p.x}/${p.y}`;
+ for (const path of paths) {
+ if (!path.length) continue;
+ let a = snap(path[0]);
+ for (let i = 1; i < path.length; i++) {
+ const b = snap(path[i]), ka = key(a), kb = key(b);
+ if (ka !== kb) edges.set(ka < kb ? `${ka}:${kb}` : `${kb}:${ka}`, [a, b]);
+ a = b;
+ }
+ }
+ return joinLinePaths([...edges.values()]);
+}
+
+function joinedLines(features: Feature[], grid: number): Feature[] {
+ const groups = new Map();
+ for (const f of features) {
+ if (!LINES.includes(f.layer)) continue;
+ const key = `${f.layer}/${f.kind}`;
+ let group = groups.get(key);
+ if (!group) {
+ group = { ...f, rings: [] };
+ groups.set(key, group);
+ }
+ for (const ring of f.rings) group.rings.push(ring);
+ }
+ return [...groups.values()].map((f) => ({ ...f, rings: grid ? generalizeLinePaths(f.rings, grid) : joinLinePaths(f.rings) }));
+}
+function roadWidth(kind: string, z: number) {
+ return /motorway|trunk/.test(kind)
+ ? 3.0
+ : /primary|secondary/.test(kind)
+ ? 2.4
+ : /tertiary/.test(kind)
+ ? 1.8
+ : /residential|unclassified|service/.test(kind)
+ ? z >= 13
+ ? 1.1
+ : 0.65
+ : 0.5;
+}
+export function prepareVector(bytes: Uint8Array, z: number): PreparedTile {
+ if (bytes.length > 2 * 1024 * 1024) throw new Error("Vector source body exceeds budget");
+ const tile = new VectorTile(new PbfReader(bytes)),
+ features: Feature[] = [],
+ labels: VectorLabel[] = [];
+ let total = 0;
+ for (const [name, layer] of Object.entries(tile.layers)) {
+ if (![...AREAS, ...LINES, "place_labels", "street_labels", "pois"].includes(name)) continue;
+ if (layer.length > 20000) throw new Error("Vector feature budget exceeded");
+ for (let i = 0; i < layer.length; i++) {
+ const f = layer.feature(i),
+ geometry = f.loadGeometry();
+ total += geometry.reduce((n, r) => n + r.length, 0);
+ if (total > 200000) throw new Error("Vector point budget exceeded");
+ if (!Number.isInteger(f.extent) || f.extent < 1 || f.extent > 65536) throw new Error("Invalid vector extent");
+ const scale = 256 / f.extent,
+ rings = geometry.map((r) => r.map((p) => ({ x: p.x * scale, y: p.y * scale })));
+ if (
+ rings.some((r) =>
+ r.some(
+ (p) => !Number.isFinite(p.x) || !Number.isFinite(p.y) || Math.abs(p.x) > 65536 || Math.abs(p.y) > 65536,
+ ),
+ )
+ )
+ throw new Error("Invalid vector coordinates");
+ const kind = String(f.properties.kind ?? ""),
+ properties = f.properties;
+ if (name.includes("labels") || name === "pois") {
+ const text = String(properties["name:en"] ?? properties.name_en ?? properties.name ?? "")
+ .trim()
+ .slice(0, 24);
+ if (!text) continue;
+ const line = rings.reduce((a, b) => (a.length >= b.length ? a : b), [] as Point[]);
+ if (!line.length) continue;
+ let anchor = line[0];
+ if (line.length > 1) {
+ let length = 0;
+ for (let j = 1; j < line.length; j++)
+ length += Math.hypot(line[j].x - line[j - 1].x, line[j].y - line[j - 1].y);
+ let remaining = length / 2;
+ for (let j = 1; j < line.length; j++) {
+ const a = line[j - 1],
+ b = line[j],
+ d = Math.hypot(b.x - a.x, b.y - a.y);
+ if (d >= remaining) {
+ const t = d ? remaining / d : 0;
+ anchor = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
+ break;
+ }
+ remaining -= d;
+ }
+ }
+ if (name === "street_labels" || anchor.x >= 0 && anchor.x < 256 && anchor.y >= 0 && anchor.y < 256)
+ labels.push({
+ name: text,
+ ...anchor,
+ rank: name === "place_labels" ? 0 : name === "street_labels" ? 2 : 4,
+ maxZoom:
+ name === "place_labels"
+ ? /country|state/.test(kind)
+ ? 10
+ : /city/.test(kind)
+ ? 14
+ : /town/.test(kind)
+ ? 16
+ : 18
+ : 18,
+ path: name === "street_labels" ? simplify(line, 0.15) : undefined,
+ });
+ continue;
+ }
+ const polygons =
+ f.type === 3
+ ? classifyRings(geometry).map((p) => p.map((r) => r.map((q) => ({ x: q.x * scale, y: q.y * scale }))))
+ : [];
+ features.push({ layer: name, kind, type: f.type, rings, polygons, properties });
+ }
+ }
+ // Complete features at successively coarser detail; never cut an arbitrary
+ // triangle prefix which could erase a polygon's holes or half a road.
+ const tolerances = [0.25, 0.5, 1, 2, 3, 5, 8, 12, 16, 20, 24];
+ for (let detail = 0; detail < tolerances.length; detail++) {
+ const drawing = detail === tolerances.length - 1 ? new BudgetMeshBuilder() : new MeshBuilder();
+ drawing.priority = Infinity;
+ drawing.polygon(
+ [
+ [
+ { x: 0, y: 0 },
+ { x: 256, y: 0 },
+ { x: 256, y: 256 },
+ { x: 0, y: 256 },
+ ],
+ ],
+ LAND,
+ );
+ const epsilon = tolerances[detail];
+ for (const layer of AREAS)
+ for (const f of features.filter((f) => f.layer === layer)) {
+ if (
+ (detail >= 2 && layer === "buildings") ||
+ (detail >= 4 && ["sites", "street_polygons", "pier_polygons"].includes(layer))
+ )
+ continue;
+ const fill = /water|ocean/.test(layer)
+ ? WATER
+ : layer === "buildings"
+ ? BUILDING
+ : /park|wood|forest|grass|scrub|garden|farmland/.test(f.kind)
+ ? PARK
+ : layer === "sites"
+ ? color(0xe3dfcf)
+ : LAND;
+ for (const polygon of f.polygons) {
+ if (detail >= 2 && Math.abs(ringArea(polygon[0])) < epsilon * epsilon) continue;
+ drawing.priority = (["ocean", "water_polygons", "land"].includes(layer) ? 1e9 : 1e6) + Math.abs(ringArea(polygon[0]));
+ drawing.polygon(
+ polygon.filter((r, i) => i === 0 || detail < 2 || Math.abs(ringArea(r)) >= epsilon * epsilon)
+ .map((r) => simplify(r, Math.min(epsilon, 2))), fill,
+ );
+ }
+ }
+ const lines = (detail >= 6 ? joinedLines(features, [0, 0.5, 1, 1, 2][detail - 6]) : features)
+ .filter((f) => LINES.includes(f.layer))
+ .sort((a, b) => roadWidth(a.kind, z) - roadWidth(b.kind, z));
+ for (const f of lines) {
+ if (detail >= 3 && /path|foot|cycle|service|tram/.test(f.kind)) continue;
+ if (detail >= 5 && f.layer === "pier_lines") continue;
+ if (detail >= 6 && f.layer === "boundaries") continue;
+ if (detail >= 7 && f.layer === "water_lines" && f.kind === "canal") continue;
+ if (detail >= 7 && f.layer === "streets" && roadWidth(f.kind, z) < 2.4) continue;
+ if (detail >= 5 && f.layer === "streets" && roadWidth(f.kind, z) < 1.8) continue;
+ const paths = f.rings.map((r) => simplify(r, Math.min(epsilon, 2)));
+ const width = roadWidth(f.kind, z);
+ drawing.priority = f.layer === "streets" ? width * 1e7 : 1e6;
+ if (f.layer === "streets") {
+ if (detail < 6) for (const p of paths) drawing.line(p, width + (detail >= 1 ? 0.3 : 0.6), EDGE);
+ for (const p of paths) drawing.line(p, width, /motorway|trunk|primary|secondary/.test(f.kind) ? MAIN : ROAD);
+ } else
+ for (const p of paths)
+ drawing.line(p, f.layer === "water_lines" ? 0.8 : 0.5, f.layer === "water_lines" ? WATER : EDGE);
+ }
+ const mesh = drawing instanceof BudgetMeshBuilder ? drawing.finish() : drawing;
+ if (mesh.points.length <= 4096 && mesh.faces.length <= 2048) {
+ const picked = labels.sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name)).slice(0, 512);
+ return {
+ mesh: mesh.entry(),
+ labels: picked,
+ vertices: mesh.points.length,
+ triangles: mesh.faces.length,
+ simplified: detail,
+ };
+ }
+ }
+ throw new Error("Vector tile cannot fit the device detail budget");
+}
+
+/** Choose stable anchors within a requested display tile. A z18 label window
+ * reuses its z14 source; street names are positioned on the clipped line. */
+export function labelsInWindow(
+ tile: PreparedTile,
+ zoom: number,
+ left: number,
+ top: number,
+ size: number,
+): VectorLabel[] {
+ const result: VectorLabel[] = [],
+ factor = 256 / size;
+ for (const source of tile.labels) {
+ if (zoom > source.maxZoom) continue;
+ let anchor: Point | undefined;
+ if (source.path) {
+ let longest = 0;
+ for (let i = 1; i < source.path.length; i++) {
+ const a = source.path[i - 1],
+ b = source.path[i],
+ dx = b.x - a.x,
+ dy = b.y - a.y;
+ let lo = 0,
+ hi = 1;
+ for (const [p, q] of [
+ [-dx, a.x - left],
+ [dx, left + size - a.x],
+ [-dy, a.y - top],
+ [dy, top + size - a.y],
+ ]) {
+ if (p === 0) {
+ if (q < 0) {
+ hi = -1;
+ break;
+ }
+ continue;
+ }
+ const r = q / p;
+ if (p < 0) lo = Math.max(lo, r);
+ else hi = Math.min(hi, r);
+ }
+ const length = (hi - lo) * Math.hypot(dx, dy);
+ if (hi > lo && length > longest) {
+ longest = length;
+ const t = (lo + hi) / 2;
+ anchor = { x: a.x + dx * t, y: a.y + dy * t };
+ }
+ }
+ if (longest * factor < 24) continue;
+ } else if (source.x >= left && source.x < left + size && source.y >= top && source.y < top + size) anchor = source;
+ if (
+ !anchor ||
+ result.some(
+ (p) =>
+ p.name === source.name ||
+ (Math.abs(p.x - anchor!.x) * factor < 76 && Math.abs(p.y - anchor!.y) * factor < 19),
+ )
+ )
+ continue;
+ result.push({ ...source, ...anchor });
+ if (result.length === 12) break;
+ }
+ return result;
+}
diff --git a/host/worker.ts b/host/worker.ts
new file mode 100644
index 0000000..f1ccad4
--- /dev/null
+++ b/host/worker.ts
@@ -0,0 +1,15 @@
+import { dispatchOffload } from "@pocketjs/framework/offload/provider";
+import { MapService } from "./service.ts";
+declare const self: { onmessage: (event: MessageEvent) => void; postMessage(value: unknown): void };
+let provider: MapService;
+const trace = process.env.POCKET_MAP_TRACE === "1";
+self.onmessage = async event => {
+ if (event.data.init) {
+ provider = new MapService(event.data.init);
+ return;
+ }
+ const started = Date.now();
+ const reply = await dispatchOffload(provider.methods(), event.data);
+ if (trace) console.log(new Date().toISOString(), `Map request id=${event.data.id} method=${event.data.method} workMs=${Date.now() - started} stats=${JSON.stringify(provider.diagnostics())} error=${reply.error ?? "none"}`);
+ self.postMessage(reply);
+};
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..cd36157
--- /dev/null
+++ b/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "pocket-map",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "license": "MIT",
+ "scripts": {
+ "setup": "bun scripts/setup.ts",
+ "3ds": "bun scripts/build.ts",
+ "host": "bun host/serve.ts",
+ "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",
+ "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"
+ },
+ "dependencies": {
+ "@mapbox/vector-tile": "3.0.0",
+ "earcut": "3.2.3",
+ "pbf": "5.1.2"
+ }
+}
diff --git a/pocket.config.ts b/pocket.config.ts
new file mode 100644
index 0000000..1be934e
--- /dev/null
+++ b/pocket.config.ts
@@ -0,0 +1,2 @@
+import { definePocketConfig } from "@pocketjs/framework/config";
+export default definePocketConfig({});
diff --git a/pocket.json b/pocket.json
new file mode 100644
index 0000000..d8c622d
--- /dev/null
+++ b/pocket.json
@@ -0,0 +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"] } },
+ "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/pocket.psp.json b/pocket.psp.json
new file mode 100644
index 0000000..da34a86
--- /dev/null
+++ b/pocket.psp.json
@@ -0,0 +1,34 @@
+{
+ "$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"
+ ],
+ "enhances": [
+ "input.analog.left"
+ ]
+ }
+ },
+ "app": {
+ "entry": "app/psp.tsx",
+ "output": "pocketmap-psp",
+ "framework": "solid",
+ "viewport": {
+ "fixed": {
+ "logical": [
+ 480,
+ 272
+ ],
+ "presentation": "native"
+ }
+ }
+ }
+}
diff --git a/runtime b/runtime
new file mode 160000
index 0000000..d5f0458
--- /dev/null
+++ b/runtime
@@ -0,0 +1 @@
+Subproject commit d5f04587fadb153e7f523cf748eeea8d4431a403
diff --git a/scripts/build.ts b/scripts/build.ts
new file mode 100644
index 0000000..f29f4aa
--- /dev/null
+++ b/scripts/build.ts
@@ -0,0 +1,13 @@
+import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "node:fs";
+import { resolve } from "node:path";
+import { resolve3dsBuildPlan } from "../runtime/tools/3ds-profile.ts";
+import { build3ds } from "../runtime/tools/3ds.ts";
+const root = resolve(import.meta.dir, "..");
+const plan = resolve3dsBuildPlan(await Bun.file(resolve(root, "pocket.json")).json());
+mkdirSync(resolve(root, "dist"), { recursive: true });
+const path = resolve(root, "dist/plan.json"); writeFileSync(path, JSON.stringify(plan, null, 2));
+await build3ds([`--plan=${path}`, `--project-root=${root}`, ...process.argv.slice(2)]);
+for (const ext of ["3dsx", "pocket", "cia"]) {
+ const from = resolve(root, `runtime/dist/3ds/pocketmap-main.${ext}`);
+ if (existsSync(from)) copyFileSync(from, resolve(root, `dist/pocketmap-main.${ext}`));
+}
diff --git a/scripts/deploy.ts b/scripts/deploy.ts
new file mode 100644
index 0000000..f4b71d0
--- /dev/null
+++ b/scripts/deploy.ts
@@ -0,0 +1,28 @@
+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");
+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" });
+chmodSync(".local/pair.key", 0o600);
+const manifest = await Bun.file("pocket.json").json();
+const slot = createHash("sha256").update(manifest.id).digest("hex").slice(0, 16);
+const script = `import ftplib, hashlib, io, json, pathlib, sys
+ftp=ftplib.FTP(); ftp.connect(sys.argv[1],5000,timeout=20); ftp.login()
+for path in ['/3DS','/pocketjs','/pocketjs/offload']:
+ try: ftp.mkd(path)
+ 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')]:
+ data=pathlib.Path(local).read_bytes()
+ ftp.storbinary('STOR '+remote,io.BytesIO(data),blocksize=65536)
+ read=io.BytesIO(); ftp.retrbinary('RETR '+remote,read.write)
+ assert read.getvalue()==data,'FTP readback mismatch'
+ 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" });
+if (result.exitCode) throw new Error(result.stderr.toString());
+await Bun.write("dist/qa/deploy.json", result.stdout); console.log(result.stdout.toString());
diff --git a/scripts/prepare-hyrule.ts b/scripts/prepare-hyrule.ts
new file mode 100644
index 0000000..11dae32
--- /dev/null
+++ b/scripts/prepare-hyrule.ts
@@ -0,0 +1,101 @@
+import { prepareMarkers } from "../host/markers.ts";
+import { createHash } from "node:crypto";
+import { existsSync, mkdirSync, renameSync, rmSync, readdirSync } from "node:fs";
+import { resolve, join } from "node:path";
+import { deflateRawSync } from "node:zlib";
+import { Database } from "bun:sqlite";
+import { createCanvas, loadImage } from "@napi-rs/canvas";
+import { packRGB } from "../host/provider.ts";
+import { ATLAS_FORMAT, HYRULE_REVISION, type AtlasManifest } from "../host/atlas.ts";
+import type { MapInfo } from "../shared/types.ts";
+
+const root = resolve(import.meta.dir, ".."), source = join(root, ".local/hyrule-source");
+const directory = join(root, ".local/hyrule"), output = join(directory, "atlas.sqlite");
+async function git(...args: string[]) {
+ const child = Bun.spawn(["git", ...args], { stdout: "pipe", stderr: "inherit" });
+ const text = await new Response(child.stdout).text();
+ if (await child.exited) throw new Error("Hyrule source checkout failed");
+ return text.trim();
+}
+mkdirSync(directory, { recursive: true });
+if (!existsSync(join(source, ".git"))) await git("clone", "--depth", "1", "--filter=blob:none", "--sparse", "--branch", "develop", "https://github.com/zeldadungeon/maps.git", source);
+if (await git("-C", source, "rev-parse", "HEAD") !== HYRULE_REVISION) {
+ await git("-C", source, "fetch", "--depth", "1", "origin", HYRULE_REVISION);
+ await git("-C", source, "checkout", "--detach", HYRULE_REVISION);
+}
+await git("-C", source, "sparse-checkout", "set", "public/botw");
+const assets = join(source, "public/botw");
+if (existsSync(output) && !process.argv.includes("--rebuild")) {
+ const db = new Database(output, { readonly: true });
+ const manifest = JSON.parse((db.query("SELECT value FROM metadata WHERE key='manifest'").get() as { value: string }).value) as AtlasManifest;
+ db.close();
+ if (manifest.revision !== HYRULE_REVISION || manifest.format !== ATLAS_FORMAT) throw new Error("Atlas version differs; use --rebuild");
+ const markers = await prepareMarkers(directory, join(source, "public/botw"));
+ console.log(`Marker index ready: ${markers} annotations`);
+ console.log(`Hyrule ready: ${manifest.tiles} local textures, ${manifest.places} searchable places`);
+ process.exit(0);
+}
+
+for (let z = 0; z <= 5; z++) {
+ if (readdirSync(join(assets, `tiles/${z}`)).filter(file => file.endsWith(".jpg")).length !== 4 ** z) throw new Error("Incomplete source tile pyramid");
+}
+console.log("Source verified: 1,365 JPEG tiles, full 24,000px map pyramid; baking on the Mac");
+const pending = join(directory, "atlas.build.sqlite");
+if (existsSync(pending)) rmSync(pending);
+const db = new Database(pending);
+db.exec(`PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;
+ CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
+ CREATE TABLE tiles (z INTEGER, x INTEGER, y INTEGER, pixels BLOB NOT NULL, PRIMARY KEY(z,x,y)) WITHOUT ROWID;
+ CREATE VIRTUAL TABLE place_search USING fts5(id UNINDEXED,name,detail,x UNINDEXED,y UNINDEXED,zoom UNINDEXED,tokenize='unicode61 remove_diacritics 2');`);
+let tiles = 0, places = 0;
+const insertTile = db.query("INSERT INTO tiles VALUES (?,?,?,?)");
+const canvas = createCanvas(256, 256), ctx = canvas.getContext("2d");
+ctx.imageSmoothingEnabled = true;
+try {
+ for (let z = 0; z <= 7; z++) {
+ const sourceZ = Math.max(0, z - 2), divide = 2 ** (z - sourceZ), crop = 750 / divide;
+ for (let y = 0; y < 2 ** sourceZ; y++) for (let x = 0; x < 2 ** sourceZ; x++) {
+ const image = await loadImage(join(assets, `tiles/${sourceZ}/${x}_${y}.jpg`));
+ if (image.width !== 750 || image.height !== 750) throw new Error("Invalid source tile dimensions");
+ db.transaction(() => {
+ for (let dy = 0; dy < divide; dy++) for (let dx = 0; dx < divide; dx++) {
+ ctx.drawImage(image, dx * crop, dy * crop, crop, crop, 0, 0, 256, 256);
+ const image565 = packRGB(ctx.getImageData(0, 0, 256, 256).data, 256, 256);
+ insertTile.run(z, x * divide + dx, y * divide + dy, deflateRawSync(image565.pixels, { level: 1 })); tiles++;
+ }
+ })();
+ if (tiles % 1024 < 16) console.log(`Baked ${tiles}/21845 textures`);
+ }
+ console.log(`Level ${z} complete (${tiles} textures)`);
+ }
+ const insertPlace = db.query("INSERT INTO place_search VALUES (?,?,?,?,?,?)");
+ const seen = new Set();
+ for (const file of ["locations", "pins", "seeds", "treasures"]) {
+ const groups = await Bun.file(join(assets, `markers/${file}.json`)).json();
+ db.transaction(() => {
+ for (const group of groups) for (const layer of group.layers ?? []) for (const marker of layer.markers ?? []) {
+ if (!Array.isArray(marker.coords) || marker.coords.length !== 2 || !marker.coords.every(Number.isFinite)) continue;
+ const [north, east] = marker.coords;
+ const x = (east + 12000) / 24000 * 256, y = (12000 - north) / 24000 * 256;
+ if (x < 0 || y < 0 || x > 256 || y > 256) continue;
+ const kind = file === "seeds" ? "Korok seed" : file === "treasures" ? "Treasure" : group.name;
+ const name = String(marker.name ?? `${kind} ${marker.id ?? places + 1}`).replace(/<[^>]+>/g, "").slice(0, 36);
+ const key = `${name}/${x.toFixed(3)}/${y.toFixed(3)}`;
+ if (seen.has(key)) continue; seen.add(key);
+ const id = "h_" + createHash("sha256").update(key).digest("hex").slice(0, 24);
+ const detail = String(file === "seeds" || file === "treasures" ? `${kind} - ${group.name}` : kind).slice(0, 60);
+ insertPlace.run(id, name, detail, x, y, group.name === "Region" ? 3 : group.name === "Subregion" ? 4 : 6); places++;
+ }
+ })();
+ }
+ const info: MapInfo = { source: createHash("sha256").update(`${ATLAS_FORMAT}/${HYRULE_REVISION}`).digest("hex").slice(0, 16),
+ name: "Hyrule - Breath of the Wild", attribution: "Nintendo | Zelda Dungeon", minZoom: 0, maxZoom: 7, space: "planar", local: true,
+ home: { id: "hyrule-home", name: "Great Plateau", detail: "Breath of the Wild", space: "planar", x: (12000 - 1900) / 24000 * 256, y: (12000 + 3750) / 24000 * 256, zoom: 4 } };
+ const manifest: AtlasManifest = { format: ATLAS_FORMAT, revision: HYRULE_REVISION, tiles, places, info };
+ if (tiles !== 21845) throw new Error("Incomplete atlas bake");
+ db.query("INSERT INTO metadata VALUES ('manifest',?)").run(JSON.stringify(manifest));
+ db.close(); renameSync(pending, output);
+ await Bun.write(join(directory, "manifest.json"), JSON.stringify(manifest, null, 2));
+ console.log(`Marker index ready: ${await prepareMarkers(directory, assets)} annotations`);
+ console.log(`Hyrule ready: ${tiles} textures and ${places} searchable places, no runtime internet access`);
+} catch (error) { db.close(); throw error; }
diff --git a/scripts/psp-smoke.js b/scripts/psp-smoke.js
new file mode 100644
index 0000000..497765a
--- /dev/null
+++ b/scripts/psp-smoke.js
@@ -0,0 +1,35 @@
+// Appended only to the --smoke guest. Hardware receives the production UI and
+// provider with a deterministic controller driver; no device IO is added here.
+(() => {
+ const frame = globalThis.frame, s = globalThis.__map;
+ let phase=0, age=0, total=0, marked="", ended=false;
+ const QA="PSP USB regression";
+ function mark(name){if(marked===name)return;const id=s.io.request("map.info",JSON.stringify({qa:name}),()=>{});if(id)marked=name;}
+ function next(){phase++;age=0;}
+ function ready(){return s.front()?.tiles.every(t=>s.frontView.state(t.input).status==="ready");}
+ function require(value,label){if(!value){mark(`FAILED ${label}`);ended=true;}}
+ globalThis.frame=(...args)=>{
+ age++;total++;args[0]=0;args[1]=0x8080;
+ if(!ended){
+ if(total>9000){mark(`FAILED timeout phase ${phase}`);ended=true;}
+ if(phase===0){if(s.info()&&ready()&&age>100){mark("map");next();}}
+ else if(phase===1){
+ if(age===30)args[0]=0x1000;
+ if(age===60)mark("keyboard");
+ if(age===80)args[0]=0x2000;
+ if(age===90){require(s.query().length>0,"keyboard type");args[0]=0x8000;}
+ if(age===100){require(s.query()==="","keyboard delete");s.setQuery("San Francisco");}
+ if(age===120)args[0]=8;
+ if(s.mode()==="results")next();
+ }
+ else if(phase===2){if(s.rows().length){mark("search");if(age>140){args[0]=0x2000;next();}}}
+ else if(phase===3){if(age>120&&age<300)args[1]=0xff80;if(age===370)mark("pan");if(age>450){next();}}
+ else if(phase===4){if(age===20)args[0]=0x200;if(age===21)args[0]=0x2200;if(age>100&&ready()){mark("zoom");next();}}
+ else if(phase===5){if(age===80)args[0]=8;if(age===110){require(s.mode()==="name","save keyboard");s.saved.changeName(QA);mark("save");}if(age===140)args[0]=8;if(s.mode()==="saved")next();}
+ else if(phase===6){if(s.saved.page()?.items.some(p=>p.name===QA)){mark("saved");if(age===120)args[0]=0x8000;if(age===160)args[0]=0x2000;}if(age>160&&!s.saved.busy()&&!s.saved.deleting()&&!s.saved.page()?.items.some(p=>p.name===QA)){mark("deleted");next();}}
+ else if(phase===7){if(age===30)args[0]=0x4000;if(age>=60&&age<70)args[0]=0x200;if([62,64,66].includes(age))args[0]|=0x40;if(age===68)args[0]|=0x2000;if(age===90){require(s.mode()==="sources","source menu");s.setSelection(s.maps().findIndex(m=>m.kind==="hyrule"));}if(age===110)args[0]=0x2000;if(age>130&&s.info()?.kind==="hyrule"&&ready()){mark("hyrule");next();}}
+ else if(phase===8){if(age===140){s.switchMap("osm");}if(age>160&&s.info()?.kind==="osm"&&ready()){mark("done");ended=true;}}
+ }
+ frame(...args);
+ };
+})();
diff --git a/scripts/psp.ts b/scripts/psp.ts
new file mode 100644
index 0000000..b53001e
--- /dev/null
+++ b/scripts/psp.ts
@@ -0,0 +1,67 @@
+import { mkdirSync, copyFileSync } from "node:fs";
+import { resolve } from "node:path";
+const root = resolve(import.meta.dir, ".."),
+ runtime = resolve(process.env.POCKETJS_RUNTIME ?? resolve(root, "runtime")),
+ out = resolve(root, "dist/psp");
+const { validateAndResolveBuildPlan } = await import(
+ `${runtime}/framework/src/manifest/resolve.ts`
+);
+const result = validateAndResolveBuildPlan(
+ await Bun.file(resolve(root, "pocket.psp.json")).json(),
+ { target: "psp" },
+);
+if (!result.ok) throw Error(JSON.stringify(result.diagnostics));
+mkdirSync(out, { recursive: true });
+const plan = resolve(out, "plan.json");
+await Bun.write(plan, JSON.stringify(result.plan, null, 2));
+const smoke = process.argv.includes("--smoke");
+if (smoke) {
+ const build = Bun.spawn(
+ [
+ process.execPath,
+ `${runtime}/tools/build.ts`,
+ `--plan=${plan}`,
+ `--project-root=${root}`,
+ `--outdir=${out}`,
+ ],
+ { cwd: runtime, stdout: "inherit", stderr: "inherit" },
+ );
+ if (await build.exited) process.exit(1);
+ const bundle = resolve(out, "pocketmap-psp.js");
+ await Bun.write(
+ bundle,
+ (await Bun.file(bundle).text()) +
+ "\n" +
+ (await Bun.file(resolve(root, "scripts/psp-smoke.js")).text()),
+ );
+}
+const child = Bun.spawn(
+ [
+ process.execPath,
+ `${runtime}/tools/psp.ts`,
+ `--plan=${plan}`,
+ `--project-root=${root}`,
+ `--outdir=${out}`,
+ "--release",
+ ...(smoke ? ["--skip-build", "--capture"] : []),
+ ...process.argv.slice(2).filter((a) => a !== "--smoke"),
+ ],
+ {
+ env: {
+ ...process.env,
+ ...(smoke ? { POCKETJS_CAPTURE_INPUT: "0:0", POCKETJS_CAP_N: "0" } : {}),
+ },
+ cwd: runtime,
+ stdin: "inherit",
+ stdout: "inherit",
+ stderr: "inherit",
+ },
+);
+if (await child.exited) process.exit(1);
+const target = resolve(runtime, "hosts/psp/target/mipsel-sony-psp/release");
+mkdirSync(resolve(root, "dist/psplink"), { recursive: true });
+copyFileSync(
+ resolve(target, "pocketjs-psp.prx"),
+ resolve(root, "dist/psplink/pocket-map.prx"),
+);
+copyFileSync(resolve(target, "EBOOT.PBP"), resolve(out, "EBOOT.PBP"));
diff --git a/scripts/quickjs-smoke.js b/scripts/quickjs-smoke.js
new file mode 100644
index 0000000..f69bfd1
--- /dev/null
+++ b/scripts/quickjs-smoke.js
@@ -0,0 +1,69 @@
+// QuickJS execution/stack check with host stubs. Pixels are covered by sim.ts.
+let node = 3, texture = 1, session = -1, ticks = 0, token = 1;
+let currentHyrule; const hyrule = scriptArgs[2] === "hyrule";
+const replies = [], tickets = new Set(), saved = [];
+globalThis.ui = { __host: "3ds-dev", __hostAbi: 8, __viewport: { w: 400, h: 240 }, __auxiliarySurface: { root: 2, w: 320, h: 240 },
+ __textures: { "shift.svg": 0, "shift-lock.svg": 1, "map-pad.svg": 2, "map-pin.svg": 3 }, __sprites: {}, createNode: () => node++, measureText: text => text.length * 7 };
+for (const name of ["landmark", "tower", "shrine", "stable", "village", "seed", "treasure", "enemy", "special"]) ui.__textures[`marker-${name}.svg`] = 4 + Object.keys(ui.__textures).length;
+for (const name of ["destroyNode", "insertBefore", "removeChild", "setStyle", "setProp", "setPropBatch", "setText", "replaceText", "uploadTexture", "setImage", "setMesh", "freeMesh", "setSprite", "animate", "cancelAnim", "setFocus", "setActive", "hitTest", "hitTestBounds", "hitTestAuxiliary", "hitTestBoundsAuxiliary", "setCursor", "setCursorPos", "loadStyles", "loadFontAtlas", "loadTileTexture", "freeTexture", "uploadImgEntry", "debugInspect", "debugRectXY", "debugRectWH", "debugPause", "debugStep", "debugStats", "__dbgActive", "__dbgPoll", "__dbgSend", "__dbgShot"]) ui[name] = () => 0;
+globalThis.offload = {
+ session: () => session, take: () => replies.shift(),
+ uploadMesh: id => tickets.has(id) ? texture++ : -1, releaseMesh: id => tickets.delete(id),
+ uploadImage: id => tickets.has(id) ? texture++ : -1, releaseImage: id => tickets.delete(id),
+ submit(raw) {
+ const r = JSON.parse(raw);
+ if (r.response === "mesh") { const id=token++;tickets.add(id);replies.push(JSON.stringify({id:r.id,mesh:{token:id,width:256,height:256,bytes:16}})); }
+ else if (r.response === "image") { const id = token++; tickets.add(id); replies.push(JSON.stringify({ id: r.id, image: { token: id, width: 256, height: r.method === "map.label" ? 32 : 256 } })); }
+ else {
+ let value;
+ if (r.method === "map.info") currentHyrule = JSON.parse(r.payload).kind ? JSON.parse(r.payload).kind === "hyrule" : hyrule;
+ if (r.method === "bookmarks.list") value = { items: saved, offset: 0, total: saved.length };
+ else if (r.method === "bookmarks.command") {
+ const c = JSON.parse(r.payload), id = "b_0123456789abcdef01234567";
+ if (c.kind === "save") saved.push({ ...c.place, id });
+ else if (c.kind === "rename") saved[0].name = c.name;
+ else saved.length = 0;
+ value = { id };
+ } else if (r.method === "map.markers") value = [[1, "Sheikah Tower", "tower", 108, 168], [2, "Shrine of Resurrection", "shrine", 108.5, 168.5]];
+ else value = r.method === "map.info" ? { source: currentHyrule ? "0123456789abcdef" : "abcdef0123456789", kind: currentHyrule ? "hyrule" : "osm", markers: true, maps: [{ kind: "hyrule", name: "Hyrule" }, { kind: "osm", name: "OSM" }], name: "Smoke map", attribution: "Test", maxZoom: currentHyrule ? 7 : 18,
+ ...(currentHyrule ? { minZoom: 0, space: "planar", local: true, home: { id: "home", name: "Plateau", detail: "Test", space: "planar", x: 108, y: 168, zoom: 4 } } : {render:"mesh",dataZoom:14}) }
+ : [hyrule ? { id: "one", name: "Kakariko", detail: "Hyrule", space: "planar", x: 166, y: 149, zoom: 6 } : { id: "one", name: "Tokyo", detail: "Japan", lat: 35.68, lon: 139.76, zoom: 14 }];
+ replies.push(JSON.stringify({ id: r.id, payload: JSON.stringify(value) }));
+ }
+ return true;
+ },
+};
+globalThis.__simHz = 60;
+function frames(n, buttons = 0, analog = 0x8080, elapsedUs) { for (let i = 0; i < n; i++) { frame(buttons, analog, [], [], [], 0x8080, elapsedUs); ticks++; } }
+function check(value, message) { if (!value) throw new Error(message); }
+try {
+ std.loadScript(scriptArgs[1] || "runtime/dist/3ds/guest/pocketmap-main.js");
+ const s = globalThis.__map; frames(30); check(s.mode() === "map", "offline mount");
+ session = 1; frames(90); check(s.front().tiles.every(t => s.frontView.state(t.input).status === "ready"), "tile reveal");
+ const center = s.camera.view(), motion = [];
+ for (const cadence of [Array(60).fill(16667), Array(30).fill(33333), Array.from({ length: 30 }, (_, i) => [16667, 33333, 50000][i % 3])]) {
+ s.camera.jump(center.x, center.y, center.zoom);
+ frames(60, 0, 0xff80, 16667); const start = s.camera.view();
+ for (const us of cadence) frames(1, 0, 0xff80, us);
+ motion.push((s.camera.view().x - start.x) * start.scale);
+ }
+ std.puts(JSON.stringify({ motionPixelsPerSecond: motion }) + "\n");
+ check(motion.every(px => Math.abs(px - 320) < .02), "held stick speed depends on presentation cadence");
+ s.camera.jump(center.x, center.y, center.zoom);
+ frames(300, 0, 0x80ff); s.zoom(1); frames(90); s.zoom(-1); frames(90);
+ s.openSearch(); s.key("t"); check(s.query() === "t", "local typing"); s.search(); frames(60); check(s.places().length === 1, "search");
+ s.go(); frames(90); check(s.pin().name === (hyrule ? "Kakariko" : "Tokyo"), "place navigation");
+ s.saveCurrent(); check(s.mode() === "name", "save naming mount"); s.saved.changeName("Saved in QuickJS"); s.key("GO"); frames(60);
+ check(s.mode() === "saved" && s.saved.page().total === 1, "saved list mount");
+ s.saved.begin(s.saved.selected(), true); s.saved.changeName("Renamed"); s.key("GO"); frames(60);
+ check(s.saved.selected().name === "Renamed", "rename mount");
+ s.saved.askRemove(); frames(20); s.saved.remove(); frames(60); check(s.saved.page().total === 0, "delete sheet lifecycle"); s.dismiss();
+ frames(1, 0x100); frames(1); frames(1, 0x200); frames(1);
+ frames(1, 0x400); frames(1, 0x400 | 0x10); frames(20, 0x400); check(s.zoomHeld(), "ZL rail mount"); frames(1);
+ s.openSources(); frames(2); check(s.mode() === "sources", "source picker mount"); s.choose(hyrule ? 1 : 0); frames(80);
+ check(s.planar() !== hyrule, "hot source switch"); s.openSources(); s.choose(hyrule ? 0 : 1); frames(80);
+ s.setMode("layers"); frames(2); s.choose(s.vector()?1:4); frames(1); check(s.annotations.layer() === "off", "label filter mount");
+ session = -1; frames(30); session = 2; frames(100);
+ check(s.diagnostics().tiles.entries <= 40 && tickets.size === 0, "resource ownership");
+ std.puts(JSON.stringify({ ok: true, frames: ticks, nodeIds: node, uploads: texture, pendingTickets: tickets.size }) + "\n");
+} catch (error) { std.puts(String(error) + "\n" + error.stack + "\n"); std.exit(1); }
diff --git a/scripts/setup.ts b/scripts/setup.ts
new file mode 100644
index 0000000..9139295
--- /dev/null
+++ b/scripts/setup.ts
@@ -0,0 +1,9 @@
+import { existsSync, mkdirSync, symlinkSync, rmSync } from "node:fs";
+import { resolve, dirname, relative } from "node:path";
+const root = resolve(import.meta.dir, "..");
+for (const [name, target] of Object.entries({ "@pocketjs/framework": "runtime", "solid-js": "runtime/node_modules/solid-js", "@napi-rs/canvas": "runtime/node_modules/@napi-rs/canvas", "@types": "runtime/node_modules/@types", "bun-types": "runtime/node_modules/bun-types" })) {
+ const source = resolve(root, target), destination = resolve(root, "node_modules", name);
+ if (!existsSync(source)) throw new Error(`Missing ${target}; initialize the runtime submodule and run bun install there`);
+ mkdirSync(dirname(destination), { recursive: true }); rmSync(destination, { recursive: true, force: true });
+ symlinkSync(relative(dirname(destination), source), destination, "dir");
+}
diff --git a/scripts/sim.ts b/scripts/sim.ts
new file mode 100644
index 0000000..ee03fe9
--- /dev/null
+++ b/scripts/sim.ts
@@ -0,0 +1,199 @@
+import { mkdirSync } from "node:fs";
+import { createCanvas } from "@napi-rs/canvas";
+import { createWasmUi } from "../runtime/hosts/web/wasm-ops.js";
+import { NODE_TYPE, PROP, BTN } from "../runtime/contracts/spec/spec.ts";
+import { encodePNG } from "../runtime/tests/png.ts";
+import { dispatchOffload } from "../runtime/tools/offload-provider.ts";
+import { MapProvider, defaultConfig } from "../host/provider.ts";
+import { MapService } from "../host/service.ts";
+import { AtlasProvider } from "../host/atlas.ts";
+import type { MapModel } from "../app/model.ts";
+import type { OffloadImage } from "../runtime/contracts/spec/offload.ts";
+const live = process.argv.includes("--live");
+const hyrule = process.argv.includes("--hyrule");
+const fixture = createCanvas(256, 256), c = fixture.getContext("2d");
+c.fillStyle = "#e8e5d7"; c.fillRect(0, 0, 256, 256); c.fillStyle = "#b3ced7"; c.fillRect(170, 0, 86, 256);
+for (let x = 14; x < 170; x += 30) { c.fillStyle = "#fffdf4"; c.fillRect(x, 0, 5, 256); }
+for (let y = 20; y < 256; y += 32) { c.fillStyle = "#fffdf4"; c.fillRect(0, y, 170, 5); }
+c.fillStyle = "#b8cfa4"; c.fillRect(50, 70, 55, 48); c.fillStyle = "#5e705e"; c.font = "13px Arial"; c.fillText("Replay fixture", 30, 155);
+const provider = hyrule ? new AtlasProvider(".local/hyrule") : new MapProvider({ ...defaultConfig, format: "raster", tileURL:"https://tile.openstreetmap.de/{z}/{x}/{y}.png", cache: live ? ".local/cache.sqlite" : ":memory:" }, live ? fetch : (async url => {
+ if (String(url).includes("photon")) return new Response(JSON.stringify({ features: [
+ { properties: { osm_type: "R", osm_id: 1, name: "San Francisco", city: "San Francisco", country: "United States", type: "city" }, geometry: { coordinates: [-122.4075, 37.7879] } },
+ { properties: { osm_type: "N", osm_id: 2, name: "Museum of Modern Art", city: "San Francisco", type: "other" }, geometry: { coordinates: [-122.4007, 37.7859] } },
+ ] }));
+ return new Response(fixture.toBuffer("image/png"));
+}) as typeof fetch);
+const service = hyrule ? new MapService({ ...defaultConfig, format: "raster", tileURL:"https://tile.openstreetmap.de/{z}/{x}/{y}.png", cache: ":memory:", atlas: ".local/hyrule", kind: "hyrule" }, async () => new Response(fixture.toBuffer("image/png"))) : undefined;
+const wasm = await createWasmUi(await Bun.file("runtime/hosts/web/pocketjs.wasm").arrayBuffer(), { width: 400, height: 480 });
+const ops = wasm.ops;
+ops.hitTestBoundsAuxiliary = (x, y) => ops.hitTestBounds!(x + 40, y + 240);
+(ops as any).__viewport = { w: 400, h: 240 };
+const auxiliary = ops.createNode(NODE_TYPE.view);
+for (const [prop, n] of [[PROP.posType, 1], [PROP.insetL, 40], [PROP.insetT, 240], [PROP.width, 320], [PROP.height, 240]]) ops.setProp(auxiliary, prop, n);
+ops.insertBefore(1, auxiliary, 0); ops.__auxiliarySurface = { root: auxiliary, w: 320, h: 240 };
+const requests: string[] = [], replies: { at: number; raw: string }[] = [];
+const images = new Map();
+let tick = 0, token = 1, session = 1, uploads = 0, maxStaging = 0, maxPending = 0, maxResident = 0, withhold = false, loseCommandAck = false;
+let replyDelay = 3;
+let hit: number | undefined;
+const checks: string[] = [], failures: string[] = [];
+Object.assign(globalThis, { ui: ops, __pak: await Bun.file("runtime/dist/3ds/guest/pocketmap-main.pak").arrayBuffer(), __simHz: 60,
+ offload: { session: () => session, submit: (raw: string) => { if (requests.length >= 8) return false; requests.push(raw); return true; },
+ take: () => { const n = replies.findIndex(r => r.at <= tick && !withhold); return n < 0 ? undefined : replies.splice(n, 1)[0].raw; },
+ uploadImage: (id: number) => {
+ const image = images.get(id); if (!image || uploads >= 1) return -1; uploads++;
+ const bytes = new Uint8Array(8 + image.pixels.length), view = new DataView(bytes.buffer);
+ view.setUint16(0, image.width, true); view.setUint16(2, image.height, true); bytes[5] = 2; bytes.set(image.pixels, 8);
+ return ops.uploadImgEntry!(bytes);
+ },
+ releaseImage: (id: number) => { images.delete(id); },
+ },
+});
+(0, eval)(await Bun.file("runtime/dist/3ds/guest/pocketmap-main.js").text());
+const s = (globalThis as any).__map as MapModel;
+if (!s) throw new Error("Guest did not mount");
+function check(value: unknown, text: string) { if (!value) { failures.push(text); throw new Error(text); } checks.push(text); }
+async function frames(n: number, buttons = 0, touch?: [number, number], analog = 0x8080) {
+ if (touch && hit === undefined) { wasm.render(); hit = ops.hitTestBounds!(touch[0] + 40, touch[1] + 240); }
+ if (!touch) hit = undefined;
+ for (let i = 0; i < n; i++) {
+ tick++; uploads = 0;
+ (globalThis as any).frame(buttons, analog, touch ? [touch[0] | touch[1] << 9] : [], touch ? [hit] : [], touch ? [1] : [], 0x8080);
+ wasm.tick();
+ const d = s.diagnostics(); maxPending = Math.max(maxPending, d.pending); maxResident = Math.max(maxResident, d.tiles.entries);
+ while (requests.length && images.size < 8) {
+ const raw = requests.shift()!;
+ const request = JSON.parse(raw), result = await dispatchOffload(service?.methods() ?? provider.methods(), request);
+ if (loseCommandAck && request.method === "bookmarks.command") {
+ loseCommandAck = false; replies.push({ at: tick + 3, raw: JSON.stringify({ id: result.id, error: "Simulated lost acknowledgement" }) }); continue;
+ }
+ if (result.image) {
+ const id = token++; images.set(id, result.image); maxStaging = Math.max(maxStaging, images.size);
+ replies.push({ at: tick + replyDelay, raw: JSON.stringify({ id: result.id, image: { token: id, width: result.image.width, height: result.image.height } }) });
+ } else replies.push({ at: tick + replyDelay, raw: JSON.stringify(result) });
+ }
+ }
+}
+async function tap(x: number, y: number) { await frames(1, 0, [x, y]); await frames(1); }
+async function press(button: number) { await frames(1, button); await frames(1); }
+mkdirSync("dist/qa", { recursive: true });
+async function shot(name: string) { await Bun.write(`dist/qa/${hyrule ? "hyrule-" : live ? "live-" : "replay-"}${name}.png`, encodePNG(wasm.render().slice(), 400, 480)); }
+await frames(3); await shot("loading"); await frames(75);
+if (live && !s.front()!.tiles.every(t => s.frontView.state(t.input).status === "ready")) console.log(s.front()!.tiles.map(t => ({ input: t.input, state: s.frontView.state(t.input) })));
+check(s.front()!.tiles.every(t => s.frontView.state(t.input).status === "ready"), "All visible tiles materialize through resource demand and native image tickets");
+await shot("map");
+await tap(30, 18); check(s.mode() === "search", "Search touch button opens local keyboard");
+await shot("keyboard");
+if (hyrule) {
+ check(s.annotations.rows().length > 0, "Real indexed game markers materialize as bounded map labels");
+ check(s.planar() && s.info()?.local, "Hyrule opens as a local finite atlas with its own home and zoom bounds");
+ s.setQuery("Kakariko"); s.search(); await frames(40); await shot("results");
+ check(s.places().some(p => p.name.includes("Kakariko")), "SQLite place search finds Kakariko without an HTTP request");
+ s.go(); await frames(100); await shot("village");
+ check(s.pin()?.space === "planar" && s.front()!.tiles.every(t => s.frontView.state(t.input).status === "ready"), "Search navigation places the pin in atlas coordinates and resolves the village tiles");
+ s.camera.jump(128, 128, 0); await frames(100); await shot("overview");
+ check(s.front()!.tiles.length === 1 && s.front()!.tiles[0].input.z === 0, "Minimum zoom covers the finite world without wrapping copies");
+ s.camera.jump(128, 128, 4); await frames(100);
+ const zoomPosition = s.camera.view();
+ await frames(1, BTN.ZL | BTN.UP); await frames(20, BTN.ZL); await shot("zoom-rail");
+ check(s.zoomHeld() && s.camera.view().zoom === 5 && s.camera.view().x === zoomPosition.x && s.camera.view().y === zoomPosition.y, "ZL owns Up/Down zoom without panning the map");
+ check(s.lookAhead().some(t => t.input.z === 6), "Zoom in primes the next level through the shared resource collection");
+ await frames(1); check(!s.zoomHeld(), "Releasing ZL dismisses its rail");
+ const resume = s.camera.view(); await tap(180, 18);
+ check(s.mode() === "sources", "Map header opens the live source chooser"); await shot("sources");
+ await tap(130, 53 + s.maps().findIndex(m => m.kind === "osm") * 30); await frames(100);
+ check(!s.planar() && s.front()!.tiles.every(t => t.input.source === s.info()!.source), "Hyrule switches to synthetic OSM without restarting the guest or daemon");
+ await tap(180, 18); await tap(130, 53 + s.maps().findIndex(m => m.kind === "hyrule") * 30); await frames(100);
+ check(s.planar() && s.camera.view().x === resume.x && s.camera.view().zoom === resume.zoom, "Switching back restores Hyrule coordinates and zoom");
+ s.setMode("layers"); await frames(2); await shot("layers"); s.choose(2); await frames(100);
+ check(s.annotations.layer() === "collectibles" && s.annotations.rows().every(m => m[2] !== "seed" && m[2] !== "treasure"), "Koroks and treasures remain hidden below their useful zoom range");
+ s.annotations.setLayer("all");
+ replyDelay = 30;
+ s.camera.jump((32 * 256 - 300) / 64, (32 * 256 + 128) / 64, 6); await frames(180);
+ const edge = { source: s.info()!.source, z: 6, x: 32, y: 32 };
+ check(s.frontView.state(edge).status === "ready", "Local prefetch prepares a tile 100px beyond the edge with half-second response latency");
+ s.camera.drag(-101, 0); await frames(1);
+ check(s.front()!.tiles.some(t => t.input.x === 32 && t.input.y === 32) && s.frontView.state(edge).status === "ready", "Crossing the local atlas edge reveals the prefetched tile immediately");
+ await frames(180, 0, undefined, 0x80ff); await frames(220);
+ check(s.front()!.tiles.every(t => s.frontView.state(t.input).status === "ready"), "The local atlas catches up after sustained motion with delayed replies");
+ check(s.lookAhead().length <= 12 && maxResident <= 40 && maxPending <= 4 && maxStaging <= 8, "Larger local prefetch stays within device request, staging and residency budgets");
+ replyDelay = 30;
+ s.camera.jump(128, 128, 6); await frames(120);
+ for (let stroke = 0; stroke < 4; stroke++) {
+ await frames(1, 0, [185, 95]);
+ for (let n = 1; n <= 6; n++) await frames(1, 0, [185 - n * 10, 95 + n * 8]);
+ await frames(30);
+ }
+ s.camera.stop(); await frames(12);
+ const directionView = s.camera.view();
+ check(s.lookAhead().length > 3 && s.lookAhead().every(t => t.input.z === 6 &&
+ ((t.column + .5) * 256 / 64 - directionView.x) - ((t.row + .5) * 256 / 64 - directionView.y) > 0),
+ "Repeated real touch strokes retain northeast-only look-ahead after lifting the stylus");
+ replyDelay = 3; await frames(100); await shot("pan");
+ check(images.size === 0, "Atlas navigation releases every consumed or cancelled image ticket");
+} else if (live) {
+ // One requested view and one explicit search. Stress replay never uses public tiles.
+ s.setQuery("San Francisco"); s.search(); await frames(35); await shot("results");
+ check(s.places().length > 0, "Live Photon search returned places");
+} else {
+ await tap(20, 50); check(s.query() === "q", "Resistive keyboard hit inserts immediately");
+ s.setQuery("San Francisco"); await tap(263, 216); await frames(35);
+ check(s.mode() === "results" && s.places().length === 2, "Explicit Find shows bounded search results"); await shot("results");
+ await press(BTN.DOWN); check(s.selection() === 1, "D-pad selects a place on the non-touch display");
+ await tap(263, 216); await frames(60); check(s.mode() === "map" && s.pin()?.name === "Museum of Modern Art", "Go centers the map on the selected result and adds a pin"); await shot("pin");
+ const before = s.camera.view();
+ await frames(1, 0, [140, 110]); await frames(1, 0, [155, 123]); await frames(1, 0, [173, 140]); await frames(1);
+ const released = s.camera.view(); await frames(10);
+ check(released.x < before.x && s.camera.view().x < released.x, "Touchpad follows stylus immediately and continues with inertia");
+ s.camera.stop(); const zoomBefore = s.camera.view().zoom;
+ await tap(287, 73); await frames(32); check(s.camera.view().zoom > zoomBefore + 0.99, "Zoom control changes scale through a smooth local transition"); await frames(50); await shot("zoom");
+ withhold = true; const position = s.camera.view().y;
+ await frames(180, 0, undefined, 0x80ff);
+ check(s.camera.view().y > position, "Sustained Circle Pad pan continues while all replies are withheld");
+ withhold = false; session = -1; await frames(12); await shot("disconnected"); session = 2; await frames(130);
+ check(s.online() && s.front()!.tiles.every(t => s.frontView.state(t.input).status === "ready"), "Reconnect fences old responses and refills the current viewport");
+ s.camera.jump(255.999, 128, 8); await frames(60); await frames(160, 0, undefined, 0xff80); await frames(100);
+ check(s.camera.view().x >= 0 && s.camera.view().x < 256, "Date-line navigation keeps coordinates and tile demand bounded");
+ await frames(1, BTN.LTRIGGER); await shot("places-menu"); await frames(1);
+ await frames(1, BTN.RTRIGGER); await shot("map-menu"); await frames(1);
+ console.log({ maxPending, maxResident, maxStaging });
+ check(maxPending <= 4 && maxResident <= 40 && maxStaging <= 8, "Request credit, texture residency and staging remain within their independent budgets");
+ await frames(100); check(images.size === 0, "Consumed and cancelled image staging is released after churn");
+ await tap(155, 216); check(s.mode() === "name", "Save view opens a separate local naming keyboard");
+ await shot("save-name"); await tap(40, 216); await tap(20, 50); check(s.saved.name() === "q", "Naming input updates locally without a host round trip");
+ s.saved.changeName("My coffee stop"); loseCommandAck = true;
+ await tap(240, 216); await frames(20);
+ check(!!s.saved.error() && provider.bookmarks.list(0).total === 1, "A lost save acknowledgement leaves a visible retry state after the Mac commits");
+ await tap(240, 216); await frames(35);
+ check(s.mode() === "saved" && s.saved.page()?.total === 1 && provider.bookmarks.list(0).total === 1, "Retry confirms the same save without duplicating user data"); await shot("saved");
+ await tap(50, 216); check(s.mode() === "name" && !!s.saved.editing(), "Rename starts from the selected saved place");
+ s.saved.changeName("Morning coffee"); await tap(240, 216); await frames(35);
+ check(s.saved.selected()?.name === "Morning coffee", "Rename updates the saved list after persistence");
+ const savedPoint = s.saved.selected()!; await tap(263, 216); await frames(35);
+ check(s.mode() === "map" && s.pin()?.id === savedPoint.id, "Go to saved place restores coordinates, zoom and pin");
+ await tap(263, 216); await frames(35); await tap(155, 216); await frames(16); await shot("delete");
+ check(!!s.saved.deleting() && s.saved.modal(), "Delete opens the animated modal confirmation");
+ await press(BTN.CROSS); await frames(15); check(provider.bookmarks.list(0).total === 1 && !s.saved.modal(), "B cancels deletion and keeps the place");
+ for (let n = 0; n < 5; n++) provider.bookmarks.command({ op: `replay_place_000${n}`, kind: "save", place: { ...savedPoint, name: `Place ${n}` } });
+ s.saved.refresh(); await frames(35); await tap(286, 152); await frames(35);
+ check(s.saved.page()?.offset === 5 && s.saved.selected()?.name === "Morning coffee", "Saved places paginate through the same right-hand control rail");
+ await tap(155, 216); await frames(16); await tap(150, 180); await frames(35);
+ check(s.saved.page()?.offset === 0 && s.saved.page()?.total === 5 && !s.saved.modal(), "Deleting the last row of a page returns to a populated page");
+ await tap(30, 18); await frames(50);
+ const oldTiles = s.front()!.tiles;
+ s.camera.stop(); s.camera.drag(256, 0); await frames(1);
+ const overlapping = s.front()!.tiles.filter(t => oldTiles.some(o => o.column === t.column && o.row === t.row));
+ check(overlapping.length > 0 && overlapping.every(t => oldTiles.includes(t)), "Crossing a tile column retains existing component identities");
+ await frames(40);
+ s.camera.jump(128, 128, 8); await frames(80);
+ const ahead = s.lookAhead(); check(ahead.length > 0 && ahead.length <= 4 && ahead.every(t => s.frontView.state(t.input).status === "ready"), "Modest look-ahead materializes no more than four adjacent tiles");
+ if (ahead.length) {
+ const t = ahead[0], scale = 2 ** t.input.z;
+ s.camera.jump((t.column + 0.5) * 256 / scale, (t.row + 0.5) * 256 / scale, t.input.z); await frames(1);
+ check(s.frontView.state(t.input).status === "ready", "Entering a prefetched tile reveals its resident texture without waiting for the network");
+ await frames(50);
+ }
+}
+const receipt = { mode: hyrule ? "complete local Hyrule atlas, compiled guest + Wasm" : live ? "live OSM DE / Photon, compiled guest + Wasm" : "deterministic synthetic provider, compiled guest + Wasm", frames: tick, checks, maxPending, maxResident, maxStaging, ...(service ? service.diagnostics() : provider.diagnostics()),
+ hardwareAcceptance: "pending", performance: "Replay validates behavior and budgets, not device frame time" };
+await Bun.write(`dist/qa/${hyrule ? "hyrule" : live ? "live" : "replay"}.json`, JSON.stringify(receipt, null, 2)); console.log(receipt); service?.close(); provider.close();
diff --git a/scripts/vector-sim.ts b/scripts/vector-sim.ts
new file mode 100644
index 0000000..62a04b2
--- /dev/null
+++ b/scripts/vector-sim.ts
@@ -0,0 +1,297 @@
+/** Compiled guest + real core + provider. Synthetic network by default; --live
+ * captures only a few explicitly viewed areas and persists their MVT responses. */
+import { mkdirSync, existsSync } from "node:fs";
+import { createWasmUi } from "../runtime/hosts/web/wasm-ops.js";
+import { NODE_TYPE, PROP, BTN } from "../runtime/contracts/spec/spec.ts";
+import { encodePNG } from "../runtime/tests/png.ts";
+import { dispatchOffload } from "../runtime/tools/offload-provider.ts";
+import { validateMesh } from "../runtime/tools/offload-wire.ts";
+import { MapService } from "../host/service.ts";
+import { defaultConfig } from "../host/config.ts";
+import { vectorFixture } from "../test/vector-fixture.ts";
+import type { OffloadImage, OffloadMesh } from "../runtime/contracts/spec/offload.ts";
+import type { MapModel } from "../app/model.ts";
+const live = process.argv.includes("--live"),
+ prefix = live ? "vector-live" : "vector";
+const provider = new MapService(
+ {
+ ...defaultConfig,
+ cache: live ? ".local/vector-qa.sqlite" : ":memory:",
+ kind: "osm",
+ atlas: existsSync(".local/hyrule/atlas.sqlite") ? ".local/hyrule" : undefined,
+ },
+ live
+ ? fetch
+ : async (url) =>
+ String(url).includes("photon")
+ ? new Response(
+ JSON.stringify({
+ features: [
+ {
+ properties: { osm_type: "N", osm_id: 1, name: "Tokyo", country: "Japan", type: "city" },
+ geometry: { coordinates: [139.76, 35.68] },
+ },
+ ],
+ }),
+ )
+ : new Response(vectorFixture(), { headers: { "cache-control": "max-age=3600" } }),
+);
+const wasm = await createWasmUi(await Bun.file("runtime/hosts/web/pocketjs.wasm").arrayBuffer(), {
+ width: 400,
+ height: 480,
+ }),
+ ops = wasm.ops;
+ops.hitTestBoundsAuxiliary = (x, y) => ops.hitTestBounds!(x + 40, y + 240);
+(ops as any).__viewport = { w: 400, h: 240 };
+const aux = ops.createNode(NODE_TYPE.view);
+for (const [p, n] of [
+ [PROP.posType, 1],
+ [PROP.insetL, 40],
+ [PROP.insetT, 240],
+ [PROP.width, 320],
+ [PROP.height, 240],
+])
+ ops.setProp(aux, p, n);
+ops.insertBefore(1, aux, 0);
+ops.__auxiliarySurface = { root: aux, w: 320, h: 240 };
+const requests: string[] = [],
+ replies: { at: number; raw: string }[] = [],
+ images = new Map(),
+ meshes = new Map();
+let tick = 0,
+ next = 1,
+ session = 1,
+ uploads = 0,
+ delay = 3,
+ withhold = false,
+ meshBytes = 0,
+ imageBytes = 0,
+ jsonBytes = 0,
+ meshReplies = 0,
+ maxPending = 0,
+ maxStaging = 0,
+ maxEntries = 0;
+const drawTimes: number[] = [],
+ checks: string[] = [];
+Object.assign(globalThis, {
+ ui: ops,
+ __pak: await Bun.file("runtime/dist/3ds/guest/pocketmap-main.pak").arrayBuffer(),
+ __simHz: 60,
+ offload: {
+ session: () => session,
+ submit: (raw: string) => {
+ if (requests.length >= 8) return false;
+ requests.push(raw);
+ return true;
+ },
+ take: () => {
+ const i = replies.findIndex((r) => r.at <= tick && !withhold);
+ return i < 0 ? undefined : replies.splice(i, 1)[0].raw;
+ },
+ uploadMesh: (id: number) => {
+ const value = meshes.get(id);
+ if (!value || uploads >= 1) return -1;
+ uploads++;
+ return ops.uploadMesh!(value.bytes);
+ },
+ releaseMesh: (id: number) => meshes.delete(id),
+ uploadImage: (id: number) => {
+ const image = images.get(id);
+ if (!image || uploads >= 1) return -1;
+ uploads++;
+ const b = new Uint8Array(8 + image.pixels.length),
+ v = new DataView(b.buffer);
+ v.setUint16(0, image.width, true);
+ v.setUint16(2, image.height, true);
+ b[5] = 2;
+ b.set(image.pixels, 8);
+ return ops.uploadImgEntry!(b);
+ },
+ releaseImage: (id: number) => images.delete(id),
+ },
+});
+(0, eval)(await Bun.file("runtime/dist/3ds/guest/pocketmap-main.js").text());
+const s = (globalThis as any).__map as MapModel;
+function check(ok: unknown, name: string) {
+ if (!ok) throw new Error(name);
+ checks.push(name);
+}
+async function frames(n: number, buttons = 0, touch?: [number, number], analog = 0x8080, inputElapsedUs?: number) {
+ let hit: number | undefined;
+ if (touch) {
+ wasm.render();
+ hit = ops.hitTestBounds!(touch[0] + 40, touch[1] + 240);
+ }
+ for (let i = 0; i < n; i++) {
+ tick++;
+ uploads = 0;
+ (globalThis as any).frame(
+ buttons,
+ analog,
+ touch ? [touch[0] | (touch[1] << 9)] : [],
+ touch ? [hit] : [],
+ touch ? [1] : [],
+ 0x8080,
+ inputElapsedUs,
+ );
+ wasm.tick();
+ const start = performance.now();
+ wasm.render();
+ drawTimes.push(performance.now() - start);
+ const d = s.diagnostics();
+ maxPending = Math.max(maxPending, d.pending);
+ maxEntries = Math.max(maxEntries, d.tiles.entries);
+ while (requests.length && images.size + meshes.size < 8) {
+ const req = JSON.parse(requests.shift()!),
+ result = await dispatchOffload(provider.methods(), req);
+ if (result.mesh) {
+ const token = next++;
+ meshes.set(token, result.mesh);
+ meshBytes += result.mesh.bytes.length + 12;
+ meshReplies++;
+ replies.push({
+ at: tick + delay,
+ raw: JSON.stringify({ id: result.id, mesh: { token, ...validateMesh(result.mesh.bytes) } }),
+ });
+ } else if (result.image) {
+ const token = next++;
+ images.set(token, result.image);
+ imageBytes += result.image.pixels.length + 20;
+ replies.push({
+ at: tick + delay,
+ raw: JSON.stringify({
+ id: result.id,
+ image: { token, width: result.image.width, height: result.image.height },
+ }),
+ });
+ } else {
+ if (req.method === "map.info" && result.payload && live) {
+ const info = JSON.parse(result.payload);
+ info.prefetch = false;
+ result.payload = JSON.stringify(info);
+ }
+ const raw = JSON.stringify(result);
+ jsonBytes += new TextEncoder().encode(raw).length + 4;
+ replies.push({ at: tick + delay, raw });
+ }
+ maxStaging = Math.max(maxStaging, images.size + meshes.size);
+ }
+ }
+}
+mkdirSync("dist/qa", { recursive: true });
+async function shot(name: string) {
+ await Bun.write(`dist/qa/${prefix}-${name}.png`, encodePNG(wasm.render().slice(), 400, 480));
+}
+try {
+ await frames(3);
+ await shot("loading");
+ await frames(110);
+ check(s.vector(), "OSM selects the geometry collection");
+ check(
+ s.front()!.tiles.every((t) => s.frontView.state(t.input).status === "ready"),
+ "Visible MVTs become native meshes",
+ );
+ await shot("map");
+ check(s.annotations.rows().length > 0, "Independent label demand reveals atlas text");
+ check(s.annotations.renderRows().length <= 12, "Only the bounded visible vector labels mount UI");
+ const before = { meshReplies, meshBytes, jsonBytes },
+ center = s.camera.view();
+ for (let z = 15; z <= 18; z++) {
+ s.zoom(1);
+ await frames(32);
+ await shot(`zoom-${z}`);
+ }
+ check(s.camera.view().zoom === 18 && s.front()?.level === 14, "Data LOD stays at 14 while camera reaches 18");
+ check(
+ meshReplies === before.meshReplies,
+ "Four additional zoom levels reuse the same geometry without tile transfers",
+ );
+ const zoomBytes = { mesh: meshBytes - before.meshBytes, json: jsonBytes - before.jsonBytes };
+ for (let z = 17; z >= 14; z--) {
+ s.zoom(-1);
+ await frames(28);
+ }
+ await shot("zoom-return");
+ if (!live) {
+ delay = 30;
+ const x = s.camera.view().x;
+ await frames(80, 0, undefined, 0xff80);
+ check(s.camera.view().x !== x, "Continuous input stays live during delayed geometry replies");
+ await shot("pan");
+ await frames(220);
+ delay = 3;
+ check(
+ s.front()!.tiles.every((t) => s.frontView.state(t.input).status === "ready"),
+ "Directional pan resolves the new visible tiles",
+ );
+ const hash = wasm.drawHash!();
+ session = 0;
+ withhold = true;
+ await frames(25, 0, undefined, 0x80ff);
+ check(wasm.drawHash!() !== hash, "Cached geometry still pans while disconnected");
+ session = 2;
+ withhold = false;
+ await frames(150);
+ s.openSearch();
+ s.setQuery("Tokyo");
+ s.search();
+ await frames(45);
+ check(s.places()[0]?.name === "Tokyo", "Place search remains a Mac capability");
+ s.go();
+ await frames(130);
+ await shot("search");
+ if (s.maps().some((m) => m.kind === "hyrule")) {
+ s.switchMap("hyrule");
+ await frames(140);
+ check(!s.vector() && s.planar(), "Hot switch retains the real Hyrule raster atlas");
+ await shot("hyrule");
+ s.switchMap("osm");
+ await frames(160);
+ check(s.vector() && !s.planar(), "Hot switch returns to OSM geometry");
+ }
+ }
+ if (!live) {
+ const center = s.camera.view();
+ for (const cadence of [Array(60).fill(16667), Array(30).fill(33333), Array.from({ length: 30 }, (_, i) => [16667, 33333, 50000][i % 3])]) {
+ s.camera.jump(center.x, center.y, 14);
+ await frames(60, 0, undefined, 0xff80, 16667);
+ const start = s.camera.view();
+ for (const us of cadence) await frames(1, 0, undefined, 0xff80, us);
+ const pixels = (s.camera.view().x - start.x) * start.scale;
+ check(Math.abs(pixels - 320) < .02, `Compiled guest holds 320 px/s over ${cadence.length} sampled frames`);
+ }
+ await frames(200);
+ }
+ check(
+ maxPending <= 3 && maxStaging <= 3 && maxEntries <= 40,
+ "Shared admission, staging and residency remain bounded",
+ );
+ await frames(100);
+ check(images.size === 0 && meshes.size === 0, "All native staging tickets drain");
+ drawTimes.sort((a, b) => a - b);
+ const receipt = {
+ ok: true,
+ live,
+ checks,
+ frames: tick,
+ meshReplies,
+ meshBytes,
+ imageBytes,
+ jsonBytes,
+ zoomBytes,
+ averageMeshBytes: meshReplies ? meshBytes / meshReplies : 0,
+ rasterEquivalentBytes: meshReplies * 131092,
+ maxPending,
+ maxStaging,
+ maxEntries,
+ wasmSoftwareMs: {
+ median: drawTimes[Math.floor(drawTimes.length * 0.5)],
+ p95: drawTimes[Math.floor(drawTimes.length * 0.95)],
+ },
+ provider: provider.diagnostics(),
+ };
+ await Bun.write(`dist/qa/${prefix}.json`, JSON.stringify(receipt, null, 2));
+ console.log(JSON.stringify(receipt, null, 2));
+} finally {
+ provider.close();
+}
diff --git a/scripts/watch-psp-smoke.ts b/scripts/watch-psp-smoke.ts
new file mode 100644
index 0000000..e19d625
--- /dev/null
+++ b/scripts/watch-psp-smoke.ts
@@ -0,0 +1,79 @@
+/** Capture real GE framebuffers at smoke-driver checkpoints via PSPLINK. */
+import { readdirSync, readFileSync, mkdirSync, statSync } from "node:fs";
+import { resolve } from "node:path";
+const root = resolve(import.meta.dir, ".."),
+ mail = resolve(root, "dist/psplink/pocket-offload/c7771f0167312c63"),
+ out = resolve(root, "dist/qa/psp");
+mkdirSync(out, { recursive: true });
+const started = Date.now();
+const seen = new Set();
+let done = false;
+let capturing = false;
+const timer = setInterval(async () => {
+ if (capturing || done) return;
+ capturing = true;
+ try {
+ for (const name of readdirSync(mail).filter((n) => /^req[0-7]$/.test(n))) {
+ try {
+ if (statSync(resolve(mail, name)).mtimeMs < started) continue;
+ const b = readFileSync(resolve(mail, name));
+ const q = JSON.parse(b.toString("utf8", 64)),
+ payload = JSON.parse(q.payload);
+ if (!payload.qa || seen.has(payload.qa)) continue;
+ seen.add(payload.qa);
+ console.log(payload.qa);
+ const stage = String(payload.qa).replace(/[^a-z0-9-]/gi, "-");
+ await Bun.sleep(200);
+ await Bun.spawn(["pspsh", "-e", "thsusp @pocketjs_main"], {
+ stdout: "ignore",
+ }).exited;
+ try {
+ await Bun.sleep(25);
+ const shot = Bun.spawn(
+ ["pspsh", "-e", `scrshot host0:/qa-${stage}.bmp`],
+ { stdout: "ignore", stderr: "inherit" },
+ );
+ if (await shot.exited) throw Error("PSP screenshot failed");
+ } finally {
+ await Bun.spawn(["pspsh", "-e", "thresm @pocketjs_main"], {
+ stdout: "ignore",
+ }).exited;
+ }
+ const png = Bun.spawn(
+ [
+ "sips",
+ "-s",
+ "format",
+ "png",
+ resolve(root, `dist/psplink/qa-${stage}.bmp`),
+ "--out",
+ resolve(out, `${stage}.png`),
+ ],
+ { stdout: "ignore", stderr: "inherit" },
+ );
+ await png.exited;
+ await Bun.write(
+ resolve(out, "smoke.json"),
+ JSON.stringify(
+ { checkpoints: [...seen], time: new Date().toISOString() },
+ null,
+ 2,
+ ),
+ );
+ if (stage === "done" || stage.startsWith("FAILED")) {
+ done = true;
+ clearInterval(timer);
+ }
+ } catch {}
+ }
+ } finally {
+ capturing = false;
+ }
+}, 40);
+setTimeout(() => {
+ if (!done) {
+ clearInterval(timer);
+ console.error("Smoke capture deadline");
+ process.exitCode = 1;
+ }
+}, 240000).unref();
diff --git a/shared/types.ts b/shared/types.ts
new file mode 100644
index 0000000..73599c9
--- /dev/null
+++ b/shared/types.ts
@@ -0,0 +1,23 @@
+export type MapKind = "hyrule" | "osm";
+export type Position = { space?: "mercator"; lat: number; lon: number } | { space: "planar"; x: number; y: number };
+export type Place = Position & { id: string; name: string; detail: string; zoom: number };
+export interface MapInfo { source: string; name: string; attribution: string; maxZoom: number; render?: "mesh"; prefetch?: boolean; dataZoom?: number; minZoom?: number;
+ space?: "mercator" | "planar"; home?: Place; local?: boolean; markers?: boolean; kind?: MapKind; maps?: { kind: MapKind; name: string }[] }
+export interface TileInput { source: string; z: number; x: number; y: number }
+export type SearchInput = Position & { query: string; source?: string };
+export interface BookmarkPage { items: Place[]; offset: number; total: number }
+export interface BookmarkCommand { op: string; source?: string; kind: "save" | "rename" | "remove"; id?: string; place?: Place; name?: string }
+export const HOME = { lat: 37.7879, lon: -122.4075, zoom: 14 };
+export function validPosition(value: unknown): value is Position {
+ if (!value || typeof value !== "object") return false;
+ const p = value as Record;
+ return p.space === "planar"
+ ? typeof p.x === "number" && Number.isFinite(p.x) && p.x >= 0 && p.x <= 256 && typeof p.y === "number" && Number.isFinite(p.y) && p.y >= 0 && p.y <= 256
+ : (p.space === undefined || p.space === "mercator") && typeof p.lat === "number" && Number.isFinite(p.lat) && Math.abs(p.lat) <= 90 && typeof p.lon === "number" && Number.isFinite(p.lon) && Math.abs(p.lon) <= 180;
+}
+
+export const MARKER_KINDS = ["landmark", "tower", "shrine", "stable", "village", "seed", "treasure", "enemy", "special"] as const;
+export type MarkerKind = typeof MARKER_KINDS[number];
+export type MarkerLayer = "all" | "travel" | "collectibles" | "enemies" | "off";
+export interface MarkerInput { source: string; z: number; x: number; y: number; layer: MarkerLayer }
+export type MapMarker = [id: number, name: string, kind: MarkerKind, x: number, y: number];
diff --git a/test/atlas.test.ts b/test/atlas.test.ts
new file mode 100644
index 0000000..c62bb6f
--- /dev/null
+++ b/test/atlas.test.ts
@@ -0,0 +1,48 @@
+import { expect, test } from "bun:test";
+import { Database } from "bun:sqlite";
+import { deflateRawSync } from "node:zlib";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { AtlasProvider, ATLAS_FORMAT } from "../host/atlas.ts";
+
+test("local atlas reads packed textures and searches planar places without an HTTP provider", () => {
+ const dir = mkdtempSync(join(tmpdir(), "pocket-map-atlas-")), db = new Database(join(dir, "atlas.sqlite"));
+ const source = "123456789abcdef0";
+ db.exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT);
+ CREATE TABLE tiles (z INTEGER,x INTEGER,y INTEGER,pixels BLOB,PRIMARY KEY(z,x,y));
+ CREATE VIRTUAL TABLE place_search USING fts5(id UNINDEXED,name,detail,x UNINDEXED,y UNINDEXED,zoom UNINDEXED);`);
+ db.query("INSERT INTO metadata VALUES ('manifest',?)").run(JSON.stringify({ format: ATLAS_FORMAT, tiles: 21845,
+ info: { source, name: "Fixture atlas", attribution: "Test", minZoom: 0, maxZoom: 7, space: "planar", local: true } }));
+ const pixels = new Uint8Array(131072);
+ for (let n = 0; n < pixels.length; n++) pixels[n] = n % 251;
+ db.query("INSERT INTO tiles VALUES (0,0,0,?)").run(deflateRawSync(pixels));
+ db.query("INSERT INTO tiles VALUES (7,127,127,?)").run(deflateRawSync(pixels));
+ db.query("INSERT INTO tiles VALUES (1,0,0,?)").run(deflateRawSync(new Uint8Array(131073)));
+ const place = db.query("INSERT INTO place_search VALUES (?,?,?,?,?,?)");
+ place.run("h1", "Kakariko Village", "Village", 151, 130, 5);
+ place.run("h2", "Kakariko Bridge", "Landmark", 151, 140, 6);
+ for (let n = 0; n < 8; n++) place.run("s" + n, "Test Shrine " + n, "Shrine", 100 + n, 100, 6);
+ db.close();
+ let atlas = new AtlasProvider(dir);
+ try {
+ expect(atlas.tile({ source, z: 0, x: 0, y: 0 }).pixels).toEqual(pixels);
+ expect(atlas.tile({ source, z: 0, x: 0, y: 0 }).pixels).toEqual(pixels);
+ expect(atlas.tile({ source, z: 7, x: 127, y: 127 }).pixels).toEqual(pixels);
+ expect(atlas.diagnostics()).toEqual({ atlasReads: 2, memoryHits: 1, downloads: 0 });
+ expect(() => atlas.tile({ source: "wrong", z: 0, x: 0, y: 0 })).toThrow();
+ expect(() => atlas.tile({ source, z: 8, x: 0, y: 0 })).toThrow();
+ expect(() => atlas.tile({ source, z: 1, x: 1, y: 1 })).toThrow("Missing installed");
+ expect(() => atlas.tile({ source, z: 1, x: 0, y: 0 })).toThrow();
+ const query = { space: "planar" as const, x: 150, y: 130 };
+ const rows = atlas.search({ ...query, query: "kak vil" });
+ expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ name: "Kakariko Village", space: "planar", x: 151, y: 130 });
+ expect(atlas.search({ ...query, query: "shrine" })).toHaveLength(5);
+ expect(atlas.search({ ...query, query: "\" * -" })).toEqual([]);
+ expect(() => atlas.search({ query: "Kakariko", lat: 0, lon: 0 })).toThrow();
+ const save = { op: "atlas_saved_place_1", kind: "save" as const, place: rows[0] };
+ const receipt = atlas.bookmarks.command(save); atlas.close(); atlas = new AtlasProvider(dir);
+ expect(atlas.bookmarks.command(save)).toBe(receipt);
+ expect(atlas.bookmarks.list(0).items[0]).toMatchObject({ space: "planar", x: 151, y: 130 });
+ } finally { atlas.close(); rmSync(dir, { recursive: true, force: true }); }
+});
diff --git a/test/bookmarks.test.ts b/test/bookmarks.test.ts
new file mode 100644
index 0000000..5842039
--- /dev/null
+++ b/test/bookmarks.test.ts
@@ -0,0 +1,47 @@
+import { test, expect } from "bun:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Bookmarks } from "../host/bookmarks.ts";
+import { Database } from "bun:sqlite";
+import type { BookmarkCommand } from "../shared/types.ts";
+const place = { id: "N1", name: "Coffee", detail: "Market Street", lat: 37.7, lon: -122.4, zoom: 16 };
+test("legacy geographic bookmarks migrate once and keep their operation receipts", () => {
+ const dir = mkdtempSync(join(tmpdir(), "pocket-map-migrate-")), path = join(dir, "places.sqlite");
+ const legacy = new Database(path);
+ legacy.exec(`CREATE TABLE places (seq INTEGER PRIMARY KEY,id TEXT,name TEXT,detail TEXT,lat REAL,lon REAL,zoom INTEGER);
+ CREATE TABLE receipts (op TEXT PRIMARY KEY,body TEXT,result TEXT);
+ INSERT INTO places VALUES (1,'old','Old favorite','San Francisco',37.7,-122.4,14);
+ INSERT INTO receipts VALUES ('retained','{}','{}');`);
+ legacy.close();
+ let db = new Bookmarks(path);
+ try {
+ expect(db.list(0).items).toEqual([{ id: "old", name: "Old favorite", detail: "San Francisco", lat: 37.7, lon: -122.4, zoom: 14 }]);
+ db.close(); db = new Bookmarks(path); expect(db.list(0).total).toBe(1);
+ const check = new Database(path, { readonly: true });
+ expect(check.query("SELECT op FROM receipts").get()).toEqual({ op: "retained" }); check.close();
+ } finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
+});
+test("saved places survive reopening, paginate and retain idempotent mutation receipts", () => {
+ const dir = mkdtempSync(join(tmpdir(), "pocket-map-saved-")); let db = new Bookmarks(join(dir, "places.sqlite"));
+ try {
+ const save: BookmarkCommand = { op: "save_receipt_0001", kind: "save", place };
+ const reply = db.command(save); const id = JSON.parse(reply).id;
+ db.close(); db = new Bookmarks(join(dir, "places.sqlite"));
+ expect(db.command(save)).toBe(reply); expect(db.list(0).total).toBe(1);
+ const rename: BookmarkCommand = { op: "rename_receipt_01", kind: "rename", id, name: "Morning coffee" };
+ expect(db.command(rename)).toBe(reply); expect(db.list(0).items[0].name).toBe("Morning coffee");
+ for (let n = 0; n < 6; n++) db.command({ op: `save_next_item_0${n}`, kind: "save", place: { ...place, name: `Place ${n}` } });
+ expect(db.list(0).items.map(p => p.name)).toEqual(["Place 5", "Place 4", "Place 3", "Place 2", "Place 1"]);
+ expect(db.list(5).items.map(p => p.name)).toEqual(["Place 0", "Morning coffee"]);
+ const remove: BookmarkCommand = { op: "remove_receipt_01", kind: "remove", id };
+ db.command(remove); db.close(); db = new Bookmarks(join(dir, "places.sqlite"));
+ expect(db.command(remove)).toBe(reply); expect(db.command(save)).toBe(reply); expect(db.list(0).total).toBe(6);
+ expect(db.list(5).items).toHaveLength(1); // delayed save cannot resurrect a deleted place
+ db.command({ op: "remove_last_page", kind: "remove", id: db.list(5).items[0].id });
+ expect(db.list(5).offset).toBe(0); expect(db.list(5).items).toHaveLength(5);
+ expect(() => db.command({ ...save, place: { ...place, name: "Different" } })).toThrow("reused");
+ expect(() => db.command({ op: "bad_coordinates_1", kind: "save", place: { ...place, lat: NaN } })).toThrow("Invalid");
+ expect(() => db.list(1)).toThrow("Invalid"); expect(() => db.list(1000)).toThrow("Invalid");
+ } finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
+});
diff --git a/test/geo.test.ts b/test/geo.test.ts
new file mode 100644
index 0000000..5cec7db
--- /dev/null
+++ b/test/geo.test.ts
@@ -0,0 +1,11 @@
+import { expect, test } from "bun:test";
+import { project, unproject, wrapTile, scaleBar } from "../app/geo.ts";
+test("Mercator round trips real cities, wraps longitude and clamps poles", () => {
+ for (const [lat, lon] of [[37.7879, -122.4075], [35.6812, 139.7671], [51.5074, -0.1278], [-33.86, 151.2]]) {
+ const point = project(lat, lon), back = unproject(point.x, point.y);
+ expect(back.lat).toBeCloseTo(lat, 8); expect(back.lon).toBeCloseTo(lon, 8);
+ }
+ expect(project(0, 180).x).toBe(project(0, -180).x);
+ expect(project(90, 0).y).toBeCloseTo(0, 8); expect(wrapTile(-1, 4)).toBe(15);
+ const bar = scaleBar(37.78, 14); expect(bar.pixels).toBeLessThanOrEqual(70); expect(bar.pixels).toBeGreaterThan(15);
+});
diff --git a/test/model.test.ts b/test/model.test.ts
new file mode 100644
index 0000000..f3fa2eb
--- /dev/null
+++ b/test/model.test.ts
@@ -0,0 +1,50 @@
+import { expect, test } from "bun:test";
+import { createRoot } from "solid-js";
+import { createOffloadClient } from "@pocketjs/framework/offload";
+import { runFrameHooks, resetFrameHooks } from "../runtime/framework/src/frame.ts";
+import { __setAnalog } from "../runtime/framework/src/analog.ts";
+import { __advanceClock, resetClock } from "../runtime/framework/src/clock.ts";
+import { BTN } from "@pocketjs/framework/input";
+import { createMap } from "../app/model.ts";
+test("camera keeps moving while host requests wait; search is an explicit bounded command", () => {
+ resetFrameHooks();
+ createRoot(dispose => {
+ const sent: string[] = [];
+ const io = createOffloadClient({ session: () => 1, submit: raw => { sent.push(raw); return true; }, take: () => undefined, uploadImage: () => 1, releaseImage() {} });
+ const s = createMap(io); const before = s.camera.view().y;
+ for (let n = 0; n < 60; n++) { __setAnalog(0x80ff); runFrameHooks(0); io.step(); }
+ expect(s.camera.view().y).toBeGreaterThan(before); expect(sent).toHaveLength(1); expect(io.pending()).toBe(1);
+ s.openSearch(); s.key("a"); s.key("SPACE"); s.key("b"); expect(s.query()).toBe("a b");
+ expect(s.submitted()).toBeUndefined(); s.search(); expect(s.submitted()?.query).toBe("a b"); expect(s.mode()).toBe("results");
+ s.dismiss(); runFrameHooks(BTN.LTRIGGER); expect(s.menu()).toBe("places");
+ runFrameHooks(BTN.LTRIGGER | BTN.CIRCLE); expect(s.mode()).toBe("search"); expect(s.menu()).toBeUndefined();
+ dispose(); io.dispose();
+ });
+ resetFrameHooks();
+});
+
+test("held full stick travels at the same speed across missed and uneven presentation intervals", () => {
+ const cadences = [60, 45, 30].map(hz => Array.from({ length: hz }, (_, i) => Math.round((i + 1) * 1e6 / hz) - Math.round(i * 1e6 / hz)));
+ cadences.push(Array.from({ length: 30 }, (_, i) => [16667, 33333, 50000][i % 3]));
+ for (const analog of [0xff80, 0x0080, 0x80ff, 0x8000]) for (const cadence of cadences) {
+ resetFrameHooks(); resetClock();
+ createRoot(dispose => {
+ const io = createOffloadClient({ session: () => 1, submit: () => true, take: () => undefined, uploadImage: () => 1, releaseImage() {} });
+ const s = createMap(io);
+ function frame(us: number) { __advanceClock(us); __setAnalog(analog); runFrameHooks(0); io.step(); }
+ for (let i = 0; i < 60; i++) frame(16667); // Settle the intentional acceleration.
+ const start = s.camera.view();
+ let previous = start;
+ for (const us of cadence) {
+ frame(us); const next = s.camera.view();
+ const speed = Math.hypot(next.x - previous.x, next.y - previous.y) * next.scale * 1e6 / us;
+ expect(Math.abs(speed - 320)).toBeLessThan(.001); previous = next;
+ }
+ expect(Math.hypot(previous.x - start.x, previous.y - start.y) * start.scale).toBeCloseTo(320, 3);
+ frame(3e6); const resumed = s.camera.view();
+ expect(Math.hypot(resumed.x - previous.x, resumed.y - previous.y) * start.scale).toBeLessThan(21.334);
+ dispose(); io.dispose();
+ });
+ }
+ resetClock(); resetFrameHooks(); __setAnalog(0x8080);
+});
diff --git a/test/navigation.test.ts b/test/navigation.test.ts
new file mode 100644
index 0000000..50979ad
--- /dev/null
+++ b/test/navigation.test.ts
@@ -0,0 +1,147 @@
+import { expect, test } from "bun:test";
+import { createRoot } from "solid-js";
+import { createTileCamera } from "@pocketjs/framework/tile-viewport";
+import { createAnnotations } from "../app/annotations.ts";
+import { createResourceRuntime } from "@pocketjs/framework/resource-view";
+import { createMapPrediction } from "../app/prediction.ts";
+import { createMap } from "../app/model.ts";
+import { createOffloadClient } from "@pocketjs/framework/offload";
+import { runFrameHooks, resetFrameHooks } from "../runtime/framework/src/frame.ts";
+import { BTN } from "@pocketjs/framework/input";
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { prepareMarkers, MarkerIndex } from "../host/markers.ts";
+import { MapService } from "../host/service.ts";
+import { ATLAS_FORMAT } from "../host/atlas.ts";
+import { defaultConfig } from "../host/config.ts";
+import type { MapInfo } from "../shared/types.ts";
+const atlas: MapInfo = { source: "123456789abcdef0", name: "Hyrule", attribution: "Test", kind: "hyrule", space: "planar", local: true, minZoom: 0, maxZoom: 7,
+ home: { id: "home", name: "Plateau", detail: "", space: "planar", x: 128, y: 128, zoom: 4 }, maps: [{ kind: "hyrule", name: "Hyrule" }, { kind: "osm", name: "OSM" }] };
+const osm: MapInfo = { source: "abcdef0123456789", name: "OSM", attribution: "Test", kind: "osm", maxZoom: 18, maps: atlas.maps };
+test("repeated diagonal strokes keep their forward corridor across lifts; zoom predicts only one next local level", () => {
+ const c = createTileCamera({ width: 400, height: 240, x: 128, y: 128, zoom: 4, minZoom: 0, maxZoom: 7 });
+ const p = createMapPrediction(); p.reset(c.view());
+ for (let stroke = 0; stroke < 4; stroke++) {
+ c.beginDrag(); for (let n = 0; n < 20; n++) { c.drag(-3, 3); p.sample(c.view(), 1 / 60, false); }
+ c.endDrag(0, 0); for (let n = 0; n < 40; n++) p.sample(c.view(), 1 / 60, false);
+ }
+ let plan = p.plan(c.view(), 4, atlas);
+ expect(plan.intent.x).toBeGreaterThan(350); expect(plan.intent.y).toBeLessThan(-350);
+ expect(plan.extra.length).toBeGreaterThan(4); expect(plan.extra.length).toBeLessThanOrEqual(12);
+ expect(plan.extra.every(t => ((t.column + .5) * 256 / 16 - c.view().x) * plan.intent.x + ((t.row + .5) * 256 / 16 - c.view().y) * plan.intent.y > 0)).toBe(true);
+ c.zoomBy(1); p.zoom(1); for (let n = 0; n < 60; n++) { c.step(1 / 60); p.sample(c.view(), 1 / 60, false); }
+ plan = p.plan(c.view(), 5, atlas); expect(plan.extra.some(t => t.input.z === 6)).toBe(true);
+ expect(plan.extra.every(t => t.input.z === 5 || t.input.z === 6)).toBe(true);
+ expect(p.plan(c.view(), 5, osm).extra.every(t => t.input.z === 5)).toBe(true);
+ expect(p.plan(c.view(), 5, osm).extra.length).toBeLessThanOrEqual(4);
+ p.zoom(-1); expect(p.plan(c.view(), 5, atlas).extra.every(t => t.input.z === 5)).toBe(true);
+ p.zoom(1); for (let n = 0; n < 301; n++) p.sample(c.view(), 1 / 60, false);
+ expect(p.plan(c.view(), 5, atlas).extra.every(t => t.input.z === 5)).toBe(true);
+});
+test("ZL chord owns the D-pad; source changes fence old reads and restore each camera", () => {
+ resetFrameHooks();
+ createRoot(dispose => {
+ const replies: string[] = [], sent: any[] = []; let withheld = false;
+ const io = createOffloadClient({ session: () => 1, submit(raw) {
+ const r = JSON.parse(raw), payload = JSON.parse(r.payload); sent.push({ ...r, payload });
+ if (r.method === "map.info") replies.push(JSON.stringify({ id: r.id, payload: JSON.stringify(payload.kind === "osm" ? osm : atlas) }));
+ else if (!withheld) replies.push(JSON.stringify({ id: r.id, error: "Fixture has no images" }));
+ return true;
+ }, take: () => replies.shift(), uploadImage: () => 1, releaseImage() {} });
+ const s = createMap(io), frames = (n: number, buttons = 0) => { for (let i = 0; i < n; i++) { runFrameHooks(buttons); io.step(); } };
+ frames(10); expect(s.planar()).toBe(true);
+ const at = s.camera.view(); frames(1, BTN.ZL | BTN.UP); frames(20, BTN.ZL);
+ expect(s.zoomHeld()).toBe(true); expect(s.camera.view().zoom).toBe(5);
+ expect(s.camera.view().x).toBe(at.x); expect(s.camera.view().y).toBe(at.y);
+ frames(1); expect(s.zoomHeld()).toBe(false);
+ frames(1, BTN.ZL | BTN.DOWN); frames(50, BTN.ZL | BTN.DOWN); frames(20);
+ expect(s.camera.view().zoom).toBeLessThan(4);
+ s.camera.jump(100, 130, 5); withheld = true; frames(4);
+ s.switchMap("osm"); frames(10); expect(s.info()?.kind).toBe("osm"); expect(s.planar()).toBe(false);
+ expect(s.lookAhead().every(t => t.input.source === osm.source)).toBe(true);
+ s.camera.jump(200, 100, 12); s.switchMap("hyrule"); frames(10);
+ expect(s.camera.view()).toMatchObject({ x: 100, y: 130, zoom: 5 });
+ expect(s.submitted()).toBeUndefined(); expect(s.pin()).toBeUndefined();
+ s.saveCurrent(); s.saved.changeName("Home"); s.saved.submit(); frames(1);
+ const command = sent.findLast(r => r.method === "bookmarks.command"); expect(command.payload.source).toBe(atlas.source);
+ s.switchMap("osm"); expect(s.info()?.kind).toBe("hyrule"); // In-flight write owns its source.
+ dispose(); io.dispose();
+ }); resetFrameHooks();
+});
+test("marker index filters zoom/category and bounds replies; delayed bookmarks retain their original source", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "map-navigation-")); mkdirSync(join(dir, "markers"));
+ try {
+ const group = (name: string, count: number) => [{ name, layers: [{ markers: Array.from({ length: count }, (_, n) => ({ id: n, name: name + n, coords: [-n * 35, n * 45] })) }] }];
+ for (const [file, groups] of [["locations", group("Landmark", 40)], ["pins", group("Shrine", 40)], ["seeds", group("Central", 40)], ["treasures", group("Gems", 40)]] as const) await Bun.write(join(dir, `markers/${file}.json`), JSON.stringify(groups));
+ expect(await prepareMarkers(dir, dir)).toBe(160);
+ const index = new MarkerIndex(join(dir, "markers.sqlite"));
+ const all = index.query({ source: atlas.source, z: 6, x: 16, y: 16, layer: "all" });
+ expect(all.length).toBeGreaterThan(0); expect(all.length).toBeLessThanOrEqual(12); expect(JSON.stringify(all).length).toBeLessThan(2500);
+ expect(index.query({ source: atlas.source, z: 6, x: 16, y: 16, layer: "collectibles" }).every(m => m[2] === "seed" || m[2] === "treasure")).toBe(true);
+ expect(index.query({ source: atlas.source, z: 2, x: 1, y: 1, layer: "collectibles" })).toEqual([]);
+ expect(index.query({ source: atlas.source, z: 6, x: 16, y: 16, layer: "off" })).toEqual([]);
+ expect(() => index.query({ source: atlas.source, z: 6, x: 32, y: 0, layer: "all" })).toThrow(); index.close();
+ const db = new Database(join(dir, "atlas.sqlite")); db.exec("CREATE TABLE metadata(key TEXT PRIMARY KEY,value TEXT)");
+ db.query("INSERT INTO metadata VALUES ('manifest',?)").run(JSON.stringify({ format: ATLAS_FORMAT, tiles: 21845, info: atlas })); db.close();
+ const service = new MapService({ ...defaultConfig, format: "raster", tileURL:"https://tile.openstreetmap.de/{z}/{x}/{y}.png", cache: ":memory:", atlas: dir, kind: "hyrule" }, () => { throw new Error("Network must not run"); });
+ try {
+ const methods = service.methods(), before = JSON.parse(methods["map.info"]('{}'));
+ const command = JSON.stringify({ source: before.source, op: "source_saved_1", kind: "save", place: atlas.home });
+ const receipt = methods["bookmarks.command"](command);
+ const other = JSON.parse(methods["map.info"]('{"kind":"osm"}'));
+ expect(other.kind).toBe("osm"); expect(methods["bookmarks.command"](command)).toBe(receipt);
+ expect(JSON.parse(methods["bookmarks.list"](JSON.stringify({ source: other.source, offset: 0 }))).total).toBe(0);
+ expect(JSON.parse(methods["bookmarks.list"](JSON.stringify({ source: before.source, offset: 0 }))).total).toBe(1);
+ expect(() => methods["bookmarks.command"](JSON.stringify({ ...JSON.parse(command), source: "wrong" }))).toThrow();
+ } finally { service.close(); }
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+});
+
+test("prediction reuse never delays a newly exposed tile and expires within a directional turn", () => {
+ const c = createTileCamera({ width: 400, height: 240, x: 128, y: 128, zoom: 4, minZoom: 0, maxZoom: 7 });
+ const p = createMapPrediction(); p.reset(c.view());
+ const first = p.plan(c.view(), 4, atlas);
+ c.drag(-5, 0); p.sample(c.view(), 1 / 60, false);
+ expect(p.plan(c.view(), 4, atlas)).toBe(first);
+ // The right viewport edge crosses a column before the 12 Hz refresh.
+ c.drag(-55, 0); p.sample(c.view(), 1 / 60, false);
+ const crossed = p.plan(c.view(), 4, atlas);
+ expect(crossed).not.toBe(first);
+ expect(crossed.visible.some(t => !first.visible.some(o => o.column === t.column && o.row === t.row))).toBe(true);
+ for (let i = 0; i < 5; i++) p.sample(c.view(), 1 / 60, false);
+ expect(p.plan(c.view(), 4, atlas)).not.toBe(crossed);
+});
+
+test("vector label candidates do not mount hidden UI and admit at most one new identity per frame", () => {
+ resetFrameHooks();
+ createRoot(dispose => {
+ const replies: string[] = [];
+ let requests = 0;
+ const io = createOffloadClient({ session: () => 1, submit(raw) {
+ const r = JSON.parse(raw), offset = requests++ * 12;
+ const rows = Array.from({ length: 12 }, (_, i) => [offset + i, `地点${offset + i}`, "landmark",
+ 128 + ((i % 3) * 120 + 70 - 200) / 16384, 128 + (Math.floor(i / 3) * 40 + 45 - 120) / 16384]);
+ replies.push(JSON.stringify({ id: r.id, payload: JSON.stringify(rows) })); return true;
+ }, take: () => replies.shift(), uploadImage: () => 1, releaseImage() {} });
+ const runtime = createResourceRuntime({ maxConcurrent: 3, startsPerFrame: 1, completionsPerFrame: 1, maxCollections: 1 });
+ const labels = createAnnotations(io, runtime), view = { x: 128, y: 128, zoom: 14 }, info: MapInfo = { ...osm, render: "mesh", dataZoom: 14, markers: true };
+ let previous = new Set();
+ for (let i = 0; i < 50; i++) {
+ labels.update(view, 14, info); runtime.step(); io.step();
+ const current = labels.renderRows();
+ expect(current.length).toBeLessThanOrEqual(12);
+ expect(current.filter(m => !previous.has(m[0])).length).toBeLessThanOrEqual(1);
+ previous = new Set(current.map(m => m[0]));
+ }
+ expect(labels.rows().length).toBeGreaterThan(12);
+ expect(labels.renderRows()).toHaveLength(12);
+ const settled = labels.renderRows(); labels.update(view, 14, info);
+ expect(labels.renderRows()).toBe(settled);
+ labels.setLayer("off"); labels.update(view, 14, info);
+ expect(labels.renderRows()).toHaveLength(0);
+ labels.reset(); expect(labels.placement(0)).toBeUndefined();
+ dispose(); io.dispose();
+ }); resetFrameHooks();
+});
diff --git a/test/provider.test.ts b/test/provider.test.ts
new file mode 100644
index 0000000..a4187b4
--- /dev/null
+++ b/test/provider.test.ts
@@ -0,0 +1,68 @@
+import { expect, test } from "bun:test";
+import { createCanvas } from "@napi-rs/canvas";
+import { HttpCache, type NetworkFetch } from "../host/cache.ts";
+import { MapProvider, defaultConfig, packRGB } from "../host/provider.ts";
+const png = () => { const c = createCanvas(256, 256), ctx = c.getContext("2d"); ctx.fillStyle = "red"; ctx.fillRect(0, 0, 128, 256); ctx.fillStyle = "blue"; ctx.fillRect(128, 0, 128, 256); return c.toBuffer("image/png"); };
+test("host decodes PNGs, keeps exact PSM color order, deduplicates and rejects arbitrary URLs", async () => {
+ let calls = 0;
+ const provider = new MapProvider({ ...defaultConfig, format: "raster", tileURL:"https://tile.openstreetmap.de/{z}/{x}/{y}.png", cache: ":memory:" }, (async () => { calls++; return new Response(png()); }) as NetworkFetch);
+ try {
+ const input = { source: provider.info.source, z: 1, x: 0, y: 0 };
+ const [a, b] = await Promise.all([provider.tile(input), provider.tile(input)]);
+ expect(a).toBe(b); expect(calls).toBe(1); expect(a.pixels.length).toBe(131072);
+ expect([...a.pixels.slice(0, 2)]).toEqual([31, 0]); expect([...a.pixels.slice(256, 258)]).toEqual([0, 248]);
+ await provider.tile(input); expect(calls).toBe(1);
+ await expect(provider.tile({ ...input, x: -1 })).rejects.toThrow();
+ await expect(provider.tile({ ...input, source: "other" })).rejects.toThrow();
+ await expect(provider.tile({ ...input, z: 99 })).rejects.toThrow();
+ expect(calls).toBe(1);
+ expect([...packRGB(new Uint8Array([0, 0, 0, 0]), 1, 1).pixels]).toEqual([255, 255]);
+ } finally { provider.close(); }
+});
+test("host cache persists reuse and conditional expiry, bounds bodies and honors no-store", async () => {
+ let calls = 0, conditional = false;
+ const cache = new HttpCache(":memory:", (async (_url, options) => {
+ calls++; conditional = !!(options?.headers as Record)["If-None-Match"];
+ return calls === 1 ? new Response("cached", { headers: { etag: "one", "cache-control": "max-age=0" } }) : new Response(null, { status: 304, headers: { "cache-control": "max-age=3600" } });
+ }) as NetworkFetch);
+ try {
+ const opts = { maxBytes: 20, ttl: 1000 };
+ await cache.get("https://example.com/tile", opts); const b = await cache.get("https://example.com/tile", opts); await cache.get("https://example.com/tile", opts);
+ expect(conditional).toBe(true); expect(calls).toBe(2); expect(new TextDecoder().decode(b)).toBe("cached");
+ } finally { cache.close(); }
+ const huge = new HttpCache(":memory:", (async () => new Response(new Uint8Array(40))) as NetworkFetch);
+ await expect(huge.get("https://example.com", { maxBytes: 20, ttl: 1000 })).rejects.toThrow("budget"); huge.close();
+ let n = 0;
+ const noStore = new HttpCache(":memory:", (async () => { n++; return new Response("ok", { headers: { "cache-control": "no-store" } }); }) as NetworkFetch);
+ await noStore.get("https://example.com", { maxBytes: 20, ttl: 1000 }); await noStore.get("https://example.com", { maxBytes: 20, ttl: 1000 }); expect(n).toBe(2); noStore.close();
+});
+test("malformed dimensions cannot trigger an unbounded host image decode", async () => {
+ const bytes = png(); bytes.writeUInt32BE(100000, 16);
+ const provider = new MapProvider({ ...defaultConfig, format: "raster", tileURL:"https://tile.openstreetmap.de/{z}/{x}/{y}.png", cache: ":memory:" }, (async () => new Response(bytes)) as NetworkFetch);
+ await expect(provider.tile({ source: provider.info.source, z: 1, x: 0, y: 0 })).rejects.toThrow("256px"); provider.close();
+});
+test("real Bun fetch accepts conditional 304 without following redirects", async () => {
+ let calls = 0, followed = 0;
+ const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(request) {
+ if (new URL(request.url).pathname === "/redirect") return new Response(null, { status: 302, headers: { location: "/forbidden" } });
+ if (new URL(request.url).pathname === "/forbidden") { followed++; return new Response("unexpected"); }
+ calls++;
+ return request.headers.has("if-none-match") ? new Response(null, { status: 304, headers: { "cache-control": "max-age=3600" } }) : new Response("tile", { headers: { etag: "v1", "cache-control": "max-age=0" } });
+ } });
+ const cache = new HttpCache(":memory:"); const options = { maxBytes: 20, ttl: 1000 }, url = `http://127.0.0.1:${server.port}`;
+ try {
+ await cache.get(url, options); expect(new TextDecoder().decode(await cache.get(url, options))).toBe("tile");
+ await cache.get(url, options); expect(calls).toBe(2);
+ await expect(cache.get(url + "/redirect", options)).rejects.toThrow("302"); expect(followed).toBe(0);
+ } finally { cache.close(); server.stop(true); }
+});
+test("search handles empty, malformed and duplicate results with a bounded response", async () => {
+ const feature = { properties: { name: "Tokyo", osm_type: "N", osm_id: 1, country: "Japan", type: "city" }, geometry: { coordinates: [139.76, 35.68] } };
+ const provider = new MapProvider({ ...defaultConfig, format: "raster", tileURL:"https://tile.openstreetmap.de/{z}/{x}/{y}.png", cache: ":memory:" }, (async url => {
+ expect(new URL(String(url)).searchParams.get("limit")).toBe("5");
+ return new Response(JSON.stringify({ features: [feature, feature, { ...feature, geometry: { coordinates: [500, 1000] } }] }));
+ }) as NetworkFetch);
+ expect(await provider.search({ query: " ", lat: 0, lon: 0 })).toEqual([]);
+ expect(await provider.search({ query: "Tokyo", lat: 0, lon: 0 })).toEqual([{ id: "N1", name: "Tokyo", detail: "Japan", lat: 35.68, lon: 139.76, zoom: 13 }]);
+ await expect(provider.search({ query: "x".repeat(81), lat: 0, lon: 0 })).rejects.toThrow(); provider.close();
+});
diff --git a/test/transport.test.ts b/test/transport.test.ts
new file mode 100644
index 0000000..14258fb
--- /dev/null
+++ b/test/transport.test.ts
@@ -0,0 +1,50 @@
+import { test, expect } from "bun:test";
+import { createServer, type Socket } from "node:net";
+import { createCanvas } from "@napi-rs/canvas";
+import { connectOffloadProvider } from "@pocketjs/framework/offload/provider";
+import { encodeOffloadRecord } from "../runtime/tools/offload-wire.ts";
+import { defaultConfig } from "../host/config.ts";
+function queue() {
+ const items: T[] = [], waits: ((value: T) => void)[] = [];
+ return { push(v: T) { const take = waits.shift(); if (take) take(v); else items.push(v); },
+ take(): Promise { return items.length ? Promise.resolve(items.shift()!) : new Promise(resolve => waits.push(resolve)); } };
+}
+test("actual map provider reconnects during PNG fetch and returns intact image pixels in a fresh process", async () => {
+ const canvas = createCanvas(256, 256), ctx = canvas.getContext("2d"); ctx.fillStyle = "red"; ctx.fillRect(0, 0, 256, 256);
+ const png = canvas.toBuffer("image/png"), fetched = queue(); let hold = true;
+ const http = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch() { fetched.push(); if (hold) await Bun.sleep(250); return new Response(png); } });
+ const peers = queue<{ socket: Socket; read(): Promise; send(method: string, id: number, payload?: unknown, image?: boolean): void }>();
+ const sockets = new Set();
+ const server = createServer(socket => {
+ sockets.add(socket); socket.on("close", () => sockets.delete(socket)); socket.on("error", () => {});
+ let paired = false, buffer = Buffer.alloc(0); const replies = queue();
+ socket.on("data", bytes => {
+ buffer = Buffer.concat([buffer, Buffer.from(bytes)]);
+ if (!paired) {
+ if (buffer.length < 64) return;
+ expect(buffer.subarray(0, 64).toString()).toBe("ab".repeat(32)); buffer = buffer.subarray(64); paired = true;
+ peers.push({ socket, read: replies.take, send(method, id, payload = {}, image = false) { socket.write(encodeOffloadRecord(JSON.stringify({ v: 1, id, method, payload: JSON.stringify(payload), ...(image ? { response: "image" } : {}) }))); } });
+ }
+ while (buffer.length >= 4) {
+ const length = buffer.readUInt32BE() & 0x7fffffff; expect(length).toBeLessThanOrEqual(131088);
+ if (buffer.length < length + 4) break;
+ replies.push(buffer.subarray(4, length + 4)); buffer = buffer.subarray(length + 4);
+ }
+ });
+ });
+ await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
+ const provider = connectOffloadProvider({ address: "127.0.0.1", port: (server.address() as { port: number }).port,
+ key: "ab".repeat(32), isolation: "process", worker: new URL("../host/worker.ts", import.meta.url),
+ data: { ...defaultConfig, format: "raster", tileURL: `${http.url}{z}/{x}/{y}.png`, searchURL: String(http.url), cache: ":memory:" } });
+ try {
+ for (let n = 0; n < 3; n++) {
+ const peer = await peers.take(); peer.send("map.info", 1); const info = JSON.parse(JSON.parse((await peer.read()).toString()).payload);
+ peer.send("map.tile", 2, { source: info.source, z: 1, x: 0, y: 0 }, true); await fetched.take(); peer.socket.destroy();
+ }
+ hold = false;
+ const peer = await peers.take(); peer.send("map.info", 1); const info = JSON.parse(JSON.parse((await peer.read()).toString()).payload);
+ peer.send("map.tile", 2, { source: info.source, z: 1, x: 0, y: 0 }, true);
+ const image = await peer.read(); expect(image.length).toBe(131088); expect(image.subarray(0, 4).toString()).toBe("PIMG"); expect(image.readUInt32LE(4)).toBe(2);
+ for (let i = 16; i < image.length; i += 2) if (image[i] !== 31 || image[i + 1] !== 0) throw new Error(`Corrupted RGB565 pixel at ${i}`);
+ } finally { provider.close(); for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(() => resolve())); http.stop(true); }
+}, 12000);
diff --git a/test/vector-fixture.ts b/test/vector-fixture.ts
new file mode 100644
index 0000000..4e79e49
--- /dev/null
+++ b/test/vector-fixture.ts
@@ -0,0 +1,129 @@
+import { PbfWriter } from "pbf";
+export type Feature = { type: number; paths: [number, number][][]; properties: Record };
+export function vectorFixture(dense = false): Uint8Array {
+ const polygon = (paths: [number, number][][], properties: Record = {}): Feature => ({
+ type: 3,
+ paths,
+ properties,
+ });
+ const line = (points: [number, number][], name: string): Feature => ({
+ type: 2,
+ paths: [points],
+ properties: { kind: "residential", name },
+ });
+ const road: Feature[] = [];
+ for (let n = 0; n < 6; n++)
+ road.push(
+ line(
+ [
+ [-5, 24 + n * 38],
+ [260, 24 + n * 38],
+ ],
+ `Street ${n}`,
+ ),
+ line(
+ [
+ [24 + n * 38, -5],
+ [24 + n * 38, 260],
+ ],
+ `Avenue ${n}`,
+ ),
+ );
+ const layers: Record = {
+ ocean: [
+ polygon([
+ [
+ [180, -5],
+ [260, -5],
+ [260, 260],
+ [180, 260],
+ ],
+ ]),
+ ],
+ land: [
+ polygon(
+ [
+ [
+ [40, 40],
+ [160, 40],
+ [160, 145],
+ [40, 145],
+ ],
+ [
+ [70, 70],
+ [70, 110],
+ [115, 110],
+ [115, 70],
+ ],
+ ],
+ { kind: "park" },
+ ),
+ ],
+ streets: road,
+ street_labels: road,
+ place_labels: [{ type: 1, paths: [[[130, 185]]], properties: { name: "日本橋", kind: "suburb" } }],
+ };
+ if (dense)
+ layers.buildings = Array.from({ length: 1600 }, (_, i) => {
+ const x = (i % 40) * 6,
+ y = Math.floor(i / 40) * 6;
+ return polygon([
+ [
+ [x, y],
+ [x + 4, y],
+ [x + 4, y + 4],
+ [x, y + 4],
+ ],
+ ]);
+ });
+ return encodeVectorFixture(layers);
+}
+
+export function encodeVectorFixture(layers: Record): Uint8Array {
+ const output = new PbfWriter();
+ for (const [name, features] of Object.entries(layers))
+ output.writeMessage(
+ 3,
+ (_, p) => {
+ p.writeVarintField(15, 2);
+ p.writeStringField(1, name);
+ p.writeVarintField(5, 4096);
+ const keys = [...new Set(features.flatMap((f) => Object.keys(f.properties)))],
+ values = [...new Set(features.flatMap((f) => Object.values(f.properties)))];
+ for (const key of keys) p.writeStringField(3, key);
+ for (const value of values) p.writeMessage(4, (v, w) => w.writeStringField(1, v), value);
+ for (const feature of features)
+ p.writeMessage(
+ 2,
+ (f, w) => {
+ w.writeVarintField(3, f.type);
+ w.writePackedVarint(
+ 2,
+ Object.entries(f.properties).flatMap(([k, v]) => [keys.indexOf(k), values.indexOf(v)]),
+ );
+ const commands: number[] = [];
+ let x = 0,
+ y = 0;
+ const zig = (n: number) => (n << 1) ^ (n >> 31);
+ for (const path of f.paths) {
+ if (!path.length) continue;
+ commands.push(9);
+ path.forEach((pt, i) => {
+ if (i === 1) commands.push(((path.length - 1) << 3) | 2);
+ const nx = Math.round(pt[0] * 16),
+ ny = Math.round(pt[1] * 16);
+ commands.push(zig(nx - x), zig(ny - y));
+ x = nx;
+ y = ny;
+ });
+ if (f.type === 3) commands.push(15);
+ }
+ w.writePackedVarint(4, commands);
+ },
+ feature,
+ );
+ },
+ null,
+ );
+ return output.finish();
+}
diff --git a/test/vector.test.ts b/test/vector.test.ts
new file mode 100644
index 0000000..3c32496
--- /dev/null
+++ b/test/vector.test.ts
@@ -0,0 +1,155 @@
+import { expect, test } from "bun:test";
+import { prepareVector, MeshBuilder, joinLinePaths, generalizeLinePaths } from "../host/vector-geometry.ts";
+import { MapProvider, defaultConfig } from "../host/provider.ts";
+import { vectorFixture, encodeVectorFixture, type Feature } from "./vector-fixture.ts";
+import { createMapPrediction } from "../app/prediction.ts";
+import { validateMesh } from "../runtime/tools/offload-wire.ts";
+test("vector tessellation preserves holes, clips triangles and quantizes shared edges", () => {
+ const b = new MeshBuilder();
+ b.polygon(
+ [
+ [
+ { x: -10, y: -10 },
+ { x: 270, y: -10 },
+ { x: 270, y: 270 },
+ { x: -10, y: 270 },
+ ],
+ [
+ { x: 80, y: 80 },
+ { x: 80, y: 176 },
+ { x: 176, y: 176 },
+ { x: 176, y: 80 },
+ ],
+ ],
+ 0xff00ff00,
+ );
+ const area = b.faces.reduce((sum, f) => {
+ const [a, c, d] = f.slice(0, 3).map((i) => b.points[i]);
+ return sum + Math.abs((c[0] - a[0]) * (d[1] - a[1]) - (c[1] - a[1]) * (d[0] - a[0])) / 512;
+ }, 0);
+ expect(area).toBe(256 * 256 - 96 * 96);
+ expect(validateMesh(b.entry().bytes).width).toBe(256);
+ expect(b.points.every((p) => p.every((v) => v >= 0 && v <= 4096))).toBe(true);
+});
+test("dense cities reduce complete detail layers within the device envelope", () => {
+ const normal = prepareVector(vectorFixture(), 14),
+ dense = prepareVector(vectorFixture(true), 14);
+ for (const p of [normal, dense]) {
+ expect(p.mesh.bytes.length).toBeLessThan(36881);
+ expect(p.vertices).toBeLessThan(4097);
+ expect(p.triangles).toBeLessThan(2049);
+ expect(p.labels.length).toBeLessThan(513);
+ expect(validateMesh(p.mesh.bytes).height).toBe(256);
+ }
+ expect(dense.simplified).toBeGreaterThanOrEqual(2);
+ expect(normal.labels.some((l) => l.name === "日本橋")).toBe(true);
+ expect(() => prepareVector(new Uint8Array(2 * 1024 * 1024 + 1), 14)).toThrow();
+});
+test("vector geometry and labels deduplicate one cached MVT; invalid source/LOD cannot fetch", async () => {
+ let calls = 0;
+ const p = new MapProvider({ ...defaultConfig, cache: ":memory:" }, async () => {
+ calls++;
+ return new Response(vectorFixture(), { headers: { "cache-control": "max-age=3600" } });
+ });
+ try {
+ const input = { source: p.info.source, z: 14, x: 2621, y: 6332 };
+ const [a, rows, b] = await Promise.all([p.mesh(input), p.markerRows(input), p.mesh(input)]);
+ expect(a).toBe(b);
+ expect(rows.length).toBeGreaterThan(0);
+ expect(calls).toBe(1);
+ await p.markerRows({ ...input, z: 18, x: input.x * 16 + 1, y: input.y * 16 + 1 });
+ await p.mesh(input);
+ expect(calls).toBe(1);
+ expect(p.info.render).toBe("mesh");
+ expect(p.info.maxZoom).toBe(18);
+ expect(p.info.dataZoom).toBe(14);
+ await expect(p.mesh({ ...input, z: 15 })).rejects.toThrow();
+ await expect(p.mesh({ ...input, source: "wrong" })).rejects.toThrow();
+ expect(calls).toBe(1);
+ } finally {
+ p.close();
+ }
+});
+test("public OSMF vector configuration never schedules unseen look-ahead", () => {
+ const p = createMapPrediction(),
+ v = { x: 128, y: 128, zoom: 4, targetZoom: 4, scale: 16, moving: true };
+ p.reset(v);
+ p.sample({ ...v, x: 130 }, 1 / 60, true);
+ expect(
+ p.plan(v, 4, {
+ source: "fixture",
+ name: "OSM",
+ attribution: "OSM",
+ maxZoom: 18,
+ render: "mesh",
+ dataZoom: 14,
+ prefetch: false,
+ }).extra,
+ ).toHaveLength(0);
+});
+
+test("coarse road joining preserves junctions, loops and every source edge", () => {
+ const point = (x: number, y: number) => ({ x, y });
+ const paths = [
+ [point(2, 0), point(1, 0)], [point(0, 0), point(1, 0)],
+ [point(2, 0), point(3, 0)], [point(2, 0), point(2, 1)],
+ [point(10, 0), point(11, 0)], [point(11, 0), point(11, 1)],
+ [point(11, 1), point(10, 0)],
+ ];
+ const joined = joinLinePaths(paths);
+ expect(joined).toHaveLength(4);
+ expect(joined.reduce((sum, p) => sum + p.length - 1, 0)).toBe(7);
+ expect(joined.some((p) => p.length === 4 && p[0].x === p[3].x && p[0].y === p[3].y)).toBe(true);
+ expect(joined.filter((p) => [p[0], p.at(-1)!].some((v) => v.x === 2 && v.y === 0))).toHaveLength(3);
+ const generalized = generalizeLinePaths([
+ [point(0.1, 0.1), point(1.1, 0.1), point(2.1, 0.1)],
+ [point(2.2, 0.2), point(1.2, 0.2), point(0.2, 0.2)],
+ ], 1);
+ expect(generalized).toHaveLength(1);
+ expect(generalized[0]).toHaveLength(3);
+});
+
+test("dense land parcels and fragmented major roads stay available without increasing device limits", () => {
+ const streets: Feature[] = [], landcover: Feature[] = [];
+ for (let row = 0; row < 12; row++)
+ for (let x = 0; x < 256; x++)
+ streets.push({ type: 2, paths: [[[x, 8 + row * 20], [x + 1, 8 + row * 20]]], properties: { kind: "motorway" } });
+ for (let i = 0; i < 1600; i++) {
+ const x = (i % 40) * 6, y = Math.floor(i / 40) * 6;
+ landcover.push({ type: 3, properties: { kind: "grass" }, paths: [[[x, y], [x + 2, y], [x + 2, y + 2], [x, y + 2]]] });
+ }
+ const prepared = prepareVector(encodeVectorFixture({ streets, landcover }), 12);
+ expect(prepared.simplified).toBeGreaterThanOrEqual(6);
+ expect(validateMesh(prepared.mesh.bytes).width).toBe(256);
+ expect(prepared.vertices).toBeLessThanOrEqual(4096);
+ expect(prepared.triangles).toBeLessThanOrEqual(2048);
+ expect(prepared.triangles).toBeGreaterThan(2); // Main roads survive.
+});
+
+test("last detail pass admits complete dense features and preserves the base plane", () => {
+ const streets: Feature[] = [];
+ // Disconnected crossing roads cannot be reduced by joining their endpoints.
+ for (let i = 0; i < 3000; i++) {
+ const a = (i % 65) * 4, b = Math.floor(i / 65) * 4;
+ streets.push({ type: 2, paths: [[[0, a], [256, b]]], properties: { kind: "motorway" } });
+ }
+ const prepared = prepareVector(encodeVectorFixture({ streets }), 8);
+ expect(prepared.simplified).toBe(10);
+ expect(prepared.triangles).toBeGreaterThan(2);
+ expect(prepared.triangles).toBeLessThanOrEqual(2048);
+ expect(prepared.vertices).toBeLessThanOrEqual(4096);
+ expect(validateMesh(prepared.mesh.bytes).height).toBe(256);
+ const view = new DataView(prepared.mesh.bytes.buffer, prepared.mesh.bytes.byteOffset);
+ const first = 16 + prepared.vertices * 4;
+ expect(view.getUint32(first + 6, true)).toBe(0xffe3edf0);
+});
+
+test("feature admission cannot partially mutate a full mesh", () => {
+ const full = new MeshBuilder(), extra = new MeshBuilder();
+ for (let i = 0; i < 2048; i++)
+ full.triangle({ x: 0, y: 0 }, { x: 256, y: 0 }, { x: 0, y: 256 }, 0xffffffff);
+ extra.line([{ x: 10, y: 10 }, { x: 100, y: 100 }], 2, 0xff00ff00);
+ const before = full.entry().bytes;
+ expect(full.append(extra)).toBe(false);
+ expect(full.entry().bytes).toEqual(before);
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..3224eea
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ESNext"], "types": ["bun"], "jsx": "preserve", "strict": true, "noEmit": true, "allowImportingTsExtensions": true, "skipLibCheck": true },
+ "include": ["app", "host", "shared", "scripts", "test", "runtime/framework/src/jsx.d.ts"]
+}