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

Actual San Francisco vector data rendered by the compiled dual-screen Pocket Map guest Pocket Map displaying the local Hyrule atlas on a compiled dual-screen guest Local Kakariko search results with the bottom-screen selection touchpad

+### San Francisco on the 3DS native renderer + +Union Square, the Embarcadero near the Ferry Building, and Golden Gate Park: + +![Union Square, San Francisco — Pocket Map's native 3DS build in Azahar](docs/images/sf-union-square-3ds.png) +![Embarcadero near the Ferry Building, San Francisco — Pocket Map's native 3DS build in Azahar](docs/images/sf-ferry-building-3ds.png) +![Golden Gate Park, San Francisco — Pocket Map's native 3DS build in Azahar](docs/images/sf-golden-gate-park-3ds.png) + +These are **native 3DS screenshots captured in Azahar**, using real OSM data and +the production app's UI. The 400×240 upper and 320×240 lower framebuffers are +stacked without scaling. They are emulator captures, not console photographs. +[Capture method, coordinates and provenance](docs/images/CAPTURES.md). OSM map data © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright), served as Shortbread vectors by VersaTiles. -These are **compiled guest + Wasm captures**, at the 3DS's 400×240 / 320×240 logical resolutions, not console photographs. Map artwork belongs to Nintendo; the pinned atlas and marker source is [Zelda Dungeon's map repository](https://github.com/zeldadungeon/maps/tree/d32a85656031d861cef38e32eb927a7d08a983a9/public/botw). Downloaded assets and generated databases stay outside Git. +Map artwork belongs to Nintendo; the pinned atlas and marker source is [Zelda Dungeon's map repository](https://github.com/zeldadungeon/maps/tree/d32a85656031d861cef38e32eb927a7d08a983a9/public/botw). Downloaded assets and generated databases stay outside Git. ## PSP over USB @@ -79,8 +90,10 @@ Typing, dragging, inertia and zoom transitions update locally. Missing tiles sho After preparing the Mac atlas, run `bun run prepare:sd` and `bun run deploy:sd <3ds-ip>` to install a **354 MiB** prepared texture pack. The console reads and inflates individual records on a native worker; terrain -misses no longer require Wi-Fi. See [installation, storage costs and the device -comparison procedure](docs/SD_MAP.md). +misses no longer require Wi-Fi. In one physical 3DS comparison, mean time to +ready terrain across four views fell from **1,178 ms over Wi-Fi to 293 ms from +SD**. This does not establish sustained 60 FPS. See [installation, storage costs, +measurements and limits](docs/SD_MAP.md). ## Complete local Hyrule atlas @@ -88,9 +101,10 @@ comparison procedure](docs/SD_MAP.md). 1,365 JPEG source tiles across six levels, about 49 MB. It rebakes the full 24,000px square source into **21,845 RGB565 textures across levels 0–7**, using 256px texture envelopes. Original source detail is preserved by subdividing -the 750px tiles; the top level is a 32,768px grid. Deflate compression is used -only for Mac storage. The 3DS receives raw pixels and performs no JPEG or -Deflate decoding. +the 750px tiles; the top level is a 32,768px grid. In the desktop streaming path, +Deflate is used for Mac storage and the 3DS receives raw pixels. With the optional +SD pack, the Mac prepares PICA texture layout and the native SD worker inflates +individual records. The 3DS performs no JPEG decoding. The completed `.local/hyrule/atlas.sqlite` is about **592 MB** and includes an FTS5 index of regions, landmarks, towers, shrines, villages, Korok seeds and @@ -184,7 +198,7 @@ const view = createResourceView(tiles, { demand: visibleTileDemand }); // view.state(tile)} fallback={() => } /> ``` -Hyrule declares `createOffloadImageCollection` and renders `ResourceImage`. +Hyrule declares `createPackedImageCollection` with a desktop fallback and renders `ResourceImage`. Rendering starts no IO. The native worker owns reception; the scheduler admits one materialization per frame; the collection returns staging and eventually frees its native handles. diff --git a/docs/images/CAPTURES.md b/docs/images/CAPTURES.md new file mode 100644 index 0000000..4f9cafc --- /dev/null +++ b/docs/images/CAPTURES.md @@ -0,0 +1,59 @@ +# Screenshot provenance + +## San Francisco, 2026-09-08 + +The three `sf-*-3ds.png` images in the README come from the **native Nintendo +3DS executable running in Azahar 2125.1.2**, with Vulkan and a 1× internal +resolution. They are emulator captures, not photographs of a console and not +Wasm screenshots. [sf-captures.json](sf-captures.json) records the app/runtime +commits, native binary hash, camera coordinates, provider counts, raw framebuffer +hashes and published PNG hashes. + +| Image | Camera latitude | Longitude | Display zoom | +| --- | ---: | ---: | ---: | +| Union Square | 37.7879 | -122.4075 | 15 | +| Ferry Building / Embarcadero | 37.7955 | -122.3937 | 16 | +| Golden Gate Park | 37.7694 | -122.4862 | 15 | + +The capture build uses the production `app/main.tsx` entry and its components. +The Mac provider sets `map.info.home` to each listed camera position and disables +predictive HTTP demand for the capture. It fetches real Shortbread MVTs from +VersaTiles through the normal provider and SQLite cache. No terrain or labels +are substituted with fixtures. The viewport is allowed to settle before frame +600 is read back; all recorded provider responses succeeded. + +The native host's `POCKETJS_CAPTURE` path performs a GX display transfer from +each PICA render target. Its raw records are decoded with the rotation and +channel-order conversion used by `runtime/tests/e2e/azahar.ts`. **The published +400×480 PNG stacks the unscaled 400×240 top screen and 320×240 bottom screen**, +with the bottom screen centered between white margins. No UI or map pixels are +painted over. Capture timing does not measure physical-device performance. + +The capture build can be prepared after the normal build has written +`dist/plan.json`: + +```sh +POCKETJS_CAP_START=600 POCKETJS_CAP_N=1 bun runtime/tools/3ds.ts \ + --plan=dist/plan.json --project-root=. \ + --outdir=.local/native-sf/guest --package-outdir=.local/native-sf/guest \ + --capture +``` + +Use a separate emulator SD directory containing the app's pairing key at +`pocketjs/offload/c7771f0167312c63.key`. Run the Mac provider against +`127.0.0.1`, return the selected camera position in its map metadata, then launch +the capture executable in Azahar. Read both `pocketjs-captures/*f0600.raw` files +after its `done` marker appears. The capture session used a separate SD directory +and restored the existing emulator configuration afterwards. Raw frames, keys, +downloaded tiles and working caches stay ignored. + +## Other images + +- `psp-map.png`, `psp-keyboard.png` and `psp-hyrule.png` are earlier physical PSP + framebuffer captures. See [PSP validation](../PSP.md). +- The earlier `vector-*.png`, Hyrule dual-screen images and interaction images + were produced by the compiled guest running against the Wasm core. They are + retained as historical visual references and are not console photographs. + +OSM data © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright). +Hyrule artwork belongs to Nintendo; see the README for its pinned source. diff --git a/docs/images/sf-captures.json b/docs/images/sf-captures.json new file mode 100644 index 0000000..a16784f --- /dev/null +++ b/docs/images/sf-captures.json @@ -0,0 +1,141 @@ +{ + "capturedAt": "2026-09-08", + "applicationCommit": "958cb8dd72d3c911636530a76b2947aca4d30a72", + "runtimeCommit": "e2226e8f69990dd8361d79aabf47bcbedc44786d", + "emulator": "Azahar 2125.1.2", + "renderer": "Vulkan, resolution_factor=1", + "entry": "app/main.tsx (production guest and UI)", + "nativeBinarySha256": "a4ef45dcafd86f54983a6e37ec3c04a4007d924c131d75fbfab9493a247b71a9", + "capture": { + "method": "3DS native GX display-transfer readback of top and auxiliary PICA targets", + "frame": 600, + "framesPerRun": 1, + "screens": { + "top": [ + 400, + 240 + ], + "bottom": [ + 320, + 240 + ] + }, + "presentation": "Top and bottom stacked without scaling, bottom centered with white side margins", + "sceneSetup": "map.info.home sets the listed camera coordinates; predictive HTTP demand disabled; real OSM provider and local cache otherwise unchanged", + "mapData": "OpenStreetMap contributors, Shortbread MVT via https://tiles.versatiles.org/tiles/osm/{z}/{x}/{y}", + "limits": "Emulator screenshots, not console photographs or hardware performance measurements" + }, + "images": [ + { + "file": "sf-union-square-3ds.png", + "camera": { + "id": "sf-union-square", + "name": "Union Square", + "detail": "San Francisco, California", + "lat": 37.7879, + "lon": -122.4075, + "zoom": 15 + }, + "size": [ + 400, + 480 + ], + "sha256": "d96ac6f5cb5e87ac03bd90ba40ac3a776c22546afbc8211a9d52229eb5932d86", + "rawReadbacks": [ + { + "name": "aux-f0600.raw", + "bytes": 307200, + "sha256": "746257d31b2bd086fd8706007f9ed1ca4750a57152d2af9ab4cb88f5d941d68f" + }, + { + "name": "f0600.raw", + "bytes": 384000, + "sha256": "541a48fc6e9e56e7a438ce2551dfda97e3bc1294070b02f5ed08a5ad9b5f971d" + } + ], + "meshReplies": 4, + "lastProviderDiagnostics": { + "osm": { + "httpHits": 0, + "downloads": 4, + "meshBytes": 91192, + "prepared": 4 + } + } + }, + { + "file": "sf-ferry-building-3ds.png", + "camera": { + "id": "sf-ferry-building", + "name": "Ferry Building", + "detail": "San Francisco, California", + "lat": 37.7955, + "lon": -122.3937, + "zoom": 16 + }, + "size": [ + 400, + 480 + ], + "sha256": "afca5b8a72412f9b2958ca44f78b87227c18cdb547844adf9e05a81dcb0aa739", + "rawReadbacks": [ + { + "name": "aux-f0600.raw", + "bytes": 307200, + "sha256": "746257d31b2bd086fd8706007f9ed1ca4750a57152d2af9ab4cb88f5d941d68f" + }, + { + "name": "f0600.raw", + "bytes": 384000, + "sha256": "af705e3c585ca076113d0cb6a777d1b095d55e983a5787021856dbf609ffd8e2" + } + ], + "meshReplies": 1, + "lastProviderDiagnostics": { + "osm": { + "httpHits": 1, + "downloads": 0, + "meshBytes": 16370, + "prepared": 1 + } + } + }, + { + "file": "sf-golden-gate-park-3ds.png", + "camera": { + "id": "sf-golden-gate-park", + "name": "Golden Gate Park", + "detail": "San Francisco, California", + "lat": 37.7694, + "lon": -122.4862, + "zoom": 15 + }, + "size": [ + 400, + 480 + ], + "sha256": "58ecc0917c077de74f3746de40c10d33c988f626d575d4a4e9f8b6f30e53de92", + "rawReadbacks": [ + { + "name": "aux-f0600.raw", + "bytes": 307200, + "sha256": "746257d31b2bd086fd8706007f9ed1ca4750a57152d2af9ab4cb88f5d941d68f" + }, + { + "name": "f0600.raw", + "bytes": 384000, + "sha256": "fba50df6dff02e84858cdd0e3c9996e1ba2312d5d31ee11608b2eb08a40f2f06" + } + ], + "meshReplies": 2, + "lastProviderDiagnostics": { + "osm": { + "httpHits": 0, + "downloads": 2, + "meshBytes": 31564, + "prepared": 2 + } + } + } + ] +} diff --git a/docs/images/sf-ferry-building-3ds.png b/docs/images/sf-ferry-building-3ds.png new file mode 100644 index 0000000000000000000000000000000000000000..20b58d63e67a1f393ac4381309bc6c57bf240ef5 GIT binary patch literal 25494 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 literal 0 HcmV?d00001 diff --git a/docs/images/sf-golden-gate-park-3ds.png b/docs/images/sf-golden-gate-park-3ds.png new file mode 100644 index 0000000000000000000000000000000000000000..72e33339de96036910c639a0c3eede738285cdad GIT binary patch literal 28006 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 W{zHyY82Ek~MB$E#Y~HO$Fa8hmoPOT` literal 0 HcmV?d00001 diff --git a/docs/images/sf-union-square-3ds.png b/docs/images/sf-union-square-3ds.png new file mode 100644 index 0000000000000000000000000000000000000000..83bb27bf7676597bd00d30fdb3c7e94a8fd7b93e GIT binary patch literal 30149 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 Ee}e6wX8-^I literal 0 HcmV?d00001