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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 46 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Pocket Map

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.
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**, Hyrule from **Breath of the Wild**, or the region and dungeon atlas from **Ocarina of Time**. 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).

Expand All @@ -21,6 +21,15 @@ OSM map data © [OpenStreetMap contributors](https://www.openstreetmap.org/copyr

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.

### Ocarina of Time

The third source uses [Ecksters' OoT Interactive Map](https://github.com/Ecksters/OoT-Interactive-Map/tree/020dab1b787bc18d1990653817765a1024bad43d), with Nintendo map artwork assembled by Peardian. It includes **456 searchable regions and rooms** and a complete **21,845-tile pyramid at levels 0–7**. Both Zelda maps can use prepared SD textures and retain independent camera positions and Mac bookmarks. [Prepare and install Ocarina of Time](docs/OCARINA.md).

![Ocarina of Time, Hyrule Field — native 3DS build in Azahar reading prepared SD textures without a paired Mac](docs/images/oot-hyrule-field-3ds.png)

This native Azahar capture reads the installed OoT pack through the 3DS SD
worker with **no paired Mac**. [Capture provenance](docs/images/CAPTURES.md#ocarina-of-time-2026-09-08).

## 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).
Expand Down Expand Up @@ -48,13 +57,41 @@ Exit ftpd and open **Pocket Map** in HBL. The deployment script installs only th

### 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.
**This build supports runtime guest updates on port 8131 alongside offload and
SD map reads.** Install the updated `.3dsx` once with `bun run 3ds` and
`bun run deploy <3ds-ip>`. While ftpd is open, import the console's development
pairing key into this checkout (the existing device key is preserved):

```sh
bun runtime/tools/3ds-dev.ts pair --host <3ds-ip> --ftp-port 5000
```

Exit ftpd and open Pocket Map. Subsequent JS and baked UI asset changes use:

```sh
bun run update # rebuild .pocket and discover the paired runtime
bun run update 192.168.8.102 # explicit IP when broadcast discovery is unavailable
bun runtime/tools/3ds-dev.ts probe --host 192.168.8.102 --out dist/qa/runtime.png
```

`L + R + SELECT` opens the native runtime menu. `X` requests a screenshot from
an attached client; `B` closes the menu. `L + R + X` checks an SD-staged
`pending.pocket`. The map's offload pairing and the runtime's device-wide
development pairing are separate keys.

**A native worker performs transfer, SD writes, hashing, admission and durable
commit.** The existing map keeps rendering during upload. At a GPU-idle
boundary, the runtime restarts JS and the UI tree, fences old offload/SD
requests and releases their GPU resources. It accepts the package only after
its first GPU frame retires and its generation is committed. Rejected packages
restore the previous guest; saved places and prepared terrain packs persist.
The current map position and other in-memory UI state restart with the guest.

**Guest updates are limited to 8 MiB and the embedded app's exact native plan.**
Native runtime, capability, font configuration, screen configuration or plan
changes require another `.3dsx` deployment. Terrain `.prp` updates still use
`deploy:sd`; they do not need to be retransferred for UI changes. Mac-only
provider changes need only a daemon restart.

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,
Expand All @@ -74,7 +111,7 @@ queue; it is not a device-render receipt.
| 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 |
| Map name on the lower screen | Switch OSM / Hyrule / Ocarina of Time 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 |
Expand Down
47 changes: 33 additions & 14 deletions app/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ 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 { HOME, MAP_KINDS, MAP_NAMES, ATLAS_KINDS, type AtlasKind, type TileInput, type MapInfo, type SearchInput, type Place, type MapKind } from "../shared/types.ts";
import { validAtlas, atlasPackName } from "../shared/atlas.ts";

import { createMapPrediction } from "./prediction.ts";
import { createAnnotations, LAYERS } from "./annotations.ts";
Expand Down Expand Up @@ -45,12 +46,13 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
const packs = resourcePacks();
const [useLocalTiles, setUseLocalTiles] = createSignal(true);
const [localSource, setLocalSource] = createSignal<string>();
const [installed, setInstalled] = createSignal<Partial<Record<AtlasKind, MapInfo>>>({});
const localMapAvailable = () => !!packs?.connected() && planar() && useLocalTiles() && localSource() === info()?.source;
const [tileStorage, setTileStorage] = createSignal<"local" | "desktop">("desktop");
const camera = createTileCamera({ ...viewport, x: p.x, y: p.y, zoom: HOME.zoom, minZoom: 1, maxZoom: 18, bounds: { width: 256, height: 256, wrapX: true } });
const runtime = createResourceRuntime({ maxConcurrent: 3, startsPerFrame: 1, completionsPerFrame: 1, maxCollections: 6, available: () => !switching() && !!info() && (io.connected() || !!packs && planar()) });
const rasterTiles = createPackedImageCollection<TileInput>(runtime, { key: i => `${i.source}/${i.z}/${i.x}/${i.y}`,
pack: i => useLocalTiles() && planar() ? { name: `hyrule-${i.source}-v1`, entry: 1 + (4 ** i.z - 1) / 3 + i.y * 2 ** i.z + i.x } : undefined,
pack: i => useLocalTiles() && planar() ? { name: info()?.pack ?? atlasPackName(info()?.kind === "oot" ? "oot" : "hyrule", i.source), entry: 1 + (4 ** i.z - 1) / 3 + i.y * 2 ** i.z + i.x } : undefined,
fallback: { client: reads, method: "map.tile", payload: JSON.stringify }, materialized: storage => { setTileStorage(storage); if (storage === "local") setLocalSource(info()?.source); },
width: 256, height: 256, maxEntries: tileEntries, maxViews: 2, maxDemandsPerView: 24, retry: { attempts: 3, delayFrames: 90, maxDelayFrames: 360 } });
const vector = () => info()?.render === "mesh";
Expand Down Expand Up @@ -86,8 +88,13 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
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 mapName = () => { const value = info(); return value?.kind ? MAP_NAMES[value.kind] : value?.name ?? "OpenStreetMap"; };
const maps = createMemo(() => {
const catalog = [...(info()?.maps ?? [])];
for (const kind of ATLAS_KINDS) { const local = installed()[kind]; if (local && !catalog.some(m => m.kind === kind)) catalog.push({ kind, name: local.name }); }
return catalog.map(m => ({ ...m, name: MAP_NAMES[m.kind] }));
});
const labelLayers = () => vector() ? [LAYERS[0],LAYERS[4]] : info()?.kind === "oot" ? [LAYERS[0], { id: "travel" as const, name: "Regions & dungeon rooms" }, 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); }
Expand All @@ -98,13 +105,17 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
function switchMap(kind: MapKind) {
if (saved.busy() || saved.modal() || typing() || switching()) return;
if (kind === info()?.kind) { dismiss(); return; }
const local = kind === "osm" ? undefined : installed()[kind];
if (!io.connected() && !local) { setSourceError("Connect your Mac to open this map."); setMode("sources"); 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"}...`);
setSourceError(""); setRequestedKind(kind); setSwitching(true); retryAt = 0; setMode("map"); setStatus(`Opening ${MAP_NAMES[kind]}...`);
if (local) { installInfo({ ...local, markers: false, maps: info()?.maps }); setLocalSource(local.source); }
}
let frame = 0, previousSession = 0, infoRequest = 0, retryAt = 0, shiftAt = -10, levelAge = 0, candidateLevel = HOME.zoom;
let confirmed = false;
let bootstrap = packs ? 0 : -1;
let bootstrapIndex = 0;
onCleanup(() => { if (bootstrap > 0) packs?.cancel(bootstrap); });
function installInfo(value: MapInfo) {
if (typeof value.source !== "string" || !/^[a-f0-9]{16}$/.test(value.source) || typeof value.name !== "string" || typeof value.attribution !== "string" || !Number.isInteger(value.maxZoom) || value.maxZoom < 1 || value.maxZoom > 18
Expand All @@ -113,7 +124,11 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
|| 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 (value.kind !== undefined && !MAP_KINDS.includes(value.kind)
|| value.pack !== undefined && !/^[a-z0-9-]{1,48}$/.test(value.pack)
|| value.worldUnits !== undefined && (!Number.isFinite(value.worldUnits) || value.worldUnits <= 0)) throw new Error("Invalid map identity");
if (value.maps !== undefined && (!Array.isArray(value.maps) || value.maps.length > MAP_KINDS.length || !value.maps.every(m => MAP_KINDS.includes(m.kind) && typeof m.name === "string" && m.name.length <= 40)
|| new Set(value.maps.map(m => m.kind)).size !== value.maps.length)) 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);
Expand Down Expand Up @@ -196,18 +211,22 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
if (infoRequest) { io.cancel(infoRequest); infoRequest = 0; }
runtime.cancel(); retryAt = 0;
if (session > 0) { if (!packs || !planar() || !useLocalTiles()) tiles.invalidate(); searches.invalidate(); labels.invalidate(); saved.refresh(); setStatus("Connecting map service"); }
else setStatus(localMapAvailable() ? "Hyrule from SD card" : "Mac disconnected - cached map");
else setStatus(localMapAvailable() ? "Map from SD card" : "Mac disconnected - cached map");
previousSession = session;
}
if (bootstrap === 0 && packs?.connected()) {
bootstrap = packs.request("pack.read", "hyrule/0", result => {
bootstrap = -1;
if (!result.ok || info() || switching()) return;
const kind = ATLAS_KINDS[bootstrapIndex];
bootstrap = packs.request("pack.read", `${kind}/0`, result => {
bootstrap = ++bootstrapIndex < ATLAS_KINDS.length ? 0 : -1;
if (!result.ok) return;
try {
const atlas = JSON.parse(result.value);
if (atlas.format !== "pocket-map-atlas-rgb565-v1" || atlas.tiles !== 21845 || atlas.info?.space !== "planar") return;
installInfo({ ...atlas.info, kind: "hyrule", markers: false });
setLocalSource(atlas.info.source); setStatus("Hyrule from SD card");
if (!validAtlas(atlas) || atlas.info.kind !== undefined && atlas.info.kind !== kind) return;
const local = { ...atlas.info, kind, pack: atlas.info.pack ?? atlasPackName(kind, atlas.info.source), markers: false };
setInstalled(old => ({ ...old, [kind]: local }));
if (!info() && !switching()) {
installInfo(local); setLocalSource(local.source); setStatus("Map from SD card");
}
} catch { /* Optional installation; the paired provider remains available. */ }
}) || 0;
}
Expand Down Expand Up @@ -264,7 +283,7 @@ export function createMap(io = offload(), viewport = { width: 400, height: 240 }
}
if (back() && front()?.tiles.every(t => frontView.state(t.input).status === "ready")) setBack(undefined);
});
return { viewport, io, runtime, tiles, vector, labels, frontView, backView, info, planar, online, zoomHeld, switching, sourceError, maps, choices, choosing, choose, openSources, switchMap, annotations, status, mode, setMode, query, setQuery, submitted, results, places, selection, setSelection, pin, menu, menuIndex,
return { viewport, io, runtime, tiles, vector, labels, frontView, backView, info, mapName, planar, online, zoomHeld, switching, sourceError, maps, choices, choosing, choose, openSources, switchMap, annotations, status, mode, setMode, query, setQuery, submitted, results, places, selection, setSelection, pin, menu, menuIndex,
localMapAvailable, tileStorage, useLocalTiles, setLocalTiles(value: boolean) { runtime.cancel(); setUseLocalTiles(value); tiles.clear(); },
shift, symbols, front, back, camera, saved, typing, listing, rows, selectedIndex, select, saveCurrent, lookAhead, search, openSearch, go, zoom, key, dismiss, runMenu,
clearBack: () => setBack(undefined),
Expand Down
Loading