diff --git a/README.md b/README.md index 4b5de55..b6b63ae 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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). @@ -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, @@ -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 | diff --git a/app/model.ts b/app/model.ts index 0dd65e1..d4a65a2 100644 --- a/app/model.ts +++ b/app/model.ts @@ -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"; @@ -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(); + const [installed, setInstalled] = createSignal>>({}); const localMapAvailable = () => !!packs?.connected() && planar() && useLocalTiles() && localSource() === info()?.source; const [tileStorage, setTileStorage] = createSignal<"local" | "desktop">("desktop"); const camera = createTileCamera({ ...viewport, x: p.x, y: p.y, zoom: HOME.zoom, minZoom: 1, maxZoom: 18, bounds: { width: 256, height: 256, wrapX: true } }); const runtime = createResourceRuntime({ maxConcurrent: 3, startsPerFrame: 1, completionsPerFrame: 1, maxCollections: 6, available: () => !switching() && !!info() && (io.connected() || !!packs && planar()) }); const rasterTiles = createPackedImageCollection(runtime, { key: i => `${i.source}/${i.z}/${i.x}/${i.y}`, - pack: i => useLocalTiles() && planar() ? { name: `hyrule-${i.source}-v1`, entry: 1 + (4 ** i.z - 1) / 3 + i.y * 2 ** i.z + i.x } : undefined, + 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"; @@ -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); } @@ -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 @@ -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); @@ -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; } @@ -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), diff --git a/app/ui.tsx b/app/ui.tsx index fb41680..9506673 100644 --- a/app/ui.tsx +++ b/app/ui.tsx @@ -132,9 +132,9 @@ function Deck(p: { s: MapModel }) { {search} - {typing() ? `${(naming() ? p.s.saved.name() : p.s.query()).slice(-31)}|` : saved() ? "Saved on your paired Mac" : results() ? p.s.query().slice(0, 32) : p.s.localMapAvailable() && !p.s.online() ? "Hyrule on SD - Mac for search & saves" : p.s.online() ? p.s.planar() ? "Hyrule - Breath of the Wild" : "Explore with your Nintendo 3DS" : "Waiting for paired Mac"} + {typing() ? `${(naming() ? p.s.saved.name() : p.s.query()).slice(-31)}|` : saved() ? "Saved on your paired Mac" : results() ? p.s.query().slice(0, 32) : p.s.localMapAvailable() && !p.s.online() ? "Map on SD - Mac for search & saves" : p.s.online() ? p.s.planar() ? p.s.mapName() : "Explore with your Nintendo 3DS" : "Waiting for paired Mac"} - + {(name, i) => p.s.choose(i())} />} @@ -151,7 +151,7 @@ export default function MapApp() { 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); + const scale = s.planar() ? { pixels: 64, label: `${Math.round(64 / v.scale * (s.info()?.worldUnits ?? 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 = ; @@ -180,7 +180,7 @@ export default function MapApp() { 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"}`} + {`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() ? `${s.info()?.name}\n${s.info()?.attribution}\nSD terrain / Mac search and saved places` : "Maps: OpenStreetMap contributors\nosm.org/copyright | Tiles: OSM DE\nPlace search: Photon by komoot"}`} {s.info()?.attribution ?? "Map data"} diff --git a/docs/OCARINA.md b/docs/OCARINA.md new file mode 100644 index 0000000..7de648b --- /dev/null +++ b/docs/OCARINA.md @@ -0,0 +1,81 @@ +# Ocarina of Time atlas + +Pocket Map includes Ocarina of Time as a third source alongside OpenStreetMap +and Breath of the Wild. **The existing PocketJS image collections and SD worker +load this atlas without framework changes.** + +## Prepare and install + +```sh +bun run prepare:oot +bun run prepare:sd --map=oot +bun run 3ds +# Keep ftpd open until upload and complete readback finish. +bun run deploy:sd 192.168.8.102 --map oot +bun run deploy 192.168.8.102 +bun run host 192.168.8.102 --oot +``` + +Exit ftpd, launch Pocket Map, then tap the map name on the lower screen and +choose **Ocarina of Time**. Hold R and choose **Switch map** to open the same +chooser with hardware buttons. Search accepts region names such as **Kakariko** +and **Forest Temple**, or room names such as **Slingshot Room**. Searches and +saved places run on the Mac. Each atlas has a separate bookmark database. + +The app checks `hyrule.prp` and `oot.prp` once during startup. Installed atlases +remain selectable without a paired Mac. A legacy Hyrule installation still +opens first; an OoT-only installation opens OoT. Switching between installed +atlases restores each camera and pin. OSM requires the paired provider. +Offline terrain navigation does not provide offline search or bookmarks. + +The generated OoT pack is **140,997,952 bytes (134.47 MiB)**, with 21,845 images +and one metadata record. Installation uses the same app asset directory as +Hyrule, with separate `oot--v1.prp` and `oot.prp` filenames. The installer +uploads a temporary file, reads the entire file back, compares SHA-256, then +activates the pack and its bootstrap. Interrupted uploads can resume; use +`--restart` if verification reports corruption. Hyrule's files are preserved. + +Raw sources live in `.local/oot-source/repo`, the Mac atlas and search index in +`.local/oot`, and prepared SD files in `.local/3ds/oot`. All are ignored by Git. +Python is needed for the FTP installer; preparation and the Mac provider use +Bun. PSP receives this same atlas through its existing USB image provider; +its build does not read 3DS resource packs. + +## Source and coordinates + +The input is [Ecksters' OoT Interactive Map](https://ootmap.com/), pinned to +[commit 020dab1](https://github.com/Ecksters/OoT-Interactive-Map/tree/020dab1b787bc18d1990653817765a1024bad43d). +The map project credits **Peardian** for its assembled images and Nintendo +owns the game artwork. The repository's MIT license applies to the map project; +Pocket Map does not redistribute the downloaded tile set or relicense the +game artwork. The attribution remains visible on the map. + +This integration uses **Child Angled View**, including the dungeon layouts +placed around the overworld. Adult and top-down variants are not included. +These are an arranged reference atlas, not one continuous in-game surface or +a live player-position feed. + +Upstream tiles use Leaflet Simple CRS at levels 8–15. Pocket Map maps them to +levels 0–7 and converts coordinates with `x = longitude * 256` and +`y = -latitude * 256`. Thus a point addresses the same pixels at every level. +The original rectangular extent is padded with black tiles to complete the +square pyramid. The baker verifies every expected source tile before treating +padding as empty terrain. The scale bar counts source-image units, not metres. + +The build parses the literal scene table without executing downloaded +JavaScript. It indexes **93 regions and 363 rooms**; search opens the largest +mapped polygon section for each area. Region labels appear from level 2 and +room labels from level 6, subject to the existing label-density budget. +Mac preprocessing converts PNGs to RGB565 and builds SQLite FTS and spatial +indices. SD preparation applies PICA texture layout and independently +compresses each record. The guest only requests identified resources. + +## Validation + +The explicit app suites cover three-source request routing, independent saved +place retries, coordinate conversion, variable atlas depths, legacy Hyrule +bootstrap compatibility, offline switching and camera restoration. Compiled +guest replays use the actual OoT atlas for search, map navigation and source +switching. Native and replay captures distinguish emulator rendering from +physical-device interaction. Generated packs are checked by inflating every +record and validating its CRC before upload. diff --git a/docs/SD_MAP.md b/docs/SD_MAP.md index 17c8633..9ee4f16 100644 --- a/docs/SD_MAP.md +++ b/docs/SD_MAP.md @@ -1,5 +1,9 @@ # Hyrule on the 3DS SD card +[Ocarina of Time](OCARINA.md) uses the same SD worker and installer with +`--map=oot` during preparation and `--map oot` during deployment. Its bootstrap +and terrain filenames are separate, so both atlases can remain installed. + The optional SD pack stores the complete Hyrule terrain atlas on the console. **Panning and zooming can load new terrain without a paired Mac**, including at startup. OSM vectors, place search, bookmarks and dynamic marker labels @@ -15,7 +19,8 @@ bun run deploy 192.168.8.102 ``` Exit ftpd and open Pocket Map. The SD bootstrap opens Hyrule directly; the Mac -connection adds search, saved places and the source chooser. The normal +connection adds search and saved places. The source chooser also lists other +installed SD atlases while offline. The normal `bun run host` command remains unchanged. A missing local image can fall back to the paired Mac. Without an installed pack, the original host path remains available. No downloaded artwork, SQLite database or generated pack is committed. diff --git a/docs/images/CAPTURES.md b/docs/images/CAPTURES.md index 4f9cafc..fc43d7f 100644 --- a/docs/images/CAPTURES.md +++ b/docs/images/CAPTURES.md @@ -47,6 +47,23 @@ after its `done` marker appears. The capture session used a separate SD director and restored the existing emulator configuration afterwards. Raw frames, keys, downloaded tiles and working caches stay ignored. +## Ocarina of Time, 2026-09-08 + +`oot-hyrule-field-3ds.png` uses the same native Azahar/Vulkan capture path, +production UI, unscaled screen composition and frame-600 readback described +above. The separate emulator SD directory contains only `oot.prp` and its +prepared terrain pack. **No Mac provider runs for this capture.** The native SD +worker opens the bootstrap after the missing Hyrule probe, loads RGB565 +textures, and displays Hyrule Field at the atlas's default camera. Dynamic +labels are absent because they require the Mac provider. + +[oot-capture.json](oot-capture.json) records source and runtime revisions, +binary and pack hashes, camera coordinates, raw framebuffer hashes and the +published PNG hash. This proves native emulator startup and SD rendering; +it is not a physical-controller test or performance measurement. Use the +same build command with `.local/native-oot/guest` as its output directory and +install the OoT files under the emulator's `pocketjs/assets/c7771f0167312c63/`. + ## Other images - `psp-map.png`, `psp-keyboard.png` and `psp-hyrule.png` are earlier physical PSP diff --git a/docs/images/oot-capture.json b/docs/images/oot-capture.json new file mode 100644 index 0000000..d8caee8 --- /dev/null +++ b/docs/images/oot-capture.json @@ -0,0 +1,35 @@ +{ + "renderer": "Azahar 2125.1.2 / native 3DS / Vulkan / 1x", + "entry": "app/main.tsx", + "runtime": "e2226e8f69990dd8361d79aabf47bcbedc44786d", + "frame": 600, + "pairedMac": false, + "storage": "3DS SD worker reading prepared OoT pack", + "sourceRevision": "020dab1b787bc18d1990653817765a1024bad43d/childAngled/v1", + "camera": { + "id": "o_s81", + "name": "Hyrule Field", + "detail": "Ocarina of Time - Region", + "space": "planar", + "x": 93.89738121360871, + "y": 75.53183042343353, + "zoom": 4 + }, + "nativeBinarySHA256": "24f8893a0290d6fd5afb96cb4089dce91c54446d2af5b8f2ebac13b497d5d9c8", + "resourcePackSHA256": "4e861d021a961bc8da38c5e394f7c8341807620c6faef27c3d61aa8c1cea5232", + "rawFrames": [ + { + "name": "aux-f0600.raw", + "bytes": 307200, + "sha256": "bdc9724e718bb1dfb9dba19c6e2fc3ad049b41e4e90f032c269ad0c0c8557c08" + }, + { + "name": "f0600.raw", + "bytes": 384000, + "sha256": "164d9ca7895b720e872cf05e27559a039fc5e83d017918a83b84c1e2fc21ee10" + } + ], + "image": "oot-hyrule-field-3ds.png", + "pngSHA256": "1cca03408fccf9cfa2acca592b9fff88c370463eba018f73055ef416845f3545", + "physicalInteraction": "Not measured by this capture" +} diff --git a/docs/images/oot-hyrule-field-3ds.png b/docs/images/oot-hyrule-field-3ds.png new file mode 100644 index 0000000..223687a Binary files /dev/null and b/docs/images/oot-hyrule-field-3ds.png differ diff --git a/host/atlas-format.ts b/host/atlas-format.ts index d29c34b..4d07375 100644 --- a/host/atlas-format.ts +++ b/host/atlas-format.ts @@ -1,2 +1,2 @@ export const HYRULE_REVISION = "d32a85656031d861cef38e32eb927a7d08a983a9"; -export const ATLAS_FORMAT = "pocket-map-atlas-rgb565-v1"; +export { ATLAS_FORMAT } from "../shared/atlas.ts"; diff --git a/host/atlas.ts b/host/atlas.ts index 52738c4..51ae3ae 100644 --- a/host/atlas.ts +++ b/host/atlas.ts @@ -8,9 +8,9 @@ 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"; +import { validAtlas } from "../shared/atlas.ts"; export { HYRULE_REVISION, ATLAS_FORMAT } from "./atlas-format.ts"; -export interface AtlasManifest { format: string; revision: string; tiles: number; places: number; info: MapInfo } +export type { AtlasManifest } from "../shared/atlas.ts"; /** The installed atlas is complete and immutable. Reads never fall through to * a network provider; user bookmarks live in a different SQLite database. */ @@ -25,9 +25,9 @@ export class AtlasProvider { 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"); + const manifest = row ? JSON.parse(row.value) : undefined; + if (!validAtlas(manifest)) { + this.db.close(); throw new Error("Incomplete or unsupported local atlas"); } if (existsSync(join(directory, "markers.sqlite"))) this.markers = new MarkerIndex(join(directory, "markers.sqlite")); this.info = { ...manifest.info, markers: !!this.markers }; @@ -44,7 +44,7 @@ export class AtlasProvider { }; } 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"); + if (source !== this.info.source || ![z, x, y].every(Number.isInteger) || z < 0 || z > this.info.maxZoom || 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; diff --git a/host/config.ts b/host/config.ts index a867d07..9e31b72 100644 --- a/host/config.ts +++ b/host/config.ts @@ -1,4 +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 } +import type { AtlasKind, MapKind } from "../shared/types.ts"; +export interface ProviderConfig { tileURL: string; format?: "raster" | "vector"; dataZoom?: number; searchURL: string; name: string; attribution: string; maxZoom: number; cache: string; kind?: MapKind; atlas?: string; atlases?: Partial> } 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/oot-format.ts b/host/oot-format.ts new file mode 100644 index 0000000..306aaee --- /dev/null +++ b/host/oot-format.ts @@ -0,0 +1,42 @@ +import type { Place } from "../shared/types.ts"; + +export const OOT_REVISION = "020dab1b787bc18d1990653817765a1024bad43d"; +export const OOT_VIEW = "childAngled"; +// Upstream Simple CRS uses scale 2^z; Pocket Map uses 256 * 2^z. +export const OOT_LEVEL_OFFSET = 8; +export const OOT_GRIDS = [[1, 1], [2, 1], [4, 2], [7, 4], [13, 8], [25, 16], [49, 31], [98, 61]] as const; +export type AtlasPlace = Extract & { room: boolean }; +type Area = { id: number; name: string; childAngledCoords?: number[][][]; rooms?: Area[] }; + +/** Read the pinned literal data table; downloaded JavaScript is never executed. */ +export function ootPlaces(text: string): AtlasPlace[] { + const line = text.split("\n").find(l => l.startsWith("var mapData = ")); + if (!line) throw Error("Missing OoT scene table"); + const scenes: Area[] = JSON.parse(line.slice("var mapData = ".length).trim().replace(/;$/, "")); + if (!Array.isArray(scenes)) throw Error("Invalid OoT scene table"); + const places: AtlasPlace[] = []; + function add(area: Area, scene?: Area) { + const polygons = area.childAngledCoords; + if (!Array.isArray(polygons) || !polygons.length) return; + if (!Number.isInteger(area.id) || typeof area.name !== "string") throw Error("Invalid OoT area"); + // A scene may span several floors. Search opens its largest mapped section. + const regions = polygons.map(ring => { + if (!Array.isArray(ring) || ring.length < 3 || !ring.every(p => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite))) throw Error("Invalid OoT polygon"); + let cross = 0, cx = 0, cy = 0; + for (let n = 0; n < ring.length; n++) { + const a = ring[n], b = ring[(n + 1) % ring.length], c = a[0] * b[1] - b[0] * a[1]; + cross += c; cx += (a[0] + b[0]) * c; cy += (a[1] + b[1]) * c; + } + if (Math.abs(cross) < 1e-12) throw Error("Degenerate OoT polygon"); + return { area: Math.abs(cross), x: cx / (3 * cross) * 256, y: -cy / (3 * cross) * 256 }; + }).sort((a, b) => b.area - a.area); + const { x, y } = regions[0]; + if (x < 0 || x >= 256 || y < 0 || y >= 256) throw Error("OoT position outside the atlas"); + const clean = (s: string) => s.replace(/<[^>]+>/g, "").replace(/[^\x20-\x7e]/g, "'").trim(); + places.push({ id: `o_${scene ? `${scene.id}_r` : "s"}${area.id}`, name: clean(area.name).slice(0, 36), + detail: clean(scene ? `${scene.name} - Room ${area.id}` : "Ocarina of Time - Region").slice(0, 60), + space: "planar", x, y, zoom: scene ? 7 : 5, room: !!scene }); + } + for (const scene of scenes) { add(scene); for (const room of scene.rooms ?? []) add(room, scene); } + return places; +} diff --git a/host/serve-usb.ts b/host/serve-usb.ts index d98fcf8..ef596b9 100644 --- a/host/serve-usb.ts +++ b/host/serve-usb.ts @@ -10,7 +10,8 @@ const config = { ...overrides, cache: resolve(root, ".local/cache.sqlite"), atlas: resolve(root, ".local/hyrule"), - kind: process.argv.includes("--hyrule") ? "hyrule" : "osm", + atlases: { hyrule: resolve(root, ".local/hyrule"), oot: resolve(root, ".local/oot") }, + kind: process.argv.includes("--oot") ? "oot" : process.argv.includes("--hyrule") ? "hyrule" : "osm", }; const provider = connectOffloadUsbProvider({ directory: resolve(root, "dist/psplink"), diff --git a/host/serve.ts b/host/serve.ts index 53f5c28..7c5e581 100644 --- a/host/serve.ts +++ b/host/serve.ts @@ -6,11 +6,12 @@ const address = process.argv.slice(2).find(arg => !arg.startsWith("--")) ?? "192 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.kind = process.argv.includes("--oot") ? "oot" : 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"); +config.atlases = { hyrule: config.atlas, oot: resolve(root, ".local/oot") }; +if (config.kind !== "osm" && !await Bun.file(resolve(config.atlases[config.kind as "hyrule" | "oot"], "atlas.sqlite")).exists()) throw new Error(`Run bun run prepare:${config.kind} before starting the 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}`); +console.log(`Pocket Map: ${config.kind} -> ${address}; local atlases and OSM available through the map chooser`); diff --git a/host/service.ts b/host/service.ts index 5ed71b6..e98226e 100644 --- a/host/service.ts +++ b/host/service.ts @@ -4,7 +4,7 @@ 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"; +import { ATLAS_KINDS, 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. */ @@ -13,9 +13,12 @@ export class MapService { 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)); + for (const kind of ATLAS_KINDS) { + const directory = config.atlases?.[kind] ?? (kind === "hyrule" ? config.atlas : undefined); + if (directory && existsSync(join(directory, "atlas.sqlite"))) this.providers.set(kind, new AtlasProvider(directory)); + } this.selected = config.kind ?? "osm"; - if (!this.providers.has(this.selected)) throw new Error("Prepare the Hyrule atlas first"); + if (!this.providers.has(this.selected)) { this.close(); throw new Error(`Run bun run prepare:${this.selected} first`); } } private resolve(source?: string) { if (source === undefined) return this.providers.get(this.selected)!; // Existing guest compatibility. diff --git a/package.json b/package.json index 69feef4..938ea25 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,15 @@ "deploy": "bun scripts/deploy.ts", "sim": "bun scripts/sim.ts", "prepare:hyrule": "bun scripts/prepare-hyrule.ts", + "prepare:oot": "bun scripts/prepare-oot.ts", "test": "bun test --conditions=browser test/geo.test.ts test/provider.test.ts test/model.test.ts test/bookmarks.test.ts test/transport.test.ts test/atlas.test.ts test/navigation.test.ts test/vector.test.ts test/sd.test.ts test/storage-benchmark.test.ts", "check": "bun run test && runtime/node_modules/.bin/tsc --noEmit", "psp": "bun scripts/psp.ts", "host:psp": "bun host/serve-usb.ts", "test:psp:watch": "bun scripts/watch-psp-smoke.ts", "prepare:sd": "bun scripts/prepare-sd.ts", - "deploy:sd": "python3 scripts/deploy-sd.py" + "deploy:sd": "python3 scripts/deploy-sd.py", + "update": "bun scripts/update.ts" }, "dependencies": { "@mapbox/vector-tile": "3.0.0", diff --git a/runtime b/runtime index e2226e8..071b2bd 160000 --- a/runtime +++ b/runtime @@ -1 +1 @@ -Subproject commit e2226e8f69990dd8361d79aabf47bcbedc44786d +Subproject commit 071b2bd5f6e51777e92dde2dd686c177027156aa diff --git a/scripts/build.ts b/scripts/build.ts index f29f4aa..c98a674 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -7,7 +7,8 @@ const plan = resolve3dsBuildPlan(await Bun.file(resolve(root, "pocket.json")).js 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"]) { +// A guest-only build must never copy a stale native binary over dist. +for (const ext of process.argv.includes("--pocket-only") ? ["pocket"] : ["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-sd.py b/scripts/deploy-sd.py index 43950f5..752ca2c 100644 --- a/scripts/deploy-sd.py +++ b/scripts/deploy-sd.py @@ -3,15 +3,18 @@ parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('host', nargs='?', default='192.168.8.102') +parser.add_argument('--map', choices=['hyrule', 'oot'], default='hyrule', dest='kind') parser.add_argument('--restart', action='store_true', help='Upload a new temporary copy from byte zero after a readback mismatch') args = parser.parse_args() root = pathlib.Path(__file__).resolve().parent.parent -meta = json.loads((root / '.local/3ds/manifest.json').read_text()) -if not isinstance(meta.get('name'), str) or not re.fullmatch(r'hyrule-[a-f0-9]{16}-v1', meta['name']): +pack_dir = root / '.local/3ds' / ('' if args.kind == 'hyrule' else args.kind) +meta = json.loads((pack_dir / 'manifest.json').read_text()) +if not isinstance(meta.get('name'), str) or not re.fullmatch(args.kind + r'-[a-f0-9]{16}-v1', meta['name']): raise ValueError('Invalid atlas identity') -local = root / '.local/3ds' / (meta['name'] + '.prp') +local = pack_dir / (meta['name'] + '.prp') +receipt_prefix = 'sd' if args.kind == 'hyrule' else 'sd-' + args.kind slot = hashlib.sha256(json.loads((root / 'pocket.json').read_text())['id'].encode()).hexdigest()[:16] base = '/pocketjs/assets/' + slot remote = base + '/' + local.name @@ -83,24 +86,25 @@ def received(block): sha256=digest.hexdigest(), expectedSha256=expected, firstDifference=first_difference) (root / 'dist/qa').mkdir(parents=True, exist_ok=True) -(root / 'dist/qa/sd-verify.json').write_text(json.dumps(verification, indent=2)) +(root / ('dist/qa/' + receipt_prefix + '-verify.json')).write_text(json.dumps(verification, indent=2)) if progress[0] != local.stat().st_size or digest.hexdigest() != expected: ftp.close() raise RuntimeError('Atlas readback differs; rerun with --restart: ' + json.dumps(verification)) if not installed: ftp.rename(partial, remote) # The small bootstrap pointer becomes visible only after its complete atlas. -bootstrap = root / '.local/3ds/hyrule.prp' +bootstrap = pack_dir / (args.kind + '.prp') +bootstrap_remote = base + '/' + args.kind + '.prp' data = bootstrap.read_bytes() with bootstrap.open('rb') as source: - ftp.storbinary('STOR ' + base + '/hyrule.prp.partial', source) + ftp.storbinary('STOR ' + bootstrap_remote + '.partial', source) actual = bytearray() -ftp.retrbinary('RETR ' + base + '/hyrule.prp.partial', actual.extend) +ftp.retrbinary('RETR ' + bootstrap_remote + '.partial', actual.extend) assert actual == data, 'Bootstrap readback differs' -ftp.rename(base + '/hyrule.prp.partial', base + '/hyrule.prp') +ftp.rename(bootstrap_remote + '.partial', bootstrap_remote) ftp.quit() receipt = dict(path=remote, bytes=local.stat().st_size, sha256=expected, verified=True, seconds=time.monotonic()-started) (root / 'dist/qa').mkdir(parents=True, exist_ok=True) -(root / 'dist/qa/sd-install.json').write_text(json.dumps(receipt, indent=2)) +(root / ('dist/qa/' + receipt_prefix + '-install.json')).write_text(json.dumps(receipt, indent=2)) print(json.dumps(receipt), flush=True) diff --git a/scripts/prepare-oot.ts b/scripts/prepare-oot.ts new file mode 100644 index 0000000..6b6aff3 --- /dev/null +++ b/scripts/prepare-oot.ts @@ -0,0 +1,91 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs"; +import { join, resolve } 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 { OOT_REVISION, OOT_VIEW, OOT_GRIDS, OOT_LEVEL_OFFSET, ootPlaces } from "../host/oot-format.ts"; +import { ATLAS_FORMAT, atlasTileCount, atlasPackName, validAtlas, type AtlasManifest } from "../shared/atlas.ts"; +import type { MapInfo } from "../shared/types.ts"; + +const root = resolve(import.meta.dir, ".."), source = join(root, ".local/oot-source/repo"), + directory = join(root, ".local/oot"), output = join(directory, "atlas.sqlite"); +const revision = `${OOT_REVISION}/${OOT_VIEW}/v1`; +mkdirSync(directory, { recursive: true }); +if (existsSync(output) && existsSync(join(directory, "markers.sqlite")) && !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); db.close(); + if (!validAtlas(manifest) || manifest.revision !== revision) throw Error("Atlas version differs; use --rebuild"); + console.log(`Ocarina of Time ready: ${manifest.tiles} tiles, ${manifest.places} searchable regions and rooms`); + process.exit(0); +} +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 Error("OoT source checkout failed"); + return text.trim(); +} +if (!existsSync(join(source, ".git"))) await git("clone", "--depth", "1", "--filter=blob:none", "--no-checkout", "https://github.com/Ecksters/OoT-Interactive-Map.git", source); +await git("-C", source, "sparse-checkout", "set", `maps/${OOT_VIEW}`, "js"); +if (await git("-C", source, "rev-parse", "HEAD") !== OOT_REVISION) await git("-C", source, "fetch", "--depth", "1", "origin", OOT_REVISION); +await git("-C", source, "checkout", "--detach", OOT_REVISION); +const assets = join(source, "maps", OOT_VIEW); +for (let z = 0; z < OOT_GRIDS.length; z++) { + const [w, h] = OOT_GRIDS[z], files = new Set(readdirSync(join(assets, String(z + OOT_LEVEL_OFFSET))).filter(f => f.endsWith(".png"))); + if (files.size !== w * h) throw Error(`Incomplete source level ${z}`); + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (!files.has(`map_tile_${x}_${y}.png`)) throw Error("Missing source tile"); +} +const places = ootPlaces(await Bun.file(join(source, "js/mapdata.js")).text()); +const home = places.find(p => p.name === "Hyrule Field" && !p.room); +if (!home || places.length < 400) throw Error("Incomplete OoT place index"); +const pending = join(directory, "atlas.build.sqlite"), markerPending = join(directory, "markers.build.sqlite"); +for (const path of [pending, markerPending]) if (existsSync(path)) rmSync(path); +const db = new Database(pending), markers = new Database(markerPending); +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');`); +markers.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 canvas = createCanvas(256, 256), ctx = canvas.getContext("2d"), + insert = db.query("INSERT INTO tiles VALUES (?,?,?,?)"), blank = deflateRawSync(new Uint8Array(131072), { level: 1 }); +let tiles = 0; +try { + for (let z = 0; z <= 7; z++) { + const [w, h] = OOT_GRIDS[z]; + for (let y = 0; y < 2 ** z; y++) { + const row: Uint8Array[] = []; + for (let x = 0; x < 2 ** z; x++) { + let pixels = blank; + if (x < w && y < h) { + const image = await loadImage(join(assets, String(z + OOT_LEVEL_OFFSET), `map_tile_${x}_${y}.png`)); + if (image.width !== 256 || image.height !== 256) throw Error("Invalid OoT source dimensions"); + ctx.fillStyle = "#000"; ctx.fillRect(0, 0, 256, 256); ctx.drawImage(image, 0, 0); + pixels = deflateRawSync(packRGB(ctx.getImageData(0, 0, 256, 256).data, 256, 256).pixels, { level: 1 }); + } + row.push(pixels); + } + db.transaction(() => { row.forEach((pixels, x) => { insert.run(z, x, y, pixels); tiles++; }); })(); + } + console.log(`OoT level ${z}: ${tiles}/${atlasTileCount(7)} tiles`); + } + const putPlace = db.query("INSERT INTO place_search VALUES (?,?,?,?,?,?)"), putMarker = markers.query("INSERT INTO markers VALUES (?,?,?,?,?,?,?,?)"), putBounds = markers.query("INSERT INTO bounds VALUES (?,?,?,?,?)"); + db.transaction(() => { for (const p of places) putPlace.run(p.id, p.name, p.detail, p.x, p.y, p.zoom); })(); + markers.transaction(() => { + places.forEach((p, n) => { putMarker.run(n + 1, p.name.slice(0, 24), /Village|Kokiri Forest/.test(p.name) ? "village" : "landmark", p.x, p.y, p.room ? 6 : 2, 7, p.room ? 5 : 0); putBounds.run(n + 1, p.x, p.x, p.y, p.y); }); + markers.query("INSERT INTO metadata VALUES (?,?)").run(revision, places.length); + })(); + const identity = createHash("sha256").update(`${ATLAS_FORMAT}/${revision}`).digest("hex").slice(0, 16); + const { room, ...start } = home; + const info: MapInfo = { source: identity, kind: "oot", name: "Ocarina of Time", attribution: "Nintendo | Peardian | Ecksters", + minZoom: 0, maxZoom: 7, space: "planar", local: true, worldUnits: 32768, + pack: atlasPackName("oot", identity), home: { ...start, zoom: 4 } }; + const manifest: AtlasManifest = { format: ATLAS_FORMAT, revision, tiles, places: places.length, info }; + if (!validAtlas(manifest)) throw Error("Incomplete OoT bake"); + db.query("INSERT INTO metadata VALUES ('manifest',?)").run(JSON.stringify(manifest)); + db.close(); markers.close(); renameSync(markerPending, join(directory, "markers.sqlite")); renameSync(pending, output); + await Bun.write(join(directory, "manifest.json"), JSON.stringify(manifest, null, 2)); + console.log(`Ocarina of Time ready: ${tiles} textures and ${places.length} searchable regions and rooms`); +} catch (error) { db.close(); markers.close(); throw error; } diff --git a/scripts/prepare-sd.ts b/scripts/prepare-sd.ts index 1e0d88e..fd2baba 100644 --- a/scripts/prepare-sd.ts +++ b/scripts/prepare-sd.ts @@ -2,12 +2,16 @@ import { Database } from "bun:sqlite"; import { inflateRawSync } from "node:zlib"; import { mkdirSync } from "node:fs"; import { resolve } from "node:path"; +import { validAtlas, atlasPackName } from "../shared/atlas.ts"; +import { ATLAS_KINDS, type AtlasKind } from "../shared/types.ts"; +const kind = (process.argv.find(a => a.startsWith("--map="))?.slice(6) ?? "hyrule") as AtlasKind; +if (!ATLAS_KINDS.includes(kind)) throw Error("Use --map=hyrule or --map=oot"); const root = resolve(import.meta.dir, ".."), runtime = resolve(process.env.POCKETJS_RUNTIME ?? resolve(root, "runtime")); const { createResourcePack, prepareTiledRGB565 } = await import( `${runtime}/tools/resource-pack.ts` ); -const db = new Database(resolve(root, ".local/hyrule/atlas.sqlite"), { +const db = new Database(resolve(root, `.local/${kind}/atlas.sqlite`), { readonly: true, }); const manifest = JSON.parse( @@ -17,25 +21,18 @@ const manifest = JSON.parse( } ).value, ); -if ( - manifest.format !== "pocket-map-atlas-rgb565-v1" || - manifest.tiles !== 21845 || - !/^[a-f0-9]{16}$/.test(manifest.info?.source ?? "") || - manifest.info.space !== "planar" || - manifest.info.minZoom !== 0 || - manifest.info.maxZoom !== 7 -) { +if (!validAtlas(manifest)) { db.close(); - throw Error("Unsupported Hyrule atlas"); + throw Error("Unsupported local atlas"); } -const out = resolve(root, ".local/3ds"); +const out = resolve(root, kind === "hyrule" ? ".local/3ds" : `.local/3ds/${kind}`); mkdirSync(out, { recursive: true }); -const name = `hyrule-${manifest.info.source}-v1`, - pack = createResourcePack(resolve(out, `${name}.prp`), 21846); +const name = atlasPackName(kind, manifest.info.source), + pack = createResourcePack(resolve(out, `${name}.prp`), manifest.tiles + 1); try { pack.add(Buffer.from(JSON.stringify(manifest))); const query = db.query("SELECT pixels FROM tiles WHERE z=? AND x=? AND y=?"); - for (let z = 0; z <= 7; z++) { + for (let z = 0; z <= manifest.info.maxZoom; z++) { for (let y = 0; y < 2 ** z; y++) for (let x = 0; x < 2 ** z; x++) { const row = query.get(z, x, y) as { pixels: Uint8Array } | null; @@ -50,8 +47,8 @@ try { } console.log(`SD atlas level ${z} complete`); } - const receipt = { ...pack.finish(), name, source: manifest.info.source }; - const bootstrap = createResourcePack(resolve(out, "hyrule.prp"), 1); + const receipt = { ...pack.finish(), kind, name, source: manifest.info.source }; + const bootstrap = createResourcePack(resolve(out, `${kind}.prp`), 1); try { bootstrap.add(Buffer.from(JSON.stringify(manifest))); bootstrap.finish(); diff --git a/scripts/sim.ts b/scripts/sim.ts index ee03fe9..9b1daa5 100644 --- a/scripts/sim.ts +++ b/scripts/sim.ts @@ -10,20 +10,21 @@ 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 hyrule = process.argv.includes("--hyrule"), oot = process.argv.includes("--oot"); +const local = hyrule || oot, kind = oot ? "oot" : "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 => { +const provider = local ? new AtlasProvider(`.local/${kind}`) : 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 service = local ? new MapService({ ...defaultConfig, format: "raster", tileURL:"https://tile.openstreetmap.de/{z}/{x}/{y}.png", cache: ":memory:", atlas: ".local/hyrule", atlases: { hyrule: ".local/hyrule", oot: ".local/oot" }, kind }, 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); @@ -77,16 +78,16 @@ async function frames(n: number, buttons = 0, touch?: [number, number], analog = 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)); } +async function shot(name: string) { await Bun.write(`dist/qa/${local ? `${kind}-` : 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) { +if (local) { 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"); + check(s.planar() && s.info()?.local, "Local atlas opens 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"); @@ -102,11 +103,11 @@ if (hyrule) { 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"); + check(!s.planar() && s.front()!.tiles.every(t => t.input.source === s.info()!.source), "Local atlas switches to synthetic OSM without restarting the guest or daemon"); + await tap(180, 18); await tap(130, 53 + s.maps().findIndex(m => m.kind === kind) * 30); await frames(100); + check(s.planar() && s.camera.view().x === resume.x && s.camera.view().zoom === resume.zoom, "Switching back restores local atlas coordinates and zoom"); + s.setMode("layers"); await frames(2); await shot("layers"); s.choose(oot ? 1 : 2); await frames(100); + if (!oot) 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); @@ -194,6 +195,6 @@ if (hyrule) { 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()), +const receipt = { mode: local ? `complete local ${kind} 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(); +await Bun.write(`dist/qa/${local ? kind : live ? "live" : "replay"}.json`, JSON.stringify(receipt, null, 2)); console.log(receipt); service?.close(); provider.close(); diff --git a/scripts/update.ts b/scripts/update.ts new file mode 100644 index 0000000..6f2dbe2 --- /dev/null +++ b/scripts/update.ts @@ -0,0 +1,14 @@ +import { resolve } from "node:path"; + +const root = resolve(import.meta.dir, ".."); +const args = process.argv.slice(2); +if (args.length > 1 || args[0]?.startsWith("-")) { + throw new Error("Usage: bun run update [3ds-ip] (Pocket Map must be running)"); +} +async function run(args: string[]): Promise { + const child = Bun.spawn([process.execPath, ...args], { cwd: root, stdin: "inherit", stdout: "inherit", stderr: "inherit" }); + if (await child.exited !== 0) throw new Error(`Command failed: ${args[0]}`); +} +await run(["scripts/build.ts", "--pocket-only"]); +await run(["runtime/tools/3ds-dev.ts", "push", "--package", resolve(root, "dist/pocketmap-main.pocket"), + ...(args[0] ? ["--host", args[0]] : [])]); diff --git a/shared/atlas.ts b/shared/atlas.ts new file mode 100644 index 0000000..94c8fd5 --- /dev/null +++ b/shared/atlas.ts @@ -0,0 +1,20 @@ +import { validPosition, type MapInfo, type AtlasKind } from "./types.ts"; + +export const ATLAS_FORMAT = "pocket-map-atlas-rgb565-v1"; +export interface AtlasManifest { format: string; revision: string; tiles: number; places: number; info: MapInfo } +export const atlasTileCount = (maxZoom: number) => (4 ** (maxZoom + 1) - 1) / 3; +export const atlasPackName = (kind: AtlasKind, source: string) => `${kind}-${source}-v1`; + +/** A complete square pyramid fits the native pack's bounded index (65,536 entries). */ +export function validAtlas(value: unknown): value is AtlasManifest { + if (!value || typeof value !== "object") return false; + const a = value as AtlasManifest, i = a.info; + return a.format === ATLAS_FORMAT && !!i && i.space === "planar" && i.minZoom === 0 + && Number.isInteger(i.maxZoom) && i.maxZoom >= 1 && i.maxZoom <= 7 + && a.tiles === atlasTileCount(i.maxZoom) && typeof i.source === "string" && /^[a-f0-9]{16}$/.test(i.source) + && typeof i.name === "string" && i.name.length <= 40 && typeof i.attribution === "string" + && (i.pack === undefined || /^[a-z0-9-]{1,48}$/.test(i.pack)) + && (i.worldUnits === undefined || Number.isFinite(i.worldUnits) && i.worldUnits > 0) + && (i.home === undefined || validPosition(i.home) && i.home.space === "planar" + && Number.isFinite(i.home.zoom) && i.home.zoom >= 0 && i.home.zoom <= i.maxZoom); +} diff --git a/shared/types.ts b/shared/types.ts index 73599c9..34e533b 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,8 +1,12 @@ -export type MapKind = "hyrule" | "osm"; +export const MAP_KINDS = ["osm", "hyrule", "oot"] as const; +export type MapKind = typeof MAP_KINDS[number]; +export const ATLAS_KINDS = ["hyrule", "oot"] as const; +export type AtlasKind = typeof ATLAS_KINDS[number]; +export const MAP_NAMES: Record = { osm: "OpenStreetMap", hyrule: "Breath of the Wild", oot: "Ocarina of Time" }; 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 }[] } + space?: "mercator" | "planar"; home?: Place; local?: boolean; markers?: boolean; kind?: MapKind; pack?: string; worldUnits?: number; 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 } diff --git a/test/atlas.test.ts b/test/atlas.test.ts index c62bb6f..5e964b3 100644 --- a/test/atlas.test.ts +++ b/test/atlas.test.ts @@ -5,6 +5,68 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { AtlasProvider, ATLAS_FORMAT } from "../host/atlas.ts"; +import { atlasTileCount, validAtlas } from "../shared/atlas.ts"; +import { ootPlaces } from "../host/oot-format.ts"; +import { MapService } from "../host/service.ts"; +import { defaultConfig } from "../host/config.ts"; +import { mkdirSync } from "node:fs"; + +test("OoT Simple CRS coordinates match baked tile indices and source data is parsed as literals", () => { + const polygon = [[[.25, -.125], [.5, -.125], [.5, -.375], [.25, -.375]]]; + const scenes = [{ id: 1, name: "Kakariko Village", childAngledCoords: polygon, + rooms: [{ id: 2, name: "Windmill", childAngledCoords: polygon }] }]; + const places = ootPlaces(`throw new Error('must never execute');\nvar mapData = ${JSON.stringify(scenes)};\n`); + expect(places).toHaveLength(2); + expect(places[0]).toMatchObject({ x: 96, y: 64, space: "planar", room: false }); + expect(places[1]).toMatchObject({ id: "o_1_r2", name: "Windmill", detail: "Kakariko Village - Room 2", zoom: 7, room: true }); + // Upstream z15 and app z7 point at the same source pixels. + expect(places[0].x * 2 ** 7).toBe(.375 * 2 ** 15); + expect(places[0].y * 2 ** 7).toBe(.25 * 2 ** 15); + expect(() => ootPlaces("var mapData = (() => [])();")).toThrow(); + expect(() => ootPlaces(`var mapData = ${JSON.stringify([{ ...scenes[0], childAngledCoords: [[[0, 0], [1, 1], [2, 2]]] }])};`)).toThrow("Degenerate"); +}); + +test("three providers keep tiles, search, and saved-place retries scoped to their own source", async () => { + const root = mkdtempSync(join(tmpdir(), "map-catalog-")); + const ids = { hyrule: "123456789abcdef0", oot: "abcdef0123456789" }; + try { + for (const kind of ["hyrule", "oot"] as const) { + const path = join(root, kind); mkdirSync(path); + const db = new Database(join(path, "atlas.sqlite")), maxZoom = kind === "oot" ? 3 : 7; + 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);`); + const manifest = { format: ATLAS_FORMAT, tiles: atlasTileCount(maxZoom), info: { + source: ids[kind], name: kind, attribution: "Fixture", kind, space: "planar", minZoom: 0, maxZoom, local: true } }; + expect(validAtlas(manifest)).toBe(true); + expect(validAtlas({ ...manifest, tiles: manifest.tiles + 1 })).toBe(false); + expect(validAtlas({ ...manifest, info: { ...manifest.info, maxZoom: 8 } })).toBe(false); + db.query("INSERT INTO metadata VALUES ('manifest',?)").run(JSON.stringify(manifest)); + const pixels = new Uint8Array(131072).fill(kind === "oot" ? 17 : 29); + db.query("INSERT INTO tiles VALUES (0,0,0,?)").run(deflateRawSync(pixels)); + db.query("INSERT INTO place_search VALUES (?,?,?,?,?,?)").run("village", "Kakariko Village", kind, kind === "oot" ? 70 : 150, 80, 3); + db.close(); + } + const service = new MapService({ ...defaultConfig, cache: ":memory:", kind: "oot", atlases: { hyrule: join(root, "hyrule"), oot: join(root, "oot") } }, () => { throw Error("Unexpected HTTP"); }); + try { + const m = service.methods(), info = JSON.parse(m["map.info"]("{}")); + expect(info.kind).toBe("oot"); expect(info.maps.map((v: any) => v.kind)).toEqual(["osm", "hyrule", "oot"]); + for (const kind of ["hyrule", "oot"] as const) { + const place = JSON.parse(await m["map.search"](JSON.stringify({ source: ids[kind], query: "Kakariko", space: "planar", x: 0, y: 0 })))[0]; + expect(place.detail).toBe(kind); + const image = await m["map.tile"](JSON.stringify({ source: ids[kind], z: 0, x: 0, y: 0 })); + expect(image.pixels[0]).toBe(kind === "oot" ? 17 : 29); + const command = JSON.stringify({ source: ids[kind], kind: "save", op: "shared_retry_token", place }); + const receipt = m["bookmarks.command"](command); + m["map.info"]('{"kind":"osm"}'); + expect(m["bookmarks.command"](command)).toBe(receipt); + expect(JSON.parse(m["bookmarks.list"](JSON.stringify({ source: ids[kind], offset: 0 }))).total).toBe(1); + } + expect(() => m["map.tile"](JSON.stringify({ source: ids.oot, z: 4, x: 0, y: 0 }))).toThrow("Invalid atlas tile"); + expect(() => m["map.info"]('{"kind":"missing"}')).toThrow(); + } finally { service.close(); } + } finally { rmSync(root, { recursive: true, force: true }); } +}); 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")); diff --git a/test/sd.test.ts b/test/sd.test.ts index ee65f7f..da9d4b2 100644 --- a/test/sd.test.ts +++ b/test/sd.test.ts @@ -10,7 +10,7 @@ import { import { runServicePumps } from "../runtime/framework/src/services.ts"; import { createMap } from "../app/model.ts"; -test("SD bootstrap opens and navigates new Hyrule tiles while the Mac is offline", () => { +test("SD discovery keeps legacy Hyrule and OoT isolated and switches both without a Mac", () => { resetFrameHooks(); const replies: string[] = [], addresses: string[] = [], @@ -20,6 +20,7 @@ test("SD bootstrap opens and navigates new Hyrule tiles while the Mac is offline uploaded = 0, remote = 0; const source = "1234567890abcdef"; + const ootSource = "fedcba0987654321"; const atlas = { format: "pocket-map-atlas-rgb565-v1", tiles: 21845, @@ -47,7 +48,7 @@ test("SD bootstrap opens and navigates new Hyrule tiles while the Mac is offline addresses.push(`${name}/${entry}`); replies.push( JSON.stringify( - name === "hyrule" + name === "oot" ? { id, payload: JSON.stringify({ ...atlas, tiles: 5461, info: { ...atlas.info, source: ootSource, kind: "oot", name: "Ocarina of Time", maxZoom: 6, pack: `oot-${ootSource}-v1` } }) } : name === "hyrule" ? { id, payload: JSON.stringify(atlas) } : { id, image: { token: ++token, width: 256, height: 256 } }, ), @@ -108,8 +109,27 @@ test("SD bootstrap opens and navigates new Hyrule tiles while the Mac is offline expect(s.tiles.stats().entries).toBeLessThanOrEqual(40); expect(addresses[0]).toBe("hyrule/0"); expect( - addresses.slice(1).every((a) => a.startsWith(`hyrule-${source}-v1/`)), + addresses.slice(1).filter(a => a !== "oot/0").every((a) => a.startsWith(`hyrule-${source}-v1/`)), ).toBe(true); + expect(s.maps().map(m => m.kind)).toEqual(["hyrule", "oot"]); + const hyrulePosition = { ...s.camera.view() }; + s.switchMap("oot"); frames(90); + expect(s.info()?.kind).toBe("oot"); + expect(s.info()?.maxZoom).toBe(6); + expect(s.localMapAvailable()).toBe(true); + expect(s.front()!.tiles.every(t => t.input.source === ootSource && s.frontView.state(t.input).status === "ready")).toBe(true); + s.camera.jump(110, 120, 6); frames(90); + expect(addresses.some(a => a.startsWith(`oot-${ootSource}-v1/`))).toBe(true); + s.switchMap("hyrule"); frames(90); + expect(s.camera.view()).toMatchObject(hyrulePosition); + expect(s.front()!.tiles.every(t => t.input.source === source && s.frontView.state(t.input).status === "ready")).toBe(true); + s.switchMap("oot"); frames(90); + expect(s.camera.view()).toMatchObject({ x: 110, y: 120, zoom: 6 }); + s.switchMap("osm"); frames(10); + expect(s.info()?.kind).toBe("oot"); + expect(s.switching()).toBe(false); + expect(s.sourceError()).toContain("Connect your Mac"); + expect(remote).toBe(0); dispose(); expect(freed.length).toBe(uploaded); expect(released.length).toBe(uploaded);