diff --git a/pocket3d/Cargo.toml b/pocket3d/Cargo.toml index 50732b65..74e36fd5 100644 --- a/pocket3d/Cargo.toml +++ b/pocket3d/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] # PSP-only crates (tier-3 target, cargo-psp toolchain) — kept out of the # desktop workspace on purpose, like the repo-root native/ crate. -exclude = ["crates/pocket3d-gu", "crates/gu-demo"] +exclude = ["crates/pocket3d-gu", "crates/gu-demo", "crates/pocket-drive"] [workspace.package] version = "0.1.0" diff --git a/pocket3d/README.md b/pocket3d/README.md index 3590f398..635dcbd9 100644 --- a/pocket3d/README.md +++ b/pocket3d/README.md @@ -33,8 +33,12 @@ pocket3d/ │ │ # entities, clipnode hull tracing (no GPU deps) │ ├── pocket-mod/ # guest hosting: one QuickJS realm, mounted surfaces, │ │ # one guest turn per tick (the mod-runtime mechanism) -│ └── pocket-ui-wgpu/ # the PocketJS `ui` surface on this base: pak feeding, -│ # HostOps for the guest, DrawList → wgpu, Blit compositor +│ ├── pocket-ui-wgpu/ # the PocketJS `ui` surface on this base: pak feeding, +│ │ # HostOps for the guest, DrawList → wgpu, Blit compositor +│ ├── pocket3d-gu/ # sceGu (PSP GE) renderer backend for cooked worlds +│ ├── gu-demo/ # PSP bring-up EBOOT: fly a camera through a .p3d map +│ └── pocket-drive/ # streamed open-data city drive on PSP (OSM → .pdrv +│ # tiles → async memory-stick streaming; see its README) └── examples/ └── uihost/ # PocketJS UI demos in a native macOS window ``` diff --git a/pocket3d/crates/pocket-drive/Cargo.lock b/pocket3d/crates/pocket-drive/Cargo.lock new file mode 100644 index 00000000..e88de3de --- /dev/null +++ b/pocket3d/crates/pocket-drive/Cargo.lock @@ -0,0 +1,138 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "glam" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436" +dependencies = [ + "libm", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pocket-drive" +version = "0.1.0" +dependencies = [ + "glam", + "libm", + "pocket3d-gu", + "psp", +] + +[[package]] +name = "pocket3d-bsp" +version = "0.1.0" +dependencies = [ + "glam", +] + +[[package]] +name = "pocket3d-gu" +version = "0.1.0" +dependencies = [ + "glam", + "libm", + "pocket3d-bsp", + "psp", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psp" +version = "0.3.12" +source = "git+https://github.com/pocket-stack/rust-psp.git?rev=2cbaf8c9bc72569c76240a1d9743de10731e5f6b#2cbaf8c9bc72569c76240a1d9743de10731e5f6b" +dependencies = [ + "bitflags", + "libm", + "num_enum", + "num_enum_derive", + "paste", + "unstringify", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unstringify" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9612d66420ead229348915b911ad9689e79dfc347fe7a876a82551c8eab36b5e" diff --git a/pocket3d/crates/pocket-drive/Cargo.toml b/pocket3d/crates/pocket-drive/Cargo.toml new file mode 100644 index 00000000..a0a67693 --- /dev/null +++ b/pocket3d/crates/pocket-drive/Cargo.toml @@ -0,0 +1,26 @@ +# pocket-drive — streamed open-data city driving on the pocket3d GE backend. +# +# A Tesla-dash-style night drive through a real city: the cooker turns +# OpenStreetMap footprints + roads into a tiled `.pdrv` mesh pack, and this +# EBOOT streams tiles in around the car as it follows a baked route. +# +# STANDALONE CRATE (excluded from the pocket3d workspace; cargo-psp only). +# Build + run under PPSSPP with `bun scripts/pocket-drive.ts` from the repo +# root. No QuickJS, no UI core — pure 3D substrate, like gu-demo. + +[package] +name = "pocket-drive" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[features] +# On-screen streaming/perf stats every 300 frames (corrupts captures; for +# PSPLINK bring-up sessions). +stats = [] + +[dependencies] +psp = { git = "https://github.com/pocket-stack/rust-psp.git", rev = "2cbaf8c9bc72569c76240a1d9743de10731e5f6b", features = ["abort-only"] } +pocket3d-gu = { path = "../pocket3d-gu" } +glam = { version = "0.33", default-features = false, features = ["libm"] } +libm = "0.2" diff --git a/pocket3d/crates/pocket-drive/README.md b/pocket3d/crates/pocket-drive/README.md new file mode 100644 index 00000000..3c6dd004 --- /dev/null +++ b/pocket3d/crates/pocket-drive/README.md @@ -0,0 +1,116 @@ +# pocket-drive — a real city, streamed onto a real PSP + +A Tesla-dash-style night drive through Midtown Manhattan on PSP hardware: +OpenStreetMap building footprints and streets, cooked into simple extruded +meshes, **streamed tile-by-tile off the memory stick** around a car that +follows a baked route past real landmarks. New blocks rise out of the ground +as they load — the streaming is the show, not a loading screen. + +| | | +| --- | --- | +| ![orbit](shots/orbit.png) | ![avenue](shots/avenue.png) | +| ![rise](shots/rise.png) | ![turn](shots/turn.png) | + +The cooked area: ~2 × 2.2 km around the Flatiron / Empire State corridor, +4 390 buildings (94 % with real surveyed heights), 822 streets, 76 k +triangles across a 19×22 grid of 128 m tiles. The whole city pack is 2.6 MB; +resident tiles at any moment are ~5 MB of a 24 MB PSP. + +``` +cooker/cook.ts Bun cooker: pinned OSM extract → tiled .pdrv pack + SVG preview +cooker/data/*.json.gz pinned extract (the source of truth; `fetch` refreshes it) +src/pack.rs zero-copy .pdrv reader + 16-aligned tile buffers +src/stream.rs residency ring, async sceIo loads, rise animation +src/scene.rs backdrop (zero-triangle infinite ground), tiles, route ribbon +src/car.rs box-art cars, route follower (arclength + corner slowdown) +src/main.rs boot, pack probing, chase/orbit camera, frame loop, capture +``` + +## Run it + +```sh +bun scripts/pocket-drive.ts # cook, build, PPSSPP, PNGs +bun scripts/pocket-drive.ts --cap-start 0 --cap-n 480 # capture the intro +bun scripts/pocket-drive.ts --embed # no ms0 file: embedded-pack path +``` + +On a real PSP: copy `dist/drive/manhattan.pdrv` to `ms0:/pocket-drive/` next +to the EBOOT (or build with `--embed`). Controls: analog looks around, +CROSS boosts, SQUARE brakes, START pauses. + +## How the streaming works + +The pack is opened once; the streamer keeps a Chebyshev ring of tiles +resident around the car (`KEEP = 5`, ~640 m) with one async `sceIoReadAsync` +in flight at a time, polled per frame — the frame loop never blocks on the +memory stick. Load order is nearest-ring-first with a penalty for tiles +behind the car, so driving into fresh blocks streams the skyline in ahead of +arrival; tiles beyond `EVICT = 6` rings are dropped (free heap, GE-safe +because the previous display list is synced before update runs). A tile +becomes visible through a 30-frame ease-out rise animation driven by the +per-tile model matrix — `scale(32768, 32768·rise, 32768)`, the same matrix +that undoes the GE's 16-bit ÷32768 vertex normalization. + +Without a memory-stick file the EBOOT falls back to a pack embedded at build +time (`DRIVE_PACK`), borrowing tile payloads straight out of `.rodata` behind +a 5-frame simulated latency — same choreography, zero copies, deterministic. + +## The look, for cheap + +- **Zero-triangle ground**: the backdrop fills sky-gradient / haze-band / + ground-gradient screen quads split at the horizon row computed from camera + pitch. The dark "infinite ground plane" is literally the clear color — no + geometry, no z-fighting with roads. +- **Baked lighting in vertex color**: walls shaded by facing (fake NW sun), + darkened toward the base (fake AO), roofs lighter with height; a per-id + hash varies building tint. One `GU_COLOR_8888 | GU_VERTEX_16BIT` draw per + tile plus one `GU_LINES` draw for the roof rim-light lines. +- **GE fog** (`sceGuFog`, 275–625 m) melts distant geometry into the same + haze color the backdrop uses at the horizon, which also hides tile pop-in + past the residency ring. +- **Route ribbon**: the navigation line is a flat miter-joined strip rebuilt + each frame from the baked route, from just behind the car to 350 m ahead. + +Typical frame: ~35 tiles, ~10 k triangles, ~80 draw calls. + +## The cooker + +`cooker/cook.ts` (Bun, zero deps) has two modes. `fetch` pulls buildings + +roads for the configured bbox from the Overpass API (or `--raw` an existing +response), clips roads to the area, projects to local meters and pins a +trimmed extract into `cooker/data/` (~220 KB gzipped) — cooking is offline +and byte-deterministic from that pin. The default mode ear-clips footprints, +extrudes them by surveyed `height` (falling back to `building:levels`, then +an id-hashed default), ribbons the streets by class width, quantizes +everything to the 12-byte GE vertex, buckets triangles into tiles, and walks +the street graph to bake the route: shortest-path legs (Dijkstra) between a +list of scenic waypoints — Flatiron → Empire State Building → Herald Square → +Garment District → Morgan Library → Park Avenue South — closed into a 4.1 km +loop. Cars (the player plus 16 box-art traffic cars running both directions) +follow it by arclength with corner slowdown at runtime. + +Add a city by adding a bbox + waypoints to `CITIES` and running `fetch`. +Nothing in the format is Manhattan-specific. + +## Format (`.pdrv` v1) + +LE, 16-byte-aligned sections: 48 B header (units/m, tile size, grid origin + +dims, offsets) → tile directory (32 B/tile: payload offset, vert/index/line +counts, i16 AABB) → route (16 B/point: x, z, cumulative arclength, speed +class) → per-tile payloads `[12 B verts][u16 indices][12 B line verts]`. +Vertices are tile-local i16 at 1 unit = 0.25 m, ABGR vertex color; index and +vertex data are consumed by the GE in place (file tiles after one writeback, +embedded tiles straight from the module image). + +## Verification + +- Cook determinism: two cooks from the pinned extract are byte-identical. +- PPSSPPHeadless (software renderer) via `scripts/pocket-drive.ts`: boot, + intro rise, avenue drive, 90° turns, oncoming traffic, and both pack + sources verified frame-by-frame; streaming stats via `--features stats` + (121 async tile loads in the first seconds, ~35 tiles/frame drawn). +- Real-hardware pass (PSPLINK, `--features stats` for `avg` numbers): + pending, same acceptance bar as gu-demo. + +Map data © OpenStreetMap contributors, via the Overpass API — ODbL. The +pinned extract in `cooker/data/` is a derived database of that data. diff --git a/pocket3d/crates/pocket-drive/build.rs b/pocket3d/crates/pocket-drive/build.rs new file mode 100644 index 00000000..300405d1 --- /dev/null +++ b/pocket3d/crates/pocket-drive/build.rs @@ -0,0 +1,26 @@ +//! Optionally embeds a cooked `.pdrv` pack (DRIVE_PACK) as the fallback for +//! running without a memory-stick file, plus the capture window envs. Empty +//! fallbacks keep bare `cargo psp` builds green. + +use std::env; +use std::fs; +use std::path::Path; + +fn main() { + let out = env::var("OUT_DIR").unwrap(); + let pack = env::var("DRIVE_PACK").unwrap_or_default(); + println!("cargo:rerun-if-env-changed=DRIVE_PACK"); + println!("cargo:rerun-if-env-changed=DRIVE_CAP_START"); + println!("cargo:rerun-if-env-changed=DRIVE_CAP_N"); + let dst = Path::new(&out).join("city.pdrv"); + if !pack.is_empty() && Path::new(&pack).exists() { + println!("cargo:rerun-if-changed={pack}"); + fs::copy(&pack, &dst).expect("copying DRIVE_PACK"); + } else { + fs::write(&dst, []).expect("writing empty city.pdrv"); + } + let cap_start = env::var("DRIVE_CAP_START").unwrap_or_default(); + let cap_n = env::var("DRIVE_CAP_N").unwrap_or_default(); + println!("cargo:rustc-env=DRIVE_CAP_START={cap_start}"); + println!("cargo:rustc-env=DRIVE_CAP_N={cap_n}"); +} diff --git a/pocket3d/crates/pocket-drive/cooker/cook.ts b/pocket3d/crates/pocket-drive/cooker/cook.ts new file mode 100644 index 00000000..453c4ca6 --- /dev/null +++ b/pocket3d/crates/pocket-drive/cooker/cook.ts @@ -0,0 +1,951 @@ +// pocket-drive cooker: open map data → a tiled, GE-ready `.pdrv` city pack. +// +// Two modes: +// +// bun cooker/cook.ts fetch [--raw overpass.json] # Overpass → data/.json.gz +// bun cooker/cook.ts [--out x.pdrv] [--svg x.svg] # data/.json.gz → pack +// +// The trimmed extract in data/ is the pinned source of truth — cooking is +// offline and deterministic. `fetch` only refreshes that pin (network, or +// --raw for an already-downloaded Overpass JSON). +// +// Units: 1 unit = 0.25 m (4 units/m). +X east, +Z south, +Y up — so yaw 0 +// (-Z) looks north. Tile-local i16 positions ride the GE's ÷32768 3D-mode +// normalization; the runtime scales back up in the model matrix. + +import { existsSync, mkdirSync } from "node:fs"; +import { gunzipSync, gzipSync } from "node:zlib"; + +// ---- city registry ---------------------------------------------------- + +interface City { + /** Overpass bbox: south, west, north, east. */ + bbox: [number, number, number, number]; + /** Projection origin (center-ish). */ + lat0: number; + lon0: number; + /** Scenic waypoints (lat, lon); the route is shortest-path legs between + * consecutive ones, closed back to the first. */ + waypoints: [number, number][]; +} + +const CITIES: Record = { + manhattan: { + bbox: [40.735, -73.998, 40.755, -73.975], + lat0: 40.745, + lon0: -73.9865, + waypoints: [ + [40.7419, -73.9884], // Broadway & W 23rd — Flatiron + [40.7443, -73.987], // Broadway & W 28th + [40.7484, -73.9857], // 5th Ave & W 34th — Empire State Building + [40.7503, -73.9884], // 6th Ave & W 35th — Herald Square + [40.7527, -73.9857], // 6th Ave & W 39th — Garment District + [40.7495, -73.9811], // Madison Ave & E 36th — Morgan Library + [40.7442, -73.9805], // Park Ave S & E 30th + [40.7404, -73.9857], // 5th Ave & E 23rd — Madison Square south + ], + }, +}; + +// ---- tunables --------------------------------------------------------- + +const UNITS_PER_M = 4; // 1 unit = 0.25 m +const TILE = 512; // units (128 m) +const LEVEL_M = 3.2; // meters per building:levels +const MIN_FOOTPRINT_M2 = 12; +const SIMPLIFY_M = 0.6; // footprint/road vertex tolerance + +/** Road classes: render width (m), y offset (units), drivable, speed factor. */ +const ROADS: Record = { + motorway: { w: 16, y: 1.8, drive: true, speed: 2.0 }, + motorway_link: { w: 8, y: 1.8, drive: true, speed: 1.3 }, + trunk: { w: 15, y: 1.6, drive: true, speed: 1.6 }, + trunk_link: { w: 8, y: 1.6, drive: true, speed: 1.2 }, + primary: { w: 15, y: 1.4, drive: true, speed: 1.25 }, + primary_link: { w: 8, y: 1.4, drive: true, speed: 1.0 }, + secondary: { w: 13, y: 1.2, drive: true, speed: 1.1 }, + secondary_link: { w: 7, y: 1.2, drive: true, speed: 1.0 }, + tertiary: { w: 11, y: 1.0, drive: true, speed: 1.0 }, + tertiary_link: { w: 6, y: 1.0, drive: true, speed: 0.9 }, + residential: { w: 9, y: 0.8, drive: true, speed: 0.85 }, + unclassified: { w: 8, y: 0.8, drive: true, speed: 0.85 }, + living_street: { w: 7, y: 0.8, drive: true, speed: 0.7 }, + pedestrian: { w: 6, y: 0.6, drive: false, speed: 0 }, +}; + +// Night-navigation palette (ABGR is packed at write time). +const PALETTE = { + road: [0.215, 0.245, 0.30] as V3, + pedestrian: [0.16, 0.185, 0.235] as V3, + wallBase: [0.335, 0.385, 0.485] as V3, + roof: [0.415, 0.465, 0.565] as V3, + roofLine: [0.615, 0.70, 0.84] as V3, +}; +const SUN = norm3([-0.55, 0.0, -0.45]); // fake sun from the NW, grazing + +type V3 = [number, number, number]; +type P2 = [number, number]; + +function norm3(v: V3): V3 { + const l = Math.hypot(v[0], v[1], v[2]); + return [v[0] / l, v[1] / l, v[2] / l]; +} + +// ---- deterministic hash (per OSM id) ---------------------------------- + +function hash01(id: number): number { + let x = id >>> 0; + x ^= x >>> 16; + x = Math.imul(x, 0x7feb352d) >>> 0; + x ^= x >>> 15; + x = Math.imul(x, 0x846ca68b) >>> 0; + x ^= x >>> 16; + return x / 0xffffffff; +} + +// ---- extract schema --------------------------------------------------- + +interface Extract { + meta: { city: string; lat0: number; lon0: number; fetched: string; source: string }; + /** Rings in local meters (x east, z south), closed implicitly. */ + buildings: { id: number; h: number; minH: number; ring: P2[] }[]; + roads: { id: number; cls: string; pts: P2[] }[]; +} + +// ---- CLI -------------------------------------------------------------- + +const argv = Bun.argv.slice(2); +const mode = argv[0] === "fetch" ? "fetch" : "cook"; +function flag(name: string, def: string): string { + const i = argv.indexOf(`--${name}`); + return i !== -1 && argv[i + 1] ? argv[i + 1] : def; +} +const cityName = flag("city", "manhattan"); +const city = CITIES[cityName]; +if (!city) { + console.error(`unknown city ${cityName} (have: ${Object.keys(CITIES).join(", ")})`); + process.exit(1); +} +const here = new URL(".", import.meta.url).pathname; +const dataGz = `${here}data/${cityName}.json.gz`; + +if (mode === "fetch") { + await fetchExtract(); +} else { + cook(); +} + +// ---- fetch: Overpass → trimmed extract -------------------------------- + +async function fetchExtract() { + const rawPath = flag("raw", ""); + let raw: any; + if (rawPath) { + raw = JSON.parse(await Bun.file(rawPath).text()); + } else { + const [s, w, n, e] = city.bbox; + const bb = `(${s},${w},${n},${e})`; + const q = `[out:json][timeout:120]; +( + way["building"]${bb}; + relation["building"]["type"="multipolygon"]${bb}; + way["highway"~"^(${Object.keys(ROADS).join("|")})$"]${bb}; +); +(._;>;); +out body;`; + console.log("cook: fetching from Overpass…"); + const res = await fetch("https://overpass-api.de/api/interpreter", { + method: "POST", + body: new URLSearchParams({ data: q }), + }); + if (!res.ok) throw new Error(`overpass: HTTP ${res.status}`); + raw = await res.json(); + } + + const nodes = new Map(); + for (const el of raw.elements) { + if (el.type === "node") nodes.set(el.id, project(el.lat, el.lon)); + } + const ways = new Map(); + for (const el of raw.elements) if (el.type === "way") ways.set(el.id, el); + + const buildings: Extract["buildings"] = []; + const usedInRel = new Set(); + + // Multipolygon relations: stitch outer ways into rings. + for (const el of raw.elements) { + if (el.type !== "relation" || el.tags?.building === undefined) continue; + const h = buildingHeight(el.tags, el.id); + if (h === null) continue; + const outers = (el.members ?? []) + .filter((m: any) => m.type === "way" && m.role !== "inner") + .map((m: any) => ways.get(m.ref)) + .filter(Boolean); + for (const w of outers) usedInRel.add(w.id); + for (const ring of stitchRings(outers.map((w: any) => w.nodes as number[]))) { + pushBuilding(buildings, el.id, h, ring, nodes); + } + } + + for (const el of raw.elements) { + if (el.type !== "way") continue; + const t = el.tags ?? {}; + if (t.building && t.building !== "no" && !usedInRel.has(el.id)) { + const h = buildingHeight(t, el.id); + if (h === null) continue; + const ids = el.nodes as number[]; + if (ids.length >= 4 && ids[0] === ids[ids.length - 1]) { + pushBuilding(buildings, el.id, h, ids.slice(0, -1), nodes); + } + } + } + + // Clip roads to the bbox (+margin): Overpass returns whole ways, and a + // street escaping the cooked area would drive the car into the void. + const [s0, w0, n0, e0] = city.bbox; + const m = 60; // meters of slack + const [clipMinX, clipMinZ] = project(n0, w0); + const [clipMaxX, clipMaxZ] = project(s0, e0); + const inBounds = (p: P2) => + p[0] > clipMinX - m && p[0] < clipMaxX + m && p[1] > clipMinZ - m && p[1] < clipMaxZ + m; + + const roads: Extract["roads"] = []; + for (const el of raw.elements) { + if (el.type !== "way") continue; + const t = el.tags ?? {}; + if (!t.highway || !(t.highway in ROADS)) continue; + if (t.tunnel === "yes" || t.covered === "yes" || Number(t.layer ?? 0) < 0) continue; + if (t.area === "yes") continue; // pedestrian plazas: not linear + const pts = (el.nodes as number[]).map((id) => nodes.get(id)).filter(Boolean) as P2[]; + let run: P2[] = []; + const flush = () => { + if (run.length >= 2) roads.push({ id: el.id, cls: t.highway, pts: run.map(round2) }); + run = []; + }; + for (const p of pts) { + if (inBounds(p)) run.push(p); + else flush(); + } + flush(); + } + + const extract: Extract = { + meta: { + city: cityName, + lat0: city.lat0, + lon0: city.lon0, + fetched: raw.osm3s?.timestamp_osm_base ?? "unknown", + source: "OpenStreetMap via Overpass API (ODbL)", + }, + buildings, + roads, + }; + mkdirSync(`${here}data`, { recursive: true }); + const gz = gzipSync(JSON.stringify(extract), { level: 9 }); + await Bun.write(dataGz, gz); + console.log( + `cook: pinned ${cityName}: ${buildings.length} buildings, ${roads.length} roads → ` + + `${dataGz} (${(gz.length / 1024).toFixed(0)} KB)`, + ); + + function pushBuilding( + out: Extract["buildings"], + id: number, + h: { h: number; minH: number }, + nodeIds: number[], + nm: Map, + ) { + const ring = nodeIds.map((n) => nm.get(n)).filter(Boolean) as P2[]; + if (ring.length < 3) return; + const simp = simplifyRing(ring.map(round2), SIMPLIFY_M); + if (simp.length < 3 || Math.abs(ringArea(simp)) < MIN_FOOTPRINT_M2) return; + out.push({ id, h: h.h, minH: h.minH, ring: simp }); + } + + function project(lat: number, lon: number): P2 { + const mPerLon = 111320 * Math.cos((city.lat0 * Math.PI) / 180); + return [(lon - city.lon0) * mPerLon, -(lat - city.lat0) * 110574]; + } + function round2(p: P2): P2 { + return [Math.round(p[0] * 100) / 100, Math.round(p[1] * 100) / 100]; + } +} + +function parseLen(v: string | undefined): number | null { + if (!v) return null; + const s = String(v).trim(); + const ft = s.match(/^([\d.]+)\s*(?:'|ft)/); + const n = ft ? parseFloat(ft[1]) * 0.3048 : parseFloat(s); + return Number.isFinite(n) ? n : null; +} + +function buildingHeight(t: any, id: number): { h: number; minH: number } | null { + let h = parseLen(t.height) ?? parseLen(t["building:height"]); + if (h === null) { + const lv = parseFloat(t["building:levels"]); + h = Number.isFinite(lv) ? lv * LEVEL_M + 1.5 : 8 + hash01(id) * 10; + } + const minH = Math.max(0, parseLen(t.min_height) ?? 0); + if (h <= minH + 0.5) return null; + return { h, minH }; +} + +/** Join way node-lists sharing endpoints into closed rings. */ +function stitchRings(ways: number[][]): number[][] { + const segs = ways.filter((w) => w.length >= 2).map((w) => [...w]); + const rings: number[][] = []; + while (segs.length) { + const ring = segs.shift()!; + let grew = true; + while (grew && ring[0] !== ring[ring.length - 1]) { + grew = false; + for (let i = 0; i < segs.length; i++) { + const s = segs[i]; + const tail = ring[ring.length - 1]; + if (s[0] === tail) ring.push(...s.slice(1)); + else if (s[s.length - 1] === tail) ring.push(...s.reverse().slice(1)); + else continue; + segs.splice(i, 1); + grew = true; + break; + } + } + if (ring.length >= 4 && ring[0] === ring[ring.length - 1]) rings.push(ring.slice(0, -1)); + } + return rings; +} + +// ---- geometry helpers ------------------------------------------------- + +function ringArea(r: P2[]): number { + let a = 0; + for (let i = 0; i < r.length; i++) { + const [x1, z1] = r[i]; + const [x2, z2] = r[(i + 1) % r.length]; + a += x1 * z2 - x2 * z1; + } + return a / 2; +} + +/** Douglas-Peucker on a closed ring (keeps the two extreme anchors). */ +function simplifyRing(ring: P2[], tol: number): P2[] { + const dedup: P2[] = []; + for (const p of ring) { + const last = dedup[dedup.length - 1]; + if (!last || Math.hypot(p[0] - last[0], p[1] - last[1]) > 1e-6) dedup.push(p); + } + if (dedup.length < 4) return dedup; + let a = 0; + let b = 0; + let best = -1; + for (let i = 0; i < dedup.length; i++) + for (const j of [0]) { + const d = Math.hypot(dedup[i][0] - dedup[j][0], dedup[i][1] - dedup[j][1]); + if (d > best) { + best = d; + a = j; + b = i; + } + } + const half1 = simplifyLine([...dedup.slice(a, b + 1)], tol); + const half2 = simplifyLine([...dedup.slice(b), ...dedup.slice(0, a + 1)], tol); + return [...half1.slice(0, -1), ...half2.slice(0, -1)]; +} + +function simplifyLine(pts: P2[], tol: number): P2[] { + if (pts.length <= 2) return pts; + let maxD = 0; + let idx = 0; + const [ax, az] = pts[0]; + const [bx, bz] = pts[pts.length - 1]; + const abx = bx - ax; + const abz = bz - az; + const ab2 = abx * abx + abz * abz; + for (let i = 1; i < pts.length - 1; i++) { + const [px, pz] = pts[i]; + let d: number; + if (ab2 < 1e-12) d = Math.hypot(px - ax, pz - az); + else { + const t = Math.max(0, Math.min(1, ((px - ax) * abx + (pz - az) * abz) / ab2)); + d = Math.hypot(px - (ax + t * abx), pz - (az + t * abz)); + } + if (d > maxD) { + maxD = d; + idx = i; + } + } + if (maxD <= tol) return [pts[0], pts[pts.length - 1]]; + const l = simplifyLine(pts.slice(0, idx + 1), tol); + const r = simplifyLine(pts.slice(idx), tol); + return [...l.slice(0, -1), ...r]; +} + +/** Ear clipping. Returns index triples into `ring`. Winding-agnostic. */ +function earcut(ring: P2[]): number[] { + const n = ring.length; + if (n < 3) return []; + const idx = [...ring.keys()]; + if (ringArea(ring) < 0) idx.reverse(); // make CCW in (x, z) + const tris: number[] = []; + let guard = 0; + while (idx.length > 3 && guard++ < 10000) { + let clipped = false; + for (let i = 0; i < idx.length; i++) { + const ia = idx[(i + idx.length - 1) % idx.length]; + const ib = idx[i]; + const ic = idx[(i + 1) % idx.length]; + const a = ring[ia]; + const b = ring[ib]; + const c = ring[ic]; + const cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + if (cross <= 1e-9) continue; // reflex or degenerate + let inside = false; + for (const j of idx) { + if (j === ia || j === ib || j === ic) continue; + if (pointInTri(ring[j], a, b, c)) { + inside = true; + break; + } + } + if (inside) continue; + tris.push(ia, ib, ic); + idx.splice(i, 1); + clipped = true; + break; + } + if (!clipped) break; // degenerate leftovers: fan the rest + } + if (idx.length === 3) tris.push(idx[0], idx[1], idx[2]); + else for (let i = 1; i + 1 < idx.length; i++) tris.push(idx[0], idx[i], idx[i + 1]); + return tris; +} + +function pointInTri(p: P2, a: P2, b: P2, c: P2): boolean { + const s = (a[0] - c[0]) * (p[1] - c[1]) - (a[1] - c[1]) * (p[0] - c[0]); + const t = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); + if (s < 0 !== t < 0 && s !== 0 && t !== 0) return false; + const d = (c[0] - b[0]) * (p[1] - b[1]) - (c[1] - b[1]) * (p[0] - b[0]); + return d === 0 || d < 0 === (s + t <= 0); +} + +// ---- cook: extract → .pdrv -------------------------------------------- + +interface TileBuf { + verts: number[]; // packed: color, x, y, z per vert + vmap: Map; + idx: number[]; + lines: number[]; // packed line-vert pairs: color, x, y, z + min: V3; + max: V3; +} + +function cook() { + if (!existsSync(dataGz)) { + console.error(`no pinned extract at ${dataGz} — run: bun cooker/cook.ts fetch`); + process.exit(1); + } + const extract: Extract = JSON.parse( + gunzipSync(new Uint8Array(readFileSyncBytes(dataGz))).toString(), + ); + const outPath = flag("out", `${repoDist()}/${cityName}.pdrv`); + const svgPath = flag("svg", `${repoDist()}/${cityName}.svg`); + + // World bounds in units (roads + buildings). + let wminx = Infinity, wminz = Infinity, wmaxx = -Infinity, wmaxz = -Infinity; + const touch = (x: number, z: number) => { + wminx = Math.min(wminx, x); wminz = Math.min(wminz, z); + wmaxx = Math.max(wmaxx, x); wmaxz = Math.max(wmaxz, z); + }; + for (const b of extract.buildings) for (const [x, z] of b.ring) touch(x * UNITS_PER_M, z * UNITS_PER_M); + for (const r of extract.roads) for (const [x, z] of r.pts) touch(x * UNITS_PER_M, z * UNITS_PER_M); + + const originX = Math.floor(wminx / TILE) * TILE; + const originZ = Math.floor(wminz / TILE) * TILE; + const nx = Math.ceil((wmaxx - originX) / TILE); + const nz = Math.ceil((wmaxz - originZ) / TILE); + const tiles: (TileBuf | null)[] = new Array(nx * nz).fill(null); + + const tileAt = (x: number, z: number): TileBuf => { + let tx = Math.floor((x - originX) / TILE); + let tz = Math.floor((z - originZ) / TILE); + tx = Math.max(0, Math.min(nx - 1, tx)); + tz = Math.max(0, Math.min(nz - 1, tz)); + const i = tz * nx + tx; + if (!tiles[i]) { + tiles[i] = { + verts: [], vmap: new Map(), idx: [], lines: [], + min: [32767, 32767, 32767], max: [-32768, -32768, -32768], + }; + } + (tiles[i] as any)._tx = tx; + (tiles[i] as any)._tz = tz; + return tiles[i]!; + }; + const tileOrigin = (t: TileBuf): P2 => [ + originX + (t as any)._tx * TILE, + originZ + (t as any)._tz * TILE, + ]; + + const addVert = (t: TileBuf, color: number, x: number, y: number, z: number): number => { + const [ox, oz] = tileOrigin(t); + const lx = Math.round(x - ox); + const ly = Math.round(y); + const lz = Math.round(z - oz); + clampCheck(lx); clampCheck(ly); clampCheck(lz); + const key = `${color},${lx},${ly},${lz}`; + const got = t.vmap.get(key); + if (got !== undefined) return got; + const i = t.verts.length / 4; + t.verts.push(color, lx, ly, lz); + t.vmap.set(key, i); + t.min = [Math.min(t.min[0], lx), Math.min(t.min[1], ly), Math.min(t.min[2], lz)]; + t.max = [Math.max(t.max[0], lx), Math.max(t.max[1], ly), Math.max(t.max[2], lz)]; + return i; + }; + const addLineVert = (t: TileBuf, color: number, x: number, y: number, z: number) => { + const [ox, oz] = tileOrigin(t); + const lx = Math.round(x - ox); + const ly = Math.round(y); + const lz = Math.round(z - oz); + t.lines.push(color, lx, ly, lz); + t.min = [Math.min(t.min[0], lx), Math.min(t.min[1], ly), Math.min(t.min[2], lz)]; + t.max = [Math.max(t.max[0], lx), Math.max(t.max[1], ly), Math.max(t.max[2], lz)]; + }; + + // ---- roads → ribbons ---- + let roadTris = 0; + for (const road of extract.roads) { + const spec = ROADS[road.cls]; + const pts = simplifyLine(road.pts.map(([x, z]) => [x * UNITS_PER_M, z * UNITS_PER_M] as P2), SIMPLIFY_M * UNITS_PER_M); + if (pts.length < 2) continue; + const half = (spec.w * UNITS_PER_M) / 2; + const color = packRGB(spec.drive ? PALETTE.road : PALETTE.pedestrian); + // Per-joint left/right offsets with miter (limited). + const L: P2[] = []; + const R: P2[] = []; + for (let i = 0; i < pts.length; i++) { + const p = pts[i]; + const din = i > 0 ? dir2(pts[i - 1], p) : dir2(p, pts[i + 1]); + const dout = i < pts.length - 1 ? dir2(p, pts[i + 1]) : din; + let mx = din[0] + dout[0]; + let mz = din[1] + dout[1]; + const ml = Math.hypot(mx, mz); + if (ml < 1e-6) { mx = -din[1]; mz = din[0]; } + else { mx /= ml; mz /= ml; } + // Miter normal = perpendicular of the averaged direction, scaled. + let nxm = -mz; + let nzm = mx; + const cosHalf = nxm * -din[1] + nzm * din[0]; + const scale = Math.min(2.5, 1 / Math.max(0.35, Math.abs(cosHalf))); + L.push([p[0] + nxm * half * scale, p[1] + nzm * half * scale]); + R.push([p[0] - nxm * half * scale, p[1] - nzm * half * scale]); + } + for (let i = 0; i + 1 < pts.length; i++) { + const midx = (pts[i][0] + pts[i + 1][0]) / 2; + const midz = (pts[i][1] + pts[i + 1][1]) / 2; + const t = tileAt(midx, midz); + const y = spec.y; + const a = addVert(t, color, L[i][0], y, L[i][1]); + const b = addVert(t, color, R[i][0], y, R[i][1]); + const c = addVert(t, color, R[i + 1][0], y, R[i + 1][1]); + const d = addVert(t, color, L[i + 1][0], y, L[i + 1][1]); + t.idx.push(a, b, c, a, c, d); + roadTris += 2; + } + } + + // ---- buildings → extruded prisms + roof edge lines ---- + let bldTris = 0; + let skipped = 0; + for (const b of extract.buildings) { + const ring = b.ring.map(([x, z]) => [x * UNITS_PER_M, z * UNITS_PER_M] as P2); + const area = ringArea(ring); + if (Math.abs(area) < MIN_FOOTPRINT_M2 * UNITS_PER_M * UNITS_PER_M) { skipped++; continue; } + const ccw = area > 0 ? ring : [...ring].reverse(); + const h = b.h * UNITS_PER_M; + const y0 = b.minH * UNITS_PER_M; + let cx = 0, cz = 0; + for (const [x, z] of ccw) { cx += x; cz += z; } + cx /= ccw.length; cz /= ccw.length; + const t = tileAt(cx, cz); + + // Per-building tint (subtle, deterministic). + const tint = 0.94 + hash01(b.id) * 0.12; + const aoTop = Math.min(1, 0.55 + (0.45 * h) / 120); // full brightness by ~30 m + const heightGlow = 1 + 0.12 * Math.min(1, h / 700); // tall towers slightly lighter + + // Walls: split verts per face for flat shading. + const n = ccw.length; + for (let i = 0; i < n; i++) { + const p = ccw[i]; + const q = ccw[(i + 1) % n]; + const ex = q[0] - p[0]; + const ez = q[1] - p[1]; + const el = Math.hypot(ex, ez); + if (el < 1e-6) continue; + // Outward normal of a CCW ring in (x, z) with +Z south: (dz, -dx). + const nrm: V3 = [ez / el, 0, -ex / el]; + const lit = 0.50 + 0.42 * Math.max(0, nrm[0] * SUN[0] + nrm[2] * SUN[2]); + const shade = lit * tint * heightGlow; + const cBot = packRGB(scale3(PALETTE.wallBase, shade * 0.52)); + const cTop = packRGB(scale3(PALETTE.wallBase, shade * aoTop)); + const a = addVert(t, cBot, p[0], y0, p[1]); + const bb = addVert(t, cBot, q[0], y0, q[1]); + const c = addVert(t, cTop, q[0], h, q[1]); + const d = addVert(t, cTop, p[0], h, p[1]); + t.idx.push(a, bb, c, a, c, d); + bldTris += 2; + } + + // Roof. + const roofC = packRGB(scale3(PALETTE.roof, tint * heightGlow)); + const tris = earcut(ccw); + for (let i = 0; i + 2 < tris.length; i += 3) { + const a = addVert(t, roofC, ccw[tris[i]][0], h, ccw[tris[i]][1]); + const bb = addVert(t, roofC, ccw[tris[i + 1]][0], h, ccw[tris[i + 1]][1]); + const c = addVert(t, roofC, ccw[tris[i + 2]][0], h, ccw[tris[i + 2]][1]); + t.idx.push(a, bb, c); + bldTris++; + } + + // Roof edge lines (the Tesla-style rim light), floated just above the + // roof plane so they never z-fight it. + const lineC = packRGB(scale3(PALETTE.roofLine, Math.min(1.15, tint * heightGlow))); + const lineY = h + 1.5; + for (let i = 0; i < n; i++) { + const p = ccw[i]; + const q = ccw[(i + 1) % n]; + addLineVert(t, lineC, p[0], lineY, p[1]); + addLineVert(t, lineC, q[0], lineY, q[1]); + } + } + + // ---- route ---- + const route = buildRoute(extract); + + // ---- write pack ---- + const buf = writePack({ originX, originZ, nx, nz, tiles, route }); + mkdirSync(outPath.slice(0, outPath.lastIndexOf("/")), { recursive: true }); + Bun.write(outPath, buf); + + // ---- stats ---- + const used = tiles.filter(Boolean) as TileBuf[]; + const maxV = Math.max(...used.map((t) => t.verts.length / 4)); + const maxI = Math.max(...used.map((t) => t.idx.length)); + const avgTri = used.reduce((s, t) => s + t.idx.length / 3, 0) / used.length; + console.log( + `cook: ${cityName} → ${outPath}\n` + + ` grid ${nx}×${nz} (${used.length} occupied), tile ${TILE}u (${TILE / UNITS_PER_M} m)\n` + + ` buildings ${extract.buildings.length - skipped} (+${skipped} skipped), roads ${extract.roads.length}\n` + + ` tris: buildings ${bldTris}, roads ${roadTris}; per-tile avg ${avgTri.toFixed(0)}, max verts ${maxV}, max idx ${maxI}\n` + + ` route: ${route.length} pts, ${(route[route.length - 1].s / UNITS_PER_M / 1000).toFixed(2)} km loop\n` + + ` pack: ${(buf.byteLength / 1024).toFixed(0)} KB`, + ); + if (maxV > 65535 || maxI > 65535) throw new Error("tile over u16 limit — shrink TILE"); + + writeSvg(svgPath, extract, route, { originX, originZ, nx, nz }); + console.log(`cook: preview → ${svgPath}`); +} + +function dir2(a: P2, b: P2): P2 { + const dx = b[0] - a[0]; + const dz = b[1] - a[1]; + const l = Math.hypot(dx, dz) || 1; + return [dx / l, dz / l]; +} + +function scale3(c: V3, s: number): V3 { + return [c[0] * s, c[1] * s, c[2] * s]; +} + +function packRGB(c: V3): number { + const r = Math.max(0, Math.min(255, Math.round(c[0] * 255))); + const g = Math.max(0, Math.min(255, Math.round(c[1] * 255))); + const b = Math.max(0, Math.min(255, Math.round(c[2] * 255))); + return (0xff000000 | (b << 16) | (g << 8) | r) >>> 0; +} + +function clampCheck(v: number) { + if (v < -32768 || v > 32767) throw new Error(`i16 overflow: ${v}`); +} + +function readFileSyncBytes(p: string): Buffer { + return require("node:fs").readFileSync(p); +} + +function repoDist(): string { + return new URL("../../../../dist/drive", import.meta.url).pathname; +} + +// ---- route building --------------------------------------------------- + +interface RoutePt { x: number; z: number; s: number; speed: number } + +function buildRoute(extract: Extract): RoutePt[] { + // Graph nodes = shared road points (keyed by rounded coordinate). + const key = (p: P2) => `${Math.round(p[0] * 10)},${Math.round(p[1] * 10)}`; + const usage = new Map(); + const drivable = extract.roads.filter((r) => ROADS[r.cls].drive && r.pts.length >= 2); + for (const r of drivable) { + for (const p of r.pts) usage.set(key(p), (usage.get(key(p)) ?? 0) + 1); + usage.set(key(r.pts[0]), (usage.get(key(r.pts[0])) ?? 0) + 1); + usage.set(key(r.pts[r.pts.length - 1]), (usage.get(key(r.pts[r.pts.length - 1])) ?? 0) + 1); + } + + interface Edge { a: string; b: string; pts: P2[]; len: number; cls: string; id: number } + const edges: Edge[] = []; + const adj = new Map(); + let eid = 0; + for (const r of drivable) { + let seg: P2[] = [r.pts[0]]; + for (let i = 1; i < r.pts.length; i++) { + seg.push(r.pts[i]); + const isNode = (usage.get(key(r.pts[i])) ?? 0) >= 2 || i === r.pts.length - 1; + if (isNode && seg.length >= 2) { + let len = 0; + for (let j = 1; j < seg.length; j++) len += Math.hypot(seg[j][0] - seg[j - 1][0], seg[j][1] - seg[j - 1][1]); + if (len > 1) { + const e: Edge = { a: key(seg[0]), b: key(seg[seg.length - 1]), pts: [...seg], len, cls: r.cls, id: eid++ }; + edges.push(e); + if (!adj.has(e.a)) adj.set(e.a, []); + if (!adj.has(e.b)) adj.set(e.b, []); + adj.get(e.a)!.push(edges.length - 1); + adj.get(e.b)!.push(edges.length - 1); + } + seg = [r.pts[i]]; + } + } + } + + // Waypoints → nearest real intersections, then shortest-path legs + // (Dijkstra over edge length) between consecutive waypoints, closed. + const mPerLon = 111320 * Math.cos((city.lat0 * Math.PI) / 180); + const nodePos = new Map(); + for (const e of edges) { + nodePos.set(e.a, e.pts[0]); + nodePos.set(e.b, e.pts[e.pts.length - 1]); + } + const nearestNode = (lat: number, lon: number): string => { + const px = (lon - city.lon0) * mPerLon; + const pz = -(lat - city.lat0) * 110574; + let best = ""; + let bestD = Infinity; + for (const [k, p] of nodePos) { + if ((adj.get(k)?.length ?? 0) < 3) continue; // real intersections only + const d = Math.hypot(p[0] - px, p[1] - pz); + if (d < bestD) { bestD = d; best = k; } + } + if (!best) throw new Error("route: no node near waypoint"); + return best; + }; + + const dijkstra = (from: string, to: string): { edge: number; fwd: boolean }[] => { + const dist = new Map([[from, 0]]); + const prev = new Map(); + const done = new Set(); + while (true) { + let cur = ""; + let curD = Infinity; + for (const [k, d] of dist) { + if (!done.has(k) && d < curD) { curD = d; cur = k; } + } + if (!cur) break; + if (cur === to) break; + done.add(cur); + for (const ei of adj.get(cur) ?? []) { + const e = edges[ei]; + const nb = e.a === cur ? e.b : e.a; + const nd = curD + e.len; + if (nd < (dist.get(nb) ?? Infinity)) { + dist.set(nb, nd); + prev.set(nb, { from: cur, edge: ei, fwd: e.a === cur }); + } + } + } + const leg: { edge: number; fwd: boolean }[] = []; + let n = to; + while (n !== from) { + const p = prev.get(n); + if (!p) return []; // unreachable + leg.unshift({ edge: p.edge, fwd: p.fwd }); + n = p.from; + } + return leg; + }; + + const stops = city.waypoints.map(([lat, lon]) => nearestNode(lat, lon)); + const path: { edge: number; fwd: boolean }[] = []; + for (let i = 0; i < stops.length; i++) { + const leg = dijkstra(stops[i], stops[(i + 1) % stops.length]); + if (!leg.length && stops[i] !== stops[(i + 1) % stops.length]) { + throw new Error(`route: waypoint ${i} unreachable`); + } + path.push(...leg); + } + + // Flatten to a polyline in units. + const pts: P2[] = []; + const speeds: number[] = []; + for (const step of path) { + const e = edges[step.edge]; + const p = step.fwd ? e.pts : [...e.pts].reverse(); + const spd = ROADS[e.cls].speed; + for (let i = pts.length ? 1 : 0; i < p.length; i++) { + pts.push([p[i][0] * UNITS_PER_M, p[i][1] * UNITS_PER_M]); + speeds.push(spd); + } + } + + // Cumulative arclength; drop micro-segments. + const route: RoutePt[] = []; + let s = 0; + for (let i = 0; i < pts.length; i++) { + if (i > 0) { + const d = Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); + if (d < 2) continue; + s += d; + } + route.push({ x: pts[i][0], z: pts[i][1], s, speed: speeds[i] }); + } + return route; +} + +// ---- pack writer ------------------------------------------------------ + +function writePack(p: { + originX: number; + originZ: number; + nx: number; + nz: number; + tiles: (TileBuf | null)[]; + route: RoutePt[]; +}): Uint8Array { + const align16 = (n: number) => (n + 15) & ~15; + const HDR = 48; + const dirOff = HDR; + const dirLen = p.nx * p.nz * 32; + const routeOff = align16(dirOff + dirLen); + const routeLen = p.route.length * 16; + let cursor = align16(routeOff + routeLen); + + const tileSpans: { off: number; v: number; i: number; l: number; t: TileBuf | null }[] = []; + for (const t of p.tiles) { + if (!t || t.idx.length === 0) { + tileSpans.push({ off: 0, v: 0, i: 0, l: 0, t: null }); + continue; + } + const v = t.verts.length / 4; + const i = t.idx.length; + const l = t.lines.length / 4; + const bytes = align16(v * 12) + align16(i * 2) + align16(l * 12); + tileSpans.push({ off: cursor, v, i, l, t }); + cursor += align16(bytes); + } + + const buf = new ArrayBuffer(cursor); + const dv = new DataView(buf); + const u8 = new Uint8Array(buf); + + // Header. + u8.set([0x50, 0x44, 0x52, 0x56], 0); // "PDRV" + dv.setUint32(4, 1, true); + dv.setFloat32(8, UNITS_PER_M, true); + dv.setFloat32(12, TILE, true); + dv.setFloat32(16, p.originX, true); + dv.setFloat32(20, p.originZ, true); + dv.setUint32(24, p.nx, true); + dv.setUint32(28, p.nz, true); + dv.setUint32(32, dirOff, true); + dv.setUint32(36, routeOff, true); + dv.setUint32(40, p.route.length, true); + dv.setUint32(44, 0, true); + + // Tile directory. + for (let ti = 0; ti < tileSpans.length; ti++) { + const s = tileSpans[ti]; + const o = dirOff + ti * 32; + dv.setUint32(o + 0, s.off, true); + dv.setUint32(o + 4, s.v, true); + dv.setUint32(o + 8, s.i, true); + dv.setUint32(o + 12, s.l, true); + const t = s.t; + const mn = t ? t.min : [0, 0, 0]; + const mx = t ? t.max : [0, 0, 0]; + dv.setInt16(o + 16, mn[0], true); + dv.setInt16(o + 18, mn[1], true); + dv.setInt16(o + 20, mn[2], true); + dv.setInt16(o + 22, mx[0], true); + dv.setInt16(o + 24, mx[1], true); + dv.setInt16(o + 26, mx[2], true); + dv.setUint32(o + 28, 0, true); + } + + // Route. + for (let i = 0; i < p.route.length; i++) { + const o = routeOff + i * 16; + dv.setFloat32(o + 0, p.route[i].x, true); + dv.setFloat32(o + 4, p.route[i].z, true); + dv.setFloat32(o + 8, p.route[i].s, true); + dv.setFloat32(o + 12, p.route[i].speed, true); + } + + // Tile payloads: [verts][idx][lineVerts], each 16-aligned. + for (const s of tileSpans) { + if (!s.t) continue; + let o = s.off; + const t = s.t; + for (let vi = 0; vi < s.v; vi++) { + dv.setUint32(o + vi * 12, t.verts[vi * 4], true); + dv.setInt16(o + vi * 12 + 4, t.verts[vi * 4 + 1], true); + dv.setInt16(o + vi * 12 + 6, t.verts[vi * 4 + 2], true); + dv.setInt16(o + vi * 12 + 8, t.verts[vi * 4 + 3], true); + } + o = align16(o + s.v * 12); + for (let ii = 0; ii < s.i; ii++) dv.setUint16(o + ii * 2, t.idx[ii], true); + o = align16(o + s.i * 2); + for (let li = 0; li < s.l; li++) { + dv.setUint32(o + li * 12, t.lines[li * 4], true); + dv.setInt16(o + li * 12 + 4, t.lines[li * 4 + 1], true); + dv.setInt16(o + li * 12 + 6, t.lines[li * 4 + 2], true); + dv.setInt16(o + li * 12 + 8, t.lines[li * 4 + 3], true); + } + } + return u8; +} + +// ---- SVG preview ------------------------------------------------------ + +function writeSvg( + path: string, + extract: Extract, + route: RoutePt[], + grid: { originX: number; originZ: number; nx: number; nz: number }, +) { + const u = UNITS_PER_M; + const parts: string[] = []; + const vb = `${grid.originX} ${grid.originZ} ${grid.nx * TILE} ${grid.nz * TILE}`; + const svgH = Math.round((900 * grid.nz) / grid.nx); + parts.push( + ``, + ``, + ); + for (let tz = 0; tz <= grid.nz; tz++) + parts.push(``); + for (let tx = 0; tx <= grid.nx; tx++) + parts.push(``); + for (const r of extract.roads) { + const spec = ROADS[r.cls]; + const d = r.pts.map(([x, z], i) => `${i ? "L" : "M"}${(x * u).toFixed(0)} ${(z * u).toFixed(0)}`).join(""); + parts.push(``); + } + for (const b of extract.buildings) { + const d = b.ring.map(([x, z], i) => `${i ? "L" : "M"}${(x * u).toFixed(0)} ${(z * u).toFixed(0)}`).join("") + "Z"; + const bright = Math.min(255, 70 + b.h * 1.1); + parts.push(``); + } + const rd = route.map((p, i) => `${i ? "L" : "M"}${p.x.toFixed(0)} ${p.z.toFixed(0)}`).join(""); + parts.push(``); + parts.push(``); + parts.push(``); + Bun.write(path, parts.join("\n")); +} diff --git a/pocket3d/crates/pocket-drive/cooker/data/manhattan.json.gz b/pocket3d/crates/pocket-drive/cooker/data/manhattan.json.gz new file mode 100644 index 00000000..e9901f48 Binary files /dev/null and b/pocket3d/crates/pocket-drive/cooker/data/manhattan.json.gz differ diff --git a/pocket3d/crates/pocket-drive/shots/avenue.png b/pocket3d/crates/pocket-drive/shots/avenue.png new file mode 100644 index 00000000..aa9e6fa0 Binary files /dev/null and b/pocket3d/crates/pocket-drive/shots/avenue.png differ diff --git a/pocket3d/crates/pocket-drive/shots/map.png b/pocket3d/crates/pocket-drive/shots/map.png new file mode 100644 index 00000000..a339b845 Binary files /dev/null and b/pocket3d/crates/pocket-drive/shots/map.png differ diff --git a/pocket3d/crates/pocket-drive/shots/orbit.png b/pocket3d/crates/pocket-drive/shots/orbit.png new file mode 100644 index 00000000..be7bebfb Binary files /dev/null and b/pocket3d/crates/pocket-drive/shots/orbit.png differ diff --git a/pocket3d/crates/pocket-drive/shots/rise.png b/pocket3d/crates/pocket-drive/shots/rise.png new file mode 100644 index 00000000..23c973ef Binary files /dev/null and b/pocket3d/crates/pocket-drive/shots/rise.png differ diff --git a/pocket3d/crates/pocket-drive/shots/turn.png b/pocket3d/crates/pocket-drive/shots/turn.png new file mode 100644 index 00000000..ba70935a Binary files /dev/null and b/pocket3d/crates/pocket-drive/shots/turn.png differ diff --git a/pocket3d/crates/pocket-drive/src/car.rs b/pocket3d/crates/pocket-drive/src/car.rs new file mode 100644 index 00000000..8e7cbe30 --- /dev/null +++ b/pocket3d/crates/pocket-drive/src/car.rs @@ -0,0 +1,181 @@ +//! Cars: prebuilt box-art meshes (drawn in place from the heap — built +//! once, written back once, never mutated) and the route follower that +//! drives them along the baked street loop. + +use alloc::vec::Vec; + +use glam::{Mat4, Vec3}; +use pocket3d_gu::to_psp_matrix; +use psp::sys::{self, GuPrimitive}; + +use crate::scene::{pack, Vert32, VTYPE_32}; +use crate::stream::Streamer; + +// ---- route sampling --------------------------------------------------- + +/// Positive wrap of an arclength into [0, total) (`f32::rem_euclid` is +/// std-only). +pub fn wrap_s(s: f32, total: f32) -> f32 { + let r = s % total; + if r < 0.0 { r + total } else { r } +} + +/// Position + unit tangent at arclength `s` (binary search over the baked +/// cumulative arclengths). +pub fn route_sample(streamer: &Streamer, s: f32) -> ((f32, f32), (f32, f32)) { + let n = streamer.route_len(); + let (mut lo, mut hi) = (0usize, n - 1); + while hi - lo > 1 { + let mid = (lo + hi) / 2; + if streamer.route(mid).s <= s { + lo = mid; + } else { + hi = mid; + } + } + let a = streamer.route(lo); + let b = streamer.route(hi); + let seg = (b.s - a.s).max(1e-3); + let t = ((s - a.s) / seg).clamp(0.0, 1.0); + let (dx, dz) = (b.x - a.x, b.z - a.z); + let len = libm::sqrtf(dx * dx + dz * dz).max(1e-3); + ((a.x + dx * t, a.z + dz * t), (dx / len, dz / len)) +} + +/// Route speed-class factor at arclength `s`. +fn route_speed(streamer: &Streamer, s: f32) -> f32 { + let n = streamer.route_len(); + let (mut lo, mut hi) = (0usize, n - 1); + while hi - lo > 1 { + let mid = (lo + hi) / 2; + if streamer.route(mid).s <= s { + lo = mid; + } else { + hi = mid; + } + } + streamer.route(lo).speed.max(0.5) +} + +// ---- follower --------------------------------------------------------- + +pub struct Follower { + pub s: f32, + /// +1 drives the route forward, -1 backward (oncoming traffic). + pub dir: f32, + /// Lateral offset to the right of travel, units. + pub lane: f32, + pub base_speed: f32, // units/s + v: f32, +} + +impl Follower { + pub fn new(s: f32, dir: f32, lane: f32, base_speed: f32) -> Self { + Self { s, dir, lane, base_speed, v: 0.0 } + } + + /// Advance one tick. `throttle` scales the target speed (boost/brake); + /// corners are anticipated by comparing tangents a car-length ahead. + pub fn update(&mut self, streamer: &Streamer, total: f32, throttle: f32, dt: f32) { + let (_, t0) = route_sample(streamer, self.s); + let ahead = wrap_s(self.s + self.dir * 140.0, total); + let (_, t1) = route_sample(streamer, ahead); + let dot = (t0.0 * t1.0 + t0.1 * t1.1).clamp(-1.0, 1.0); + let ang = libm::acosf(dot); + let corner = (1.0 - 1.35 * ang).clamp(0.30, 1.0); + let class = route_speed(streamer, self.s); + let target = self.base_speed * class * corner * throttle; + self.v += (target - self.v) * 0.055; + self.s = wrap_s(self.s + self.dir * self.v * dt, total); + } + + /// World pose: position (with lane offset) and yaw facing travel. + pub fn pose(&self, streamer: &Streamer) -> (Vec3, f32) { + let (p, t) = route_sample(streamer, self.s); + let (tx, tz) = (t.0 * self.dir, t.1 * self.dir); + // Right of travel: (-tz, tx). + let x = p.0 + -tz * self.lane; + let z = p.1 + tx * self.lane; + let yaw = libm::atan2f(-tx, -tz); + (Vec3::new(x, 0.0, z), yaw) + } + + pub fn speed(&self) -> f32 { + self.v + } +} + +// ---- meshes ----------------------------------------------------------- + +const GLASS: [f32; 3] = [0.085, 0.105, 0.15]; +const TIRE: [f32; 3] = [0.045, 0.05, 0.06]; +const TAIL: [f32; 3] = [1.0, 0.16, 0.13]; +const HEAD: [f32; 3] = [1.0, 0.94, 0.78]; + +pub const PLAYER_BODY: [f32; 3] = [0.90, 0.92, 0.95]; +pub const TRAFFIC_BODIES: [[f32; 3]; 6] = [ + [0.44, 0.46, 0.50], + [0.24, 0.26, 0.32], + [0.48, 0.20, 0.19], + [0.20, 0.30, 0.44], + [0.58, 0.58, 0.60], + [0.30, 0.26, 0.22], +]; + +fn shade(c: [f32; 3], k: f32) -> u32 { + pack([c[0] * k, c[1] * k, c[2] * k]) +} + +/// Axis-aligned box as 12 triangles with per-face fake lighting. +fn push_box(v: &mut Vec, c: [f32; 3], lit: bool, center: [f32; 3], half: [f32; 3]) { + let [cx, cy, cz] = center; + let [hx, hy, hz] = half; + let p = |sx: f32, sy: f32, sz: f32| (cx + sx * hx, cy + sy * hy, cz + sz * hz); + // (corner signs per face, brightness) + let faces: [([[f32; 3]; 4], f32); 6] = [ + ([[-1., 1., -1.], [1., 1., -1.], [1., 1., 1.], [-1., 1., 1.]], 1.0), // top + ([[-1., -1., 1.], [1., -1., 1.], [1., -1., -1.], [-1., -1., -1.]], 0.38), // bottom + ([[1., -1., -1.], [1., -1., 1.], [1., 1., 1.], [1., 1., -1.]], 0.72), // +x + ([[-1., -1., 1.], [-1., -1., -1.], [-1., 1., -1.], [-1., 1., 1.]], 0.72), // -x + ([[-1., -1., -1.], [1., -1., -1.], [1., 1., -1.], [-1., 1., -1.]], 0.62), // -z front + ([[1., -1., 1.], [-1., -1., 1.], [-1., 1., 1.], [1., 1., 1.]], 0.58), // +z rear + ]; + for (corners, bright) in faces { + let color = if lit { shade(c, bright) } else { pack(c) }; + let q: [(f32, f32, f32); 4] = + core::array::from_fn(|i| p(corners[i][0], corners[i][1], corners[i][2])); + for &(a, b, cc) in &[(0usize, 1usize, 2usize), (0, 2, 3)] { + for &i in &[a, b, cc] { + v.push(Vert32 { color, x: q[i].0, y: q[i].1, z: q[i].2 }); + } + } + } +} + +/// A ~4.6 m sedan in 0.25 m units, nose toward -Z (yaw-0 forward). +pub fn build_car(body: [f32; 3]) -> Vec { + let mut v = Vec::new(); + push_box(&mut v, body, true, [0.0, 2.6, 0.2], [3.8, 1.8, 9.0]); // body + push_box(&mut v, GLASS, true, [0.0, 5.3, 0.8], [3.3, 1.05, 4.6]); // cabin + for &(x, z) in &[(-3.3, -5.4), (3.3, -5.4), (-3.3, 5.4), (3.3, 5.4)] { + push_box(&mut v, TIRE, true, [x, 1.4, z], [0.7, 1.4, 1.4]); + } + push_box(&mut v, TAIL, false, [0.0, 3.9, 9.15], [3.0, 0.30, 0.15]); // light bar + push_box(&mut v, HEAD, false, [-2.3, 3.4, -9.1], [1.0, 0.25, 0.15]); + push_box(&mut v, HEAD, false, [2.3, 3.4, -9.1], [1.0, 0.25, 0.15]); + v +} + +/// Draw a prebuilt car mesh (heap-resident, already written back). +pub unsafe fn draw_car(mesh: &[Vert32], pos: Vec3, yaw: f32) { + let model = Mat4::from_translation(pos) * Mat4::from_rotation_y(yaw); + sys::sceGuSetMatrix(sys::MatrixMode::Model, &to_psp_matrix(model)); + sys::sceGuDrawArray( + GuPrimitive::Triangles, + VTYPE_32, + mesh.len() as i32, + core::ptr::null(), + mesh.as_ptr() as *const core::ffi::c_void, + ); + sys::sceGuSetMatrix(sys::MatrixMode::Model, &to_psp_matrix(Mat4::IDENTITY)); +} diff --git a/pocket3d/crates/pocket-drive/src/main.rs b/pocket3d/crates/pocket-drive/src/main.rs new file mode 100644 index 00000000..6ff0906b --- /dev/null +++ b/pocket3d/crates/pocket-drive/src/main.rs @@ -0,0 +1,397 @@ +#![no_std] +#![no_main] + +//! pocket-drive: a Tesla-dash-style night drive through a real city on a +//! real PSP. Open map data (OpenStreetMap buildings + streets, cooked to +//! simple extruded meshes by `cooker/cook.ts`) streams in tile by tile +//! around a car that follows a baked route past real landmarks; new blocks +//! rise from the ground as they load. +//! +//! Pack source: a `.pdrv` on the memory stick (true sceIo async streaming) +//! or, when absent, a pack embedded at build time via DRIVE_PACK (loads are +//! then borrowed with a small simulated latency, keeping the choreography). +//! +//! Controls: analog = look around · CROSS = boost · SQUARE = brake · +//! START = pause. With DRIVE_CAP_START/N baked, presented frames dump to +//! ms0:/dc_cap/fNNNN.raw and the app exits — the gu-demo capture contract. + +extern crate alloc; + +mod car; +mod pack; +mod scene; +mod stream; + +use core::ffi::c_void; + +use alloc::vec::Vec; +use glam::Vec3; +use pocket3d_gu::{Camera3d, FramePool}; +use psp::sys::{ + self, ClearBuffer, CtrlButtons, CtrlMode, DisplayPixelFormat, DisplaySetBufSync, + GuContextType, GuState, GuSyncBehavior, GuSyncMode, IoOpenFlags, SceUid, TexturePixelFormat, +}; +use psp::vram_alloc::get_vram_allocator; +use psp::{Align16, BUF_WIDTH, SCREEN_HEIGHT, SCREEN_WIDTH}; + +use crate::stream::{Source, Streamer}; + +psp::module!("pocket_drive", 1, 1); + +static mut LIST: Align16<[u32; 0x40000]> = Align16([0; 0x40000]); + +// 16-byte aligned so the GE can read embedded tile payloads in place. +static PACK: Align16<[u8; include_bytes!(concat!(env!("OUT_DIR"), "/city.pdrv")).len()]> = + Align16(*include_bytes!(concat!(env!("OUT_DIR"), "/city.pdrv"))); + +static CAP_START: &str = env!("DRIVE_CAP_START"); +static CAP_N: &str = env!("DRIVE_CAP_N"); + +const PACK_PATHS: [&[u8]; 3] = [ + b"ms0:/pocket-drive/manhattan.pdrv\0", + b"ms0:/PSP/GAME/pocket-drive/manhattan.pdrv\0", + b"host0:/dist/drive/manhattan.pdrv\0", +]; + +const INTRO_FRAMES: u32 = 220; +const INTRO_BLEND: u32 = 70; +const DRIVE_START: u32 = 150; +const CHASE_DIST: f32 = 46.0; +const CHASE_HEIGHT: f32 = 18.0; +const CHASE_PITCH: f32 = -0.12; +const TRAFFIC: usize = 16; + +fn cap_window() -> (u32, u32) { + let parse = |s: &str, d: u32| s.parse::().unwrap_or(d); + (parse(CAP_START, u32::MAX), parse(CAP_N, 0)) +} + +fn psp_main() { + unsafe { + psp::enable_home_button(); + init_graphics(); + sys::sceCtrlSetSamplingCycle(0); + sys::sceCtrlSetSamplingMode(CtrlMode::Analog); + + let mut streamer = match open_pack() { + Ok(s) => s, + Err(e) => halt(e), + }; + let total = streamer.route(streamer.route_len() - 1).s; + + // Cars: player on the ribbon line, traffic staggered both ways. + let player_mesh = car::build_car(car::PLAYER_BODY); + let traffic_meshes: Vec> = car::TRAFFIC_BODIES + .iter() + .map(|&c| car::build_car(c)) + .collect(); + pocket3d_gu::writeback(vert_bytes(&player_mesh)); + for m in &traffic_meshes { + pocket3d_gu::writeback(vert_bytes(m)); + } + let mut player = car::Follower::new(0.0, 1.0, 0.0, 64.0); + let mut traffic: Vec = (0..TRAFFIC) + .map(|i| { + let dir = if i % 2 == 0 { 1.0 } else { -1.0 }; + let s0 = total * (i as f32 + 0.4) / TRAFFIC as f32; + car::Follower::new(s0, dir, 7.0, 46.0 + (i % 4) as f32 * 5.0) + }) + .collect(); + + let mut pool = FramePool::new(); + let (cap_start, cap_n) = cap_window(); + let mut frame: u32 = 0; + let mut paused = false; + let mut prev_buttons = CtrlButtons::empty(); + let mut cam_yaw: f32 = 0.0; + let mut look_yaw: f32 = 0.0; + let mut look_pitch: f32 = 0.0; + let dt = 1.0 / 60.0; + + loop { + // ---- input ---- + let mut pad = sys::SceCtrlData::default(); + sys::sceCtrlReadBufferPositive(&mut pad, 1); + if pad.buttons.contains(CtrlButtons::START) + && !prev_buttons.contains(CtrlButtons::START) + { + paused = !paused; + } + prev_buttons = pad.buttons; + let throttle = if pad.buttons.contains(CtrlButtons::CROSS) { + 1.9 + } else if pad.buttons.contains(CtrlButtons::SQUARE) { + 0.22 + } else { + 1.0 + }; + let ax = (pad.lx as f32 - 128.0) / 127.0; + let ay = (pad.ly as f32 - 128.0) / 127.0; + let look_x = if ax.abs() > 0.18 { ax } else { 0.0 }; + let look_y = if ay.abs() > 0.18 { ay } else { 0.0 }; + look_yaw += (look_x * -1.1 - look_yaw) * 0.10; + look_pitch += (look_y * -0.26 - look_pitch) * 0.10; + + // ---- simulate ---- + let driving = frame >= DRIVE_START && !paused; + if driving { + player.update(&streamer, total, throttle, dt); + for t in traffic.iter_mut() { + t.update(&streamer, total, 1.0, dt); + } + } + let (car_pos, car_yaw) = player.pose(&streamer); + + // ---- camera ---- + let cam = camera(frame, car_pos, car_yaw, &mut cam_yaw, look_yaw, look_pitch); + + // ---- stream ---- + let (hx, hz) = (-libm::sinf(car_yaw), -libm::cosf(car_yaw)); + streamer.update(car_pos.x, car_pos.z, hx, hz, frame); + + // ---- record ---- + sys::sceGuStart(GuContextType::Direct, core::ptr::addr_of_mut!(LIST) as *mut c_void); + pocket3d_gu::begin_3d(&cam); + scene::pass_begin(); + scene::backdrop(&mut pool, &cam); + let stats = scene::draw_tiles(&streamer, &cam, frame); + scene::draw_ribbon(&mut pool, &streamer, player.s, total); + for (i, t) in traffic.iter().enumerate() { + let (p, yaw) = t.pose(&streamer); + car::draw_car(&traffic_meshes[i % traffic_meshes.len()], p, yaw); + } + car::draw_car(&player_mesh, car_pos, car_yaw); + scene::pass_end(); + pocket3d_gu::end_3d(); + sys::sceGuFinish(); + sys::sceGuSync(GuSyncMode::Finish, GuSyncBehavior::Wait); + sys::sceDisplayWaitVblankStart(); + sys::sceGuSwapBuffers(); + pool.reset(); + + // On-screen stats corrupt captures (dprintln repaints the whole + // debug console into the framebuffer) — opt in via `--features + // stats` for PSPLINK/bring-up sessions only. + #[cfg(feature = "stats")] + if frame % 300 == 0 { + psp::dprintln!( + "[drive] f={} tiles={} tris={} resident={} loads={} v={}", + frame, + stats.tiles, + stats.tris, + streamer.resident, + streamer.loads, + player.speed() as i32 + ); + } + #[cfg(not(feature = "stats"))] + let _ = &stats; + + if frame >= cap_start && frame < cap_start.saturating_add(cap_n) { + cap_dump(frame - cap_start); + if frame + 1 == cap_start + cap_n { + sys::sceKernelExitGame(); + } + } + frame = frame.wrapping_add(1); + } + } +} + +/// Intro orbit around the parked car, blending into the chase camera as it +/// pulls away; afterwards a smoothed chase with analog look-around. +fn camera( + frame: u32, + car_pos: Vec3, + car_yaw: f32, + cam_yaw: &mut f32, + look_yaw: f32, + look_pitch: f32, +) -> Camera3d { + // Chase pose (always computed so the blend target is live). + let delta = wrap_angle(car_yaw - *cam_yaw); + *cam_yaw += delta * 0.075; + let yaw = *cam_yaw + look_yaw; + let fwd = Vec3::new(-libm::sinf(yaw), 0.0, -libm::cosf(yaw)); + let chase_pos = car_pos - fwd * CHASE_DIST + Vec3::new(0.0, CHASE_HEIGHT, 0.0); + let chase = (chase_pos, yaw, CHASE_PITCH + look_pitch); + + let cam = if frame < INTRO_FRAMES { + // Slow orbit, drifting down toward the chase height. + let t = frame as f32 / INTRO_FRAMES as f32; + let a = 2.4 + frame as f32 * 0.006; + let radius = 95.0 - 25.0 * t; + let height = 46.0 - 20.0 * t; + let pos = car_pos + Vec3::new(libm::sinf(a) * radius, height, libm::cosf(a) * radius); + let d = car_pos + Vec3::new(0.0, 6.0, 0.0) - pos; + let dxz = libm::sqrtf(d.x * d.x + d.z * d.z); + let orbit = (pos, libm::atan2f(-d.x, -d.z), libm::atan2f(d.y, dxz)); + *cam_yaw = car_yaw; // keep the chase yaw settled for the handoff + let blend_start = INTRO_FRAMES - INTRO_BLEND; + if frame >= blend_start { + let bt = (frame - blend_start) as f32 / INTRO_BLEND as f32; + let bt = bt * bt * (3.0 - 2.0 * bt); + ( + orbit.0.lerp(chase.0, bt), + orbit.1 + wrap_angle(chase.1 - orbit.1) * bt, + orbit.2 + (chase.2 - orbit.2) * bt, + ) + } else { + orbit + } + } else { + chase + }; + + Camera3d { + pos: cam.0, + yaw: cam.1, + pitch: cam.2, + fov_y: 72f32.to_radians(), + ..Camera3d::default() + } +} + +fn wrap_angle(a: f32) -> f32 { + let mut a = a; + while a > core::f32::consts::PI { + a -= core::f32::consts::TAU; + } + while a < -core::f32::consts::PI { + a += core::f32::consts::TAU; + } + a +} + +fn vert_bytes(v: &[scene::Vert32]) -> &[u8] { + unsafe { core::slice::from_raw_parts(v.as_ptr() as *const u8, core::mem::size_of_val(v)) } +} + +/// Prefer a memory-stick pack (real streaming); fall back to the embedded +/// one. Leaks the meta prefix — it lives for the whole run anyway. +unsafe fn open_pack() -> Result { + for path in PACK_PATHS { + let fd = sys::sceIoOpen(path.as_ptr(), IoOpenFlags::RD_ONLY, 0o444); + if fd.0 < 0 { + continue; + } + let mut hdr = [0u8; pack::HEADER]; + if sys::sceIoRead(fd, hdr.as_mut_ptr() as *mut c_void, pack::HEADER as u32) + != pack::HEADER as i32 + { + sys::sceIoClose(fd); + continue; + } + let info = pack::parse_header(&hdr)?; + let len = pack::meta_len(&info); + let mut meta = pack::AlignedBuf::new(len); + sys::sceIoLseek32(fd, 0, sys::IoWhence::Set); + if sys::sceIoRead(fd, meta.as_mut_ptr() as *mut c_void, len as u32) != len as i32 { + sys::sceIoClose(fd); + continue; + } + let meta_slice: &'static [u8] = + core::slice::from_raw_parts(meta.as_mut_ptr(), len); + core::mem::forget(meta); + psp::dprintln!("[drive] streaming from file pack"); + return Ok(Streamer::new(info, meta_slice, Source::File(fd))); + } + + let data: &'static [u8] = &PACK.0; + if data.is_empty() { + return Err("no city pack: put manhattan.pdrv on ms0:/pocket-drive/ or build with DRIVE_PACK"); + } + pocket3d_gu::writeback(data); + let info = pack::parse_header(data)?; + psp::dprintln!("[drive] using embedded pack"); + Ok(Streamer::new(info, data, Source::Embedded(data))) +} + +unsafe fn init_graphics() { + let allocator = match get_vram_allocator() { + Ok(a) => a, + Err(_) => halt("vram allocator"), + }; + let fbp0 = allocator + .alloc_texture_pixels(BUF_WIDTH, SCREEN_HEIGHT, TexturePixelFormat::Psm8888) + .as_mut_ptr_from_zero(); + let fbp1 = allocator + .alloc_texture_pixels(BUF_WIDTH, SCREEN_HEIGHT, TexturePixelFormat::Psm8888) + .as_mut_ptr_from_zero(); + let zbp = allocator + .alloc_texture_pixels(BUF_WIDTH, SCREEN_HEIGHT, TexturePixelFormat::Psm4444) + .as_mut_ptr_from_zero(); + + sys::sceGuInit(); + sys::sceGuStart(GuContextType::Direct, core::ptr::addr_of_mut!(LIST) as *mut c_void); + sys::sceGuDrawBuffer(DisplayPixelFormat::Psm8888, fbp0 as _, BUF_WIDTH as i32); + sys::sceGuDispBuffer( + SCREEN_WIDTH as i32, + SCREEN_HEIGHT as i32, + fbp1 as _, + BUF_WIDTH as i32, + ); + sys::sceGuDepthBuffer(zbp as _, BUF_WIDTH as i32); + sys::sceGuOffset(2048 - (SCREEN_WIDTH / 2), 2048 - (SCREEN_HEIGHT / 2)); + sys::sceGuViewport(2048, 2048, SCREEN_WIDTH as i32, SCREEN_HEIGHT as i32); + sys::sceGuScissor(0, 0, SCREEN_WIDTH as i32, SCREEN_HEIGHT as i32); + sys::sceGuEnable(GuState::ScissorTest); + sys::sceGuShadeModel(psp::sys::ShadingModel::Smooth); + sys::sceGuClearColor(0xff000000); + sys::sceGuClearDepth(0); + sys::sceGuClear(ClearBuffer::COLOR_BUFFER_BIT | ClearBuffer::DEPTH_BUFFER_BIT); + sys::sceGuFinish(); + sys::sceGuSync(GuSyncMode::Finish, GuSyncBehavior::Wait); + sys::sceDisplayWaitVblankStart(); + sys::sceGuDisplay(true); +} + +/// Dump the display framebuffer to ms0:/dc_cap/fNNNN.raw (the shared +/// PocketJS capture layout; PPSSPP maps ms0: to its memstick root). +unsafe fn cap_dump(idx: u32) { + if idx == 0 { + sys::sceIoMkdir(b"ms0:/dc_cap\0".as_ptr(), 0o777); + } + let mut name: [u8; 22] = *b"ms0:/dc_cap/f0000.raw\0"; + let mut v = idx; + let mut i = 16usize; + loop { + name[i] = b'0' + (v % 10) as u8; + v /= 10; + if i == 13 { + break; + } + i -= 1; + } + let mut top: *mut c_void = core::ptr::null_mut(); + let mut bw: usize = 0; + let mut fmt = DisplayPixelFormat::Psm8888; + sys::sceDisplayGetFrameBuf(&mut top, &mut bw, &mut fmt, DisplaySetBufSync::Immediate); + let mut addr = top as u32; + if addr < 0x0400_0000 { + addr += 0x0400_0000; + } + addr |= 0x4000_0000; + let fd = sys::sceIoOpen( + name.as_ptr(), + IoOpenFlags::CREAT | IoOpenFlags::WR_ONLY | IoOpenFlags::TRUNC, + 0o777, + ); + if fd.0 >= 0 { + sys::sceIoWrite(fd, addr as *const c_void, 512 * 272 * 4); + sys::sceIoClose(fd); + } +} + +unsafe fn halt(msg: &str) -> ! { + psp::dprintln!("[drive halt] {}", msg); + loop { + sys::sceDisplayWaitVblankStart(); + } +} + +// Keep the unused-field lint honest: SceUid is only constructed by sceIoOpen. +#[allow(dead_code)] +fn _uid_assert(u: SceUid) -> i32 { + u.0 +} diff --git a/pocket3d/crates/pocket-drive/src/pack.rs b/pocket3d/crates/pocket-drive/src/pack.rs new file mode 100644 index 00000000..a97f3c1a --- /dev/null +++ b/pocket3d/crates/pocket-drive/src/pack.rs @@ -0,0 +1,152 @@ +//! `.pdrv` city pack: header + tile directory + route, then per-tile +//! payloads of GE-ready geometry. All offsets 16-aligned, little-endian +//! (cooked by `cooker/cook.ts`; this reader and that writer must agree). +//! +//! Tile payload layout: `[tri verts][pad16][u16 indices][pad16][line verts]`. +//! Vertex = `{color: u32 ABGR, x,y,z: i16, pad: u16}` (12 B), tile-local, +//! riding the GE's 16-bit ÷32768 normalization (undone in the model matrix). + +use alloc::alloc::{alloc, dealloc, Layout}; +use core::slice; + +pub const VERT_SIZE: usize = 12; +pub const DIR_ENTRY: usize = 32; +pub const ROUTE_ENTRY: usize = 16; +pub const HEADER: usize = 48; + +#[derive(Clone, Copy, Debug)] +pub struct PackInfo { + pub tile_size: f32, + pub origin_x: f32, + pub origin_z: f32, + pub nx: usize, + pub nz: usize, + pub dir_off: usize, + pub route_off: usize, + pub route_count: usize, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct TileDir { + pub off: usize, + pub vcount: usize, + pub icount: usize, + pub lcount: usize, + pub min: [i16; 3], + pub max: [i16; 3], +} + +#[derive(Clone, Copy, Debug)] +pub struct RoutePt { + pub x: f32, + pub z: f32, + pub s: f32, + pub speed: f32, +} + +fn u32le(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} +fn f32le(b: &[u8], o: usize) -> f32 { + f32::from_bits(u32le(b, o)) +} +fn i16le(b: &[u8], o: usize) -> i16 { + i16::from_le_bytes([b[o], b[o + 1]]) +} + +pub fn parse_header(b: &[u8]) -> Result { + if b.len() < HEADER || &b[0..4] != b"PDRV" { + return Err("bad pack magic"); + } + if u32le(b, 4) != 1 { + return Err("bad pack version"); + } + Ok(PackInfo { + tile_size: f32le(b, 12), + origin_x: f32le(b, 16), + origin_z: f32le(b, 20), + nx: u32le(b, 24) as usize, + nz: u32le(b, 28) as usize, + dir_off: u32le(b, 32) as usize, + route_off: u32le(b, 36) as usize, + route_count: u32le(b, 40) as usize, + }) +} + +/// Meta prefix length (header + directory + route) — what a file source +/// must read up front. +pub fn meta_len(info: &PackInfo) -> usize { + info.route_off + info.route_count * ROUTE_ENTRY +} + +pub fn tile_dir(meta: &[u8], info: &PackInfo, idx: usize) -> TileDir { + let o = info.dir_off + idx * DIR_ENTRY; + TileDir { + off: u32le(meta, o) as usize, + vcount: u32le(meta, o + 4) as usize, + icount: u32le(meta, o + 8) as usize, + lcount: u32le(meta, o + 12) as usize, + min: [i16le(meta, o + 16), i16le(meta, o + 18), i16le(meta, o + 20)], + max: [i16le(meta, o + 22), i16le(meta, o + 24), i16le(meta, o + 26)], + } +} + +pub fn route_pt(meta: &[u8], info: &PackInfo, idx: usize) -> RoutePt { + let o = info.route_off + idx * ROUTE_ENTRY; + RoutePt { + x: f32le(meta, o), + z: f32le(meta, o + 4), + s: f32le(meta, o + 8), + speed: f32le(meta, o + 12), + } +} + +const fn align16(n: usize) -> usize { + (n + 15) & !15 +} + +/// Byte length of one tile's payload. +pub fn tile_len(d: &TileDir) -> usize { + align16(d.vcount * VERT_SIZE) + align16(d.icount * 2) + align16(d.lcount * VERT_SIZE) +} + +/// Views into a loaded tile payload: (tri verts, indices, line verts). +pub fn tile_views<'a>(d: &TileDir, data: &'a [u8]) -> (&'a [u8], &'a [u8], &'a [u8]) { + let v_end = d.vcount * VERT_SIZE; + let i_off = align16(v_end); + let i_end = i_off + d.icount * 2; + let l_off = align16(i_end); + let l_end = l_off + d.lcount * VERT_SIZE; + (&data[0..v_end], &data[i_off..i_end], &data[l_off..l_end]) +} + +/// 16-aligned heap buffer the GE can read in place (after writeback). +pub struct AlignedBuf { + ptr: *mut u8, + len: usize, +} + +impl AlignedBuf { + pub fn new(len: usize) -> Self { + let layout = Layout::from_size_align(len.max(16), 16).unwrap(); + let ptr = unsafe { alloc(layout) }; + assert!(!ptr.is_null(), "pack buffer alloc failed"); + Self { ptr, len } + } + pub fn as_slice(&self) -> &[u8] { + unsafe { slice::from_raw_parts(self.ptr, self.len) } + } + pub fn as_mut_ptr(&mut self) -> *mut u8 { + self.ptr + } +} + +impl Drop for AlignedBuf { + fn drop(&mut self) { + let layout = Layout::from_size_align(self.len.max(16), 16).unwrap(); + unsafe { dealloc(self.ptr, layout) }; + } +} + +// The GE never writes; single-threaded EBOOT. +unsafe impl Send for AlignedBuf {} diff --git a/pocket3d/crates/pocket-drive/src/scene.rs b/pocket3d/crates/pocket-drive/src/scene.rs new file mode 100644 index 00000000..23ffa65d --- /dev/null +++ b/pocket3d/crates/pocket-drive/src/scene.rs @@ -0,0 +1,270 @@ +//! Scene pass: night-navigation backdrop, streamed city tiles, and the +//! route ribbon. Everything here is untextured vertex color — texturing is +//! disabled once for the whole 3D pass (`pass_begin`) instead of per draw. + +use core::ffi::c_void; + +use glam::{Mat4, Vec3}; +use pocket3d_gu::{to_psp_matrix, Camera3d, FramePool}; +use psp::sys::{self, ClearBuffer, GuPrimitive, GuState, VertexType}; + +use crate::car::{route_sample, wrap_s}; +use crate::stream::Streamer; + +// ---- palette ---------------------------------------------------------- + +pub const GROUND: [f32; 3] = [0.055, 0.068, 0.10]; +pub const HAZE: [f32; 3] = [0.165, 0.205, 0.30]; +pub const SKY_TOP: [f32; 3] = [0.03, 0.04, 0.085]; +pub const ROUTE_BLUE: [f32; 3] = [0.28, 0.62, 1.0]; + +/// Fog range in units (0.25 m each): geometry fades into the haze band. +pub const FOG_NEAR: f32 = 1100.0; +pub const FOG_FAR: f32 = 2500.0; +/// Tiles farther than this (center distance) are skipped entirely. +pub const FAR_CULL: f32 = 2750.0; + +pub fn pack(c: [f32; 3]) -> u32 { + let r = (c[0].clamp(0.0, 1.0) * 255.0) as u32; + let g = (c[1].clamp(0.0, 1.0) * 255.0) as u32; + let b = (c[2].clamp(0.0, 1.0) * 255.0) as u32; + 0xff00_0000 | (b << 16) | (g << 8) | r +} + +fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} + +// ---- vertex layouts --------------------------------------------------- + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Vert2d { + color: u32, + x: i16, + y: i16, + z: i16, + _pad: i16, +} + +const VTYPE_2D: VertexType = VertexType::from_bits_truncate( + VertexType::COLOR_8888.bits() | VertexType::VERTEX_16BIT.bits() | VertexType::TRANSFORM_2D.bits(), +); + +/// Tile geometry: the cooked 12-byte `{color, i16 xyz}` vertex, indexed. +const VTYPE_TILE: VertexType = VertexType::from_bits_truncate( + VertexType::COLOR_8888.bits() + | VertexType::VERTEX_16BIT.bits() + | VertexType::INDEX_16BIT.bits() + | VertexType::TRANSFORM_3D.bits(), +); +const VTYPE_LINE: VertexType = VertexType::from_bits_truncate( + VertexType::COLOR_8888.bits() | VertexType::VERTEX_16BIT.bits() | VertexType::TRANSFORM_3D.bits(), +); + +/// CPU-built float geometry (ribbon, cars). +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Vert32 { + pub color: u32, + pub x: f32, + pub y: f32, + pub z: f32, +} + +pub const VTYPE_32: VertexType = VertexType::from_bits_truncate( + VertexType::COLOR_8888.bits() | VertexType::VERTEX_32BITF.bits() | VertexType::TRANSFORM_3D.bits(), +); + +// ---- pass state ------------------------------------------------------- + +/// After `begin_3d`: untextured pass, fog configured. Fog stays enabled for +/// all world-space draws and is disabled again in `pass_end`. +pub unsafe fn pass_begin() { + sys::sceGuDisable(GuState::Texture2D); + sys::sceGuFog(FOG_NEAR, FOG_FAR, pack(HAZE)); + sys::sceGuEnable(GuState::Fog); +} + +pub unsafe fn pass_end() { + sys::sceGuDisable(GuState::Fog); + sys::sceGuEnable(GuState::Texture2D); +} + +// ---- backdrop --------------------------------------------------------- + +/// Clear, then fill the screen with the night gradient: sky down to a haze +/// band at the horizon row, ground shading darker toward the bottom. The +/// ground half is what reads as an infinite dark ground plane — zero +/// triangles of real geometry, and streamed tiles z-test over it. +pub unsafe fn backdrop(pool: &mut FramePool, cam: &Camera3d) { + sys::sceGuClearColor(pack(GROUND)); + sys::sceGuClearDepth(0); + sys::sceGuClear(ClearBuffer::COLOR_BUFFER_BIT | ClearBuffer::DEPTH_BUFFER_BIT); + + let half_fov_tan = libm::tanf(cam.fov_y * 0.5); + let horizon = 136.0 * (1.0 + libm::tanf(cam.pitch) / half_fov_tan); + let h = horizon.clamp(28.0, 244.0) as i16; + + // Sky gradient reaches the haze a little above the horizon so distant + // fogged buildings melt into the band instead of silhouetting. + let quads: [(i16, i16, [f32; 3], [f32; 3]); 3] = [ + (0, h - 22, SKY_TOP, mix(HAZE, SKY_TOP, 0.15)), + (h - 22, h, mix(HAZE, SKY_TOP, 0.15), HAZE), + (h, 272, mix(HAZE, GROUND, 0.35), GROUND), + ]; + + sys::sceGuDisable(GuState::DepthTest); + sys::sceGuDisable(GuState::Fog); + for (y0, y1, top, bottom) in quads { + if y1 <= y0 { + continue; + } + let (ct, cb) = (pack(top), pack(bottom)); + let strip = [ + Vert2d { color: ct, x: 0, y: y0, z: 0, _pad: 0 }, + Vert2d { color: ct, x: 480, y: y0, z: 0, _pad: 0 }, + Vert2d { color: cb, x: 0, y: y1, z: 0, _pad: 0 }, + Vert2d { color: cb, x: 480, y: y1, z: 0, _pad: 0 }, + ]; + let bytes = core::slice::from_raw_parts( + strip.as_ptr() as *const u8, + core::mem::size_of_val(&strip), + ); + let data = pool.upload(bytes); + sys::sceGuDrawArray( + GuPrimitive::TriangleStrip, + VTYPE_2D, + 4, + core::ptr::null(), + data as *const c_void, + ); + } + sys::sceGuEnable(GuState::Fog); + sys::sceGuEnable(GuState::DepthTest); +} + +// ---- tiles ------------------------------------------------------------ + +pub struct DrawStats { + pub tiles: u32, + pub tris: u32, +} + +/// Draw every resident tile that survives distance + frustum culling. +/// A tile still rising is drawn with a squashed Y in its model matrix — +/// the streaming choreography. +pub unsafe fn draw_tiles(streamer: &Streamer, cam: &Camera3d, frame: u32) -> DrawStats { + let frustum = cam.frustum(); + let ts = streamer.info.tile_size; + let mut stats = DrawStats { tiles: 0, tris: 0 }; + + for tz in 0..streamer.info.nz { + for tx in 0..streamer.info.nx { + let idx = tz * streamer.info.nx + tx; + let Some((verts, indices, lines, rise)) = streamer.ready(idx, frame) else { + continue; + }; + let ox = streamer.info.origin_x + tx as f32 * ts; + let oz = streamer.info.origin_z + tz as f32 * ts; + let cx = ox + ts * 0.5 - cam.pos.x; + let cz = oz + ts * 0.5 - cam.pos.z; + if cx * cx + cz * cz > FAR_CULL * FAR_CULL { + continue; + } + let d = streamer.dir(idx); + let mins = Vec3::new(ox + d.min[0] as f32, d.min[1] as f32, oz + d.min[2] as f32); + let maxs = Vec3::new(ox + d.max[0] as f32, d.max[1] as f32, oz + d.max[2] as f32); + if !frustum.intersects_aabb(mins, maxs) { + continue; + } + + let model = Mat4::from_translation(Vec3::new(ox, 0.0, oz)) + * Mat4::from_scale(Vec3::new(32768.0, 32768.0 * rise, 32768.0)); + sys::sceGuSetMatrix(sys::MatrixMode::Model, &to_psp_matrix(model)); + + if !indices.is_empty() { + sys::sceGuDrawArray( + GuPrimitive::Triangles, + VTYPE_TILE, + (indices.len() / 2) as i32, + indices.as_ptr() as *const c_void, + verts.as_ptr() as *const c_void, + ); + stats.tris += indices.len() as u32 / 6; + } + if !lines.is_empty() { + sys::sceGuDrawArray( + GuPrimitive::Lines, + VTYPE_LINE, + (lines.len() / 12) as i32, + core::ptr::null(), + lines.as_ptr() as *const c_void, + ); + } + stats.tiles += 1; + } + } + sys::sceGuSetMatrix(sys::MatrixMode::Model, &to_psp_matrix(Mat4::IDENTITY)); + stats +} + +// ---- route ribbon ----------------------------------------------------- + +const RIBBON_BACK: f32 = 60.0; // units behind the car +const RIBBON_AHEAD: f32 = 1400.0; // units ahead +const RIBBON_HALF: f32 = 3.0; +const RIBBON_Y: f32 = 2.6; +const RIBBON_STEP: f32 = 40.0; + +/// The Tesla-navigation blue path: a flat strip hugging the route from just +/// behind the car to a few hundred meters ahead, rebuilt each frame. +pub unsafe fn draw_ribbon(pool: &mut FramePool, streamer: &Streamer, car_s: f32, total: f32) { + let color = pack(ROUTE_BLUE); + let mut verts: [Vert32; 256] = [Vert32 { color, x: 0.0, y: 0.0, z: 0.0 }; 256]; + let mut n = 0usize; + + let steps = ((RIBBON_BACK + RIBBON_AHEAD) / RIBBON_STEP) as i32; + let mut prev: Option<(f32, f32, f32, f32)> = None; // lx, lz, rx, rz + for i in 0..=steps { + let s = wrap_s(car_s - RIBBON_BACK + i as f32 * RIBBON_STEP, total); + let (pos, tan) = route_sample(streamer, s); + // Right of the tangent: (-tz, tx). + let (rx, rz) = (-tan.1, tan.0); + let l = (pos.0 + rx * RIBBON_HALF, pos.1 + rz * RIBBON_HALF); + let r = (pos.0 - rx * RIBBON_HALF, pos.1 - rz * RIBBON_HALF); + if let Some((plx, plz, prx, przz)) = prev { + if n + 6 <= verts.len() { + let quad = [ + (plx, plz), + (prx, przz), + (l.0, l.1), + (prx, przz), + (r.0, r.1), + (l.0, l.1), + ]; + for (x, z) in quad { + verts[n] = Vert32 { color, x, y: RIBBON_Y, z }; + n += 1; + } + } + } + prev = Some((l.0, l.1, r.0, r.1)); + } + if n == 0 { + return; + } + let bytes = core::slice::from_raw_parts(verts.as_ptr() as *const u8, n * 16); + let data = pool.upload(bytes); + sys::sceGuDrawArray( + GuPrimitive::Triangles, + VTYPE_32, + n as i32, + core::ptr::null(), + data as *const c_void, + ); +} diff --git a/pocket3d/crates/pocket-drive/src/stream.rs b/pocket3d/crates/pocket-drive/src/stream.rs new file mode 100644 index 00000000..e35fde38 --- /dev/null +++ b/pocket3d/crates/pocket-drive/src/stream.rs @@ -0,0 +1,251 @@ +//! Tile streamer: keeps a ring of tiles resident around the car, loading at +//! most one tile per frame — from the memory stick via async sceIo when the +//! pack is a file, or by borrowing the embedded pack (with a simulated +//! latency so the load choreography stays visible and deterministic). +//! +//! Load order is nearest-ring-first with a bias toward tiles ahead of the +//! car, so driving into fresh blocks streams the skyline in before it +//! matters. Newly ready tiles ride a rise-from-the-ground animation +//! (`rise()`), which is what makes streaming legible on screen. + +use alloc::vec::Vec; +use core::ffi::c_void; + +use psp::sys::{self, IoWhence, SceUid}; + +use crate::pack::{self, AlignedBuf, PackInfo, TileDir}; + +/// Chebyshev tile radius kept resident (and loaded on demand). +pub const KEEP: i32 = 5; +/// Radius beyond which resident tiles are dropped (hysteresis vs KEEP). +pub const EVICT: i32 = 6; +/// Frames of the rise-from-ground animation. +pub const RISE_FRAMES: u32 = 30; +/// Simulated load latency for the embedded-pack source, in frames. +const EMBED_LATENCY: u32 = 5; + +pub enum Source { + Embedded(&'static [u8]), + File(SceUid), +} + +enum TileData { + Owned(AlignedBuf), + Borrowed(&'static [u8]), +} + +impl TileData { + fn bytes(&self) -> &[u8] { + match self { + TileData::Owned(b) => b.as_slice(), + TileData::Borrowed(s) => s, + } + } +} + +enum TileState { + Vacant, + Loading, + Ready { data: TileData, born: u32 }, +} + +struct InFlight { + idx: usize, + buf: AlignedBuf, +} + +pub struct Streamer { + pub info: PackInfo, + meta: &'static [u8], + source: Source, + states: Vec, + inflight: Option, + embed_pending: Option<(usize, u32)>, + pub loads: u32, + pub resident: u32, +} + +impl Streamer { + pub fn new(info: PackInfo, meta: &'static [u8], source: Source) -> Self { + let mut states = Vec::new(); + states.resize_with(info.nx * info.nz, || TileState::Vacant); + Self { + info, + meta, + source, + states, + inflight: None, + embed_pending: None, + loads: 0, + resident: 0, + } + } + + pub fn dir(&self, idx: usize) -> TileDir { + pack::tile_dir(self.meta, &self.info, idx) + } + + pub fn route_len(&self) -> usize { + self.info.route_count + } + + pub fn route(&self, i: usize) -> pack::RoutePt { + pack::route_pt(self.meta, &self.info, i) + } + + /// Tile grid coordinates for a world position. + pub fn tile_of(&self, x: f32, z: f32) -> (i32, i32) { + ( + libm::floorf((x - self.info.origin_x) / self.info.tile_size) as i32, + libm::floorf((z - self.info.origin_z) / self.info.tile_size) as i32, + ) + } + + /// Geometry views for a ready tile, plus its rise factor (0..1). + pub fn ready(&self, idx: usize, frame: u32) -> Option<(&[u8], &[u8], &[u8], f32)> { + match &self.states[idx] { + TileState::Ready { data, born } => { + let d = self.dir(idx); + let (v, i, l) = pack::tile_views(&d, data.bytes()); + Some((v, i, l, rise(*born, frame))) + } + _ => None, + } + } + + /// One streaming step: poll the async read, kick the next best load, + /// evict far tiles. Call once per frame, before recording the display + /// list (the previous list is synced by then, so drops are GE-safe). + pub fn update(&mut self, car_x: f32, car_z: f32, ahead_x: f32, ahead_z: f32, frame: u32) { + self.poll(frame); + + let (ctx, ctz) = self.tile_of(car_x, car_z); + + // Evict outside the hysteresis ring. + for tz in 0..self.info.nz as i32 { + for tx in 0..self.info.nx as i32 { + let ring = (tx - ctx).abs().max((tz - ctz).abs()); + if ring <= EVICT { + continue; + } + let idx = (tz * self.info.nx as i32 + tx) as usize; + if matches!(self.states[idx], TileState::Ready { .. }) { + self.states[idx] = TileState::Vacant; + self.resident -= 1; + } + } + } + + // One load in flight at a time. + if self.inflight.is_some() || self.embed_pending.is_some() { + return; + } + + // Best vacant tile in the KEEP ring: nearest first, ahead of the + // car breaking ties (ahead_* is a unit heading vector). + let mut best: Option<(f32, usize)> = None; + for dz in -KEEP..=KEEP { + for dx in -KEEP..=KEEP { + let (tx, tz) = (ctx + dx, ctz + dz); + if tx < 0 || tz < 0 || tx >= self.info.nx as i32 || tz >= self.info.nz as i32 { + continue; + } + let idx = (tz * self.info.nx as i32 + tx) as usize; + if !matches!(self.states[idx], TileState::Vacant) { + continue; + } + let d = self.dir(idx); + if d.off == 0 { + continue; // empty tile: nothing to load + } + let cx = self.info.origin_x + (tx as f32 + 0.5) * self.info.tile_size; + let cz = self.info.origin_z + (tz as f32 + 0.5) * self.info.tile_size; + let rx = cx - car_x; + let rz = cz - car_z; + let dist = libm::sqrtf(rx * rx + rz * rz); + let behind = if rx * ahead_x + rz * ahead_z < 0.0 { 1.35 } else { 1.0 }; + let score = dist * behind; + if best.map_or(true, |(s, _)| score < s) { + best = Some((score, idx)); + } + } + } + let Some((_, idx)) = best else { return }; + self.kick(idx); + } + + fn kick(&mut self, idx: usize) { + let d = self.dir(idx); + match &self.source { + Source::Embedded(_) => { + self.states[idx] = TileState::Loading; + self.embed_pending = Some((idx, EMBED_LATENCY)); + } + Source::File(fd) => { + let len = pack::tile_len(&d); + let mut buf = AlignedBuf::new(len); + unsafe { + sys::sceIoLseek32(*fd, d.off as i32, IoWhence::Set); + sys::sceIoReadAsync(*fd, buf.as_mut_ptr() as *mut c_void, len as u32); + } + self.states[idx] = TileState::Loading; + self.inflight = Some(InFlight { idx, buf }); + } + } + } + + fn poll(&mut self, frame: u32) { + if let Some((idx, ticks)) = self.embed_pending { + if ticks > 0 { + self.embed_pending = Some((idx, ticks - 1)); + } else { + let Source::Embedded(pack_bytes) = self.source else { + unreachable!() + }; + let d = self.dir(idx); + let data = &pack_bytes[d.off..d.off + pack::tile_len(&d)]; + self.states[idx] = TileState::Ready { + data: TileData::Borrowed(data), + born: frame, + }; + self.embed_pending = None; + self.loads += 1; + self.resident += 1; + } + return; + } + if self.inflight.is_none() { + return; + } + let Source::File(fd) = self.source else { return }; + let mut res: i64 = 0; + let rc = unsafe { sys::sceIoPollAsync(fd, &mut res) }; + if rc == 1 { + return; // still busy + } + let fl = self.inflight.take().unwrap(); + let d = self.dir(fl.idx); + if rc < 0 || (res as usize) < pack::tile_len(&d) { + // Read failed: leave the tile vacant; it will be retried. + self.states[fl.idx] = TileState::Vacant; + return; + } + unsafe { pocket3d_gu::writeback(fl.buf.as_slice()) }; + self.states[fl.idx] = TileState::Ready { + data: TileData::Owned(fl.buf), + born: frame, + }; + self.loads += 1; + self.resident += 1; + } +} + +/// Rise-from-the-ground factor: 0 at birth, easing out to 1. +pub fn rise(born: u32, frame: u32) -> f32 { + let t = frame.saturating_sub(born) as f32 / RISE_FRAMES as f32; + if t >= 1.0 { + return 1.0; + } + let inv = 1.0 - t; + 1.0 - inv * inv * inv +} diff --git a/scripts/pocket-drive.ts b/scripts/pocket-drive.ts new file mode 100644 index 00000000..eec0a061 --- /dev/null +++ b/scripts/pocket-drive.ts @@ -0,0 +1,122 @@ +// pocket-drive driver: cook the city pack, build the streaming-drive EBOOT, +// run it under PPSSPPHeadless (software renderer) with the pack on the +// emulated memory stick, and convert dumped frames to PNGs for eyeballing. +// The city-drive sibling of scripts/gu-demo.ts. +// +// bun scripts/pocket-drive.ts # frames 8..40 +// bun scripts/pocket-drive.ts --cap-start 0 --cap-n 600 -r +// bun scripts/pocket-drive.ts --embed # no ms0 file: embedded pack path +// +// Output PNGs land in dist/pocket-drive/. + +import { $ } from "bun"; +import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { resolvePspBuildToolchain } from "./psp-toolchain.ts"; + +const repo = new URL("..", import.meta.url).pathname; +const home = process.env.HOME ?? ""; +const crateDir = `${repo}pocket3d/crates/pocket-drive/`; + +// ---- args ------------------------------------------------------------- +const argv = Bun.argv.slice(2); +function flag(name: string, def: string): string { + const i = argv.indexOf(`--${name}`); + return i !== -1 && argv[i + 1] ? argv[i + 1] : def; +} +const capStart = flag("cap-start", "8"); +const capN = flag("cap-n", "32"); +const release = argv.includes("-r") || argv.includes("--release"); +const embed = argv.includes("--embed"); + +// ---- 1. cook ---------------------------------------------------------- +const pdrv = `${repo}dist/drive/manhattan.pdrv`; +console.log("pocket-drive: cooking manhattan"); +await $`bun cooker/cook.ts --out ${pdrv}`.cwd(crateDir); + +// ---- 2. cargo psp (same canonical contract as scripts/gu-demo.ts) ------ +let toolchain: ReturnType; +try { + toolchain = resolvePspBuildToolchain(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} +const sdk = toolchain.sdk.path; + +const env = { + ...toolchain.environment, + RUSTFLAGS: "-A linker-messages -A unexpected-cfgs -A unstable-name-collisions", + CRATE_CC_NO_DEFAULTS: "1", + TARGET_CC: "clang", + TARGET_AR: `${toolchain.llvmBin}/llvm-ar`, + TARGET_CFLAGS: + `-target mipsel-sony-psp -mcpu=mips2 -msingle-float -mlittle-endian -mno-abicalls -fno-pic -G0 -mno-check-zero-division ` + + `-fno-stack-protector -I${sdk}/psp/include -I${sdk}/psp/sdk/include`, + AR_mipsel_sony_psp: `${toolchain.llvmBin}/llvm-ar`, + RANLIB_mipsel_sony_psp: `${toolchain.llvmBin}/llvm-ranlib`, + RUST_PSP_TARGET: `${repo}native/targets/mipsel-sony-psp.json`, + RUST_PSP_ABORT_ONLY: "1", + CARGO_PROFILE_DEV_OPT_LEVEL: process.env.CARGO_PROFILE_DEV_OPT_LEVEL ?? "3", + DRIVE_PACK: embed ? pdrv : "", + DRIVE_CAP_START: capStart, + DRIVE_CAP_N: capN, +}; + +console.log(`pocket-drive: cargo psp (cap=${capStart}+${capN}${embed ? ", embedded pack" : ""})`); +const cargoArgs = release ? ["--release"] : []; +await $`${toolchain.rustup} run ${toolchain.manifest.rust.toolchain} cargo psp ${cargoArgs}` + .cwd(crateDir) + .env(env); + +const profile = release ? "release" : "debug"; +const ebootDir = `${crateDir}target/mipsel-sony-psp/${profile}`; +const named = `${ebootDir}/pocket-drive.EBOOT.PBP`; +if (existsSync(named)) { + await Bun.write(`${ebootDir}/EBOOT.PBP`, await Bun.file(named).arrayBuffer()); +} +if (!existsSync(`${ebootDir}/EBOOT.PBP`)) { + console.error(`no EBOOT.PBP under ${ebootDir}`); + process.exit(1); +} + +// ---- 3. PPSSPPHeadless ------------------------------------------------- +const ppsspp = + process.env.PPSSPP_HEADLESS ?? `${home}/ppsspp-src/build/PPSSPPHeadless`; +if (!existsSync(ppsspp)) { + console.log(`PPSSPPHeadless not found at ${ppsspp}; EBOOT at ${ebootDir}/EBOOT.PBP`); + process.exit(0); +} +// The pack on the emulated memory stick — the real streaming path. +if (!embed) { + mkdirSync(`${home}/.ppsspp/pocket-drive`, { recursive: true }); + await Bun.write( + `${home}/.ppsspp/pocket-drive/manhattan.pdrv`, + await Bun.file(pdrv).arrayBuffer(), + ); +} else { + rmSync(`${home}/.ppsspp/pocket-drive`, { recursive: true, force: true }); +} +const capDir = `${home}/.ppsspp/dc_cap`; +rmSync(capDir, { recursive: true, force: true }); +const timeout = process.env.DRIVE_TIMEOUT ?? "180"; +console.log("pocket-drive: PPSSPPHeadless (software renderer)"); +await $`${ppsspp} --graphics=software --timeout=${timeout} ${ebootDir}/EBOOT.PBP` + .nothrow() + .quiet(); + +// ---- 4. convert ------------------------------------------------------- +const outDir = `${repo}dist/pocket-drive`; +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); +const raws = existsSync(capDir) + ? readdirSync(capDir).filter((f) => f.endsWith(".raw")).sort() + : []; +if (raws.length === 0) { + console.error("no frames dumped — check the run"); + process.exit(1); +} +for (const raw of raws) { + const png = `${outDir}/${raw.replace(".raw", ".png")}`; + await $`magick -size 512x272 -depth 8 RGBA:${capDir}/${raw} -alpha off -crop 480x272+0+0 +repage PNG24:${png}`.quiet(); +} +console.log(`pocket-drive: ${raws.length} frames -> ${outDir}/`);