diff --git a/apps/launcher/app.tsx b/apps/launcher/app.tsx index eda241b8..f587a3f0 100644 --- a/apps/launcher/app.tsx +++ b/apps/launcher/app.tsx @@ -1,5 +1,5 @@ // apps/launcher/app.tsx — the Pocket Launcher: Cover Flow over every -// PSP-admissible app in the repo (docs/LAUNCHER.md). +// host-admitted app in the repo (docs/LAUNCHER.md). // // The deck is the 2D core's perspective pipeline (the same TEX_TRI path // motions page 4 ships): one perspective root, one 2:1 cover card per app, @@ -17,15 +17,27 @@ import { createEffect, createSignal, onMount, Show, untrack } from "solid-js"; import { registerTexture } from "@pocketjs/framework"; import { Image, Text, View, type NodeMirror } from "@pocketjs/framework/components"; import { animate, createJumpBatch } from "@pocketjs/framework/animation"; -import { BTN } from "@pocketjs/framework/input"; +import { BTN, touches } from "@pocketjs/framework/input"; import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; import { appTable, frozenShot, launchApp } from "@pocketjs/framework/launcher"; import { ticksPerFrame } from "@pocketjs/framework/clock"; -import { REGISTRY, type RegistryApp } from "./registry.generated.ts"; +import { getOps, hostViewport } from "@pocketjs/framework/host"; -/** Card box: 192×96 at left-[144] top-[58] (class literals below — the deck - * geometry lives in the transforms, the box never moves). */ -/** Rail geometry: first neighbor offset, then per-card spacing. */ +export interface RegistryApp { + output: string; + id: string; + title: string; + cover: string; + refl: string; +} + +export interface LauncherProps { + registry: readonly RegistryApp[]; +} + +/** Card box: 192×218, centered from the live host viewport; orientation + * changes only move the deck origin. Rail geometry is the first-neighbor + * offset followed by per-card spacing. */ const RAIL_FIRST = 124; const RAIL_STEP = 44; const RAIL_TILT = 55; @@ -78,14 +90,20 @@ function displayTitle(app: RegistryApp): string { return cut > 0 ? title.slice(0, cut) : title; } -export default function Launcher() { +export default function Launcher(props: LauncherProps) { + const initialViewport = hostViewport(getOps()) ?? { w: 480, h: 272 }; + const [viewport, setViewport] = createSignal(initialViewport); + const cardLeft = () => viewport().w / 2 - 96; + const cardTop = () => + Math.max(24, Math.round((viewport().h - 76 - 218) / 2)); + // The host table is the runtime truth for what is embedded; the generated // registry carries display data (titles + baked covers). Show their // intersection, in registry order. No table -> browse-only degraded mode. const table = appTable(); const apps = table - ? REGISTRY.filter((r) => table.apps.some((a) => a.output === r.output)) - : [...REGISTRY]; + ? props.registry.filter((r) => table.apps.some((a) => a.output === r.output)) + : [...props.registry]; const shot = frozenShot(); if (shot >= 0) registerTexture("launcher.shot", shot); const resume = table?.resume ?? null; @@ -217,6 +235,36 @@ export default function Launcher() { // CIRCLE confirms (the console's home convention — CROSS-as-confirm had // users launching with O and landing in the RESUME app every time); // CROSS and SELECT both back out to the interrupted app. + let previousTouchIds = new Set(); + onFrame(() => { + const nextViewport = hostViewport(getOps()); + if ( + nextViewport && + (nextViewport.w !== viewport().w || nextViewport.h !== viewport().h) + ) { + setViewport(nextViewport); + } + const contacts = touches(); + const nextTouchIds = new Set(contacts.map((contact) => contact.id)); + const pressed = contacts.find((contact) => !previousTouchIds.has(contact.id)); + previousTouchIds = nextTouchIds; + if (!pressed) return; + + // Three large screen zones keep touch useful even while perspective + // makes individual cover hit boxes overlap: left/right browse, center + // launches the card that the title identifies. + const third = viewport().w / 3; + if (pressed.x < third) { + pos = null; + setSel((current) => clampSel(current - 1)); + } else if (pressed.x >= third * 2) { + pos = null; + setSel((current) => clampSel(current + 1)); + } else { + const app = apps[sel()]; + if (app) launchApp(app.output); + } + }); onButtonPress(BTN.CIRCLE, () => { const app = apps[sel()]; if (app) launchApp(app.output); @@ -236,18 +284,21 @@ export default function Launcher() { it next to the covers) — black floor, cool center glow behind the deck, faint sheen under the cards. Stretched 256×128 → full screen with bilinear, like the frozen shot. */} - + = 0}> {/* The interrupted app's last frame, stretched back to full screen under a scrim — the "overlay" illusion (docs/LAUNCHER.md). */} - - + + 0} fallback={ - + No apps embedded — build with tools/launcher.ts } @@ -267,8 +318,10 @@ export default function Launcher() { (cardEls[i] = el)} debugName="Card" - class="absolute left-[144] top-[52] w-[192] h-[218]" + class="absolute w-[192] h-[218]" style={{ + insetL: cardLeft(), + insetT: cardTop(), translateX: t.translateX, translateZ: t.translateZ, rotateY: t.rotateY, @@ -282,7 +335,10 @@ export default function Launcher() { })} - + {displayTitle(selected())} {`${sel() + 1} / ${apps.length} · ${selected().id}`} @@ -293,11 +349,11 @@ export default function Launcher() { - + {table ? resume - ? "tap or hold L / R · CIRCLE launch · CROSS back" - : "tap L / R to step · hold to flow · CIRCLE launch" + ? "L/R · CIRCLE launch · CROSS back · touch sides/center" + : "L/R · CIRCLE launch · touch sides/center" : "browse only — this host cannot switch apps"} diff --git a/apps/launcher/images.json b/apps/launcher/images.json index 602dac1c..1cd205d2 100644 --- a/apps/launcher/images.json +++ b/apps/launcher/images.json @@ -2,6 +2,12 @@ "covers/launcher-bg.png": { "linear": true }, + "covers/cover-note-main.png": { + "linear": true + }, + "covers/refl-note-main.png": { + "linear": true + }, "covers/cover-cafe-main.png": { "linear": true }, diff --git a/apps/launcher/main.tsx b/apps/launcher/main.tsx index 195c184c..4e8523fe 100644 --- a/apps/launcher/main.tsx +++ b/apps/launcher/main.tsx @@ -1,5 +1,6 @@ // @title PocketJS: Launcher import Launcher from "./app.tsx"; import { mount } from "@pocketjs/framework"; +import { REGISTRY } from "./registry.generated.ts"; -mount(() => ); +mount(() => ); diff --git a/apps/launcher/registry.generated.ts b/apps/launcher/registry.generated.ts index 89e300d6..dc21c7aa 100644 --- a/apps/launcher/registry.generated.ts +++ b/apps/launcher/registry.generated.ts @@ -1,6 +1,6 @@ // GENERATED by tools/launcher.ts scan — do not edit by hand; COMMIT // the regenerated file (tests/launcher-sim.test.ts asserts freshness). -// The display-side PSP/Vita union: the launcher app imports it for +// The display-side PSP/Vita/Symbian union: the launcher imports it for // titles + cover asset keys; each host's target-specific appTable // (spec op 39) stays the runtime truth for what is embedded. @@ -17,6 +17,7 @@ export interface RegistryApp { } export const REGISTRY: readonly RegistryApp[] = [ + { output: "note-main", id: "dev.pocket-stack.note", title: "Pocket Note", cover: "covers/cover-note-main.png", refl: "covers/refl-note-main.png" }, { output: "cafe-main", id: "dev.pocket-stack.cafe", title: "PocketJS: Café", cover: "covers/cover-cafe-main.png", refl: "covers/refl-cafe-main.png" }, { output: "chrome-main", id: "dev.pocket-stack.chrome", title: "PocketJS: Chrome", cover: "covers/cover-chrome-main.png", refl: "covers/refl-chrome-main.png" }, { output: "cursor-main", id: "dev.pocket-stack.cursor", title: "PocketJS: Cursor", cover: "covers/cover-cursor-main.png", refl: "covers/refl-cursor-main.png" }, diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index c3080229..976edf60 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -219,9 +219,10 @@ export const OP = { // Launching `current` relaunches it fresh — there is // no suspend anywhere in this protocol. appShot: 41, // () -> handle | -1. Texture of the frozen frame the - // SELECT summon captured: the FULL 480x272 frame - // downscaled into 256x128 PSM_8888 (stored slightly - // squeezed — draw it at screen aspect to undo it). + // SELECT summon captured: the FULL current logical + // frame downscaled into 256x128 PSM_8888 (stored + // squeezed — draw it at current viewport aspect to + // undo it; consoles are 480x272). // Valid inside the summoned launcher guest until the // next switch; -1 otherwise. } as const; diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7f20a3d0..c6568b15 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -196,14 +196,15 @@ PocketJS/ plugin collects per-file: (a) candidate class strings, (b) text codepoints — both from AST literals *and template quasis*, never regex over quotes. 2. **Compile styles & fonts.** `tailwind.ts` validates tokens (all-or-nothing - per literal), assigns styleIds, writes `styles.bin` + `styles.generated.ts` - (excluded from future scans). `bake-font.ts` bakes atlas slots for the - collected charset. `pak.ts` packs styles.bin + atlases + images → - `.pak`. + per literal), assigns styleIds, writes `styles.bin`, and keeps an ignored + `styles.generated.ts` mirror for inspection (excluded from future scans). + `bake-font.ts` bakes atlas slots for the collected charset. `pak.ts` packs + styles.bin + atlases + images → `.pak`. 3. **Pass 2 — bundle.** `Bun.build` with an onLoad plugin that serves the - *cached* pass-1 transforms (styles.generated.ts now exists), `format: - "iife"`, `minify:false`, `target:"browser"`. Output `.js` next to the - pak. + *cached* pass-1 transforms plus this build's in-memory generated style + module, `format:"iife"`, `minify:false`, `target:"browser"`. Output + `.js` next to the pak. Parallel builds therefore cannot import one + another's transient style table. The PSP build (`tools/psp.ts`) then runs `rustup run nightly-2026-05-28 cargo psp` with the exact env block from `runtime/build.ts` (LLVM PATH, diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index 03c31eb3..3725ff2d 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -1,6 +1,6 @@ # The Pocket Launcher — in-device app switching -One console package — PSP EBOOT or Vita VPK — with every target-compatible +One device package — PSP EBOOT, Vita VPK, or Nokia E7 SIS — with every target-compatible app inside and a Cover Flow picker on top. This document is the contract for the multi-app host, the three `app*` surface ops, and the SELECT summon policy. RUNTIMES.md owns the ontology; nothing here changes it — a launcher is an @@ -28,7 +28,7 @@ the system summon chord. |----|------|-----------|-----------| | 39 | `appTable` | `() -> string` | JSON `{ apps: [{output, id, title}], current, resume }`. `current` is the running bundle's output name; `resume` is the app interrupted by the last SELECT summon (null after a cold boot or an explicit launch). Hosts without app switching omit the op (same rule as `debugStats`). | | 40 | `appLaunch` | `(output: string) -> 0\|1` | Request a switch. The host finishes the CURRENT frame (draw + present), then swaps guests before the next one. Returns 0 for an unknown output (no switch scheduled). Calling it with `current` relaunches fresh. | -| 41 | `appShot` | `() -> handle \| -1` | Texture handle of the frozen frame captured when the running app was summoned away: the FULL 480×272 frame downscaled into 256×128 PSM_8888 (stored slightly squeezed; drawn at screen aspect, which undoes it). Valid in the guest booted by a summon until the next switch; -1 otherwise. | +| 41 | `appShot` | `() -> handle \| -1` | Texture handle of the frozen frame captured when the running app was summoned away: the full current logical frame downscaled into 256×128 PSM_8888 (stored squeezed; drawn at the current viewport aspect, which undoes it). Console frames are 480×272. Valid in the guest booted by a summon until the next switch; -1 otherwise. | `@pocketjs/framework/launcher` wraps these (`appTable()`, `launchApp()`, `frozenShot()`, `launcherActive()`) and degrades to `null`/no-op on hosts @@ -51,6 +51,12 @@ ops behavior change. Apps that bind SELECT (e.g. Pocket Talk) keep it in their standalone package and lose it under the launcher — that is the price of a system chord, stated here once. +The E7 host spells the same system action with the physical QWERTY +Backspace/Home keys. It forwards that action to the launcher as SELECT and +strips it from every other guest. Escape remains CROSS. Q/E map to the +left/right triggers and T/S to TRIANGLE/SQUARE, so the launcher remains fully +operable without an on-screen controller. + ## Admission The embedded set is COMPUTED, not curated: every `apps/*/pocket.json` whose @@ -82,14 +88,17 @@ boots the next package; vita2d and input remain process-owned. ## Build pipeline -`bun tools/launcher.ts` owns the artifact chain. `--target psp|vita` +`bun tools/launcher.ts` owns the artifact chain. `--target psp|vita|symbian` selects admission, bundle variants, packages, and the native backend; `psp` is the default for compatibility: 1. **scan** — resolve every app manifest for the selected target and dedupe by `app.output`. PSP writes `dist/launcher-registry.{json,tsv}`; Vita writes `dist/launcher/vita/launcher-registry.{json,tsv}`. The committed display - registry is the PSP/Vita union, while `appTable()` remains the runtime truth. + registry is the PSP/Vita/Symbian union, while `appTable()` remains the runtime truth. + A plain in-repository `scan` is the only command that updates + `apps/launcher/{registry.generated.ts,images.json}`; external scans leave + those files untouched. 2. **covers** — boot each admitted app in `hosts/sim`, settle 90 virtual frames, render, box-downscale the full frame to 256×128, write `apps/launcher/covers/cover-.png` (generated, deterministic — @@ -98,7 +107,10 @@ is the default for compatibility: 3. **pack** — every admitted app + the launcher becomes a `.pocket` package (`contracts/spec/pocket-package.ts`) with the selected target variant. PSP uses `dist/packages/`; Vita uses `dist/launcher/vita/packages/` so density-2 - bundles never overwrite the PSP/sim outputs. + bundles never overwrite the PSP/sim outputs. Pack/build materialize a private + `.launcher-source` under `dist/launcher//` containing the selected + registry, image metadata, entry, and cover assets; compiler/watch processes + never observe a temporary target registry in the committed source tree. 4. **build** — the selected backend embeds those packages VERBATIM (`hosts/psp/build.rs` or `hosts/vita/build.rs`; the core reader extracts js/pak zero-copy at boot). PSP retains its aggregate FNV-1a64 build @@ -107,17 +119,46 @@ is the default for compatibility: `dist/vita/launcher-main.vpk`; ordinary single-app builds retain their classic inline embed. +For `symbian`, scan uses the private `symbian-e7-dev` resolver rather than +pretending the experimental host is a production target. Only apps with a +real dynamic/live viewport are admitted (currently Hero and Note); fixed +480×272 PSP apps are not silently letterboxed into the E7 catalog. The +committed launcher manifest stays byte-identical for PSP/Vita; inside the +locked Symbian transaction the tool derives its E7-only dynamic viewport and +touch/live enhancements. The Symbian pack step always recompiles every guest, +then writes an eight-field `catalog.tsv` and a 16-byte-aligned `catalog.bin` +containing those exact target-thinned `.pocket` files. The Qt host validates +each package footer, target, ABI, and identity before boot. The native builder +reserves a 1 MiB-aligned GCCE writable-data base from the full raw embedded +byte count, so a large catalog cannot overlap the executable's qrc rodata. + ```sh bun tools/launcher.ts build --target psp -- --release bun tools/launcher.ts build --target vita -- --release +bun tools/launcher.ts build --target symbian ``` +The Symbian build may repeat `--include-manifest /absolute/path/to/pocket.json` +to add explicitly requested external projects. The tool resolves each declared +entry by walking up from that manifest, applies the same private E7 admission +gate, and builds the launcher from its isolated target registry without ever +writing the committed display registry; absolute local paths never enter the +emitted registry or source diff. This +extension is deliberately unavailable to PSP/Vita builds, whose computed +in-repository admission set remains unchanged. + ## Hosts - **hosts/psp** — the original native implementation of everything above. - **hosts/vita** — the same package-table and ops contract with a process-global frame/input tape across fresh guests, SELECT interception, CPU-oracle frozen shots, and an explicit GXM-safe guest-resource reset. +- **hosts/symbian** — the E7 Qt process owns the fullscreen window, timer, + keyboard, touch stream, and catalog blob. Each switch happens after a + synchronous presented frame, then frees the complete QuickJS realm and + native `Ui` before the next package boots. The launcher background, frozen + shot, deck origin, title, and footer use the live 640×360/360×640 viewport; + touch divides the current width into browse/launch thirds. - **hosts/sim** — `hosts/sim/launcher.ts` drives the same protocol over per-guest `bootWorld`s: strips SELECT, performs the downscale with the same box filter, uploads the shot into the next world, answers the three diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index bf40b7b2..b34fa849 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -11,7 +11,7 @@ pocketjs/ │ ├─ core/ pocketjs-core — retained UI tree, taffy layout, damage + raster (standalone crate) │ ├─ backends/ platform render backends (ESP32-P4 PPA is a standalone no_std crate) │ ├─ wasm/ core compiled to wasm32 for web/sim hosts (standalone crate) -│ ├─ symbian/ no_std core static library for the GCCE/Symbian host (standalone crate) +│ ├─ symbian/ no_std Symbian UI static library: C ABI, capture raster + GLES2 DrawList backend (standalone crate) │ ├─ pocket3d/ the 3D core family (bsp, cook, gu, vita) + desktop examples │ ├─ crates/ non-3D engine crates: pocket-mod, pocket-ui-surface, pocket-ui-wgpu, pocket-vrm, pocket-widget │ └─ Cargo.toml the desktop workspace root (core/, wasm/, symbian/, and diff --git a/docs/SYMBIAN_E7.md b/docs/SYMBIAN_E7.md index 5e24678f..a94f1873 100644 --- a/docs/SYMBIAN_E7.md +++ b/docs/SYMBIAN_E7.md @@ -6,7 +6,7 @@ E7 (RM-626). The probe exercises the compiler, Qt ABI, E32 executable, resource registration, SIS packaging, and signing. The private runtime additionally links the PocketJS Rust core and pinned QuickJS, embeds a compiled application and its `.pak`, and exposes the host operations needed to draw and accept -button input. The separate MTP command proves byte delivery to the phone; +button and native-resolution touch input. The separate MTP command proves byte delivery to the phone; installation and launch remain device-side confirmations. This workflow does not flash or modify firmware. A CFW may relax installation @@ -140,25 +140,42 @@ bun tools/symbian.ts build app \ It writes both the installable package and a build receipt: ```text -dist/symbian/pocketjs-e7-runtime.sis -dist/symbian/pocketjs-e7-runtime.receipt.json +dist/symbian/hero-main.sis +dist/symbian/hero-main.receipt.json ``` -The receipt records the application output, package UID and version, pinned -QuickJS revision, and SHA-256 hashes for the JavaScript, `.pak`, resolved plan, -and Rust core. Stage the SIS over the same readback-verified USB/MTP path: +Every manifest gets a stable private-range UID derived from its Pocket app id, +a collision-resistant executable name, its own application-menu caption, and +output-specific SIS/receipt filenames. Multiple PocketJS apps can therefore be +installed side by side instead of replacing one shared runtime package. The +receipt records that identity, the package version, pinned QuickJS revision, +and SHA-256 hashes for the JavaScript, `.pak`, resolved plan, Rust core, and +optional launcher catalog. It also records the deterministic GCCE data base: +the historical 4 MiB baseline plus the raw embedded JS/pak/catalog byte count, +rounded to 1 MiB. This keeps large DeepZoom packs and multi-app catalogs above +the read-only qrc segment without weakening the linker's overlap check. Stage +the SIS over the same readback-verified USB/MTP path: ```sh bun tools/symbian.ts doctor --device --coda-usb -bun tools/symbian.ts deploy dist/symbian/pocketjs-e7-runtime.sis +bun tools/symbian.ts deploy dist/symbian/hero-main.sis ``` Install it from `File manager > Mass memory > Installs`, then launch -**PocketJS E7 Runtime**. Symbian package upgrades must use a version greater +the manifest's title. Symbian package upgrades must use a version greater than the version already installed on the phone. After installing `1.0.0`, for example, pass `--sis-version 1.0.1` for the next build rather than reusing `1.0.0`. +External Pocket projects use the same authority without copying the toolchain: + +```sh +bun tools/symbian.ts build app \ + --manifest /path/to/project/pocket.json \ + --project-root /path/to/project \ + --outdir /path/to/project/dist/symbian +``` + The experimental host has these runtime semantics: - PocketJS uses the full native Qt window as its logical viewport: `640x360` @@ -177,19 +194,44 @@ The experimental host has these runtime semantics: the default rate). Builds reject non-positive rates and rates that do not divide the core's fixed 60 Hz clock exactly. - Arrow keys map to the four directions, the navigation center/Select key and - keyboard Enter to `CIRCLE`, Escape to `CROSS`, and Space to `START`. + keyboard Enter to `CIRCLE`, Escape to `CROSS`, Space to `START`, Q/E to the + left/right triggers, and T/S to `TRIANGLE`/`SQUARE`. +- Touch frame v2 uses tagged 10-bit coordinates for the E7's full 640-pixel + axis while retaining the original untagged 9-bit wire for PSP/Vita-era + hosts. `symbian-e7-dev` advertises `input.touch`; the framework exposes the + same immutable per-frame contact snapshots on both encodings. + +## Build the E7 Pocket Launcher + +The Launcher SIS contains target-thinned `.pocket` packages and keeps exactly +one guest realm/core alive at a time. Home or Backspace summons the launcher; +choosing a card destroys the current guest and cold-boots the next package. +The built-in catalog admits only manifests with a genuine live E7 viewport. +Additional external projects can be included explicitly without adding their +machine-specific paths to committed launcher sources: -The current public input wire has 9-bit touch coordinates and cannot represent -the E7's 640-pixel axis. The host therefore disables touch snapshots at native -E7 sizes, and `symbian-e7-dev` does not advertise `input.touch`. Applications -must not treat touch as a supported Symbian target contract yet. +```sh +bun tools/launcher.ts build --target symbian \ + --include-manifest /path/to/another-app/pocket.json \ + --include-manifest /path/to/pocket-figma/pocket.json +``` + +The tool locates each external project root from its declared entry, builds +and validates the exact `.pocket` packages, derives the launcher's E7-only +dynamic manifest without changing its PSP/Vita contract, records a +16-byte-aligned catalog, and produces: + +```text +dist/launcher/symbian/launcher-main.sis +dist/launcher/symbian/launcher-main.receipt.json +``` For physical acceptance, launch the app in landscape, change some visible state, then close/open the keyboard or rotate the phone. The UI should fill and reflow at `360x640`, preserve that state, and return to `640x360` without a restart, red error screen, stale strip, or black margin. -`PocketJS E7 Runtime` version `1.1.1` passed the manual landscape/portrait +`PocketJS E7 Runtime` version `1.1.1` passed the original manual landscape/portrait launch and live-relayout check on an RM-626. ## Optional CODA device agent @@ -213,8 +255,9 @@ macOS workflow: keep the phone in Nokia Suite mode, select USB in CODA, and run: pocket symbian coda usb # Or include it in the complete device check: pocket symbian doctor --device --coda-usb -# Launch the installed PocketJS runtime without touching the phone: -pocket symbian coda usb launch +# Launch an installed app without touching the phone (read the exact name +# from that app's *.receipt.json): +pocket symbian coda usb launch PocketJsLauncherMainECEF4AC6.exe ``` The host opens only the exact Nokia E7 Suite-mode VID/PID and claims CODA's @@ -224,12 +267,13 @@ querying a serial number or IMEI. This avoids relying on the old Nokia USB driver binding that modern macOS no longer provides. A successful check reports the agent version, for example `4.0.23:app`. -`coda usb launch` waits for the device's Locator service list, requires the -`Processes` service, and sends CODA's non-debug-controlled `Processes.start` -command for `PocketJsE7Runtime.exe`. Success includes the CODA process context, -for example `CODA process: p2382`. An alternate installed executable basename -can be supplied as the final argument. The command never terminates an existing -process; close the app first if CODA reports that it is already running. +`coda usb launch ` waits for the device's Locator service list, +requires the `Processes` service, and sends CODA's non-debug-controlled +`Processes.start` command for that exact independently packaged executable. +Success includes the CODA process context, for example +`CODA process: p2382`. The command never guesses which app to start and never +terminates an existing process; close the app first if CODA reports that it is +already running. This is a repeatable remote-launch path for device testing, not yet a source-level debugger. CODA also exposes run control, logging, memory, @@ -255,7 +299,8 @@ install-server policy. The toolchain now implements the GCCE-compatible Rust core, QuickJS execution and Promise-job draining, the base HostOps surface, embedded compiled JavaScript and `.pak` resources, fixed-step presentation, live native-viewport -relayout, and button input. The app build and MTP readback checks make both the +relayout, buttons, native touch, independent app identities, and cold +multi-package switching. The app build and MTP readback checks make both the native package and delivery substrate repeatable. A signed SIS is time-dependent and therefore is not expected to be byte-for-byte reproducible between builds. diff --git a/engine/core/src/draw.rs b/engine/core/src/draw.rs index b9dd1c84..667f4fb7 100644 --- a/engine/core/src/draw.rs +++ b/engine/core/src/draw.rs @@ -500,6 +500,7 @@ fn disc_texture( psm: spec::psm::PSM_8888, palette: None, linear: false, + revision: 0, }, ); if handle < 0 { diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index 9b2414ed..74edfc65 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -66,6 +66,12 @@ pub struct Texture { /// Sample with bilinear filtering (spec::img::FLAG_LINEAR). Pure sampling /// hint for backends — the pixel bytes are identical either way. pub linear: bool, + /// Changes only when this live texture's bytes are overwritten in place. + /// + /// Generation-tagged handles catch free/reuse, while this revision lets + /// GPU backends refresh one still-live binding without retransferring + /// every other texture. + revision: u64, } impl Texture { @@ -525,6 +531,7 @@ impl Ui { psm, palette, linear: flags & spec::img::FLAG_LINEAR != 0, + revision: 0, }; let handle = tex_alloc(&mut self.textures, &mut self.tex_free, tex); if handle >= 0 { @@ -598,6 +605,7 @@ impl Ui { tex.byte_len, ); } + tex.revision = tex.revision.wrapping_add(1); self.bump_raster_revision(); true } @@ -1183,6 +1191,17 @@ impl Ui { self.textures[slot as usize].tex.as_ref().map(Texture::view) } + /// Content revision of a live texture. Generation-tagged handles catch + /// free/reuse; this value changes when the bytes behind one still-live + /// handle are overwritten in place. + pub fn texture_revision(&self, handle: i32) -> Option { + let slot = tex_resolve(&self.textures, handle)?; + self.textures[slot as usize] + .tex + .as_ref() + .map(|texture| texture.revision) + } + /// Number of texture slots ever allocated (live or free) — the walk /// bound for `texture_at` (the wgpu backend's texture-sync sweep). pub fn texture_slot_count(&self) -> usize { @@ -1197,6 +1216,15 @@ impl Ui { Some((make_tex_handle(s.gen, slot), t.view())) } + /// Versioned texture-slot walk for GPU caches. This is additive to the + /// long-standing `texture_at`/`TexView` API so downstream struct literals + /// and exhaustive patterns do not break merely to support cache refresh. + pub fn texture_at_versioned(&self, slot: u32) -> Option<(i32, u64, TexView<'_>)> { + let s = self.textures.get(slot as usize)?; + let t = s.tex.as_ref()?; + Some((make_tex_handle(s.gen, slot), t.revision, t.view())) + } + /// A registered font atlas (backends read glyph bitmaps through this). pub fn font_atlas(&self, slot: u8) -> Option<&text::Atlas> { self.fonts.atlas(slot) diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index 118a6753..10d6c285 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -2834,6 +2834,7 @@ fn update_texture_t8_overwrites_in_place() { data[1024..].fill(1); let plane = ui.upload_texture(&data, 4, 4, spec::psm::PSM_T8); assert!(plane >= 0); + assert_eq!(ui.texture_revision(plane), Some(0)); // Overwrite with palette entry 1 = green, pixels still index 1. let mut pal = alloc::vec![0u8; 1024]; @@ -2843,10 +2844,12 @@ fn update_texture_t8_overwrites_in_place() { let view = ui.texture(plane).expect("plane still live"); assert_eq!(view.palette.unwrap()[4..8], abgr(0, 255, 0, 255).to_le_bytes()); assert_eq!(view.pixels, &px[..]); + assert_eq!(ui.texture_revision(plane), Some(1)); // Size/format/liveness misuse changes nothing and reports false. assert!(!ui.update_texture_t8(plane, &pal[..100], &px), "short palette"); assert!(!ui.update_texture_t8(plane, &pal, &px[..8]), "wrong pixel count"); + assert_eq!(ui.texture_revision(plane), Some(1)); let rgba = ui.upload_texture(&[0u8; 4 * 4 * 4], 4, 4, spec::psm::PSM_8888); assert!(!ui.update_texture_t8(rgba, &pal, &px), "non-T8 texture"); ui.free_texture(plane); diff --git a/engine/crates/pocket-ui-wgpu/src/render.rs b/engine/crates/pocket-ui-wgpu/src/render.rs index 51cf0f44..52907bc8 100644 --- a/engine/crates/pocket-ui-wgpu/src/render.rs +++ b/engine/crates/pocket-ui-wgpu/src/render.rs @@ -33,11 +33,20 @@ struct FontTexture { glyph_count: u16, } -/// One live image texture, cached by core SLOT. `handle` is the -/// generation-tagged handle the upload was made for; a slot whose current -/// handle differs (freed, then reused) is re-uploaded by `sync_textures`. -struct ImageBind { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ImageVersion { handle: i32, + revision: u64, +} + +#[inline] +fn image_cache_hit(cached: Option, current: ImageVersion) -> bool { + cached == Some(current) +} + +/// One live image texture, cached by core SLOT. +struct ImageBind { + version: ImageVersion, bind: wgpu::BindGroup, } @@ -345,7 +354,7 @@ impl UiRenderer { // mirroring the core contract. let slot = (handle as u32 & spec::TEX_SLOT_MASK) as usize; match self.images.get(slot).and_then(|i| i.as_ref()) { - Some(i) if i.handle == handle => &i.bind, + Some(i) if i.version.handle == handle => &i.bind, _ => continue, } } @@ -652,8 +661,8 @@ impl UiRenderer { /// Mirror the core's textures into GPU resources. Font atlases are /// append-only; image slots are not — `freeTexture` empties a slot and /// a later upload reuses it under a new generation-tagged handle, so - /// each slot re-uploads whenever its current handle changes and drops - /// its cache entry when the core frees it. + /// each slot re-uploads whenever its current handle or content revision + /// changes and drops its cache entry when the core frees it. fn sync_textures(&mut self, gpu: &Gpu, ui: &Ui) { // Font slots. A slot re-uploads when its glyph count moved — hosts // may extend an atlas at runtime (IME input rasterizing new @@ -679,13 +688,17 @@ impl UiRenderer { self.images.resize_with(slots, || None); } for slot in 0..slots { - match ui.texture_at(slot as u32) { - Some((handle, view)) => { - if self.images[slot].as_ref().is_some_and(|e| e.handle == handle) { + match ui.texture_at_versioned(slot as u32) { + Some((handle, revision, view)) => { + let version = ImageVersion { handle, revision }; + if image_cache_hit( + self.images[slot].as_ref().map(|entry| entry.version), + version, + ) { continue; } self.images[slot] = to_rgba8(&view).map(|rgba| ImageBind { - handle, + version, bind: self.upload_image(gpu, &rgba, view.w, view.h, view.linear), }); } @@ -825,6 +838,29 @@ impl UiRenderer { } } +#[cfg(test)] +mod tests { + use super::{image_cache_hit, ImageVersion}; + + #[test] + fn image_cache_decision_reuploads_in_place_content_revision() { + let cached = ImageVersion { handle: 7, revision: 4 }; + assert!(image_cache_hit( + Some(cached), + ImageVersion { handle: 7, revision: 4 }, + )); + assert!(!image_cache_hit( + Some(cached), + ImageVersion { handle: 7, revision: 5 }, + )); + assert!(!image_cache_hit( + Some(cached), + ImageVersion { handle: 8, revision: 4 }, + )); + assert!(!image_cache_hit(None, cached)); + } +} + /// Expand a core texture (PSM 5650/8888/4444/T8) to tightly-packed RGBA8. fn to_rgba8(view: &TexView) -> Option> { let count = (view.w * view.h) as usize; diff --git a/engine/symbian/Cargo.toml b/engine/symbian/Cargo.toml index f0ff6729..8393437d 100644 --- a/engine/symbian/Cargo.toml +++ b/engine/symbian/Cargo.toml @@ -1,5 +1,6 @@ -# pocketjs-symbian-core — pocketjs-core plus its deterministic software -# rasterizer, exposed as a C static library for the Qt/Symbian host. +# pocketjs-symbian-core — pocketjs-core plus the GLES2 DrawList backend and +# deterministic capture rasterizer, exposed as a C static library for the +# Qt/Symbian host. QGLWidget owns the graphics context and presentation. # # This is deliberately a standalone workspace. The custom Symbian target # needs nightly build-std while the desktop engine workspace stays on the diff --git a/engine/symbian/src/gles2.rs b/engine/symbian/src/gles2.rs new file mode 100644 index 00000000..8dd268a0 --- /dev/null +++ b/engine/symbian/src/gles2.rs @@ -0,0 +1,1404 @@ +//! OpenGL ES 2 DrawList backend for the Nokia E7 Qt host. +//! +//! QGLWidget owns the EGL context. These entry points are called only while +//! that context is current. Geometry remains the core's deterministic, +//! CPU-clipped DrawList; the GPU owns rasterization, texture filtering, +//! blending, and presentation. + +#![cfg_attr(test, allow(dead_code))] + +use alloc::{vec, vec::Vec}; +use core::ffi::{c_char, c_void}; +use core::mem::size_of; +use core::ptr; + +use pocketjs_core::spec; +use pocketjs_core::text::Atlas; +use pocketjs_core::{TexView, Ui}; + +type GLenum = u32; +type GLuint = u32; +type GLint = i32; +type GLsizei = i32; +type GLboolean = u8; +type GLbitfield = u32; +type GLfloat = f32; +type GLsizeiptr = isize; + +const GL_FALSE: GLboolean = 0; +const GL_FLOAT: GLenum = 0x1406; +const GL_UNSIGNED_BYTE: GLenum = 0x1401; +const GL_TRIANGLES: GLenum = 0x0004; +const GL_ARRAY_BUFFER: GLenum = 0x8892; +const GL_DYNAMIC_DRAW: GLenum = 0x88e8; +const GL_VERTEX_SHADER: GLenum = 0x8b31; +const GL_FRAGMENT_SHADER: GLenum = 0x8b30; +const GL_COMPILE_STATUS: GLenum = 0x8b81; +const GL_LINK_STATUS: GLenum = 0x8b82; +const GL_TEXTURE_2D: GLenum = 0x0de1; +const GL_TEXTURE0: GLenum = 0x84c0; +const GL_RGBA: GLenum = 0x1908; +const GL_LUMINANCE_ALPHA: GLenum = 0x190a; +const GL_LINEAR: GLint = 0x2601; +const GL_NEAREST: GLint = 0x2600; +const GL_CLAMP_TO_EDGE: GLint = 0x812f; +const GL_TEXTURE_MAG_FILTER: GLenum = 0x2800; +const GL_TEXTURE_MIN_FILTER: GLenum = 0x2801; +const GL_TEXTURE_WRAP_S: GLenum = 0x2802; +const GL_TEXTURE_WRAP_T: GLenum = 0x2803; +const GL_UNPACK_ALIGNMENT: GLenum = 0x0cf5; +const GL_BLEND: GLenum = 0x0be2; +const GL_ONE: GLenum = 1; +const GL_SRC_ALPHA: GLenum = 0x0302; +const GL_ONE_MINUS_SRC_ALPHA: GLenum = 0x0303; +const GL_COLOR_BUFFER_BIT: GLbitfield = 0x0000_4000; +const GL_SCISSOR_TEST: GLenum = 0x0c11; +const GL_DEPTH_TEST: GLenum = 0x0b71; +const GL_CULL_FACE: GLenum = 0x0b44; +const GL_MAX_TEXTURE_SIZE: GLenum = 0x0d33; +const GL_NO_ERROR: GLenum = 0; + +unsafe extern "C" { + fn glActiveTexture(texture: GLenum); + fn glAttachShader(program: GLuint, shader: GLuint); + fn glBindAttribLocation(program: GLuint, index: GLuint, name: *const c_char); + fn glBindBuffer(target: GLenum, buffer: GLuint); + fn glBindTexture(target: GLenum, texture: GLuint); + fn glBlendFuncSeparate( + source_rgb: GLenum, + destination_rgb: GLenum, + source_alpha: GLenum, + destination_alpha: GLenum, + ); + fn glBufferData(target: GLenum, size: GLsizeiptr, data: *const c_void, usage: GLenum); + fn glClear(mask: GLbitfield); + fn glClearColor(red: GLfloat, green: GLfloat, blue: GLfloat, alpha: GLfloat); + fn glCompileShader(shader: GLuint); + fn glCreateProgram() -> GLuint; + fn glCreateShader(kind: GLenum) -> GLuint; + fn glDeleteBuffers(count: GLsizei, buffers: *const GLuint); + fn glDeleteProgram(program: GLuint); + fn glDeleteShader(shader: GLuint); + fn glDeleteTextures(count: GLsizei, textures: *const GLuint); + fn glDisable(capability: GLenum); + fn glDisableVertexAttribArray(index: GLuint); + fn glDrawArrays(mode: GLenum, first: GLint, count: GLsizei); + fn glEnable(capability: GLenum); + fn glEnableVertexAttribArray(index: GLuint); + fn glGenBuffers(count: GLsizei, buffers: *mut GLuint); + fn glGenTextures(count: GLsizei, textures: *mut GLuint); + fn glGetError() -> GLenum; + fn glGetIntegerv(parameter: GLenum, value: *mut GLint); + fn glGetProgramiv(program: GLuint, parameter: GLenum, value: *mut GLint); + fn glGetShaderiv(shader: GLuint, parameter: GLenum, value: *mut GLint); + fn glGetUniformLocation(program: GLuint, name: *const c_char) -> GLint; + fn glLinkProgram(program: GLuint); + fn glPixelStorei(parameter: GLenum, value: GLint); + fn glScissor(x: GLint, y: GLint, width: GLsizei, height: GLsizei); + fn glShaderSource( + shader: GLuint, + count: GLsizei, + source: *const *const c_char, + length: *const GLint, + ); + fn glTexImage2D( + target: GLenum, + level: GLint, + internal_format: GLint, + width: GLsizei, + height: GLsizei, + border: GLint, + format: GLenum, + kind: GLenum, + pixels: *const c_void, + ); + fn glTexParameteri(target: GLenum, parameter: GLenum, value: GLint); + fn glUniform1i(location: GLint, value: GLint); + fn glUniform2f(location: GLint, x: GLfloat, y: GLfloat); + fn glUseProgram(program: GLuint); + fn glVertexAttribPointer( + index: GLuint, + size: GLint, + kind: GLenum, + normalized: GLboolean, + stride: GLsizei, + pointer: *const c_void, + ); + fn glViewport(x: GLint, y: GLint, width: GLsizei, height: GLsizei); +} + +const VERTEX_SHADER: &[u8] = b" +attribute vec2 a_position; +attribute vec2 a_uv; +attribute vec4 a_color; +uniform vec2 u_viewport; +varying mediump vec2 v_uv; +varying lowp vec4 v_color; +void main() { + vec2 ndc = vec2( + a_position.x * 2.0 / u_viewport.x - 1.0, + 1.0 - a_position.y * 2.0 / u_viewport.y + ); + gl_Position = vec4(ndc, 0.0, 1.0); + v_uv = a_uv; + v_color = a_color; +} +\0"; + +const FRAGMENT_SHADER: &[u8] = b" +precision mediump float; +uniform sampler2D u_texture; +varying mediump vec2 v_uv; +varying lowp vec4 v_color; +void main() { + gl_FragColor = texture2D(u_texture, v_uv) * v_color; +} +\0"; + +const ATTR_POSITION: &[u8] = b"a_position\0"; +const ATTR_UV: &[u8] = b"a_uv\0"; +const ATTR_COLOR: &[u8] = b"a_color\0"; +const UNIFORM_VIEWPORT: &[u8] = b"u_viewport\0"; +const UNIFORM_TEXTURE: &[u8] = b"u_texture\0"; + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +struct Vertex { + position: [f32; 2], + uv: [f32; 2], + /// DrawList colors are 0xAABBGGRR, whose little-endian bytes are RGBA. + color: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Clip { + x: i32, + y: i32, + w: i32, + h: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Command { + texture: GLuint, + first: i32, + count: i32, + clip: Clip, +} + +#[derive(Clone, Copy)] +struct ImageTexture { + handle: i32, + revision: u64, + name: GLuint, + dirty: bool, +} + +impl ImageTexture { + #[inline] + fn matches(&self, handle: i32, revision: u64) -> bool { + self.handle == handle && self.revision == revision && !self.dirty + } +} + +#[derive(Clone, Copy)] +struct FontTexture { + name: GLuint, + source: usize, + coverage_w: u32, + coverage_h: u32, + logical_w: u32, + logical_h: u32, + texture_w: u32, + texture_h: u32, + columns: u32, + glyph_count: u16, + dirty: bool, +} + +struct Renderer { + program: GLuint, + vertex_buffer: GLuint, + white: GLuint, + viewport_uniform: GLint, + images: Vec>, + fonts: Vec>, + vertices: Vec, + commands: Vec, + max_texture_size: u32, +} + +static mut RENDERER: Option = None; + +#[inline] +fn xy(word: u32) -> (f32, f32) { + ( + (word as u16 as i16) as f32, + ((word >> 16) as u16 as i16) as f32, + ) +} + +#[inline] +fn wh(word: u32) -> (f32, f32) { + ((word & 0xffff) as f32, ((word >> 16) & 0xffff) as f32) +} + +#[inline] +fn next_pow2(mut value: u32) -> u32 { + if value <= 1 { + return 1; + } + value -= 1; + value |= value >> 1; + value |= value >> 2; + value |= value >> 4; + value |= value >> 8; + value |= value >> 16; + value + 1 +} + +/// Drain stale context errors before an operation whose result we inspect. +/// +/// A finite bound avoids hanging forever on a broken/lost context whose +/// implementation keeps reporting an error without clearing it. +unsafe fn clear_errors() { + for _ in 0..32 { + if glGetError() == GL_NO_ERROR { + break; + } + } +} + +unsafe fn compile_shader(kind: GLenum, source: &[u8]) -> Option { + let shader = glCreateShader(kind); + if shader == 0 { + return None; + } + let pointer = source.as_ptr() as *const c_char; + glShaderSource(shader, 1, &pointer, ptr::null()); + glCompileShader(shader); + let mut ok = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &mut ok); + if ok == 0 { + glDeleteShader(shader); + None + } else { + Some(shader) + } +} + +unsafe fn create_program() -> Option { + let vertex = compile_shader(GL_VERTEX_SHADER, VERTEX_SHADER)?; + let fragment = match compile_shader(GL_FRAGMENT_SHADER, FRAGMENT_SHADER) { + Some(shader) => shader, + None => { + glDeleteShader(vertex); + return None; + } + }; + let program = glCreateProgram(); + if program == 0 { + glDeleteShader(vertex); + glDeleteShader(fragment); + return None; + } + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glBindAttribLocation(program, 0, ATTR_POSITION.as_ptr() as *const c_char); + glBindAttribLocation(program, 1, ATTR_UV.as_ptr() as *const c_char); + glBindAttribLocation(program, 2, ATTR_COLOR.as_ptr() as *const c_char); + glLinkProgram(program); + glDeleteShader(vertex); + glDeleteShader(fragment); + let mut ok = 0; + glGetProgramiv(program, GL_LINK_STATUS, &mut ok); + if ok == 0 { + glDeleteProgram(program); + None + } else { + Some(program) + } +} + +unsafe fn upload_texture( + pixels: &[u8], + width: u32, + height: u32, + format: GLenum, + linear: bool, +) -> Option { + if width == 0 || height == 0 || pixels.is_empty() { + return None; + } + clear_errors(); + let mut name = 0; + glGenTextures(1, &mut name); + if name == 0 { + return None; + } + glBindTexture(GL_TEXTURE_2D, name); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + let filter = if linear { GL_LINEAR } else { GL_NEAREST }; + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D( + GL_TEXTURE_2D, + 0, + format as GLint, + width as GLsizei, + height as GLsizei, + 0, + format, + GL_UNSIGNED_BYTE, + pixels.as_ptr() as *const c_void, + ); + if glGetError() == GL_NO_ERROR { + Some(name) + } else { + glDeleteTextures(1, &name); + clear_errors(); + None + } +} + +fn texture_rgba(view: TexView<'_>) -> Option> { + let count = (view.w as usize).checked_mul(view.h as usize)?; + let mut rgba = vec![0u8; count.checked_mul(4)?]; + match view.psm { + spec::psm::PSM_5650 => { + if view.pixels.len() < count * 2 { + return None; + } + for (index, bytes) in view.pixels[..count * 2].chunks_exact(2).enumerate() { + let pixel = u16::from_le_bytes([bytes[0], bytes[1]]) as u32; + let red = pixel & 0x1f; + let green = (pixel >> 5) & 0x3f; + let blue = (pixel >> 11) & 0x1f; + rgba[index * 4] = ((red << 3) | (red >> 2)) as u8; + rgba[index * 4 + 1] = ((green << 2) | (green >> 4)) as u8; + rgba[index * 4 + 2] = ((blue << 3) | (blue >> 2)) as u8; + rgba[index * 4 + 3] = 255; + } + } + spec::psm::PSM_8888 => { + if view.pixels.len() < rgba.len() { + return None; + } + rgba.copy_from_slice(&view.pixels[..count * 4]); + } + spec::psm::PSM_4444 => { + if view.pixels.len() < count * 2 { + return None; + } + for (index, bytes) in view.pixels[..count * 2].chunks_exact(2).enumerate() { + let pixel = u16::from_le_bytes([bytes[0], bytes[1]]) as u32; + rgba[index * 4] = ((pixel & 0x0f) * 17) as u8; + rgba[index * 4 + 1] = (((pixel >> 4) & 0x0f) * 17) as u8; + rgba[index * 4 + 2] = (((pixel >> 8) & 0x0f) * 17) as u8; + rgba[index * 4 + 3] = (((pixel >> 12) & 0x0f) * 17) as u8; + } + } + spec::psm::PSM_T8 => { + let palette = view.palette?; + if palette.len() < 1024 || view.pixels.len() < count { + return None; + } + for (index, &palette_index) in view.pixels[..count].iter().enumerate() { + let source = palette_index as usize * 4; + rgba[index * 4..index * 4 + 4].copy_from_slice(&palette[source..source + 4]); + } + } + _ => return None, + } + Some(rgba) +} + +fn font_luminance_alpha(coverage: &[u8]) -> Vec { + let mut pixels = Vec::with_capacity(coverage.len().saturating_mul(2)); + for &alpha in coverage { + pixels.extend_from_slice(&[255, alpha]); + } + pixels +} + +impl Renderer { + unsafe fn new() -> Option { + clear_errors(); + let program = create_program()?; + glUseProgram(program); + let viewport_uniform = + glGetUniformLocation(program, UNIFORM_VIEWPORT.as_ptr() as *const c_char); + let texture_uniform = + glGetUniformLocation(program, UNIFORM_TEXTURE.as_ptr() as *const c_char); + if viewport_uniform < 0 || texture_uniform < 0 { + glDeleteProgram(program); + return None; + } + glUniform1i(texture_uniform, 0); + + let mut vertex_buffer = 0; + glGenBuffers(1, &mut vertex_buffer); + if vertex_buffer == 0 { + glDeleteProgram(program); + return None; + } + let mut max_texture_size = 0; + glGetIntegerv(GL_MAX_TEXTURE_SIZE, &mut max_texture_size); + if max_texture_size <= 0 || glGetError() != GL_NO_ERROR { + glDeleteBuffers(1, &vertex_buffer); + glDeleteProgram(program); + return None; + } + glActiveTexture(GL_TEXTURE0); + let white = match upload_texture(&[255, 255, 255, 255], 1, 1, GL_RGBA, false) { + Some(texture) => texture, + None => { + glDeleteBuffers(1, &vertex_buffer); + glDeleteProgram(program); + return None; + } + }; + Some(Self { + program, + vertex_buffer, + white, + viewport_uniform, + images: Vec::new(), + fonts: Vec::new(), + vertices: Vec::new(), + commands: Vec::new(), + max_texture_size: max_texture_size as u32, + }) + } + + unsafe fn destroy(&mut self) { + self.reset_resources(); + glDeleteTextures(1, &self.white); + glDeleteBuffers(1, &self.vertex_buffer); + glDeleteProgram(self.program); + self.white = 0; + self.vertex_buffer = 0; + self.program = 0; + } + + unsafe fn reset_resources(&mut self) { + for image in &mut self.images { + if let Some(texture) = image.take() { + glDeleteTextures(1, &texture.name); + } + } + for font in &mut self.fonts { + if let Some(texture) = font.take() { + glDeleteTextures(1, &texture.name); + } + } + self.images.clear(); + self.fonts.clear(); + self.vertices.clear(); + self.commands.clear(); + } + + /// Mark caches stale without touching GL. Lifecycle and asset-loading C + /// calls may run while QGLWidget's context is not current; deletion and + /// replacement are deferred to the next `render`. + fn invalidate_resources(&mut self) { + for texture in self.images.iter_mut().flatten() { + texture.dirty = true; + } + for texture in self.fonts.iter_mut().flatten() { + texture.dirty = true; + } + } + + fn invalidate_font(&mut self, slot: u8) { + if let Some(Some(texture)) = self.fonts.get_mut(slot as usize) { + texture.dirty = true; + } + } + + unsafe fn upload_image(&self, view: TexView<'_>) -> Option { + if view.w > self.max_texture_size || view.h > self.max_texture_size { + return None; + } + let rgba = texture_rgba(view)?; + upload_texture(&rgba, view.w, view.h, GL_RGBA, view.linear) + } + + fn font_grid(&self, atlas: &Atlas) -> Option<(u32, u32, u32)> { + let coverage_w = atlas.coverage_width(); + let coverage_h = atlas.coverage_height(); + if coverage_w == 0 + || coverage_h == 0 + || coverage_w > self.max_texture_size + || coverage_h > self.max_texture_size + { + return None; + } + let max_columns = self.max_texture_size / coverage_w; + let mut columns = 1; + while columns < max_columns && columns.saturating_mul(columns) < atlas.glyph_count as u32 { + columns += 1; + } + let dimensions = |columns: u32| -> Option<(u32, u32)> { + let rows = (atlas.glyph_count as u32).div_ceil(columns); + Some(( + next_pow2(columns.checked_mul(coverage_w)?), + next_pow2(rows.checked_mul(coverage_h)?), + )) + }; + let (mut width, mut height) = dimensions(columns)?; + if width > self.max_texture_size || height > self.max_texture_size { + columns = max_columns; + (width, height) = dimensions(columns)?; + } + if width > self.max_texture_size || height > self.max_texture_size { + None + } else { + Some((columns, width, height)) + } + } + + unsafe fn upload_font(&self, atlas: &Atlas) -> Option { + let (columns, texture_w, texture_h) = self.font_grid(atlas)?; + let coverage_w = atlas.coverage_width(); + let coverage_h = atlas.coverage_height(); + let mut alpha = vec![0u8; (texture_w as usize).checked_mul(texture_h as usize)?]; + for glyph in 0..atlas.glyph_count { + let source = atlas.glyph_rows(glyph); + let x = (glyph as u32 % columns) * coverage_w; + let y = (glyph as u32 / columns) * coverage_h; + for row in 0..coverage_h as usize { + let source_start = row * coverage_w as usize; + let target_start = (y as usize + row) * texture_w as usize + x as usize; + alpha[target_start..target_start + coverage_w as usize] + .copy_from_slice(&source[source_start..source_start + coverage_w as usize]); + } + } + // GLES2 alpha-only textures sample as (0, 0, 0, A), which would + // multiply every DrawList text color to black in the shared shader. + // LUMINANCE_ALPHA preserves white RGB while coverage still scales A. + let pixels = font_luminance_alpha(&alpha); + let name = upload_texture( + &pixels, + texture_w, + texture_h, + GL_LUMINANCE_ALPHA, + true, + )?; + Some(FontTexture { + name, + source: atlas.bitmap.as_ptr() as usize, + coverage_w, + coverage_h, + logical_w: atlas.cell_w, + logical_h: atlas.cell_h, + texture_w, + texture_h, + columns, + glyph_count: atlas.glyph_count, + dirty: false, + }) + } + + unsafe fn sync_resources(&mut self, ui: &Ui) -> bool { + let mut ok = true; + let slots = ui.texture_slot_count(); + if self.images.len() < slots { + self.images.resize_with(slots, || None); + } + for slot in 0..self.images.len() { + match ui.texture_at_versioned(slot as u32) { + Some((handle, revision, view)) => { + if self.images[slot] + .as_ref() + .is_some_and(|texture| texture.matches(handle, revision)) + { + continue; + } + if let Some(old) = self.images[slot].take() { + glDeleteTextures(1, &old.name); + } + self.images[slot] = match self.upload_image(view) { + Some(name) => Some(ImageTexture { + handle, + revision, + name, + dirty: false, + }), + None => { + ok = false; + None + } + }; + } + None => { + if let Some(old) = self.images[slot].take() { + glDeleteTextures(1, &old.name); + } + } + } + } + + if self.fonts.len() < spec::MAX_FONT_SLOTS { + self.fonts.resize_with(spec::MAX_FONT_SLOTS, || None); + } + for slot in 0..spec::MAX_FONT_SLOTS { + match ui.font_atlas(slot as u8) { + Some(atlas) => { + let unchanged = self.fonts[slot].as_ref().is_some_and(|font| { + !font.dirty + && font.source == atlas.bitmap.as_ptr() as usize + && font.glyph_count == atlas.glyph_count + }); + if unchanged { + continue; + } + if let Some(old) = self.fonts[slot].take() { + glDeleteTextures(1, &old.name); + } + self.fonts[slot] = match self.upload_font(atlas) { + Some(texture) => Some(texture), + None => { + ok = false; + None + } + }; + } + None => { + if let Some(old) = self.fonts[slot].take() { + glDeleteTextures(1, &old.name); + } + } + } + } + ok + } + + #[inline] + fn image_name(&self, handle: i32) -> Option { + if handle < 0 { + return None; + } + let slot = handle as u32 & spec::TEX_SLOT_MASK; + self.images + .get(slot as usize) + .and_then(|entry| *entry) + .filter(|entry| entry.handle == handle) + .map(|entry| entry.name) + } + + fn quad( + &mut self, + top_left: [f32; 2], + bottom_right: [f32; 2], + uv0: [f32; 2], + uv1: [f32; 2], + colors: [u32; 4], + ) { + let top_left_vertex = Vertex { + position: top_left, + uv: uv0, + color: colors[0], + }; + let top_right_vertex = Vertex { + position: [bottom_right[0], top_left[1]], + uv: [uv1[0], uv0[1]], + color: colors[1], + }; + let bottom_right_vertex = Vertex { + position: bottom_right, + uv: uv1, + color: colors[2], + }; + let bottom_left_vertex = Vertex { + position: [top_left[0], bottom_right[1]], + uv: [uv0[0], uv1[1]], + color: colors[3], + }; + self.vertices.extend_from_slice(&[ + top_left_vertex, + top_right_vertex, + bottom_right_vertex, + top_left_vertex, + bottom_right_vertex, + bottom_left_vertex, + ]); + } + + fn flush(&mut self, texture: GLuint, clip: Clip, start: &mut usize) { + let end = self.vertices.len(); + if end > *start { + self.commands.push(Command { + texture, + first: *start as i32, + count: (end - *start) as i32, + clip, + }); + *start = end; + } + } + + fn build(&mut self, words: &[u32], logical_width: u32, logical_height: u32) { + self.vertices.clear(); + self.commands.clear(); + let full = Clip { + x: 0, + y: 0, + w: logical_width as i32, + h: logical_height as i32, + }; + let mut clip = full; + let mut clip_stack = Vec::::new(); + let mut texture = self.white; + let mut start = 0usize; + let mut index = 0usize; + + while index < words.len() { + match words[index] { + spec::draw_op::RECT if index + 4 <= words.len() => { + if texture != self.white { + self.flush(texture, clip, &mut start); + texture = self.white; + } + let (x, y) = xy(words[index + 1]); + let (width, height) = wh(words[index + 2]); + let color = words[index + 3]; + if width > 0.0 && height > 0.0 && color >> 24 != 0 { + self.quad( + [x, y], + [x + width, y + height], + [0.0, 0.0], + [1.0, 1.0], + [color; 4], + ); + } + index += 4; + } + spec::draw_op::GRAD_RECT if index + 6 <= words.len() => { + if texture != self.white { + self.flush(texture, clip, &mut start); + texture = self.white; + } + let (x, y) = xy(words[index + 1]); + let (width, height) = wh(words[index + 2]); + let from = words[index + 3]; + let to = words[index + 4]; + let direction = words[index + 5]; + let colors = if direction == spec::GradDir::ToTop as u32 { + [to, to, from, from] + } else if direction == spec::GradDir::ToLeft as u32 { + [to, from, from, to] + } else if direction == spec::GradDir::ToRight as u32 { + [from, to, to, from] + } else { + [from, from, to, to] + }; + if width > 0.0 && height > 0.0 { + self.quad( + [x, y], + [x + width, y + height], + [0.0, 0.0], + [1.0, 1.0], + colors, + ); + } + index += 6; + } + spec::draw_op::GLYPH_RUN if index + 3 <= words.len() => { + let slot = (words[index + 1] & 0xff) as usize; + let count = (words[index + 1] >> 16) as usize; + let next = index + 3 + count * 2; + if next > words.len() { + break; + } + let Some(font) = self.fonts.get(slot).and_then(|font| *font) else { + index = next; + continue; + }; + if texture != font.name { + self.flush(texture, clip, &mut start); + texture = font.name; + } + let color = words[index + 2]; + for glyph in 0..count { + let body = index + 3 + glyph * 2; + let (x, y) = xy(words[body]); + let glyph_id = (words[body + 1] & 0xffff) as u16; + if glyph_id >= font.glyph_count { + continue; + } + let column = glyph_id as u32 % font.columns; + let row = glyph_id as u32 / font.columns; + let u0 = column as f32 * font.coverage_w as f32 / font.texture_w as f32; + let v0 = row as f32 * font.coverage_h as f32 / font.texture_h as f32; + let u1 = (column * font.coverage_w + font.coverage_w) as f32 + / font.texture_w as f32; + let v1 = (row * font.coverage_h + font.coverage_h) as f32 + / font.texture_h as f32; + self.quad( + [x, y], + [x + font.logical_w as f32, y + font.logical_h as f32], + [u0, v0], + [u1, v1], + [color; 4], + ); + } + index = next; + } + spec::draw_op::TEX_QUAD if index + 9 <= words.len() => { + let handle = words[index + 1] as i32; + let Some(name) = self.image_name(handle) else { + index += 9; + continue; + }; + if texture != name { + self.flush(texture, clip, &mut start); + texture = name; + } + let (x, y) = xy(words[index + 2]); + let (width, height) = wh(words[index + 3]); + if width > 0.0 && height > 0.0 { + self.quad( + [x, y], + [x + width, y + height], + [ + f32::from_bits(words[index + 4]), + f32::from_bits(words[index + 5]), + ], + [ + f32::from_bits(words[index + 6]), + f32::from_bits(words[index + 7]), + ], + [words[index + 8]; 4], + ); + } + index += 9; + } + spec::draw_op::TEX_TRI if index + 12 <= words.len() => { + let handle = words[index + 1] as i32; + let Some(name) = self.image_name(handle) else { + index += 12; + continue; + }; + if texture != name { + self.flush(texture, clip, &mut start); + texture = name; + } + let color = words[index + 11]; + for vertex in 0..3 { + let offset = index + 2 + vertex * 3; + let (x, y) = xy(words[offset]); + self.vertices.push(Vertex { + position: [x, y], + uv: [ + f32::from_bits(words[offset + 1]), + f32::from_bits(words[offset + 2]), + ], + color, + }); + } + index += 12; + } + spec::draw_op::TRI if index + 7 <= words.len() => { + if texture != self.white { + self.flush(texture, clip, &mut start); + texture = self.white; + } + for vertex in 0..3 { + let (x, y) = xy(words[index + 1 + vertex]); + self.vertices.push(Vertex { + position: [x, y], + uv: [0.0, 0.0], + color: words[index + 4 + vertex], + }); + } + index += 7; + } + spec::draw_op::SCISSOR if index + 3 <= words.len() => { + self.flush(texture, clip, &mut start); + clip_stack.push(clip); + let (x, y) = xy(words[index + 1]); + let (width, height) = wh(words[index + 2]); + clip = Clip { + x: x as i32, + y: y as i32, + w: width as i32, + h: height as i32, + }; + index += 3; + } + spec::draw_op::SCISSOR_POP => { + self.flush(texture, clip, &mut start); + clip = clip_stack.pop().unwrap_or(full); + index += 1; + } + _ => break, + } + } + self.flush(texture, clip, &mut start); + } + + fn physical_clip( + clip: Clip, + logical_width: i32, + logical_height: i32, + target_x: i32, + target_y: i32, + target_width: i32, + target_height: i32, + window_height: i32, + ) -> Clip { + let x0 = clip.x.clamp(0, logical_width); + let y0 = clip.y.clamp(0, logical_height); + let x1 = (clip.x + clip.w).clamp(0, logical_width); + let y1 = (clip.y + clip.h).clamp(0, logical_height); + let scale_floor = |value: i32, target: i32, logical: i32| -> i32 { + (value as i64 * target as i64 / logical as i64) as i32 + }; + let scale_ceil = |value: i32, target: i32, logical: i32| -> i32 { + ((value as i64 * target as i64 + logical as i64 - 1) / logical as i64) as i32 + }; + let left = target_x + scale_floor(x0, target_width, logical_width); + let right = target_x + scale_ceil(x1, target_width, logical_width); + let top = target_y + scale_floor(y0, target_height, logical_height); + let bottom = target_y + scale_ceil(y1, target_height, logical_height); + Clip { + x: left, + y: window_height - bottom, + w: (right - left).max(0), + h: (bottom - top).max(0), + } + } + + unsafe fn render( + &mut self, + ui: &mut Ui, + target_x: i32, + target_y: i32, + target_width: i32, + target_height: i32, + window_width: i32, + window_height: i32, + ) -> bool { + clear_errors(); + if window_width <= 0 || window_height <= 0 { + return true; + } + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, window_width, window_height); + glClearColor(0.0, 0.0, 0.0, 1.0); + glClear(GL_COLOR_BUFFER_BIT); + if target_width <= 0 || target_height <= 0 { + return true; + } + + let draw_list: *const pocketjs_core::DrawList = ui.draw(); + let ui_ref: &Ui = &*(ui as *const Ui); + let words = &(*draw_list).words; + let (logical_width, logical_height) = ui_ref.viewport(); + let logical_width = logical_width.max(1.0) as u32; + let logical_height = logical_height.max(1.0) as u32; + if !self.sync_resources(ui_ref) { + return false; + } + self.build(words, logical_width, logical_height); + + glUseProgram(self.program); + glUniform2f( + self.viewport_uniform, + logical_width as f32, + logical_height as f32, + ); + glActiveTexture(GL_TEXTURE0); + glViewport( + target_x, + window_height - target_y - target_height, + target_width, + target_height, + ); + glDisable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + glEnable(GL_BLEND); + glBlendFuncSeparate( + GL_SRC_ALPHA, + GL_ONE_MINUS_SRC_ALPHA, + GL_ONE, + GL_ONE_MINUS_SRC_ALPHA, + ); + glEnable(GL_SCISSOR_TEST); + + if !self.vertices.is_empty() { + glBindBuffer(GL_ARRAY_BUFFER, self.vertex_buffer); + glBufferData( + GL_ARRAY_BUFFER, + (self.vertices.len() * size_of::()) as isize, + self.vertices.as_ptr() as *const c_void, + GL_DYNAMIC_DRAW, + ); + let stride = size_of::() as i32; + glEnableVertexAttribArray(0); + glEnableVertexAttribArray(1); + glEnableVertexAttribArray(2); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, stride, ptr::null()); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, stride, 8usize as *const c_void); + glVertexAttribPointer(2, 4, GL_UNSIGNED_BYTE, 1, stride, 16usize as *const c_void); + } + + let mut bound = 0; + for command in &self.commands { + if command.texture != bound { + glBindTexture(GL_TEXTURE_2D, command.texture); + bound = command.texture; + } + let physical = Self::physical_clip( + command.clip, + logical_width as i32, + logical_height as i32, + target_x, + target_y, + target_width, + target_height, + window_height, + ); + if physical.w <= 0 || physical.h <= 0 { + continue; + } + glScissor(physical.x, physical.y, physical.w, physical.h); + glDrawArrays(GL_TRIANGLES, command.first, command.count); + } + glDisable(GL_SCISSOR_TEST); + glDisableVertexAttribArray(0); + glDisableVertexAttribArray(1); + glDisableVertexAttribArray(2); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindTexture(GL_TEXTURE_2D, 0); + glGetError() == GL_NO_ERROR + } +} + +pub unsafe fn initialize() -> bool { + if let Some(renderer) = RENDERER.as_mut() { + renderer.destroy(); + } + RENDERER = Renderer::new(); + RENDERER.is_some() +} + +pub unsafe fn reset_resources() { + if let Some(renderer) = RENDERER.as_mut() { + renderer.reset_resources(); + } +} + +pub unsafe fn invalidate_resources() { + if let Some(renderer) = RENDERER.as_mut() { + renderer.invalidate_resources(); + } +} + +pub unsafe fn invalidate_font(slot: u8) { + if let Some(renderer) = RENDERER.as_mut() { + renderer.invalidate_font(slot); + } +} + +pub unsafe fn shutdown() { + if let Some(mut renderer) = RENDERER.take() { + renderer.destroy(); + } +} + +pub unsafe fn render( + ui: &mut Ui, + target_x: i32, + target_y: i32, + target_width: i32, + target_height: i32, + window_width: i32, + window_height: i32, +) -> bool { + RENDERER.as_mut().is_some_and(|renderer| { + renderer.render( + ui, + target_x, + target_y, + target_width, + target_height, + window_width, + window_height, + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pack_xy(x: i16, y: i16) -> u32 { + x as u16 as u32 | ((y as u16 as u32) << 16) + } + + fn pack_wh(width: u16, height: u16) -> u32 { + width as u32 | ((height as u32) << 16) + } + + fn view<'a>( + pixels: &'a [u8], + width: u32, + height: u32, + psm: u32, + palette: Option<&'a [u8]>, + ) -> TexView<'a> { + TexView { + pixels, + w: width, + h: height, + psm, + palette, + linear: false, + } + } + + fn planner(handle: i32, texture_name: GLuint) -> Renderer { + let slot = (handle as u32 & spec::TEX_SLOT_MASK) as usize; + let mut images = vec![None; slot + 1]; + images[slot] = Some(ImageTexture { + handle, + revision: 0, + name: texture_name, + dirty: false, + }); + Renderer { + program: 0, + vertex_buffer: 0, + white: 1, + viewport_uniform: 0, + images, + fonts: Vec::new(), + vertices: Vec::new(), + commands: Vec::new(), + max_texture_size: 2048, + } + } + + #[test] + fn psm_fixtures_expand_to_rgba_and_reject_short_input() { + let psm5650 = [ + 0x1f, 0x00, // red + 0xe0, 0x07, // green + 0x00, 0xf8, // blue + ]; + assert_eq!( + texture_rgba(view(&psm5650, 3, 1, spec::psm::PSM_5650, None)), + Some(vec![ + 255, 0, 0, 255, + 0, 255, 0, 255, + 0, 0, 255, 255, + ]), + ); + + assert_eq!( + texture_rgba(view(&[0x21, 0x43], 1, 1, spec::psm::PSM_4444, None)), + Some(vec![17, 34, 51, 68]), + ); + assert_eq!( + texture_rgba(view(&[1, 2, 3, 4], 1, 1, spec::psm::PSM_8888, None)), + Some(vec![1, 2, 3, 4]), + ); + + let mut palette = vec![0u8; 1024]; + palette[8..12].copy_from_slice(&[9, 8, 7, 6]); + assert_eq!( + texture_rgba(view( + &[2], + 1, + 1, + spec::psm::PSM_T8, + Some(&palette), + )), + Some(vec![9, 8, 7, 6]), + ); + assert_eq!( + texture_rgba(view(&[1, 2, 3], 1, 1, spec::psm::PSM_8888, None)), + None, + ); + } + + #[test] + fn texture_cache_version_includes_revision_and_dirty_state() { + let mut texture = ImageTexture { + handle: 7, + revision: 4, + name: 9, + dirty: false, + }; + assert!(texture.matches(7, 4)); + assert!(!texture.matches(7, 5)); + assert!(!texture.matches(8, 4)); + texture.dirty = true; + assert!(!texture.matches(7, 4)); + } + + #[test] + fn tex_tri_decoder_preserves_vertices_batches_and_nested_scissors() { + let handle = 0; + let texture = 9; + let color = 0x8040_3020; + let tri = [ + spec::draw_op::TEX_TRI, + handle as u32, + pack_xy(-3, 4), + 0.125f32.to_bits(), + 0.25f32.to_bits(), + pack_xy(20, 5), + 0.75f32.to_bits(), + 0.25f32.to_bits(), + pack_xy(7, 30), + 0.5f32.to_bits(), + 0.875f32.to_bits(), + color, + ]; + let mut words = vec![ + spec::draw_op::SCISSOR, + pack_xy(10, 20), + pack_wh(100, 80), + ]; + words.extend_from_slice(&tri); + words.extend_from_slice(&[ + spec::draw_op::SCISSOR, + pack_xy(20, 30), + pack_wh(50, 40), + ]); + words.extend_from_slice(&tri); + words.extend_from_slice(&[ + spec::draw_op::SCISSOR_POP, + spec::draw_op::SCISSOR_POP, + 0xffff_ffff, // Unknown ops stop safely; following words are ignored. + spec::draw_op::RECT, + pack_xy(0, 0), + pack_wh(10, 10), + 0xffff_ffff, + ]); + + let mut renderer = planner(handle, texture); + renderer.build(&words, 200, 120); + + assert_eq!(renderer.vertices.len(), 6); + assert_eq!( + renderer.vertices[0], + Vertex { + position: [-3.0, 4.0], + uv: [0.125, 0.25], + color, + }, + ); + assert_eq!( + renderer.commands, + vec![ + Command { + texture, + first: 0, + count: 3, + clip: Clip { + x: 10, + y: 20, + w: 100, + h: 80, + }, + }, + Command { + texture, + first: 3, + count: 3, + clip: Clip { + x: 20, + y: 30, + w: 50, + h: 40, + }, + }, + ], + ); + } + + #[test] + fn luminance_alpha_font_pixels_preserve_text_rgb_and_coverage() { + assert_eq!( + font_luminance_alpha(&[0, 64, 255, 0]), + vec![255, 0, 255, 64, 255, 255, 255, 0], + ); + + let color = 0xffcc_bbaa; + let mut renderer = planner(0, 9); + renderer.fonts.push(Some(FontTexture { + name: 7, + source: 0, + coverage_w: 8, + coverage_h: 8, + logical_w: 8, + logical_h: 8, + texture_w: 8, + texture_h: 8, + columns: 1, + glyph_count: 1, + dirty: false, + })); + renderer.build( + &[ + spec::draw_op::GLYPH_RUN, + 1 << 16, + color, + pack_xy(4, 5), + 0, + ], + 100, + 50, + ); + + assert_eq!(renderer.vertices.len(), 6); + assert!(renderer.vertices.iter().all(|vertex| vertex.color == color)); + assert_eq!(renderer.commands.len(), 1); + assert_eq!(renderer.commands[0].texture, 7); + } + + #[test] + fn truncated_draw_op_stops_without_partial_geometry() { + let mut renderer = planner(0, 9); + renderer.build( + &[spec::draw_op::TEX_TRI, 0, pack_xy(1, 2)], + 100, + 50, + ); + assert!(renderer.vertices.is_empty()); + assert!(renderer.commands.is_empty()); + } + + #[test] + fn logical_scissor_maps_to_bottom_left_physical_coordinates() { + assert_eq!( + Renderer::physical_clip( + Clip { + x: 25, + y: 10, + w: 50, + h: 20, + }, + 100, + 50, + 10, + 20, + 200, + 100, + 200, + ), + Clip { + x: 60, + y: 120, + w: 100, + h: 40, + }, + ); + } +} diff --git a/engine/symbian/src/lib.rs b/engine/symbian/src/lib.rs index aa076ede..5ea190b5 100644 --- a/engine/symbian/src/lib.rs +++ b/engine/symbian/src/lib.rs @@ -1,15 +1,15 @@ -//! Symbian C ABI for PocketJS's retained UI core and deterministic software -//! rasterizer. +//! Symbian C ABI for PocketJS's retained UI core, GLES2 DrawList backend, and +//! deterministic capture rasterizer. //! //! The Qt host owns QuickJS and calls this library synchronously from its UI //! thread. There is exactly one `Ui` instance. Strings and blobs are borrowed //! as `(ptr, len)` for the duration of a call and copied by the core whenever //! they must outlive it. //! -//! Rendering returns tightly packed, top-left-origin ARGB32 pixels. On the -//! little-endian ARM target that is B,G,R,A byte order, exactly what Qt 4's -//! `QImage::Format_ARGB32` expects. The pointer remains valid until the next -//! render, viewport change, init, or shutdown call. +//! QGLWidget owns the graphics context and calls the GLES2 entry points only +//! while it is current. The software capture entry points return tightly +//! packed, top-left-origin ARGB32 pixels; those pointers remain valid until +//! the next capture, viewport change, init, or shutdown call. #![cfg_attr(target_os = "none", no_std)] #![cfg_attr(target_os = "none", feature(alloc_error_handler))] @@ -27,6 +27,9 @@ use pocketjs_core::damage::{DamagePolicy, DamageTracker, DEFAULT_DAMAGE_REGIONS} use pocketjs_core::raster; use pocketjs_core::Ui; +#[cfg(any(target_os = "none", test))] +mod gles2; + const C_MALLOC_ALIGNMENT: usize = 8; #[inline] @@ -130,6 +133,12 @@ fn clear_framebuffer() { /// Reset the single UI instance. `raster_density == 0` selects density 1. #[no_mangle] pub extern "C" fn ui_init(raster_density: u32) { + #[cfg(target_os = "none")] + unsafe { + // This call may happen without a current GL context, so the backend + // only marks its caches stale and defers replacement until render. + gles2::invalidate_resources(); + } unsafe { UI = Some(Ui::new_with_raster_density(raster_density.max(1))); } @@ -139,6 +148,10 @@ pub extern "C" fn ui_init(raster_density: u32) { /// Drop all retained UI, texture, font, and framebuffer allocations. #[no_mangle] pub extern "C" fn ui_shutdown() { + #[cfg(target_os = "none")] + unsafe { + gles2::invalidate_resources(); + } unsafe { UI = None; } @@ -338,7 +351,19 @@ pub extern "C" fn ui_load_styles(ptr: *const u8, len: usize) -> i32 { #[no_mangle] pub extern "C" fn ui_load_font_atlas(ptr: *const u8, len: usize) -> i32 { - ui().load_font_atlas(unsafe { bytes(ptr, len) }) as i32 + let blob = unsafe { bytes(ptr, len) }; + let loaded = ui().load_font_atlas(blob); + #[cfg(target_os = "none")] + if loaded { + if let Some(&slot) = blob.get(12) { + unsafe { + // Loading happens in a host callback, not necessarily with + // QGLWidget's context current. Defer GL deletion/re-upload. + gles2::invalidate_font(slot); + } + } + } + loaded as i32 } #[no_mangle] @@ -353,6 +378,67 @@ pub extern "C" fn ui_tick() { ui().tick(); } +#[no_mangle] +pub extern "C" fn ui_gl_initialize() -> i32 { + #[cfg(target_os = "none")] + unsafe { + return gles2::initialize() as i32; + } + #[cfg(not(target_os = "none"))] + 0 +} + +#[no_mangle] +pub extern "C" fn ui_gl_reset_resources() { + #[cfg(target_os = "none")] + unsafe { + gles2::reset_resources(); + } +} + +#[no_mangle] +pub extern "C" fn ui_gl_shutdown() { + #[cfg(target_os = "none")] + unsafe { + gles2::shutdown(); + } +} + +#[no_mangle] +pub extern "C" fn ui_gl_render( + target_x: i32, + target_y: i32, + target_width: i32, + target_height: i32, + window_width: i32, + window_height: i32, +) -> i32 { + #[cfg(target_os = "none")] + unsafe { + return gles2::render( + ui(), + target_x, + target_y, + target_width, + target_height, + window_width, + window_height, + ) as i32; + } + #[cfg(not(target_os = "none"))] + { + let _ = ( + target_x, + target_y, + target_width, + target_height, + window_width, + window_height, + ); + 0 + } +} + #[no_mangle] pub extern "C" fn ui_draw_hash() -> u64 { draw_hash(&ui().draw().words) diff --git a/framework/compiler/jsx-plugin.ts b/framework/compiler/jsx-plugin.ts index 5e623b8f..3b8b0164 100644 --- a/framework/compiler/jsx-plugin.ts +++ b/framework/compiler/jsx-plugin.ts @@ -38,17 +38,43 @@ const ANIMATION_PATH = new URL("../src/animation.ts", import.meta.url).pathname; const COMPONENTS_PATH = new URL("../src/components.ts", import.meta.url).pathname; const COMPONENTS_VUE_VAPOR_PATH = new URL("../src/components-vue-vapor.ts", import.meta.url).pathname; const CONFIG_PATH = new URL("../src/config.ts", import.meta.url).pathname; +const CLOCK_PATH = new URL("../src/clock.ts", import.meta.url).pathname; +const DEVTOOLS_PATH = new URL("../src/devtools.ts", import.meta.url).pathname; +const EFFECTS_PATH = new URL("../src/effects.ts", import.meta.url).pathname; +const HOST_PATH = new URL("../src/host.ts", import.meta.url).pathname; +const HOT_PATH = new URL("../src/hot.ts", import.meta.url).pathname; const INPUT_API_PATH = new URL("../src/input-api.ts", import.meta.url).pathname; const LAUNCHER_PATH = new URL("../src/launcher.ts", import.meta.url).pathname; const LIFECYCLE_PATH = new URL("../src/lifecycle.ts", import.meta.url).pathname; const LIFECYCLE_VUE_VAPOR_PATH = new URL("../src/lifecycle-vue-vapor.ts", import.meta.url).pathname; const OSK_PATH = new URL("../src/osk.tsx", import.meta.url).pathname; +const MANIFEST_PATH = new URL("../src/manifest/index.ts", import.meta.url).pathname; +const PACKAGE_PATH = new URL( + "../../contracts/spec/pocket-package.ts", + import.meta.url, +).pathname; const PLATFORM_PATH = new URL("../src/platform.ts", import.meta.url).pathname; const PRELUDE_PATH = new URL("../src/prelude.ts", import.meta.url).pathname; +const GENERATED_STYLES_PATH = new URL( + "../src/styles.generated.ts", + import.meta.url, +).pathname; +const VITA_PACKAGE_PATH = new URL( + "../../tools/vita-package.ts", + import.meta.url, +).pathname; const VUE_VAPOR_RUNTIME_PATH = new URL( "../../node_modules/vue/dist/vue.runtime-with-vapor.esm-browser.prod.js", import.meta.url, ).pathname; +const SOLID_RUNTIME_PATH = new URL( + "../../node_modules/solid-js/dist/solid.js", + import.meta.url, +).pathname; +const SOLID_UNIVERSAL_RUNTIME_PATH = new URL( + "../../node_modules/solid-js/universal/dist/universal.js", + import.meta.url, +).pathname; const PACKAGE_NAME = "@pocketjs/framework"; const CACHE_DIR = new URL("../../.cache/transforms/", import.meta.url).pathname; @@ -335,9 +361,21 @@ export function packagePath(spec: string, framework: PocketFramework): string | return FRAMEWORKS.solid.subpaths[subpath] ?? null; } if (spec === `${PACKAGE_NAME}/vue-vapor` || spec.startsWith(`${PACKAGE_NAME}/vue-vapor/`)) { - return FRAMEWORKS["vue-vapor"].subpaths[subpath] ?? null; + return FRAMEWORKS["vue-vapor"].subpaths[subpath] ?? { + clock: CLOCK_PATH, + effects: EFFECTS_PATH, + }[subpath] ?? null; } - return FRAMEWORKS[framework].subpaths[subpath] ?? null; + return FRAMEWORKS[framework].subpaths[subpath] ?? { + clock: CLOCK_PATH, + devtools: DEVTOOLS_PATH, + effects: EFFECTS_PATH, + host: HOST_PATH, + hot: HOT_PATH, + manifest: MANIFEST_PATH, + package: PACKAGE_PATH, + "vita-package": VITA_PACKAGE_PATH, + }[subpath] ?? null; } export function frameworkVariantPath(path: string, framework: PocketFramework): string { @@ -470,11 +508,25 @@ export async function transformFile( export function jsxPlugin( framework: PocketFramework, - opts: { entry?: string; features?: BuildFeatures } = {}, + opts: { + entry?: string; + features?: BuildFeatures; + generatedStyles?: string; + } = {}, ): BunPlugin { return { name: `pocketjs-${framework}-jsx`, setup(build) { + // External applications may have their own node_modules. Resolve both + // sides of the renderer boundary to PocketJS's browser-mode Solid copy, + // otherwise identical packages at different paths form two reactive + // ownership domains and lifecycle hooks silently stop crossing it. + build.onResolve({ filter: /^solid-js$/ }, () => ({ + path: SOLID_RUNTIME_PATH, + })); + build.onResolve({ filter: /^solid-js\/universal$/ }, () => ({ + path: SOLID_UNIVERSAL_RUNTIME_PATH, + })); build.onResolve({ filter: /^@pocketjs\/framework(?:\/.*)?$/ }, (args) => { const path = packagePath(args.path, framework); return path ? { path } : undefined; @@ -503,7 +555,10 @@ export function jsxPlugin( } build.onLoad({ filter: /\.tsx?$/ }, async (args) => { if (args.path.includes("/node_modules/") || args.path.endsWith(".d.ts")) return undefined; - let src = await Bun.file(args.path).text(); + let src = args.path === GENERATED_STYLES_PATH && + opts.generatedStyles !== undefined + ? opts.generatedStyles + : await Bun.file(args.path).text(); if (framework === "vue-vapor" && args.path === opts.entry) { src = `import "@pocketjs/framework/prelude";\n${src}`; } diff --git a/framework/src/deepzoom.ts b/framework/src/deepzoom.ts index 32c195c0..0a3b8528 100644 --- a/framework/src/deepzoom.ts +++ b/framework/src/deepzoom.ts @@ -28,7 +28,7 @@ import { onCleanup, type JSX as SolidJSX } from "solid-js"; import { BTN, ENUMS, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; import { ticksPerFrame } from "./clock.ts"; -import { getOps } from "./host.ts"; +import { getOps, hostViewport } from "./host.ts"; import { analogX, analogY, onFrame } from "./frame.ts"; import * as hot from "./hot.ts"; import { platform } from "./platform.ts"; @@ -96,7 +96,10 @@ export interface DeepZoomGesture { export interface DeepZoomProps { doc: TileDoc; - /** Viewport size (defaults to the PSP screen). */ + /** + * Fixed viewport size. When both are omitted, DeepZoom follows the live + * logical viewport published by the host and falls back to the PSP screen. + */ width?: number; height?: number; /** Textured-tile loads per frame (decode+upload budget; default 2). */ @@ -141,8 +144,10 @@ interface MountedTile { } export function DeepZoom(props: DeepZoomProps): SolidJSX.Element { - const vw = props.width ?? SCREEN_W; - const vh = props.height ?? SCREEN_H; + const fixedViewport = props.width !== undefined || props.height !== undefined; + const initialViewport = fixedViewport ? null : hostViewport(getOps()); + let vw = props.width ?? initialViewport?.w ?? SCREEN_W; + let vh = props.height ?? initialViewport?.h ?? SCREEN_H; const budget = props.loadBudget ?? 2; const prefetch = props.prefetch ?? 1; const bind = props.bindInput ?? true; @@ -267,17 +272,49 @@ export function DeepZoom(props: DeepZoomProps): SolidJSX.Element { } }; + const updateZoomBounds = (): void => { + minZoom = Math.min(vw / doc.w, vh / doc.h); + // Preserve the same physical sampling ceiling on every target. A + // density-specific pyramid advertises proportionally larger level scales + // and therefore keeps the same logical zoom range automatically. + maxZoom = Math.max(minZoom, (doc.levels[0].scale * 2) / platform.pixelRatio); + }; + + const atFitZoom = (): boolean => + Math.abs(zoom - minZoom) <= + Number.EPSILON * 8 * Math.max(1, Math.abs(zoom), Math.abs(minZoom)); + + const syncLiveViewport = (): void => { + if (fixedViewport) return; + const viewport = hostViewport(getOps()); + if ( + !viewport || + !Number.isFinite(viewport.w) || + !Number.isFinite(viewport.h) || + viewport.w <= 0 || + viewport.h <= 0 || + (viewport.w === vw && viewport.h === vh) + ) { + return; + } + + const followFit = atFitZoom(); + vw = viewport.w; + vh = viewport.h; + setProp(container, "style", { width: vw, height: vh }); + updateZoomBounds(); + zoom = followFit + ? minZoom + : Math.min(maxZoom, Math.max(minZoom, zoom)); + }; + // ---- doc (re)initialization ----------------------------------------------- const initDoc = (d: TileDoc): void => { clearActive(); for (const m of overviewMounted.splice(0)) unmountTile(overviewWorld, m); doc = d; setProp(container, "style", { bgColor: doc.bg }); - minZoom = Math.min(vw / doc.w, vh / doc.h); - // Preserve the same physical sampling ceiling on every target. A - // density-specific pyramid advertises proportionally larger level scales - // and therefore keeps the same logical zoom range automatically. - maxZoom = Math.max(minZoom, (doc.levels[0].scale * 2) / platform.pixelRatio); + updateZoomBounds(); zoom = minZoom; cx = doc.w / 2; cy = doc.h / 2; @@ -362,6 +399,7 @@ export function DeepZoom(props: DeepZoomProps): SolidJSX.Element { }; onFrame((buttons) => { + syncLiveViewport(); if (doc !== props.doc) initDoc(props.doc); // app swapped pages // Virtual-clock scaling: 60/simulationHz ticks elapse per frame. The diff --git a/framework/src/touch.ts b/framework/src/touch.ts index 50c37b8f..0c253efa 100644 --- a/framework/src/touch.ts +++ b/framework/src/touch.ts @@ -11,25 +11,41 @@ export interface TouchContact { readonly y: number; } -const COORD_BITS = 9; -const COORD_MASK = (1 << COORD_BITS) - 1; -const ID_SHIFT = COORD_BITS * 2; +const LEGACY_COORD_BITS = 9; +const LEGACY_COORD_MASK = (1 << LEGACY_COORD_BITS) - 1; +const LEGACY_ID_SHIFT = LEGACY_COORD_BITS * 2; +const WIDE_MARKER = 0x80000000; +const WIDE_COORD_BITS = 10; +const WIDE_COORD_MASK = (1 << WIDE_COORD_BITS) - 1; +const WIDE_ID_SHIFT = WIDE_COORD_BITS * 2; const EMPTY: readonly TouchContact[] = Object.freeze([]); let snapshot: readonly TouchContact[] = EMPTY; -/** Internal host-frame hook. Each u32 packs x:9, y:9, id:8. */ +/** + * Internal host-frame hook. + * + * Existing hosts pack x:9, y:9, id:8 with bit 31 clear. Native viewports + * wider than 512 use the append-only wide form: bit31=1, x:10, y:10, id:8. + * Per-contact detection keeps every PSP/Vita tape and host byte-compatible. + */ export function __setTouches(packed: readonly number[] | undefined): void { if (!packed || packed.length === 0) { snapshot = EMPTY; return; } snapshot = Object.freeze( - packed.slice(0, 8).map((value) => Object.freeze({ - id: (value >>> ID_SHIFT) & 0xff, - x: value & COORD_MASK, - y: (value >>> COORD_BITS) & COORD_MASK, - })), + packed.slice(0, 8).map((value) => { + const wide = (value & WIDE_MARKER) !== 0; + const coordBits = wide ? WIDE_COORD_BITS : LEGACY_COORD_BITS; + const coordMask = wide ? WIDE_COORD_MASK : LEGACY_COORD_MASK; + const idShift = wide ? WIDE_ID_SHIFT : LEGACY_ID_SHIFT; + return Object.freeze({ + id: (value >>> idShift) & 0xff, + x: value & coordMask, + y: (value >>> coordBits) & coordMask, + }); + }), ); } @@ -45,8 +61,18 @@ export function __resetTouches(): void { /** Test/capture helper matching the native frame wire format. */ export function __packTouch(id: number, x: number, y: number): number { return ( - ((id & 0xff) << ID_SHIFT) | - ((y & COORD_MASK) << COORD_BITS) | - (x & COORD_MASK) + ((id & 0xff) << LEGACY_ID_SHIFT) | + ((y & LEGACY_COORD_MASK) << LEGACY_COORD_BITS) | + (x & LEGACY_COORD_MASK) + ) >>> 0; +} + +/** Test/native helper for logical viewports up to 1024 pixels per axis. */ +export function __packTouchWide(id: number, x: number, y: number): number { + return ( + WIDE_MARKER | + ((id & 0xff) << WIDE_ID_SHIFT) | + ((y & WIDE_COORD_MASK) << WIDE_COORD_BITS) | + (x & WIDE_COORD_MASK) ) >>> 0; } diff --git a/hosts/symbian/runtime/catalog.bin b/hosts/symbian/runtime/catalog.bin new file mode 100644 index 00000000..6827d9fd --- /dev/null +++ b/hosts/symbian/runtime/catalog.bin @@ -0,0 +1 @@ +standalone diff --git a/hosts/symbian/runtime/catalog.tsv b/hosts/symbian/runtime/catalog.tsv new file mode 100644 index 00000000..eb96cb76 --- /dev/null +++ b/hosts/symbian/runtime/catalog.tsv @@ -0,0 +1 @@ +# Empty in standalone builds. The launcher builder replaces this catalog. diff --git a/hosts/symbian/runtime/main.cpp b/hosts/symbian/runtime/main.cpp index 9e48f957..d87ce407 100644 --- a/hosts/symbian/runtime/main.cpp +++ b/hosts/symbian/runtime/main.cpp @@ -8,18 +8,23 @@ #include #include #include -#include -#include #include #include #include #include +#include #include #include +#ifdef POCKETJS_PERF_TRACE +#include +#endif #include #include +#include +#include #include +#include extern "C" { #include "quickjs.h" @@ -48,18 +53,51 @@ extern "C" { namespace { const int kMaximumViewportExtent = 640; -const int kTouchCoordinateExtent = 512; const int kAnalogCenter = 0x8080; const int kMaximumTouches = 8; const int kCoreTicksPerFrame = 60 / POCKETJS_FRAME_RATE; +const int kPocketPackageHeaderSize = 16; +const int kPocketPackageVariantSize = 40; +const int kPocketPackageSectionSize = 16; +const int kPocketPackageTargetBytes = 16; +const int kPocketPackageAlignment = 16; +const int kPocketSectionIdentity = 1; +const int kPocketSectionJavaScript = 3; +const int kPocketSectionPack = 4; +const uint32_t kPocketPackageMagic = 0x544b4350U; +const uint32_t kPocketPackageVersion = 1; +const int kPakHeaderSize = 32; +const int kPakEntrySize = 24; +const uint32_t kPakMagic = 0x4b504344U; +const uint16_t kPakVersion = 1; +const int kShotWidth = 256; +const int kShotHeight = 128; +const uint32_t kPixelStorage8888 = 3; + +QGLFormat pocketJsGlFormat() +{ + QGLFormat format; + format.setRgba(true); + format.setDoubleBuffer(true); + format.setDepth(false); + format.setStencil(false); + format.setAccum(false); + format.setSampleBuffers(false); + return format; +} +const int kButtonSelect = 0x0001; const int kButtonStart = 0x0008; const int kButtonUp = 0x0010; const int kButtonRight = 0x0020; const int kButtonDown = 0x0040; const int kButtonLeft = 0x0080; +const int kButtonLeftTrigger = 0x0100; +const int kButtonRightTrigger = 0x0200; +const int kButtonTriangle = 0x1000; const int kButtonCircle = 0x2000; const int kButtonCross = 0x4000; +const int kButtonSquare = 0x8000; enum HostOperation { HostCreateNode, @@ -84,6 +122,7 @@ enum HostOperation { HostLoadStyles, HostLoadFontAtlas, HostMeasureText, + HostLoadTileTexture, HostFreeTexture, HostUploadImgEntry, HostDebugInspect, @@ -93,6 +132,401 @@ enum HostOperation { HostDebugStep }; +struct EmbeddedApp +{ + QString output; + QString id; + QString title; + int packageOffset; + int packageLength; + QSize logicalViewport; + bool liveViewport; +}; + +struct GuestPayload +{ + QByteArray javaScript; + QByteArray pack; +}; + +class PocketJsRuntime; + +bool lookupActivePackEntry( + PocketJsRuntime *runtime, + const char *key, + size_t keyLength, + const uint8_t **data, + size_t *length +); + +JSValue hostAppTable( + JSContext *context, + JSValueConst thisValue, + int argc, + JSValueConst *argv +); +JSValue hostAppLaunch( + JSContext *context, + JSValueConst thisValue, + int argc, + JSValueConst *argv +); +JSValue hostAppShot( + JSContext *context, + JSValueConst thisValue, + int argc, + JSValueConst *argv +); + +int alignPocketOffset(int value) +{ + if (value < 0 || value > 0x7fffffff - (kPocketPackageAlignment - 1)) { + return -1; + } + return (value + kPocketPackageAlignment - 1) & + ~(kPocketPackageAlignment - 1); +} + +bool readU16( + const QByteArray &bytes, + int offset, + uint16_t *value +) +{ + if (offset < 0 || offset > bytes.size() - 2) return false; + const unsigned char *data = + reinterpret_cast(bytes.constData() + offset); + *value = static_cast( + static_cast(data[0]) | + (static_cast(data[1]) << 8) + ); + return true; +} + +bool readU32( + const QByteArray &bytes, + int offset, + uint32_t *value +) +{ + if (offset < 0 || offset > bytes.size() - 4) return false; + const unsigned char *data = + reinterpret_cast(bytes.constData() + offset); + *value = + static_cast(data[0]) | + (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | + (static_cast(data[3]) << 24); + return true; +} + +bool readU64( + const QByteArray &bytes, + int offset, + quint64 *value +) +{ + uint32_t low = 0; + uint32_t high = 0; + if (!readU32(bytes, offset, &low) || + !readU32(bytes, offset + 4, &high)) { + return false; + } + *value = static_cast(low) | + (static_cast(high) << 32); + return true; +} + +bool findPakEntry( + const QByteArray &pack, + const char *key, + size_t keyLength, + const uint8_t **data, + size_t *length +) +{ + *data = 0; + *length = 0; + if (key == 0 || keyLength > static_cast(0x7fffffff) || + pack.size() < kPakHeaderSize) { + return false; + } + + uint32_t magic = 0; + uint16_t version = 0; + uint32_t count = 0; + uint32_t rawDirectoryOffset = 0; + uint32_t rawNamesOffset = 0; + if (!readU32(pack, 0, &magic) || + !readU16(pack, 4, &version) || + !readU32(pack, 8, &count) || + !readU32(pack, 12, &rawDirectoryOffset) || + !readU32(pack, 16, &rawNamesOffset) || + magic != kPakMagic || + version != kPakVersion || + rawDirectoryOffset > static_cast(pack.size()) || + rawNamesOffset > static_cast(pack.size())) { + return false; + } + + const int directoryOffset = static_cast(rawDirectoryOffset); + const int namesOffset = static_cast(rawNamesOffset); + if (count > static_cast( + (pack.size() - directoryOffset) / kPakEntrySize)) { + return false; + } + + for (uint32_t index = 0; index < count; ++index) { + const int entry = directoryOffset + + static_cast(index) * kPakEntrySize; + uint32_t rawBlobOffset = 0; + uint32_t rawBlobLength = 0; + uint32_t rawNameOffset = 0; + uint16_t nameLength = 0; + if (!readU32(pack, entry + 4, &rawBlobOffset) || + !readU32(pack, entry + 8, &rawBlobLength) || + !readU32(pack, entry + 12, &rawNameOffset) || + !readU16(pack, entry + 16, &nameLength) || + rawNameOffset > static_cast(pack.size() - namesOffset) || + nameLength > static_cast( + pack.size() - namesOffset - static_cast(rawNameOffset)) || + rawBlobOffset > static_cast(pack.size()) || + rawBlobLength > static_cast( + pack.size() - static_cast(rawBlobOffset))) { + return false; + } + + const int nameOffset = namesOffset + static_cast(rawNameOffset); + if (keyLength != static_cast(nameLength) || + memcmp(pack.constData() + nameOffset, key, keyLength) != 0) { + continue; + } + *data = reinterpret_cast( + pack.constData() + static_cast(rawBlobOffset) + ); + *length = static_cast(rawBlobLength); + return true; + } + return false; +} + +quint64 pocketHash(const QByteArray &bytes, int length) +{ + quint64 hash = Q_UINT64_C(0xcbf29ce484222325); + const quint64 prime = Q_UINT64_C(0x100000001b3); + const unsigned char *data = + reinterpret_cast(bytes.constData()); + for (int index = 0; index < length; ++index) { + hash ^= static_cast(data[index]); + hash *= prime; + } + return hash; +} + +bool sectionBounds( + const QByteArray &package, + int sectionEntry, + uint32_t *kind, + int *offset, + int *length +) +{ + uint32_t rawOffset = 0; + uint32_t rawLength = 0; + if (!readU32(package, sectionEntry, kind) || + !readU32(package, sectionEntry + 8, &rawOffset) || + !readU32(package, sectionEntry + 12, &rawLength) || + rawOffset > static_cast(package.size()) || + rawLength > static_cast(package.size()) - rawOffset || + rawOffset + rawLength > static_cast(package.size() - 8)) { + return false; + } + *offset = static_cast(rawOffset); + *length = static_cast(rawLength); + return true; +} + +bool decodeIdentity( + const QByteArray &bytes, + QString *output, + QString *id, + QString *title +) +{ + QString *fields[3] = { output, id, title }; + int offset = 0; + for (int field = 0; field < 3; ++field) { + uint16_t length = 0; + if (!readU16(bytes, offset, &length)) return false; + offset += 2; + if (offset < 0 || offset > bytes.size() - static_cast(length)) { + return false; + } + *fields[field] = QString::fromUtf8( + bytes.constData() + offset, + static_cast(length) + ); + offset += static_cast(length); + } + return offset == bytes.size(); +} + +bool parsePocketPackage( + const QByteArray &package, + const EmbeddedApp &app, + GuestPayload *payload, + QString *error +) +{ + if (package.size() < kPocketPackageHeaderSize + 8) { + *error = "embedded .pocket is truncated"; + return false; + } + + uint32_t magic = 0; + uint32_t version = 0; + uint32_t manifestLength = 0; + uint32_t variantCount = 0; + if (!readU32(package, 0, &magic) || + !readU32(package, 4, &version) || + !readU32(package, 8, &manifestLength) || + !readU32(package, 12, &variantCount) || + magic != kPocketPackageMagic || + version != kPocketPackageVersion) { + *error = "embedded .pocket has an unsupported header"; + return false; + } + + quint64 storedHash = 0; + if (!readU64(package, package.size() - 8, &storedHash) || + pocketHash(package, package.size() - 8) != storedHash) { + *error = "embedded .pocket footer hash does not match"; + return false; + } + + if (manifestLength > static_cast(package.size()) - + kPocketPackageHeaderSize) { + *error = "embedded .pocket manifest is out of bounds"; + return false; + } + const int tableOffset = alignPocketOffset( + kPocketPackageHeaderSize + static_cast(manifestLength) + ); + const int packageBodyEnd = package.size() - 8; + if (tableOffset < 0 || + tableOffset > packageBodyEnd || + variantCount > static_cast( + (packageBodyEnd - tableOffset) / kPocketPackageVariantSize)) { + *error = "embedded .pocket variant table is out of bounds"; + return false; + } + + int identityOffset = -1; + int identityLength = 0; + int javaScriptOffset = -1; + int javaScriptLength = 0; + int packOffset = -1; + int packLength = 0; + bool foundVariant = false; + + for (uint32_t variant = 0; variant < variantCount; ++variant) { + const int entry = tableOffset + + static_cast(variant) * kPocketPackageVariantSize; + int targetLength = 0; + while (targetLength < kPocketPackageTargetBytes && + package.at(entry + targetLength) != '\0') { + ++targetLength; + } + const QByteArray target = package.mid(entry, targetLength); + + uint32_t hostAbi = 0; + uint32_t sectionCount = 0; + uint32_t rawSectionsOffset = 0; + if (!readU32(package, entry + 16, &hostAbi) || + !readU32(package, entry + 20, §ionCount) || + !readU32(package, entry + 24, &rawSectionsOffset)) { + *error = "embedded .pocket variant is truncated"; + return false; + } + if (target != "symbian-e7-dev") continue; + if (hostAbi != static_cast(POCKETJS_HOST_ABI)) { + *error = "embedded .pocket host ABI does not match this runtime"; + return false; + } + if (rawSectionsOffset > static_cast(packageBodyEnd) || + sectionCount > static_cast( + (packageBodyEnd - static_cast(rawSectionsOffset)) / + kPocketPackageSectionSize)) { + *error = "embedded .pocket section table is out of bounds"; + return false; + } + + foundVariant = true; + for (uint32_t section = 0; section < sectionCount; ++section) { + const int sectionEntry = static_cast(rawSectionsOffset) + + static_cast(section) * kPocketPackageSectionSize; + uint32_t kind = 0; + int offset = 0; + int length = 0; + if (!sectionBounds( + package, + sectionEntry, + &kind, + &offset, + &length)) { + *error = "embedded .pocket section is out of bounds"; + return false; + } + if (kind == kPocketSectionIdentity) { + identityOffset = offset; + identityLength = length; + } else if (kind == kPocketSectionJavaScript) { + javaScriptOffset = offset; + javaScriptLength = length; + } else if (kind == kPocketSectionPack) { + packOffset = offset; + packLength = length; + } + } + break; + } + + if (!foundVariant) { + *error = "embedded .pocket has no symbian-e7-dev variant"; + return false; + } + if (identityOffset < 0 || javaScriptOffset < 0 || + javaScriptLength < 1 || + package.at(javaScriptOffset + javaScriptLength - 1) != '\0') { + *error = "embedded .pocket is missing identity or NUL-terminated JS"; + return false; + } + + QString output; + QString id; + QString title; + if (!decodeIdentity( + package.mid(identityOffset, identityLength), + &output, + &id, + &title) || + output != app.output || + id != app.id || + title != app.title) { + *error = "embedded .pocket identity does not match its catalog row"; + return false; + } + + payload->javaScript = package.mid( + javaScriptOffset, + javaScriptLength - 1 + ); + payload->pack = packOffset < 0 + ? QByteArray() + : package.mid(packOffset, packLength); + return true; +} + bool intArgument( JSContext *context, int argc, @@ -444,6 +878,38 @@ JSValue hostOperation( JS_FreeCString(context, text); return JS_NewFloat64(context, da); + case HostLoadTileTexture: { + if (!stringArgument( + context, argc, argv, 0, &text, &textLength)) { + return JS_EXCEPTION; + } + if (!intArgument(context, argc, argv, 1, &a)) { + JS_FreeCString(context, text); + return JS_EXCEPTION; + } + int32_t handle = -1; + const uint8_t *entry = 0; + size_t entryLength = 0; + PocketJsRuntime *runtime = static_cast( + JS_GetContextOpaque(context) + ); + if (a >= 0 && + lookupActivePackEntry( + runtime, + text, + textLength, + &entry, + &entryLength)) { + handle = ui_upload_tileset_tile( + entry, + entryLength, + static_cast(a) + ); + } + JS_FreeCString(context, text); + return JS_NewInt32(context, handle); + } + case HostFreeTexture: if (!intArgument(context, argc, argv, 0, &a)) return JS_EXCEPTION; ui_free_texture(a); @@ -500,12 +966,15 @@ void addHostOperation( } bool installHostOps( + PocketJsRuntime *owner, JSContext *context, JSValueConst global, int viewportWidth, - int viewportHeight + int viewportHeight, + bool multiApp ) { + JS_SetContextOpaque(context, owner); JSValue ui = JS_NewObject(context); if (JS_IsException(ui)) return false; @@ -531,6 +1000,7 @@ bool installHostOps( addHostOperation(context, ui, "loadStyles", 1, HostLoadStyles); addHostOperation(context, ui, "loadFontAtlas", 1, HostLoadFontAtlas); addHostOperation(context, ui, "measureText", 2, HostMeasureText); + addHostOperation(context, ui, "loadTileTexture", 2, HostLoadTileTexture); addHostOperation(context, ui, "freeTexture", 1, HostFreeTexture); addHostOperation(context, ui, "uploadImgEntry", 1, HostUploadImgEntry); addHostOperation(context, ui, "debugInspect", 1, HostDebugInspect); @@ -538,6 +1008,26 @@ bool installHostOps( addHostOperation(context, ui, "debugRectWH", 0, HostDebugRectWH); addHostOperation(context, ui, "debugPause", 1, HostDebugPause); addHostOperation(context, ui, "debugStep", 0, HostDebugStep); + if (multiApp) { + JS_SetPropertyStr( + context, + ui, + "appTable", + JS_NewCFunction(context, hostAppTable, "appTable", 0) + ); + JS_SetPropertyStr( + context, + ui, + "appLaunch", + JS_NewCFunction(context, hostAppLaunch, "appLaunch", 1) + ); + JS_SetPropertyStr( + context, + ui, + "appShot", + JS_NewCFunction(context, hostAppShot, "appShot", 0) + ); + } JSValue viewport = JS_NewObject(context); JS_SetPropertyStr( @@ -583,6 +1073,17 @@ int buttonForKey(int key) return kButtonDown; case Qt::Key_Left: return kButtonLeft; + case Qt::Key_Backspace: + case Qt::Key_Home: + return kButtonSelect; + case Qt::Key_Q: + return kButtonLeftTrigger; + case Qt::Key_E: + return kButtonRightTrigger; + case Qt::Key_T: + return kButtonTriangle; + case Qt::Key_S: + return kButtonSquare; case Qt::Key_Select: case Qt::Key_Return: case Qt::Key_Enter: @@ -596,32 +1097,50 @@ int buttonForKey(int key) } } -} // namespace - -class PocketJsRuntime : public QWidget +class PocketJsRuntime : public QGLWidget { public: PocketJsRuntime(); ~PocketJsRuntime(); + QByteArray appTableJson() const; + int frozenShotHandle() const; + bool lookupPackEntry( + const char *key, + size_t keyLength, + const uint8_t **data, + size_t *length + ) const; + bool requestAppLaunch(const QString &output); protected: bool event(QEvent *event); void keyPressEvent(QKeyEvent *event); void keyReleaseEvent(QKeyEvent *event); void focusOutEvent(QFocusEvent *event); - void paintEvent(QPaintEvent *event); + void initializeGL(); + void paintGL(); void resizeEvent(QResizeEvent *event); void timerEvent(QTimerEvent *event); private: bool initialize(const QSize &viewport); + bool initializeCatalog(); + bool bootGuest(int appIndex, const QSize &windowViewport); bool applyPendingViewport(); + void captureFrozenShot(); + void destroyGuest(); + void finishPendingSwitch(); bool loadResource(const QString &path, QByteArray *bytes); + bool loadGuestPayload(int appIndex, GuestPayload *payload); + bool parseCatalog(const QByteArray &index); bool drainJobs(); + bool recoverGuestFailure(int appIndex); bool validViewport(const QSize &viewport) const; + QRect presentationRect() const; QString takeException(JSContext *context); void fail(const QString &message); void queueViewport(const QSize &viewport); + void requestSummon(); void runFrame(); void updateTouches(QTouchEvent *event); @@ -632,21 +1151,57 @@ class PocketJsRuntime : public QWidget JSValue resizeViewport_; QByteArray appJavaScript_; QByteArray appPack_; - QImage framebuffer_; + QByteArray catalogBlob_; + QByteArray frozenShot_; +#ifdef POCKETJS_PERF_TRACE + QByteArray perfTraceBuffer_; +#endif + QVector apps_; QBasicTimer timer_; QLabel *errorLabel_; QVector touches_; QSize viewportSize_; QSize pendingViewportSize_; + QSize windowSize_; int buttons_; + int currentApp_; + int pendingApp_; + int resumeApp_; + int frozenShotHandle_; bool coreInitialized_; bool initialized_; + bool guestLiveViewport_; bool hasPendingViewport_; + bool pendingSummon_; + bool selectLatched_; bool failed_; + bool glInitialized_; }; +bool lookupActivePackEntry( + PocketJsRuntime *runtime, + const char *key, + size_t keyLength, + const uint8_t **data, + size_t *length +) +{ + return runtime != 0 && + runtime->lookupPackEntry(key, keyLength, data, length); +} + +bool PocketJsRuntime::lookupPackEntry( + const char *key, + size_t keyLength, + const uint8_t **data, + size_t *length +) const +{ + return findPakEntry(appPack_, key, keyLength, data, length); +} + PocketJsRuntime::PocketJsRuntime() - : QWidget(0), + : QGLWidget(pocketJsGlFormat()), runtime_(0), context_(0), global_(JS_UNDEFINED), @@ -658,11 +1213,22 @@ PocketJsRuntime::PocketJsRuntime() POCKETJS_INITIAL_LOGICAL_HEIGHT ), buttons_(0), + currentApp_(0), + pendingApp_(-1), + resumeApp_(-1), + frozenShotHandle_(-1), coreInitialized_(false), initialized_(false), + guestLiveViewport_(true), hasPendingViewport_(true), - failed_(false) + pendingSummon_(false), + selectLatched_(true), + failed_(false), + glInitialized_(false) { +#ifdef POCKETJS_PERF_TRACE + QFile::remove("E:/Installs/pocketjs-perf.tsv"); +#endif setAttribute(Qt::WA_OpaquePaintEvent, true); setAttribute(Qt::WA_AcceptTouchEvents, true); setAttribute(Qt::WA_AutoOrientation, true); @@ -691,6 +1257,18 @@ PocketJsRuntime::PocketJsRuntime() PocketJsRuntime::~PocketJsRuntime() { timer_.stop(); + destroyGuest(); + if (glInitialized_ && isValid()) { + makeCurrent(); + ui_gl_shutdown(); + doneCurrent(); + } + glInitialized_ = false; +} + +void PocketJsRuntime::destroyGuest() +{ + initialized_ = false; if (context_ != 0) { JS_FreeValue(context_, resizeViewport_); JS_FreeValue(context_, frame_); @@ -703,12 +1281,22 @@ PocketJsRuntime::~PocketJsRuntime() runtime_ = 0; } if (coreInitialized_) { - // QImage borrows the Rust framebuffer without a cleanup callback. - // Release that view before ui_shutdown() drops the backing storage. - framebuffer_ = QImage(); + if (glInitialized_ && isValid()) { + makeCurrent(); + ui_gl_reset_resources(); + doneCurrent(); + } ui_shutdown(); coreInitialized_ = false; } + global_ = JS_UNDEFINED; + frame_ = JS_UNDEFINED; + resizeViewport_ = JS_UNDEFINED; + appJavaScript_.clear(); + appPack_.clear(); + touches_.clear(); + buttons_ = 0; + frozenShotHandle_ = -1; } bool PocketJsRuntime::loadResource( @@ -733,6 +1321,162 @@ bool PocketJsRuntime::validViewport(const QSize &viewport) const viewport.height() <= kMaximumViewportExtent; } +bool PocketJsRuntime::parseCatalog(const QByteArray &index) +{ + const QList lines = index.split('\n'); + int previousEnd = 0; + for (int lineNumber = 0; lineNumber < lines.size(); ++lineNumber) { + QByteArray line = lines.at(lineNumber); + if (line.endsWith('\r')) line.chop(1); + if (line.isEmpty() || line.startsWith("#")) continue; + + const QList fields = line.split('\t'); + if (fields.size() != 8) { + fail( + QString("PocketJS catalog row %1 must have 8 tab fields") + .arg(lineNumber + 1) + ); + return false; + } + + bool offsetOk = false; + bool lengthOk = false; + bool widthOk = false; + bool heightOk = false; + const int offset = fields.at(3).toInt(&offsetOk); + const int length = fields.at(4).toInt(&lengthOk); + const int width = fields.at(5).toInt(&widthOk); + const int height = fields.at(6).toInt(&heightOk); + const QByteArray viewportMode = fields.at(7); + if (!offsetOk || !lengthOk || !widthOk || !heightOk || + offset < 0 || length <= 0 || + offset % kPocketPackageAlignment != 0 || + offset < previousEnd || + offset > catalogBlob_.size() - length || + width <= 0 || height <= 0 || + width > kMaximumViewportExtent || + height > kMaximumViewportExtent || + (viewportMode != "live" && viewportMode != "fixed")) { + fail( + QString("PocketJS catalog row %1 has invalid bounds or viewport") + .arg(lineNumber + 1) + ); + return false; + } + + EmbeddedApp app; + app.output = QString::fromUtf8( + fields.at(0).constData(), + fields.at(0).size() + ); + app.id = QString::fromUtf8( + fields.at(1).constData(), + fields.at(1).size() + ); + app.title = QString::fromUtf8( + fields.at(2).constData(), + fields.at(2).size() + ); + app.packageOffset = offset; + app.packageLength = length; + app.logicalViewport = QSize(width, height); + app.liveViewport = viewportMode == "live"; + if (app.output.isEmpty() || app.id.isEmpty() || app.title.isEmpty()) { + fail( + QString("PocketJS catalog row %1 has an empty identity field") + .arg(lineNumber + 1) + ); + return false; + } + for (int index = 0; index < apps_.size(); ++index) { + if (apps_.at(index).output == app.output) { + fail( + QString("PocketJS catalog repeats output %1") + .arg(app.output) + ); + return false; + } + } + apps_.append(app); + previousEnd = offset + length; + } + + if (!apps_.isEmpty() && + apps_.first().id != "dev.pocket-stack.launcher") { + fail("PocketJS catalog entry zero is not the launcher"); + return false; + } + return true; +} + +bool PocketJsRuntime::initializeCatalog() +{ + QByteArray index; + if (!loadResource(":/pocketjs/catalog.tsv", &index) || + !loadResource(":/pocketjs/catalog.bin", &catalogBlob_)) { + return false; + } + apps_.clear(); + if (!parseCatalog(index)) return false; + + if (apps_.isEmpty()) { + EmbeddedApp app; + app.output = "app"; + app.id = "dev.pocket-stack.app"; + app.title = "PocketJS App"; + app.packageOffset = -1; + app.packageLength = 0; + app.logicalViewport = QSize( + POCKETJS_INITIAL_LOGICAL_WIDTH, + POCKETJS_INITIAL_LOGICAL_HEIGHT + ); + // This preserves the original standalone runtime: the OS window is + // the logical viewport and every orientation change relayouts it. + app.liveViewport = true; + apps_.append(app); + catalogBlob_.clear(); + } + return true; +} + +bool PocketJsRuntime::loadGuestPayload( + int appIndex, + GuestPayload *payload +) +{ + if (appIndex < 0 || appIndex >= apps_.size()) { + fail("PocketJS tried to boot an unknown catalog entry"); + return false; + } + const EmbeddedApp &app = apps_.at(appIndex); + if (app.packageOffset < 0) { + if (!loadResource(":/pocketjs/app.js", &payload->javaScript) || + !loadResource(":/pocketjs/app.pak", &payload->pack)) { + return false; + } + if (payload->javaScript.isEmpty()) { + fail(":/pocketjs/app.js is empty"); + return false; + } + return true; + } + + QString error; + const QByteArray package = catalogBlob_.mid( + app.packageOffset, + app.packageLength + ); + if (!parsePocketPackage(package, app, payload, &error)) { + fail( + QString("PocketJS cannot boot %1\n\n%2") + .arg(app.output) + .arg(error) + ); + return false; + } + return true; +} + bool PocketJsRuntime::initialize(const QSize &viewport) { if (!validViewport(viewport)) { @@ -743,22 +1487,55 @@ bool PocketJsRuntime::initialize(const QSize &viewport) ); return false; } + windowSize_ = viewport; + if (!initializeCatalog()) return false; + return bootGuest(0, viewport); +} + +bool PocketJsRuntime::bootGuest( + int appIndex, + const QSize &windowViewport +) +{ + GuestPayload payload; + if (!loadGuestPayload(appIndex, &payload)) return false; + + const EmbeddedApp &app = apps_.at(appIndex); + const QSize viewport = app.liveViewport + ? windowViewport + : app.logicalViewport; + if (!validViewport(viewport)) { + fail( + QString("PocketJS received an invalid guest viewport: %1x%2") + .arg(viewport.width()) + .arg(viewport.height()) + ); + return false; + } + currentApp_ = appIndex; + guestLiveViewport_ = app.liveViewport; viewportSize_ = viewport; - pendingViewportSize_ = viewport; + pendingViewportSize_ = windowViewport; + windowSize_ = windowViewport; hasPendingViewport_ = false; + selectLatched_ = true; + appJavaScript_ = payload.javaScript; + appPack_ = payload.pack; ui_init(1); coreInitialized_ = true; ui_set_viewport(viewport.width(), viewport.height()); - - if (!loadResource(":/pocketjs/app.js", &appJavaScript_) || - !loadResource(":/pocketjs/app.pak", &appPack_)) { - return false; - } - if (appJavaScript_.isEmpty()) { - fail(":/pocketjs/app.js is empty"); - return false; + if (!frozenShot_.isEmpty()) { + frozenShotHandle_ = ui_upload_texture( + reinterpret_cast(frozenShot_.constData()), + static_cast(frozenShot_.size()), + kShotWidth, + kShotHeight, + kPixelStorage8888 + ); + } else { + frozenShotHandle_ = -1; } runtime_ = JS_NewRuntime(); @@ -776,10 +1553,12 @@ bool PocketJsRuntime::initialize(const QSize &viewport) global_ = JS_GetGlobalObject(context_); if (!installHostOps( + this, context_, global_, viewport.width(), - viewport.height())) { + viewport.height(), + apps_.size() > 1)) { fail(takeException(context_)); return false; } @@ -889,6 +1668,150 @@ QString PocketJsRuntime::takeException(JSContext *context) return text; } +void appendJsonString(QByteArray *json, const QString &value) +{ + static const char hex[] = "0123456789abcdef"; + const QByteArray utf8 = value.toUtf8(); + json->append('"'); + for (int index = 0; index < utf8.size(); ++index) { + const unsigned char byte = + static_cast(utf8.at(index)); + if (byte == '"') { + json->append("\\\""); + } else if (byte == '\\') { + json->append("\\\\"); + } else if (byte < 0x20) { + json->append("\\u00"); + json->append(hex[(byte >> 4) & 0x0f]); + json->append(hex[byte & 0x0f]); + } else { + json->append(static_cast(byte)); + } + } + json->append('"'); +} + +QByteArray PocketJsRuntime::appTableJson() const +{ + QByteArray json("{\"apps\":["); + for (int index = 0; index < apps_.size(); ++index) { + if (index > 0) json.append(','); + const EmbeddedApp &app = apps_.at(index); + json.append("{\"output\":"); + appendJsonString(&json, app.output); + json.append(",\"id\":"); + appendJsonString(&json, app.id); + json.append(",\"title\":"); + appendJsonString(&json, app.title); + json.append('}'); + } + json.append("],\"current\":"); + appendJsonString(&json, apps_.at(currentApp_).output); + json.append(",\"resume\":"); + if (resumeApp_ >= 0 && resumeApp_ < apps_.size()) { + appendJsonString(&json, apps_.at(resumeApp_).output); + } else { + json.append("null"); + } + json.append('}'); + return json; +} + +int PocketJsRuntime::frozenShotHandle() const +{ + return frozenShotHandle_; +} + +bool PocketJsRuntime::requestAppLaunch(const QString &output) +{ + for (int index = 0; index < apps_.size(); ++index) { + if (apps_.at(index).output == output) { + pendingApp_ = index; + pendingSummon_ = false; + return true; + } + } + return false; +} + +void PocketJsRuntime::requestSummon() +{ + if (apps_.size() <= 1 || currentApp_ == 0) return; + pendingApp_ = 0; + pendingSummon_ = true; +} + +JSValue hostAppTable( + JSContext *context, + JSValueConst, + int, + JSValueConst * +) +{ + PocketJsRuntime *runtime = static_cast( + JS_GetContextOpaque(context) + ); + if (runtime == 0) return JS_ThrowInternalError( + context, + "PocketJS runtime context is missing" + ); + const QByteArray json = runtime->appTableJson(); + return JS_NewStringLen( + context, + json.constData(), + static_cast(json.size()) + ); +} + +JSValue hostAppLaunch( + JSContext *context, + JSValueConst, + int argc, + JSValueConst *argv +) +{ + PocketJsRuntime *runtime = static_cast( + JS_GetContextOpaque(context) + ); + if (runtime == 0) return JS_ThrowInternalError( + context, + "PocketJS runtime context is missing" + ); + const char *output = 0; + size_t outputLength = 0; + if (!stringArgument( + context, + argc, + argv, + 0, + &output, + &outputLength)) { + return JS_EXCEPTION; + } + const bool scheduled = runtime->requestAppLaunch( + QString::fromUtf8(output, static_cast(outputLength)) + ); + JS_FreeCString(context, output); + return JS_NewInt32(context, scheduled ? 1 : 0); +} + +JSValue hostAppShot( + JSContext *context, + JSValueConst, + int, + JSValueConst * +) +{ + PocketJsRuntime *runtime = static_cast( + JS_GetContextOpaque(context) + ); + if (runtime == 0) return JS_ThrowInternalError( + context, + "PocketJS runtime context is missing" + ); + return JS_NewInt32(context, runtime->frozenShotHandle()); +} + void PocketJsRuntime::fail(const QString &message) { if (failed_) return; @@ -900,7 +1823,31 @@ void PocketJsRuntime::fail(const QString &message) errorLabel_->setGeometry(rect()); errorLabel_->show(); errorLabel_->raise(); - repaint(); +} + +bool PocketJsRuntime::recoverGuestFailure(int appIndex) +{ + if (!failed_ || apps_.size() <= 1 || appIndex == 0) return false; + + // Match the console broken-guest rule: a malformed or throwing embedded + // app cannot strand the whole multi-app SIS. Log the diagnostic, retire + // every partially-created guest resource, and cold-boot app 0. A broken + // launcher (or the classic single-app SIS) keeps fail()'s visible stop. + const QByteArray diagnostic = errorLabel_->text().toUtf8(); + qWarning("%s", diagnostic.constData()); + pendingApp_ = -1; + pendingSummon_ = false; + resumeApp_ = -1; + frozenShot_.clear(); + destroyGuest(); + + failed_ = false; + errorLabel_->hide(); + if (!bootGuest(0, size())) return false; + + timer_.start(qMax(1, 1000 / POCKETJS_FRAME_RATE), this); + update(); + return true; } bool PocketJsRuntime::drainJobs() @@ -923,7 +1870,19 @@ void PocketJsRuntime::queueViewport(const QSize &viewport) // to a geometry it cannot render. if (viewport.width() <= 0 || viewport.height() <= 0) return; + if (initialized_ && !guestLiveViewport_) { + if (viewport == windowSize_) return; + windowSize_ = viewport; + pendingViewportSize_ = viewport; + hasPendingViewport_ = false; + buttons_ = 0; + touches_.clear(); + update(); + return; + } + if (initialized_ && viewport == viewportSize_) { + windowSize_ = viewport; pendingViewportSize_ = viewport; hasPendingViewport_ = false; return; @@ -932,6 +1891,7 @@ void PocketJsRuntime::queueViewport(const QSize &viewport) pendingViewportSize_ = viewport; hasPendingViewport_ = true; + windowSize_ = viewport; // Coordinates and held keyboard directions belong to the old screen // orientation. Never deliver them against the replacement layout. @@ -955,9 +1915,6 @@ bool PocketJsRuntime::applyPendingViewport() return false; } - // framebuffer_ borrows the Rust Vec returned by ui_render_incremental(). - // Drop that QImage view before ui_set_viewport() clears/reallocates it. - framebuffer_ = QImage(); ui_set_viewport(viewport.width(), viewport.height()); viewportSize_ = viewport; buttons_ = 0; @@ -986,8 +1943,55 @@ bool PocketJsRuntime::applyPendingViewport() return true; } +QRect PocketJsRuntime::presentationRect() const +{ + if (guestLiveViewport_ || viewportSize_.isEmpty()) return rect(); + + const int availableWidth = qMax(1, width()); + const int availableHeight = qMax(1, height()); + int targetWidth = viewportSize_.width(); + int targetHeight = viewportSize_.height(); + + const double fit = qMin( + static_cast(availableWidth) / targetWidth, + static_cast(availableHeight) / targetHeight + ); + double scale = fit; + if (fit >= 1.0) { + scale = static_cast(fit); + } + targetWidth = qMax(1, static_cast(targetWidth * scale)); + targetHeight = qMax(1, static_cast(targetHeight * scale)); + return QRect( + (availableWidth - targetWidth) / 2, + (availableHeight - targetHeight) / 2, + targetWidth, + targetHeight + ); +} + void PocketJsRuntime::runFrame() { +#ifdef POCKETJS_PERF_TRACE + static QTime frameClock; + int frameDeltaMs = 0; + if (frameClock.isValid()) { + frameDeltaMs = frameClock.restart(); + } else { + frameClock.start(); + } + QTime perfTimer; + perfTimer.start(); +#endif + int frameButtons = buttons_; + if (apps_.size() > 1 && currentApp_ != 0) { + const bool selectPressed = + (frameButtons & kButtonSelect) != 0; + if (selectPressed && !selectLatched_) requestSummon(); + selectLatched_ = selectPressed; + frameButtons &= ~kButtonSelect; + } + JSValue touchArray = JS_NewArray(context_); for (int index = 0; index < touches_.size(); ++index) { JS_SetPropertyUint32( @@ -999,7 +2003,7 @@ void PocketJsRuntime::runFrame() } JSValue arguments[3]; - arguments[0] = JS_NewInt32(context_, buttons_); + arguments[0] = JS_NewInt32(context_, frameButtons); arguments[1] = JS_NewInt32(context_, kAnalogCenter); arguments[2] = touchArray; JSValue result = JS_Call( @@ -1019,55 +2023,152 @@ void PocketJsRuntime::runFrame() } JS_FreeValue(context_, result); if (!drainJobs()) return; +#ifdef POCKETJS_PERF_TRACE + const int jsMs = perfTimer.elapsed(); +#endif for (int tick = 0; tick < kCoreTicksPerFrame; ++tick) { ui_tick(); } - const uint8_t *pixels = ui_render_incremental(); - const uint32_t width = ui_framebuffer_width(); - const uint32_t height = ui_framebuffer_height(); - const uint32_t stride = ui_framebuffer_stride(); - const size_t length = ui_framebuffer_len(); - if (pixels == 0 || - width != static_cast(viewportSize_.width()) || - height != static_cast(viewportSize_.height()) || - stride < width * 4 || - length < static_cast(stride) * height) { - fail( - QString("PocketJS core returned an invalid %1x%2 framebuffer") - .arg(viewportSize_.width()) - .arg(viewportSize_.height()) +#ifdef POCKETJS_PERF_TRACE + const int tickEndMs = perfTimer.elapsed(); +#endif + // QGLWidget::updateGL() is synchronous: paintGL() submits this frame and + // auto-swaps before a pending guest switch can release its DrawList and + // texture storage. + updateGL(); +#ifdef POCKETJS_PERF_TRACE + const int presentEndMs = perfTimer.elapsed(); + static int perfFrame = 0; + const bool sampleFrame = perfFrame < 20 || (perfFrame % 30) == 0; + if (sampleFrame) { + const QByteArray output = + currentApp_ >= 0 && currentApp_ < apps_.size() + ? apps_.at(currentApp_).output.toUtf8() + : QByteArray("unknown"); + perfTraceBuffer_.append( + QByteArray::number(perfFrame) + "\t" + + output + "\t" + + QByteArray::number(frameDeltaMs) + "\t" + + QByteArray::number(jsMs) + "\t" + + QByteArray::number(tickEndMs - jsMs) + "\t" + + QByteArray::number(presentEndMs - tickEndMs) + "\t" + + QByteArray::number(presentEndMs) + "\n" ); + } + // Flush once per second. Per-frame file opens on the E7 perturb the very + // cadence this diagnostic is intended to measure. + if ((perfFrame % 30) == 0 && !perfTraceBuffer_.isEmpty()) { + QFile trace("E:/Installs/pocketjs-perf.tsv"); + if (trace.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { + if (trace.write(perfTraceBuffer_) == perfTraceBuffer_.size()) { + perfTraceBuffer_.clear(); + } + } + } + ++perfFrame; +#endif + finishPendingSwitch(); +} + +void PocketJsRuntime::captureFrozenShot() +{ + frozenShot_.clear(); + if (!glInitialized_ || !isValid()) return; + const QRect sourceRect = presentationRect().intersected(rect()); + if (sourceRect.isEmpty()) return; + + QByteArray pixels; + pixels.resize(sourceRect.width() * sourceRect.height() * 4); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels( + sourceRect.x(), + height() - sourceRect.y() - sourceRect.height(), + sourceRect.width(), + sourceRect.height(), + GL_RGBA, + GL_UNSIGNED_BYTE, + pixels.data() + ); + if (glGetError() != GL_NO_ERROR) { + frozenShot_.clear(); return; } - framebuffer_ = QImage( - reinterpret_cast(pixels), - static_cast(width), - static_cast(height), - static_cast(stride), - QImage::Format_ARGB32 + // GLES readback is RGBA and bottom-left-origin. Convert once on the + // summon path; steady frames never allocate or read back the framebuffer. + QImage frame(sourceRect.size(), QImage::Format_ARGB32); + for (int y = 0; y < sourceRect.height(); ++y) { + QRgb *target = reinterpret_cast(frame.scanLine(y)); + const unsigned char *source = + reinterpret_cast(pixels.constData()) + + (sourceRect.height() - 1 - y) * sourceRect.width() * 4; + for (int x = 0; x < sourceRect.width(); ++x) { + target[x] = qRgba( + source[x * 4], + source[x * 4 + 1], + source[x * 4 + 2], + 0xff + ); + } + } + const QImage shot = frame.scaled( + kShotWidth, + kShotHeight, + Qt::IgnoreAspectRatio, + Qt::SmoothTransformation ); - repaint(); + frozenShot_.resize(kShotWidth * kShotHeight * 4); + unsigned char *target = reinterpret_cast( + frozenShot_.data() + ); + for (int y = 0; y < kShotHeight; ++y) { + for (int x = 0; x < kShotWidth; ++x) { + const QRgb pixel = shot.pixel(x, y); + const int offset = (y * kShotWidth + x) * 4; + target[offset] = static_cast(qRed(pixel)); + target[offset + 1] = static_cast(qGreen(pixel)); + target[offset + 2] = static_cast(qBlue(pixel)); + target[offset + 3] = 0xff; + } + } +} + +void PocketJsRuntime::finishPendingSwitch() +{ + if (pendingApp_ < 0) return; + + const int nextApp = pendingApp_; + const bool summon = pendingSummon_; + pendingApp_ = -1; + pendingSummon_ = false; + if (summon) { + resumeApp_ = currentApp_; + } else { + frozenShot_.clear(); + resumeApp_ = -1; + } + + // updateGL() above is synchronous: the outgoing frame is already visible. + // Nothing from the next guest is evaluated until the entire QuickJS realm + // and native Ui have been released. + destroyGuest(); + if (!bootGuest(nextApp, size())) { + recoverGuestFailure(nextApp); + } } void PocketJsRuntime::updateTouches(QTouchEvent *touchEvent) { touches_.clear(); - // The current public frame wire has 9-bit x/y fields. A 640px E7 - // viewport cannot be represented without truncation, so do not publish - // touch contacts until the framework negotiates the wider wire. The - // target profile intentionally does not advertise input.touch. if (!initialized_ || - hasPendingViewport_ || - size() != viewportSize_ || - viewportSize_.width() > kTouchCoordinateExtent || - viewportSize_.height() > kTouchCoordinateExtent) { + hasPendingViewport_) { return; } - const QRect target = rect(); + const QRect target = presentationRect(); + if (target.isEmpty()) return; const QList points = touchEvent->touchPoints(); for (int index = 0; @@ -1083,14 +2184,27 @@ void PocketJsRuntime::updateTouches(QTouchEvent *touchEvent) position.y() >= target.top() + target.height()) { continue; } - int x = static_cast(position.x() - target.left()); - int y = static_cast(position.y() - target.top()); + int x = static_cast( + (position.x() - target.left()) * + viewportSize_.width() / + target.width() + ); + int y = static_cast( + (position.y() - target.top()) * + viewportSize_.height() / + target.height() + ); x = qBound(0, x, viewportSize_.width() - 1); y = qBound(0, y, viewportSize_.height() - 1); + // Compatibility extension wire. Legacy contacts keep bit 31 clear + // with 9-bit x/y. E7 contacts set it and carry 10-bit x/y so the + // native 640px viewport never truncates coordinates: + // bit31=1, x[0..9], y[10..19], id[20..27]. const uint32_t packed = - ((static_cast(point.id()) & 0xff) << 18) | - ((static_cast(y) & 0x1ff) << 9) | - (static_cast(x) & 0x1ff); + 0x80000000U | + ((static_cast(point.id()) & 0xff) << 20) | + ((static_cast(y) & 0x3ff) << 10) | + (static_cast(x) & 0x3ff); touches_.append(packed); } } @@ -1104,7 +2218,7 @@ bool PocketJsRuntime::event(QEvent *event) event->accept(); return true; } - return QWidget::event(event); + return QGLWidget::event(event); } void PocketJsRuntime::keyPressEvent(QKeyEvent *event) @@ -1119,7 +2233,7 @@ void PocketJsRuntime::keyPressEvent(QKeyEvent *event) event->accept(); return; } - QWidget::keyPressEvent(event); + QGLWidget::keyPressEvent(event); } void PocketJsRuntime::keyReleaseEvent(QKeyEvent *event) @@ -1134,23 +2248,83 @@ void PocketJsRuntime::keyReleaseEvent(QKeyEvent *event) event->accept(); return; } - QWidget::keyReleaseEvent(event); + QGLWidget::keyReleaseEvent(event); } void PocketJsRuntime::focusOutEvent(QFocusEvent *event) { buttons_ = 0; touches_.clear(); - QWidget::focusOutEvent(event); + QGLWidget::focusOutEvent(event); +} + +void PocketJsRuntime::initializeGL() +{ + const char *version = reinterpret_cast(glGetString(GL_VERSION)); + const char *vendor = reinterpret_cast(glGetString(GL_VENDOR)); + const char *renderer = reinterpret_cast(glGetString(GL_RENDERER)); + GLint maximumTextureSize = 0; + glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximumTextureSize); + qWarning( + "PocketJS GLES2: version=%s vendor=%s renderer=%s maxTexture=%d", + version == 0 ? "unknown" : version, + vendor == 0 ? "unknown" : vendor, + renderer == 0 ? "unknown" : renderer, + maximumTextureSize + ); +#ifdef POCKETJS_PERF_TRACE + QFile trace("E:/Installs/pocketjs-perf.tsv"); + if (trace.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { + trace.write("# gles_version\t"); + trace.write(version == 0 ? "unknown" : version); + trace.write("\n# gles_vendor\t"); + trace.write(vendor == 0 ? "unknown" : vendor); + trace.write("\n# gles_renderer\t"); + trace.write(renderer == 0 ? "unknown" : renderer); + trace.write("\n# max_texture_size\t"); + trace.write(QByteArray::number(maximumTextureSize)); + trace.write( + "\nframe\tapp\tframe_delta_ms\tjs_ms\ttick_ms" + "\tupdate_gl_ms\ttotal_ms\n" + ); + } +#endif + if (maximumTextureSize < 512) { + fail("PocketJS requires an OpenGL ES 2 texture size of at least 512."); + return; + } + glInitialized_ = ui_gl_initialize() != 0; + if (!glInitialized_) { + fail("PocketJS could not initialize the OpenGL ES 2 renderer."); + } } -void PocketJsRuntime::paintEvent(QPaintEvent *) +void PocketJsRuntime::paintGL() { - QPainter painter(this); - painter.fillRect(rect(), Qt::black); - if (!framebuffer_.isNull() && !failed_) { - painter.setRenderHint(QPainter::SmoothPixmapTransform, false); - painter.drawImage(0, 0, framebuffer_); + if (!glInitialized_ || !coreInitialized_ || failed_) { + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, width(), height()); + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + return; + } + const QRect target = presentationRect(); + if (ui_gl_render( + target.x(), + target.y(), + target.width(), + target.height(), + width(), + height() + ) == 0) { + fail("PocketJS OpenGL ES 2 rendering failed."); + return; + } + if (pendingApp_ >= 0 && pendingSummon_) { + // QGLWidget auto-swaps only after paintGL returns, so this captures + // the outgoing guest's just-rendered back buffer, never an undefined + // post-swap buffer. + captureFrozenShot(); } } @@ -1158,7 +2332,7 @@ void PocketJsRuntime::resizeEvent(QResizeEvent *event) { queueViewport(event->size()); errorLabel_->setGeometry(rect()); - QWidget::resizeEvent(event); + QGLWidget::resizeEvent(event); } void PocketJsRuntime::timerEvent(QTimerEvent *event) @@ -1174,16 +2348,29 @@ void PocketJsRuntime::timerEvent(QTimerEvent *event) if (!isVisible() || !isFullScreen() || !validViewport(size())) { return; } + if (!isValid()) { + fail("PocketJS could not obtain a valid OpenGL ES 2 context."); + return; + } if (!initialize(size())) return; + } else if (!isValid()) { + fail("PocketJS lost its OpenGL ES 2 context."); + return; } - if (!applyPendingViewport()) return; + if (!applyPendingViewport()) { + recoverGuestFailure(currentApp_); + return; + } runFrame(); + if (failed_) recoverGuestFailure(currentApp_); return; } - QWidget::timerEvent(event); + QGLWidget::timerEvent(event); } +} // namespace + int main(int argc, char *argv[]) { QApplication application(argc, argv); diff --git a/hosts/symbian/runtime/pocketjs-e7-runtime.pro b/hosts/symbian/runtime/pocketjs-e7-runtime.pro index 11d1b4e0..2c58452e 100644 --- a/hosts/symbian/runtime/pocketjs-e7-runtime.pro +++ b/hosts/symbian/runtime/pocketjs-e7-runtime.pro @@ -1,6 +1,9 @@ TEMPLATE = app -TARGET = PocketJsE7Runtime -QT += core gui +isEmpty(POCKETJS_SYMBIAN_TARGET): error(POCKETJS_SYMBIAN_TARGET is required) +isEmpty(POCKETJS_SYMBIAN_CAPTION): error(POCKETJS_SYMBIAN_CAPTION is required) +TARGET = $$POCKETJS_SYMBIAN_TARGET +DEPLOYMENT.display_name = $$POCKETJS_SYMBIAN_CAPTION +QT += core gui opengl CONFIG += release CONFIG -= debug app_bundle diff --git a/hosts/symbian/runtime/pocketjs-runtime.qrc b/hosts/symbian/runtime/pocketjs-runtime.qrc index b798df79..a3aaa5dc 100644 --- a/hosts/symbian/runtime/pocketjs-runtime.qrc +++ b/hosts/symbian/runtime/pocketjs-runtime.qrc @@ -2,5 +2,7 @@ app.js app.pak + catalog.tsv + catalog.bin diff --git a/hosts/symbian/runtime/pocketjs_symbian_core.h b/hosts/symbian/runtime/pocketjs_symbian_core.h index 38d00470..0f6d4322 100644 --- a/hosts/symbian/runtime/pocketjs_symbian_core.h +++ b/hosts/symbian/runtime/pocketjs_symbian_core.h @@ -32,6 +32,11 @@ int32_t ui_upload_texture( uint32_t psm ); int32_t ui_upload_img_entry(const uint8_t *data, size_t len); +int32_t ui_upload_tileset_tile( + const uint8_t *data, + size_t len, + uint32_t index +); void ui_free_texture(int32_t handle); void ui_set_image(int32_t id, int32_t texture); void ui_set_sprite( @@ -74,6 +79,17 @@ void ui_debug_pause(int32_t on); void ui_debug_step(void); void ui_tick(void); +int32_t ui_gl_initialize(void); +void ui_gl_reset_resources(void); +void ui_gl_shutdown(void); +int32_t ui_gl_render( + int32_t target_x, + int32_t target_y, + int32_t target_width, + int32_t target_height, + int32_t window_width, + int32_t window_height +); const uint8_t *ui_render_incremental(void); uint32_t ui_framebuffer_width(void); uint32_t ui_framebuffer_height(void); diff --git a/package.json b/package.json index bc1355c7..a4d387c3 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "launcher": "bun tools/launcher.ts", "launcher:psp": "bun tools/launcher.ts build --target psp --", "launcher:vita": "bun tools/launcher.ts build --target vita --", + "launcher:symbian": "bun tools/launcher.ts build --target symbian --", "e2e:launcher": "bun tests/e2e/launcher-ppsspp.ts", "e2e:launcher:vita": "bun tests/e2e/launcher-vita3k.ts", "pocket:pack": "bun tools/pocket-pack.ts", diff --git a/site/content/docs/build-pipeline.md b/site/content/docs/build-pipeline.md index 64093d53..358aea59 100644 --- a/site/content/docs/build-pipeline.md +++ b/site/content/docs/build-pipeline.md @@ -295,7 +295,7 @@ without touching the JS heap — is covered in the [Native contract](/docs/nativ ## Pass 2 — bundle -With `styles.generated.ts` now written, `Bun.build` bundles the app: +With the style table compiled, `Bun.build` bundles the app: ```ts Bun.build({ @@ -307,15 +307,18 @@ Bun.build({ define: { "process.env.NODE_ENV": '"production"' }, minify: false, sourcemap: "none", - plugins: [jsxPlugin(framework, { entry })], + plugins: [jsxPlugin(framework, { entry, generatedStyles })], }); ``` The plugin's `onLoad` hook intercepts every project `.ts`/`.tsx` file and serves the **cached pass‑1 transform** (`node_modules` and `.d.ts` fall through to -Bun). The bundle is therefore built from *exactly* the code the class/charset -scan saw — the two passes agree on the module graph by construction, so a style -can never be shipped that the bundle doesn't use, or vice versa. +Bun). It also serves this build's generated style module directly from memory; +the ignored `framework/src/styles.generated.ts` file is only a human-readable +mirror. Parallel target builds therefore cannot consume one another's transient +style table. The bundle is built from *exactly* the code the class/charset scan +saw — the two passes agree on the module graph by construction, so a style can +never be shipped that the bundle doesn't use, or vice versa. With a resolved plan, pass 1 also replaces literal `hasFeature("capability.id")` calls with `true` or `false`; normal tree shaking diff --git a/tests/launcher-sim.test.ts b/tests/launcher-sim.test.ts index 5ebb6d22..2aabab1e 100644 --- a/tests/launcher-sim.test.ts +++ b/tests/launcher-sim.test.ts @@ -12,11 +12,35 @@ // by frame. import { describe, expect, test } from "bun:test"; -import { BTN } from "../contracts/spec/spec.ts"; -import { scanDisplayRegistry, scanRegistry } from "../tools/launcher.ts"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { BTN, IMG_FLAG_LINEAR } from "../contracts/spec/spec.ts"; +import { + launcherGeneratedSources, + prepareIsolatedLauncherSource, + scanDisplayRegistry, + scanRegistry, + withLauncherSourceLock, +} from "../tools/launcher.ts"; +import { + resolveSymbianE7BuildPlan, + SYMBIAN_E7_DEV_TARGET_ID, +} from "../tools/symbian-profile.ts"; +import { unpack } from "../framework/compiler/pak.ts"; +import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; import { bootLauncherWorld, type LauncherWorld } from "../hosts/sim/launcher.ts"; import { bootWorld, treeHasText } from "../hosts/sim/sim.ts"; +const repository = new URL("..", import.meta.url).pathname; + const settle = async (w: LauncherWorld, frames: number) => { for (let i = 0; i < frames; i++) await w.step(0); }; @@ -66,13 +90,327 @@ describe("launcher registry admission", () => { test("committed registry.generated.ts is fresh (re-run tools/launcher.ts scan)", async () => { const { REGISTRY } = await import("../apps/launcher/registry.generated.ts"); + const displayRegistry = scanDisplayRegistry(new Set()); expect(REGISTRY.map((r) => ({ output: r.output, id: r.id, title: r.title }))).toEqual( - scanDisplayRegistry(new Set()).apps.map((a) => ({ + displayRegistry.apps.map((a) => ({ output: a.output, id: a.id, title: a.title, })), ); + const generated = launcherGeneratedSources(displayRegistry); + expect( + await Bun.file( + new URL("../apps/launcher/registry.generated.ts", import.meta.url), + ).text(), + ).toBe(generated.registryTs); + expect( + await Bun.file( + new URL("../apps/launcher/images.json", import.meta.url), + ).text(), + ).toBe(generated.imagesJson); + }); + + test("serializes one checkout even when cache environments differ", async () => { + let active = 0; + let maxActive = 0; + const build = (cache: string) => + withLauncherSourceLock(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await Bun.sleep(20); + active -= 1; + }, { POCKET_STACK_CACHE_DIR: cache }); + await Promise.all([ + build("/tmp/pocketjs-launcher-lock-a"), + build("/tmp/pocketjs-launcher-lock-b"), + ]); + expect(maxActive).toBe(1); + }); + + test("never writes generated sources for non-scan excludes or bad backend args", () => { + const registrySource = join( + repository, + "apps/launcher/registry.generated.ts", + ); + const imagesSource = join(repository, "apps/launcher/images.json"); + const registryBefore = readFileSync(registrySource); + const imagesBefore = readFileSync(imagesSource); + const emittedPaths = [ + join(repository, "dist/launcher-registry.json"), + join(repository, "dist/launcher-registry.tsv"), + ]; + const emittedBefore = emittedPaths.map((path) => + existsSync(path) ? readFileSync(path) : undefined + ); + try { + const invalid = Bun.spawnSync([ + "bun", + "tools/launcher.ts", + "build", + "--target", + "symbian", + "--", + "--unknown-backend-option", + ], { cwd: repository }); + expect(invalid.exitCode).toBe(1); + expect(new TextDecoder().decode(invalid.stderr)).toContain( + "unknown Symbian backend option --unknown-backend-option", + ); + expect(readFileSync(registrySource)).toEqual(registryBefore); + expect(readFileSync(imagesSource)).toEqual(imagesBefore); + + const covers = Bun.spawnSync([ + "bun", + "tools/launcher.ts", + "covers", + "--target", + "psp", + "--exclude", + "hero-main", + ], { cwd: repository }); + if (covers.exitCode !== 0) { + throw new Error( + `launcher covers failed:\n${new TextDecoder().decode(covers.stderr)}`, + ); + } + expect(readFileSync(registrySource)).toEqual(registryBefore); + expect(readFileSync(imagesSource)).toEqual(imagesBefore); + } finally { + emittedPaths.forEach((path, index) => { + const previous = emittedBefore[index]; + if (previous) writeFileSync(path, previous); + else rmSync(path, { force: true }); + }); + } + }); + + test("keeps generated source bytes unchanged throughout a failing non-scan command", async () => { + const external = mkdtempSync(join(tmpdir(), "pocketjs-launcher-throw-")); + const registrySource = join( + repository, + "apps/launcher/registry.generated.ts", + ); + const imagesSource = join(repository, "apps/launcher/images.json"); + const registryBefore = readFileSync(registrySource); + const imagesBefore = readFileSync(imagesSource); + const emittedPaths = [ + join(repository, "dist/launcher/symbian/launcher-registry.json"), + join(repository, "dist/launcher/symbian/launcher-registry.tsv"), + ]; + const emittedBefore = emittedPaths.map((path) => + existsSync(path) ? readFileSync(path) : undefined + ); + const output = "launcher-throw-probe"; + try { + const manifest = JSON.parse( + readFileSync(join(repository, "apps/hero/pocket.json"), "utf8"), + ); + manifest.id = "dev.pocket-stack.launcher.throw-probe"; + manifest.name = output; + manifest.title = "AAAA Launcher Throw Probe"; + manifest.app.entry = "app.tsx"; + manifest.app.output = output; + delete manifest.app.viewport.fixed; + const externalManifest = join(external, "pocket.json"); + writeFileSync(externalManifest, JSON.stringify(manifest, null, 2)); + writeFileSync( + join(external, "app.tsx"), + "export default function App( {\n", + ); + + let exited = false; + const failed = Bun.spawn( + [ + "bun", + "tools/launcher.ts", + "covers", + "--target", + "symbian", + "--include-manifest", + externalManifest, + ], + { + cwd: repository, + stdout: "ignore", + stderr: "ignore", + }, + ); + const exit = failed.exited.then((code) => { + exited = true; + return code; + }); + while (!exited) { + expect(readFileSync(registrySource)).toEqual(registryBefore); + expect(readFileSync(imagesSource)).toEqual(imagesBefore); + await Bun.sleep(5); + } + expect(await exit).not.toBe(0); + expect(readFileSync(registrySource)).toEqual(registryBefore); + expect(readFileSync(imagesSource)).toEqual(imagesBefore); + } finally { + emittedPaths.forEach((path, index) => { + const previous = emittedBefore[index]; + if (previous) writeFileSync(path, previous); + else rmSync(path, { force: true }); + }); + for (const path of [ + join(repository, "dist/.plans", `${output}.json`), + join(repository, "dist", `${output}.js`), + join(repository, "dist", `${output}.pak`), + join(repository, "apps/launcher/covers", `cover-${output}.png`), + join(repository, "apps/launcher/covers", `refl-${output}.png`), + ]) { + rmSync(path, { force: true }); + } + rmSync(external, { recursive: true, force: true }); + } + }); + + test("keeps the full display union in isolated PSP and Vita sources", () => { + const dist = join(repository, "dist"); + mkdirSync(dist, { recursive: true }); + const testRoot = mkdtempSync(join(dist, ".launcher-console-source-test-")); + try { + const displayRegistry = scanDisplayRegistry(new Set()); + const expected = launcherGeneratedSources(displayRegistry); + for (const target of ["psp", "vita"] as const) { + const source = join(testRoot, target); + const launcher = prepareIsolatedLauncherSource( + target, + displayRegistry, + source, + ); + expect(readFileSync(join(source, "registry.generated.ts"), "utf8")).toBe( + expected.registryTs, + ); + expect(readFileSync(join(source, "images.json"), "utf8")).toBe( + expected.imagesJson, + ); + const manifest = JSON.parse(readFileSync(launcher.manifest, "utf8")); + const resolution = validateAndResolveBuildPlan(manifest, { target }); + expect(resolution.ok).toBe(true); + if (!resolution.ok) continue; + expect(resolution.plan.app.entry).toBe( + relative(repository, join(source, "main.tsx")).replaceAll("\\", "/"), + ); + expect(resolution.plan.viewport.logical).toEqual([480, 272]); + } + } finally { + rmSync(testRoot, { recursive: true, force: true }); + } + }); + + test("compiles an isolated E7 launcher with exactly 1 + 2N images", async () => { + const dist = join(repository, "dist"); + mkdirSync(dist, { recursive: true }); + const testRoot = mkdtempSync(join(dist, ".launcher-isolation-test-")); + const source = join(testRoot, "source"); + const output = join(testRoot, "output"); + const planPath = join(testRoot, "plan.json"); + const registrySource = join( + repository, + "apps/launcher/registry.generated.ts", + ); + const imagesSource = join(repository, "apps/launcher/images.json"); + const stylesSource = join(repository, "framework/src/styles.generated.ts"); + const registryBefore = readFileSync(registrySource); + const imagesBefore = readFileSync(imagesSource); + const stylesBefore = existsSync(stylesSource) + ? readFileSync(stylesSource) + : undefined; + try { + const targetRegistry = scanRegistry( + new Set(), + SYMBIAN_E7_DEV_TARGET_ID, + ); + const launcher = prepareIsolatedLauncherSource( + SYMBIAN_E7_DEV_TARGET_ID, + targetRegistry, + source, + ); + const manifest = JSON.parse(readFileSync(launcher.manifest, "utf8")); + const plan = resolveSymbianE7BuildPlan(manifest); + writeFileSync(planPath, JSON.stringify(plan, null, 2) + "\n"); + + expect(manifest.app.entry).toBe( + relative(repository, join(source, "main.tsx")).replaceAll("\\", "/"), + ); + expect(readFileSync(join(source, "main.tsx"), "utf8")).toContain( + "", + ); + expect(readFileSync(registrySource)).toEqual(registryBefore); + expect(readFileSync(imagesSource)).toEqual(imagesBefore); + + let exited = false; + const compiler = Bun.spawn( + [ + "bun", + "tools/build.ts", + `--plan=${planPath}`, + `--project-root=${repository}`, + `--outdir=${output}`, + ], + { cwd: repository, stdout: "ignore", stderr: "pipe" }, + ); + const exit = compiler.exited.then((code) => { + exited = true; + return code; + }); + while (!exited) { + expect(readFileSync(registrySource)).toEqual(registryBefore); + expect(readFileSync(imagesSource)).toEqual(imagesBefore); + await Bun.sleep(5); + } + const exitCode = await exit; + const stderr = await new Response(compiler.stderr).text(); + if (exitCode !== 0) { + throw new Error(`isolated launcher compile failed:\n${stderr}`); + } + + const entries = unpack( + new Uint8Array( + readFileSync(join(output, "launcher-main.pak")), + ), + ); + const imageEntries = entries.filter((entry) => + entry.key.startsWith("ui:img.") + ); + const images = imageEntries.map((entry) => entry.key); + expect(images).toHaveLength(1 + targetRegistry.apps.length * 2); + expect(images).toEqual( + [ + "ui:img.covers/launcher-bg.png", + ...targetRegistry.apps.flatMap((app) => [ + `ui:img.covers/cover-${app.output}.png`, + `ui:img.covers/refl-${app.output}.png`, + ]), + ].sort(), + ); + for (const entry of imageEntries) { + const image = new DataView( + entry.data.buffer, + entry.data.byteOffset, + entry.data.byteLength, + ); + expect(entry.data[5]! & IMG_FLAG_LINEAR).toBe(IMG_FLAG_LINEAR); + if (entry.key.includes("/refl-")) { + expect([image.getUint16(0, true), image.getUint16(2, true)]).toEqual( + [128, 64], + ); + } else { + expect([image.getUint16(0, true), image.getUint16(2, true)]).toEqual( + [256, 128], + ); + } + } + expect(readFileSync(registrySource)).toEqual(registryBefore); + expect(readFileSync(imagesSource)).toEqual(imagesBefore); + } finally { + if (stylesBefore) writeFileSync(stylesSource, stylesBefore); + else rmSync(stylesSource, { force: true }); + rmSync(testRoot, { recursive: true, force: true }); + } }); }); diff --git a/tests/platform-runtime.test.ts b/tests/platform-runtime.test.ts index 6adbe80f..c9d199c5 100644 --- a/tests/platform-runtime.test.ts +++ b/tests/platform-runtime.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { jsxPlugin, + packagePath, transformFile, type PocketFramework, } from "../framework/compiler/jsx-plugin.ts"; @@ -103,6 +104,109 @@ test("feature folding rejects dynamic and shadowed calls", async () => { expect(result.code.match(/hasFeature\(/g)).toHaveLength(2); }); +test("the build resolver owns every published framework export", async () => { + const packageJson = await Bun.file( + new URL("../package.json", import.meta.url), + ).json() as { exports: Record }; + for (const exported of Object.keys(packageJson.exports)) { + const spec = exported === "." + ? "@pocketjs/framework" + : `@pocketjs/framework${exported.slice(1)}`; + const framework = exported === "./vue-vapor" || + exported.startsWith("./vue-vapor/") + ? "vue-vapor" + : "solid"; + expect(packagePath(spec, framework), spec).not.toBeNull(); + } +}); + +test("external apps share PocketJS's Solid runtime identity", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-external-solid-")); + const entry = join(directory, "main.ts"); + const duplicatePackage = join(directory, "node_modules", "solid-js"); + try { + await Bun.write(join(duplicatePackage, "package.json"), JSON.stringify({ + name: "solid-js", + version: "0.0.0-duplicate", + type: "module", + exports: "./index.js", + })); + await Bun.write( + join(duplicatePackage, "index.js"), + 'export const createSignal = () => "DUPLICATE_SOLID_RUNTIME";\n', + ); + await Bun.write(entry, ` + import { createSignal } from "solid-js"; + import { render } from "@pocketjs/framework/solid"; + globalThis.__externalSolidProbe = [createSignal, render]; + `); + const result = await Bun.build({ + entrypoints: [entry], + format: "iife", + target: "browser", + conditions: ["browser"], + define: { + "process.env.NODE_ENV": '"production"', + __POCKET_TARGET__: '"symbian-e7-dev"', + __POCKET_HOST_ABI__: "4", + __POCKET_FEATURES__: "{}", + __POCKET_PIXEL_RATIO__: "1", + }, + plugins: [jsxPlugin("solid", { entry })], + }); + expect(result.success).toBe(true); + expect(await result.outputs[0]!.text()).not.toContain( + "DUPLICATE_SOLID_RUNTIME", + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("parallel bundles receive their own generated style table", async () => { + const directory = await mkdtemp(join(tmpdir(), "pocketjs-parallel-styles-")); + const entry = join(directory, "main.ts"); + try { + await Bun.write(entry, ` + import { render } from "@pocketjs/framework/solid"; + globalThis.__parallelStyleProbe = render; + `); + const bundle = async (marker: string): Promise => { + const result = await Bun.build({ + entrypoints: [entry], + format: "iife", + target: "browser", + conditions: ["browser"], + define: { + "process.env.NODE_ENV": '"production"', + __POCKET_TARGET__: '"symbian-e7-dev"', + __POCKET_HOST_ABI__: "4", + __POCKET_FEATURES__: "{}", + __POCKET_PIXEL_RATIO__: "1", + }, + plugins: [jsxPlugin("solid", { + entry, + generatedStyles: + `globalThis.__pocketStyleBuildMarker = ${JSON.stringify(marker)};\n` + + `export const STYLE_IDS: Record = ${JSON.stringify({ [marker]: 1 })};`, + })], + }); + expect(result.success).toBe(true); + return await result.outputs[0]!.text(); + }; + const [first, second] = await Promise.all([ + bundle("STYLE_BUILD_ALPHA"), + bundle("STYLE_BUILD_BETA"), + ]); + expect(first).toContain("STYLE_BUILD_ALPHA"); + expect(first).not.toContain("STYLE_BUILD_BETA"); + expect(second).toContain("STYLE_BUILD_BETA"); + expect(second).not.toContain("STYLE_BUILD_ALPHA"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + async function bundleFeatureBranch( framework: PocketFramework, available: boolean, diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 0be919aa..2b6f7d50 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -63,6 +63,12 @@ import { mount as publicMount, render as publicRender } from "../framework/src/i import { pushButtonHandlerBlock, onButtonPress, onFrame } from "../framework/src/lifecycle.ts"; import { rootMirror } from "../framework/src/renderer.ts"; import { ActionBar, ActionHandler, FocusGrid, Modal, Portal, Text, View } from "../framework/src/components.ts"; +import { + DeepZoom, + type DeepZoomGesture, + type DeepZoomView, + type TileDoc, +} from "../framework/src/deepzoom.ts"; import { resetPack } from "../framework/src/pak.ts"; import { encodeImageEntry, pack } from "../framework/compiler/pak.ts"; import { @@ -1023,6 +1029,133 @@ describe("public render() (index.ts)", () => { expect(globals.__pocketResizeViewport).toBeUndefined(); }); + test("DeepZoom follows the live viewport while preserving a non-fit view", () => { + const ops = host.ops as HostOps & { __viewport?: { w: number; h: number } }; + const frame = () => + (globalThis as { frame?: (buttons: number) => void }).frame?.(0); + const doc: TileDoc = { + name: "live viewport", + w: 1000, + h: 500, + bg: 0xff202020, + tile: 100, + levels: [{ + scale: 1, + cols: 10, + rows: 5, + key: "ui:tile.live-viewport", + grid: Array(5).fill("aaaaaaaaaa"), + solids: [0xff808080], + }], + }; + const views: DeepZoomView[] = []; + let gesture: DeepZoomGesture | null = null; + ops.__viewport = { w: 640, h: 360 }; + + const dispose = publicRender( + () => + DeepZoom({ + doc, + prefetch: 0, + gestureSource: () => gesture, + onView: (view) => views.push({ ...view }), + }) as unknown as NodeMirror, + { ops }, + ); + const container = rootMirror.children[0].children[0]; + const activeWorld = container.children[1]; + + frame(); + expect(views.at(-1)?.minZoom).toBeCloseTo(0.64); + expect(views.at(-1)?.zoom).toBeCloseTo(0.64); + expect(activeWorld.children).toHaveLength(50); + + host.clear(); + ops.__viewport = { w: 360, h: 640 }; + frame(); + expect(views.at(-1)?.minZoom).toBeCloseTo(0.36); + expect(views.at(-1)?.zoom).toBeCloseTo(0.36); + expect(host.of("setProp")).toEqual(expect.arrayContaining([ + ["setProp", container.id, PROP.width, 360], + ["setProp", container.id, PROP.height, 640], + ])); + expect(rootMirror.children[0].children[0]).toBe(container); + expect(container.children[1]).toBe(activeWorld); + + gesture = { panX: -36, panY: 0, zoomFactor: 2 }; + frame(); + gesture = null; + expect(views.at(-1)?.zoom).toBeCloseTo(0.72); + expect(views.at(-1)?.centerX).toBeCloseTo(600); + expect(activeWorld.children).toHaveLength(30); + + host.clear(); + ops.__viewport = { w: 640, h: 360 }; + frame(); + expect(views.at(-1)?.minZoom).toBeCloseTo(0.64); + expect(views.at(-1)?.zoom).toBeCloseTo(0.72); + expect(views.at(-1)?.centerX).toBeCloseTo(1000 - 320 / 0.72); + expect(views.at(-1)?.centerX).not.toBeCloseTo(doc.w / 2); + expect(activeWorld.children).toHaveLength(45); + expect(rootMirror.children[0].children[0]).toBe(container); + expect(container.children[1]).toBe(activeWorld); + + dispose(); + }); + + test("DeepZoom keeps an explicit viewport fixed", () => { + const ops = host.ops as HostOps & { __viewport?: { w: number; h: number } }; + const views: DeepZoomView[] = []; + const doc: TileDoc = { + name: "fixed viewport", + w: 1000, + h: 500, + bg: 0xff202020, + tile: 100, + levels: [{ + scale: 1, + cols: 10, + rows: 5, + key: "ui:tile.fixed-viewport", + grid: Array(5).fill("aaaaaaaaaa"), + solids: [0xff808080], + }], + }; + ops.__viewport = { w: 640, h: 360 }; + + const dispose = publicRender( + () => + DeepZoom({ + doc, + width: 480, + height: 272, + prefetch: 0, + onView: (view) => views.push({ ...view }), + }) as unknown as NodeMirror, + { ops }, + ); + const container = rootMirror.children[0].children[0]; + const frame = () => + (globalThis as { frame?: (buttons: number) => void }).frame?.(0); + frame(); + expect(views.at(-1)?.minZoom).toBeCloseTo(0.48); + expect(views.at(-1)?.zoom).toBeCloseTo(0.48); + + host.clear(); + ops.__viewport = { w: 360, h: 640 }; + frame(); + expect(views.at(-1)?.minZoom).toBeCloseTo(0.48); + expect(views.at(-1)?.zoom).toBeCloseTo(0.48); + expect( + host.of("setProp").filter((call) => + call[1] === container.id && + (call[2] === PROP.width || call[2] === PROP.height) + ), + ).toEqual([]); + + dispose(); + }); + test("Portal mounts overlay content outside the app layer", () => { const dispose = publicRender( () => diff --git a/tests/symbian-package.test.ts b/tests/symbian-package.test.ts new file mode 100644 index 00000000..15f08a3b --- /dev/null +++ b/tests/symbian-package.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { + symbianDataBaseForEmbeddedBytes, + symbianExecutableName, + symbianPackageIdentity, + symbianUidForAppId, + validateSymbianDevelopmentUid, +} from "../tools/symbian-package.ts"; + +function plan( + id: string, + title: string, + output: string, +): ResolvedBuildPlan { + return { + app: { + id, + title, + entry: "app/main.tsx", + output, + framework: "solid", + }, + target: { id: "symbian-e7-dev", hostAbi: 4 }, + viewport: { + logical: [640, 360], + physical: [640, 360], + presentation: "native", + rasterDensity: 1, + }, + features: {}, + planHash: `sha256:${"0".repeat(64)}`, + }; +} + +describe("independent Symbian package identity", () => { + test("moves writable data above large embedded qrc payloads", () => { + expect(symbianDataBaseForEmbeddedBytes(0)).toBe("0x400000"); + expect(symbianDataBaseForEmbeddedBytes(1)).toBe("0x500000"); + expect(symbianDataBaseForEmbeddedBytes(6 * 1024 * 1024)).toBe( + "0xa00000", + ); + expect(() => symbianDataBaseForEmbeddedBytes(-1)).toThrow( + "non-negative safe integer", + ); + expect(() => symbianDataBaseForEmbeddedBytes(0x10000000)).toThrow( + "above the E7 limit", + ); + }); + + test("derives stable private UIDs from Pocket ids", () => { + expect(symbianUidForAppId("dev.pocket-stack.openstrike")).toBe( + "0xE86B9226", + ); + expect(symbianUidForAppId("dev.pocket-stack.figma")).toBe("0xEEB7A533"); + expect(symbianUidForAppId("dev.pocket-stack.launcher")).toBe( + "0xECEF4AC6", + ); + }); + + test("keeps every installed path unique and Symbian-safe", () => { + const identity = symbianPackageIdentity( + plan("dev.pocket-stack.figma", "Pocket Figma", "pocket-figma"), + ); + expect(identity).toEqual({ + appId: "dev.pocket-stack.figma", + appOutput: "pocket-figma", + title: "Pocket Figma", + uid: "0xEEB7A533", + executable: "PocketJsPocketFigmaEEB7A533", + sisFile: "pocket-figma.sis", + receiptFile: "pocket-figma.receipt.json", + }); + expect(identity.executable.length).toBeLessThanOrEqual(31); + + expect( + symbianPackageIdentity( + plan( + "dev.pocket-stack.launcher", + "PocketJS: Launcher", + "launcher-main", + ), + ).title, + ).toBe("PocketJS: Launcher"); + }); + + test("truncates long target names before the collision-resistant UID", () => { + const executable = symbianExecutableName( + "this-is-a-very-long-pocket-application-output", + "0xE1234567", + ); + expect(executable).toBe("PocketJsThisIsAVeryLongE1234567"); + expect(executable.length).toBe(31); + }); + + test("allows an explicit development UID but rejects protected UIDs", () => { + expect( + symbianPackageIdentity( + plan("dev.pocket-stack.app", "App", "app"), + "0xe1234567", + ).uid, + ).toBe("0xE1234567"); + expect(() => validateSymbianDevelopmentUid("0x20012345")).toThrow( + "unprotected development range", + ); + }); + + test("rejects package metadata that can break the generated PKG", () => { + expect(() => + symbianPackageIdentity( + plan("dev.pocket-stack.app", 'Bad "caption"', "app"), + ) + ).toThrow("safe ASCII"); + expect(() => + symbianPackageIdentity( + plan("dev.pocket-stack.app", "Bad $$system(id)", "app"), + ) + ).toThrow("safe ASCII"); + }); +}); diff --git a/tests/symbian-runtime.test.ts b/tests/symbian-runtime.test.ts index f2776096..7ce3f10f 100644 --- a/tests/symbian-runtime.test.ts +++ b/tests/symbian-runtime.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; import { @@ -8,17 +15,259 @@ import { SYMBIAN_E7_DEV_TARGET_ID, resolveSymbianE7BuildPlan, } from "../tools/symbian-profile.ts"; +import { + encodeSymbianCatalog, + includeExternalManifests, + launcherGeneratedSources, + needsLauncherCompile, + scanRegistry, + withLauncherSourceLock, + type SymbianCatalogEntry, +} from "../tools/launcher.ts"; import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; const repository = new URL("..", import.meta.url).pathname; describe("experimental Nokia E7 runtime profile", () => { + test("serializes the shared launcher source tree across targets", async () => { + const root = mkdtempSync(join(tmpdir(), "pocketjs-launcher-source-lock-")); + try { + const env = { POCKET_STACK_CACHE_DIR: join(root, "cache") }; + let active = 0; + let maxActive = 0; + const build = () => withLauncherSourceLock(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await Bun.sleep(20); + active -= 1; + }, env); + await Promise.all([build(), build()]); + expect(maxActive).toBe(1); + + const launcher = readFileSync( + join(repository, "tools/launcher.ts"), + "utf8", + ); + const sourceLock = launcher.indexOf( + "await withLauncherSourceLock(async () =>", + ); + const outputLock = launcher.indexOf( + "await withSymbianBuildTransaction(paths.output", + sourceLock, + ); + expect(sourceLock).toBeGreaterThan(-1); + expect(outputLock).toBeGreaterThan(sourceLock); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("never reuses stale Symbian guests and requires both cached outputs elsewhere", () => { + const root = mkdtempSync(join(tmpdir(), "pocketjs-launcher-freshness-")); + try { + writeFileSync(join(root, "probe.js"), "js"); + writeFileSync(join(root, "probe.pak"), "pak"); + expect( + needsLauncherCompile("probe", SYMBIAN_E7_DEV_TARGET_ID, root, false), + ).toBe(true); + expect(needsLauncherCompile("probe", "psp", root, false)).toBe(false); + rmSync(join(root, "probe.pak")); + expect(needsLauncherCompile("probe", "psp", root, false)).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("encodes an aligned verbatim package catalog for the Qt host", () => { + const entry = ( + output: string, + id: string, + title: string, + bytes: number[], + liveViewport: boolean, + ): SymbianCatalogEntry => ({ + plan: { + app: { + output, + id, + title, + entry: "app.tsx", + framework: "solid", + }, + target: { id: SYMBIAN_E7_DEV_TARGET_ID, hostAbi: SYMBIAN_E7_DEV_HOST_ABI }, + viewport: { + logical: liveViewport ? [640, 360] : [480, 272], + physical: liveViewport ? [640, 360] : [480, 272], + presentation: "native", + rasterDensity: 1, + }, + features: {}, + planHash: `sha256:${"0".repeat(64)}`, + }, + packageBytes: new Uint8Array(bytes), + liveViewport, + }); + const launcher = entry( + "launcher-main", + "dev.pocket-stack.launcher", + "PocketJS: Launcher", + [1, 2, 3], + false, + ); + const hero = entry( + "hero-main", + "dev.pocket-stack.hero", + "PocketJS: Hero", + [4, 5], + true, + ); + const catalog = encodeSymbianCatalog([launcher, hero]); + expect(new TextDecoder().decode(catalog.index)).toBe( + "launcher-main\tdev.pocket-stack.launcher\tPocketJS: Launcher\t0\t3\t480\t272\tfixed\n" + + "hero-main\tdev.pocket-stack.hero\tPocketJS: Hero\t16\t2\t640\t360\tlive\n", + ); + expect([...catalog.blob.subarray(0, 3)]).toEqual([1, 2, 3]); + expect([...catalog.blob.subarray(3, 16)]).toEqual(new Array(13).fill(0)); + expect([...catalog.blob.subarray(16)]).toEqual([4, 5]); + }); + test("does not register an unproven production target", () => { expect(Object.keys(POCKET_TARGETS)).toEqual(["psp", "vita", "pocketbook", "macos-widget"]); expect(POCKET_TARGETS).not.toHaveProperty(SYMBIAN_E7_DEV_TARGET_ID); expect(SYMBIAN_E7_DEV_HOST_ABI).toBe(4); }); + test("launcher admission includes only genuine E7 live-viewport apps", () => { + const outputs = scanRegistry( + new Set(), + SYMBIAN_E7_DEV_TARGET_ID, + ).apps.map((app) => app.output); + expect(outputs).toEqual(["note-main", "hero-main"]); + expect(outputs).not.toContain("cafe-main"); + expect(outputs).not.toContain("launcher-main"); + }); + + test("thins the E7 launcher source to admitted Cover Flow textures", () => { + const registry = scanRegistry(new Set(), SYMBIAN_E7_DEV_TARGET_ID); + const generated = launcherGeneratedSources(registry); + expect(generated.registryTs).toContain("cover-note-main.png"); + expect(generated.registryTs).toContain("cover-hero-main.png"); + expect(generated.registryTs).not.toContain("cover-cafe-main.png"); + expect(generated.registryTs).toContain("refl-note-main.png"); + expect(JSON.parse(generated.imagesJson)).toEqual({ + "covers/launcher-bg.png": { linear: true }, + "covers/cover-note-main.png": { linear: true }, + "covers/refl-note-main.png": { linear: true }, + "covers/cover-hero-main.png": { linear: true }, + "covers/refl-hero-main.png": { linear: true }, + }); + }); + + test("adds explicit external manifests without leaking paths or dirtying sources", () => { + const external = mkdtempSync(join(tmpdir(), "pocketjs-launcher-external-")); + const registrySource = join( + repository, + "apps/launcher/registry.generated.ts", + ); + const imagesSource = join(repository, "apps/launcher/images.json"); + const registryBefore = readFileSync(registrySource); + const imagesBefore = readFileSync(imagesSource); + const emittedPaths = [ + join( + repository, + "dist/launcher/symbian/launcher-registry.json", + ), + join( + repository, + "dist/launcher/symbian/launcher-registry.tsv", + ), + ]; + const emittedBefore = emittedPaths.map((path) => + existsSync(path) ? readFileSync(path) : undefined + ); + try { + const manifest = JSON.parse( + readFileSync(join(repository, "apps/hero/pocket.json"), "utf8"), + ); + manifest.id = "dev.pocket-stack.external-probe"; + manifest.name = "external-probe"; + manifest.title = "External Probe"; + manifest.app.entry = "app.tsx"; + manifest.app.output = "external-probe"; + delete manifest.app.viewport.fixed; + const externalManifest = join(external, "pocket.json"); + writeFileSync(externalManifest, JSON.stringify(manifest, null, 2)); + writeFileSync(join(external, "app.tsx"), "export default function App() {}\n"); + + const scanned = Bun.spawnSync([ + "bun", + "tools/launcher.ts", + "scan", + "--target", + "symbian", + "--include-manifest", + externalManifest, + ], { cwd: repository }); + expect(scanned.exitCode).toBe(0); + expect(readFileSync(registrySource)).toEqual(registryBefore); + expect(readFileSync(imagesSource)).toEqual(imagesBefore); + + const emitted = JSON.parse(readFileSync( + join( + repository, + "dist/launcher/symbian/launcher-registry.json", + ), + "utf8", + )) as { + apps: Array>; + }; + const app = emitted.apps.find( + (entry) => entry.output === "external-probe", + ); + expect(app).toMatchObject({ + id: "dev.pocket-stack.external-probe", + manifest: "pocket.json", + }); + expect(app).not.toHaveProperty("projectRoot"); + expect(JSON.stringify(emitted)).not.toContain(external); + } finally { + emittedPaths.forEach((path, index) => { + const previous = emittedBefore[index]; + if (previous) writeFileSync(path, previous); + else rmSync(path, { force: true }); + }); + rmSync(external, { recursive: true, force: true }); + } + }); + + test("external manifests cannot replace the launcher identity", () => { + const external = mkdtempSync(join(tmpdir(), "pocketjs-launcher-collision-")); + try { + const manifest = JSON.parse( + readFileSync(join(repository, "apps/launcher/pocket.json"), "utf8"), + ); + manifest.app.entry = "app.tsx"; + manifest.app.viewport.dynamic = { + default: [640, 360], + min: [360, 360], + max: [640, 640], + }; + const manifestPath = join(external, "pocket.json"); + writeFileSync(manifestPath, JSON.stringify(manifest)); + writeFileSync(join(external, "app.tsx"), "export default function App() {}\n"); + expect(() => + includeExternalManifests( + scanRegistry(new Set(), SYMBIAN_E7_DEV_TARGET_ID), + [manifestPath], + new Set(), + SYMBIAN_E7_DEV_TARGET_ID, + ) + ).toThrow("external output launcher-main duplicates"); + } finally { + rmSync(external, { recursive: true, force: true }); + } + }); + test("selects the Hero's dynamic E7 viewport without changing its PSP viewport", () => { const manifest = JSON.parse( readFileSync(join(repository, "apps/hero/pocket.json"), "utf8"), @@ -58,6 +307,7 @@ describe("experimental Nokia E7 runtime profile", () => { }); expect(target.capabilities).toEqual([ "input.buttons", + "input.touch", "display.viewport.live", "text.glyphs.baked", ]); @@ -75,7 +325,7 @@ describe("experimental Nokia E7 runtime profile", () => { expect(hero).toContain('class="flex-row flex-wrap items-center gap-4"'); }); - test("binds the strict target contract, live viewport, and safe E7 input", () => { + test("binds the strict target contract, live viewport, and E7 input", () => { const runtime = readFileSync( join(repository, "hosts/symbian/runtime/main.cpp"), "utf8", @@ -88,6 +338,10 @@ describe("experimental Nokia E7 runtime profile", () => { join(repository, "hosts/symbian/runtime/pocketjs-runtime.qrc"), "utf8", ); + const coreHeader = readFileSync( + join(repository, "hosts/symbian/runtime/pocketjs_symbian_core.h"), + "utf8", + ); const buildApp = readFileSync( join(repository, "tools/symbian/container/pocketjs-symbian-build-app"), "utf8", @@ -106,28 +360,84 @@ describe("experimental Nokia E7 runtime profile", () => { "for (int tick = 0; tick < kCoreTicksPerFrame; ++tick)", ); expect(runtime.match(/ui_tick\(\);/g)).toHaveLength(1); - expect(runtime).toContain("QImage::Format_ARGB32"); - expect(runtime).toContain("point.id()) & 0xff) << 18"); + expect(runtime).toContain("#include "); + expect(runtime).toContain("class PocketJsRuntime : public QGLWidget"); + expect(runtime).toContain("QGLFormat pocketJsGlFormat()"); + expect(runtime).toContain("format.setDoubleBuffer(true)"); + expect(runtime).toContain("format.setDepth(false)"); + expect(runtime).toContain("format.setStencil(false)"); + expect(runtime).toContain("format.setSampleBuffers(false)"); + expect(runtime).toContain("QGLWidget(pocketJsGlFormat())"); + expect(runtime).toContain("glInitialized_ = ui_gl_initialize() != 0;"); + expect(runtime).toContain( + "PocketJS could not obtain a valid OpenGL ES 2 context.", + ); + expect(runtime).toContain("PocketJS lost its OpenGL ES 2 context."); + expect(runtime).toContain("glInitialized_ && isValid()"); + expect(runtime).toContain("void PocketJsRuntime::paintGL()"); + expect(runtime).toContain("if (ui_gl_render("); + expect(runtime).toContain("const QRect target = presentationRect();"); + expect(runtime).toContain("glReadPixels("); + expect(runtime).toContain( + "const QRect sourceRect = presentationRect().intersected(rect());", + ); + expect(runtime).toContain("height() - sourceRect.y() - sourceRect.height()"); + expect(runtime).toContain("if (pendingApp_ >= 0 && pendingSummon_)"); + expect(runtime).not.toContain("grabFrameBuffer"); + expect(runtime).toContain("updateGL();"); + expect(runtime).not.toContain("QPainter"); + expect(runtime).not.toContain("framebuffer_"); + expect(runtime).not.toContain("ui_render("); + expect(runtime).toContain("frame_delta_ms"); + expect(runtime).toContain("update_gl_ms"); + expect(runtime).not.toContain("\\tgpu_ms"); + expect(runtime).toContain("perfTraceBuffer_.append("); + expect(runtime).toContain("0x80000000U |"); + expect(runtime).toContain("point.id()) & 0xff) << 20"); + expect(runtime).toContain("static_cast(y) & 0x3ff) << 10"); + expect(runtime).toContain("static_cast(x) & 0x3ff"); expect(runtime).toContain("position.x() >= target.left() + target.width()"); expect(runtime).toContain("event->isAutoRepeat()"); expect(runtime).toContain("case Qt::Key_Select:"); + expect(runtime).toContain("case Qt::Key_Backspace:"); + expect(runtime).toContain("case Qt::Key_Q:"); + expect(runtime).toContain("return kButtonLeftTrigger;"); + expect(runtime).toContain("case Qt::Key_E:"); + expect(runtime).toContain("return kButtonRightTrigger;"); + expect(runtime).toContain("case Qt::Key_T:"); + expect(runtime).toContain("return kButtonTriangle;"); + expect(runtime).toContain("case Qt::Key_S:"); + expect(runtime).toContain("return kButtonSquare;"); expect(runtime).toContain("setAttribute(Qt::WA_AutoOrientation, true)"); expect(runtime).not.toContain("WA_LockLandscapeOrientation"); expect(runtime).toContain('"__pocketResizeViewport"'); expect(runtime).toContain("queueViewport(event->size())"); expect(runtime).toContain("queueViewport(size())"); - expect(runtime).toContain("framebuffer_ = QImage();"); + expect(runtime).not.toContain("kTouchCoordinateExtent"); + expect(runtime).toContain("QRect PocketJsRuntime::presentationRect() const"); + expect(runtime).toContain("viewportSize_.width() /"); + expect(runtime).toContain("viewportSize_.height() /"); + expect(runtime).not.toContain("kLogicalWidth"); + expect(runtime).not.toContain('"__textures"'); + expect(runtime).toContain("const uint32_t kPakMagic = 0x4b504344U;"); + expect(runtime).toContain("bool findPakEntry("); + expect(runtime).toContain("rawBlobLength > static_cast("); expect(runtime).toContain( - "width != static_cast(viewportSize_.width())", + "memcmp(pack.constData() + nameOffset, key, keyLength)", ); + expect(runtime).toContain("case HostLoadTileTexture:"); + expect(runtime).toContain("ui_upload_tileset_tile("); expect(runtime).toContain( - "viewportSize_.width() > kTouchCoordinateExtent", + 'addHostOperation(context, ui, "loadTileTexture", 2, HostLoadTileTexture)', ); - expect(runtime).not.toContain("presentationRect"); - expect(runtime).not.toContain("kLogicalWidth"); - expect(runtime).not.toContain('"__textures"'); + expect(coreHeader).toContain("int32_t ui_upload_tileset_tile("); + expect(project).toContain("QT += core gui opengl"); + expect(project).not.toContain("DEFINES += POCKETJS_PERF_TRACE"); expect(project).toContain("TARGET.EPOCHEAPSIZE = 0x400000 0x2000000"); + expect(project).toContain( + "DEPLOYMENT.display_name = $$POCKETJS_SYMBIAN_CAPTION", + ); expect(project).toContain( "isEmpty(POCKETJS_HOST_ABI): error(POCKETJS_HOST_ABI is required)", ); @@ -154,17 +464,115 @@ describe("experimental Nokia E7 runtime profile", () => { expect(buildApp).toContain("integer extents from 1 through 640"); expect(resources).toContain('app.js'); expect(resources).toContain('app.pak'); + expect(resources).toContain('catalog.tsv'); + expect(resources).toContain('catalog.bin'); + + expect(coreHeader).toContain("int32_t ui_gl_initialize(void);"); + expect(coreHeader).toContain("void ui_gl_reset_resources(void);"); + expect(coreHeader).toContain("void ui_gl_shutdown(void);"); + expect(coreHeader).toContain("int32_t ui_gl_render("); - const clearImage = runtime.indexOf("framebuffer_ = QImage();"); + const applyViewport = runtime.indexOf( + "bool PocketJsRuntime::applyPendingViewport()", + ); const resizeCore = runtime.indexOf( "ui_set_viewport(viewport.width(), viewport.height());", - clearImage, + applyViewport, ); const callHook = runtime.indexOf("resizeViewport_,", resizeCore); const drainJobs = runtime.indexOf("if (!drainJobs()) return false;", callHook); - expect(clearImage).toBeGreaterThan(-1); - expect(resizeCore).toBeGreaterThan(clearImage); + const scheduleGl = runtime.indexOf("update();", drainJobs); + expect(applyViewport).toBeGreaterThan(-1); + expect(resizeCore).toBeGreaterThan(applyViewport); expect(callHook).toBeGreaterThan(resizeCore); expect(drainJobs).toBeGreaterThan(callHook); + expect(scheduleGl).toBeGreaterThan(drainJobs); + }); + + test("embeds validated .pocket guests and cold-switches after presentation", () => { + const runtime = readFileSync( + join(repository, "hosts/symbian/runtime/main.cpp"), + "utf8", + ); + + expect(runtime).toContain("kPocketPackageMagic = 0x544b4350U"); + expect(runtime).toContain('target != "symbian-e7-dev"'); + expect(runtime).toContain( + "hostAbi != static_cast(POCKETJS_HOST_ABI)", + ); + expect(runtime).toContain( + "pocketHash(package, package.size() - 8) != storedHash", + ); + expect(runtime).toContain( + "embedded .pocket identity does not match its catalog row", + ); + expect(runtime).toContain('"appTable"'); + expect(runtime).toContain('"appLaunch"'); + expect(runtime).toContain('"appShot"'); + expect(runtime).toContain("JS_SetContextOpaque(context, owner)"); + expect(runtime).toContain("frameButtons &= ~kButtonSelect"); + expect(runtime).toContain("selectPressed && !selectLatched_"); + + const runFrame = runtime.indexOf( + "void PocketJsRuntime::runFrame()", + ); + const tick = runtime.indexOf("ui_tick();", runFrame); + const present = runtime.indexOf("updateGL();", tick); + const finish = runtime.indexOf("finishPendingSwitch();", present); + const teardown = runtime.indexOf("destroyGuest();", finish); + const reboot = runtime.indexOf( + "if (!bootGuest(nextApp, size()))", + teardown, + ); + const recover = runtime.indexOf( + "recoverGuestFailure(nextApp);", + reboot, + ); + expect(runFrame).toBeGreaterThan(-1); + expect(tick).toBeGreaterThan(runFrame); + expect(present).toBeGreaterThan(tick); + expect(finish).toBeGreaterThan(present); + expect(teardown).toBeGreaterThan(finish); + expect(reboot).toBeGreaterThan(teardown); + expect(recover).toBeGreaterThan(reboot); + expect(runtime).toContain( + "apps_.size() <= 1 || appIndex == 0", + ); + + const freeContext = runtime.indexOf("JS_FreeContext(context_);"); + const freeRuntime = runtime.indexOf("JS_FreeRuntime(runtime_);", freeContext); + const shutdownCore = runtime.indexOf("ui_shutdown();", freeRuntime); + const clearPack = runtime.indexOf("appPack_.clear();", shutdownCore); + expect(freeContext).toBeGreaterThan(-1); + expect(freeRuntime).toBeGreaterThan(freeContext); + expect(shutdownCore).toBeGreaterThan(freeRuntime); + expect(clearPack).toBeGreaterThan(shutdownCore); + + const launcher = readFileSync( + join(repository, "apps/launcher/app.tsx"), + "utf8", + ); + expect(launcher).toContain( + 'import { BTN, touches } from "@pocketjs/framework/input"', + ); + expect(launcher).toContain("const third = viewport().w / 3"); + expect(launcher).toContain("pressed.x < third"); + expect(launcher).toContain("pressed.x >= third * 2"); + expect(launcher).toContain("launchApp(app.output)"); + expect(launcher).not.toContain('debugName="LauncherCanvas"'); + expect(launcher).not.toContain("canvasStyle"); + expect(launcher).toContain( + ' viewport().w / 2 - 96"); + expect(launcher).toContain( + "Math.max(24, Math.round((viewport().h - 76 - 218) / 2))", + ); + expect(launcher).toContain( + 'class="absolute left-0 right-0 bottom-8 flex-col items-center gap-1"', + ); + expect(launcher).toContain( + 'class="absolute left-0 right-0 bottom-2 text-center', + ); }); }); diff --git a/tests/symbian-toolchain.test.ts b/tests/symbian-toolchain.test.ts index 5a498f9d..808e4491 100644 --- a/tests/symbian-toolchain.test.ts +++ b/tests/symbian-toolchain.test.ts @@ -1,9 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { cpSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, + renameSync, rmSync, writeFileSync, } from "node:fs"; @@ -22,8 +25,10 @@ import { symbianDockerSetupArguments, symbianDownloadsRoot, symbianImplementationDigest, + withSymbianGuestBuildLock, withSymbianRuntimeBuildLock, } from "../tools/symbian-toolchain.ts"; +import { withArtifactLock } from "../tools/psp-toolchain.ts"; const repository = new URL("..", import.meta.url).pathname; const temporary: string[] = []; @@ -70,8 +75,6 @@ describe("canonical Symbian E7 toolchain", () => { rev: "0fc946fb670c0c29bc0135f510bcb0f595415a61", }); expect(SYMBIAN_TOOLCHAIN.runtime).toEqual({ - uid: "0xE7A11010", - output: "dist/symbian/pocketjs-e7-runtime.sis", sisVersion: "1.0.0", rustToolchain: "nightly-2026-07-02", frameRate: 30, @@ -177,9 +180,39 @@ describe("canonical Symbian E7 toolchain", () => { expect(setup).toContain("tools/mifconv.cpp"); expect(setup).toContain("markersSha256: $markers"); expect(setup).toContain("pocketjs-symbian-doctor"); + expect(setup).toContain( + "for alias_pair in GLES2:gles2 EGL:egl GLES:gles; do", + ); + expect(setup).toContain( + 'ln -s "$target_name" "$stage/sdk/epoc32/include/$alias_name"', + ); expect(doctor).toContain("sha256sum --check --status"); expect(doctor).toContain("signsis -o"); + expect(doctor).toContain( + 'test "$(readlink "$root/sdk/epoc32/include/GLES2")" = gles2', + ); + expect(doctor).toContain( + 'test "$(readlink "$root/sdk/epoc32/include/EGL")" = egl', + ); + expect(doctor).toContain( + 'test -s "$root/sdk/include/QtOpenGL/QGLWidget"', + ); + expect(doctor).toContain( + 'test -s "$root/sdk/epoc32/release/armv5/lib/QtOpenGL.dso"', + ); + expect(doctor).toContain( + 'test -s "$root/sdk/epoc32/release/armv5/lib/libGLESv2.dso"', + ); + expect(doctor).toContain( + 'test -s "$root/sdk/epoc32/release/armv5/lib/libEGL.dso"', + ); + expect(doctor).toContain("#include "); + expect(doctor).toContain("class PocketJsGlSmoke : public QGLWidget"); + expect(doctor).toContain("glClear(GL_COLOR_BUFFER_BIT);"); + expect(doctor).toContain("QT += core gui opengl"); expect(doctor).toContain('cd "$smoke"'); + expect(doctor).toContain("make -j2 >/dev/null"); + expect(doctor).toContain('test -s "$smoke/PocketJsDoctorSmoke.exe"'); expect(doctor).toContain("makesis smoke.pkg smoke-unsigned.sis"); expect(codaUsbProbe).toContain("NokiaVendorId = 0x0421"); expect(codaUsbProbe).toContain("NokiaE7SuiteProductId = 0x0335"); @@ -202,8 +235,34 @@ describe("canonical Symbian E7 toolchain", () => { expect(buildApp).toContain("-std=gnu99"); expect(buildApp).toContain("-O0"); expect(buildApp).toContain("POCKETJS_CORE_LIBRARY"); + expect(buildApp).toContain("POCKETJS_SYMBIAN_TARGET"); + expect(buildApp).toContain("POCKETJS_SYMBIAN_CAPTION"); + expect(buildApp).toContain('package_json="$payload/package.json"'); + expect(buildApp).toContain('catalog_index="$payload/catalog.tsv"'); + expect(buildApp).toContain('cp "$catalog_blob" "$build/catalog.bin"'); + expect(buildApp).toContain("catalogIndex: (if $catalogIndexSha256"); + expect(buildApp).toContain( + '"QMAKE_${executable}_LFLAGS=-Ttext 0x80000 -Tdata $data_base"', + ); + expect(buildApp).toContain("embeddedBytes: ($embeddedBytes | tonumber)"); + expect(buildApp).toContain('schemaVersion: 2'); expect(buildApp).toContain("output_stage=$(mktemp -d /out/"); expect(buildApp).toContain('mv -f "$candidate" "$output"'); + const sisPattern = buildApp.match( + /\.sisFile \| strings \| select\(test\("([^"]+)"\)\)/, + )?.[1]; + const receiptPattern = buildApp.match( + /\.receiptFile \| strings \| select\(test\("([^"]+)"\)\)/, + )?.[1]; + expect(sisPattern).toBeDefined(); + expect(receiptPattern).toBeDefined(); + expect(new RegExp(JSON.parse(`"${sisPattern}"`)).test("launcher-main.sis")) + .toBe(true); + expect( + new RegExp(JSON.parse(`"${receiptPattern}"`)).test( + "launcher-main.receipt.json", + ), + ).toBe(true); expect(buildApp).toContain("actual_uid=$(od "); expect(buildApp).toContain("sha256sum --check --status"); expect(buildApp).toContain("SIS version must be three decimal components"); @@ -388,7 +447,7 @@ describe("canonical Symbian E7 toolchain", () => { const orchestrator = readFileSync(join(repository, "tools/symbian.ts"), "utf8"); const transaction = orchestrator.indexOf( - "return await withSymbianRuntimeBuildLock(outputRoot", + "const transaction = async () =>", ); expect(transaction).toBeGreaterThan(-1); expect(orchestrator.indexOf("rmSync(payload", transaction)).toBeGreaterThan( @@ -396,6 +455,49 @@ describe("canonical Symbian E7 toolchain", () => { ); expect(orchestrator.indexOf('resolve(payload, "plan.json")', transaction)) .toBeGreaterThan(transaction); + expect( + orchestrator.indexOf( + "return await withSymbianRuntimeBuildLock(outputRoot, transaction)", + transaction, + ), + ).toBeGreaterThan(transaction); + expect(orchestrator).toContain( + "!activeBuildTransactions.has(options.transaction)", + ); + }); + + test("serializes guest compilation across independent output roots", async () => { + const root = mkdtempSync(join(tmpdir(), "pocketjs-symbian-guest-lock-")); + temporary.push(root); + const env = { POCKET_STACK_CACHE_DIR: join(root, "cache") }; + let active = 0; + let maxActive = 0; + const compile = () => withSymbianGuestBuildLock(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await Bun.sleep(20); + active -= 1; + }, env); + await Promise.all([compile(), compile()]); + expect(maxActive).toBe(1); + + const orchestrator = readFileSync( + join(repository, "tools/symbian.ts"), + "utf8", + ); + expect(orchestrator).toContain( + "await withSymbianGuestBuildLock(async () =>", + ); + const launcher = readFileSync( + join(repository, "tools/launcher.ts"), + "utf8", + ); + expect(launcher).toContain( + "await withSymbianGuestBuildLock(async () =>", + ); + expect(launcher).toContain( + "await withSymbianBuildTransaction(paths.output", + ); }); test("changes the implementation digest when a repository snapshot changes", () => { @@ -422,45 +524,71 @@ describe("canonical Symbian E7 toolchain", () => { test("CODA launch wire and reply parser stay byte-exact", async () => { const compiler = Bun.which("cc"); expect(compiler, "cc is required to validate the shipped CODA client").toBeTruthy(); - const build = mkdtempSync(join(tmpdir(), "pocketjs-coda-protocol-")); - temporary.push(build); + const fixture = join(repository, "tests/fixtures/coda-usb-protocol-test.c"); + const implementation = join(repository, "tools/symbian/coda-usb-probe.c"); + const digest = createHash("sha256") + .update(readFileSync(fixture)) + .update(readFileSync(implementation)) + .update(`${compiler}\0${process.platform}\0${process.arch}`) + .digest("hex"); + const build = join( + tmpdir(), + "pocketjs-coda-protocol-cache", + digest, + ); const binary = join(build, "coda-usb-protocol-test"); - const compiled = Bun.spawn({ - cmd: [ - compiler!, - "-std=c11", - "-Wall", - "-Wextra", - "-Werror", - "-Wno-unused-function", - join(repository, "tests/fixtures/coda-usb-protocol-test.c"), - "-o", - binary, - ], - cwd: repository, - stdout: "pipe", - stderr: "pipe", + await withArtifactLock(`${build}.lock`, async () => { + if (existsSync(binary)) return; + mkdirSync(build, { recursive: true }); + const staged = `${binary}.tmp-${process.pid}-${Date.now()}`; + const compiled = Bun.spawn({ + cmd: [ + compiler!, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-Wno-unused-function", + fixture, + "-o", + staged, + ], + cwd: repository, + stdout: "pipe", + stderr: "pipe", + }); + const [compiledExit, compiledStderr] = await Promise.all([ + compiled.exited, + new Response(compiled.stderr).text(), + ]); + expect(compiledExit, compiledStderr).toBe(0); + renameSync(staged, binary); + rmSync(staged, { force: true }); }); - const [compiledExit, compiledStderr] = await Promise.all([ - compiled.exited, - new Response(compiled.stderr).text(), - ]); - expect(compiledExit, compiledStderr).toBe(0); - // Bun 1.3 on macOS can wait indefinitely in spawnSync for this - // intentionally silent fixture; the async subprocess path does not. + // Bun 1.3 on macOS can wait indefinitely in spawnSync while endpoint + // security validates a freshly linked executable. Cache by exact C source + // and use an explicit watchdog so the protocol proof is fast after its + // first launch and can never strand the whole test process. const tested = Bun.spawn({ cmd: [binary], cwd: repository, stdout: "ignore", stderr: "pipe", }); + let timedOut = false; + const watchdog = setTimeout(() => { + timedOut = true; + tested.kill(); + }, 55_000); const [testedExit, testedStderr] = await Promise.all([ tested.exited, new Response(tested.stderr).text(), ]); + clearTimeout(watchdog); + expect(timedOut, "CODA protocol fixture did not start within 55 seconds").toBe(false); expect(testedExit, testedStderr).toBe(0); - }, 30_000); + }, 60_000); test("Docker invocations are amd64, pinned, and narrowly mounted", () => { const root = mkdtempSync(join(tmpdir(), "pocketjs-symbian-repository-")); diff --git a/tests/touch.test.ts b/tests/touch.test.ts index d930c2ce..b25adf8e 100644 --- a/tests/touch.test.ts +++ b/tests/touch.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { __packTouch, + __packTouchWide, __resetTouches, __setTouches, touches, @@ -20,6 +21,17 @@ describe("touch frame snapshot", () => { ]); }); + test("decodes wide E7 coordinates alongside legacy contacts", () => { + __setTouches([ + __packTouchWide(9, 639, 359), + __packTouch(3, 479, 271), + ]); + expect(touches()).toEqual([ + { id: 9, x: 639, y: 359 }, + { id: 3, x: 479, y: 271 }, + ]); + }); + test("publishes an immutable per-frame snapshot and clears on release", () => { const hostValues = [__packTouch(1, 20, 40)]; __setTouches(hostValues); diff --git a/tools/build.ts b/tools/build.ts index c443f501..b06eaa64 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -6,11 +6,12 @@ // pass 1 transform & collect: framework-specific JSX + TS over every // .tsx/.ts reachable from the app entry (content-hash cached), // collecting candidate class strings + text codepoints from the AST. -// compile tailwind.ts -> styles.bin + framework/src/styles.generated.ts; +// compile tailwind.ts -> styles.bin + an ignored styles.generated.ts mirror; // bake-font.ts -> font atlas per used slot; demo images (PNG/SVG or a // placeholder); pak.ts packs it all -> dist/.pak. -// pass 2 Bun.build (plugin serves the CACHED pass-1 transforms, iife, -// target browser, minify false) -> dist/.js. +// pass 2 Bun.build (plugin serves the CACHED pass-1 transforms plus this +// build's in-memory generated styles, iife, target browser, +// minify false) -> dist/.js. // // Flags: // --framework=solid|vue-vapor select the framework for this build @@ -283,7 +284,11 @@ if (styles.records.length === 0) { console.warn(" tailwind: no class literals compiled — is the app unstyled?"); } const generatedPath = join(ROOT, "framework/src/styles.generated.ts"); -await Bun.write(generatedPath, generateStylesModule(styles)); +const generatedStyles = generateStylesModule(styles); +// Keep the ignored mirror for docs/site tooling and human inspection. Pass 2 +// receives this build's source directly through jsxPlugin, so concurrent +// targets can never import another build's transient STYLE_IDS table. +await Bun.write(generatedPath, generatedStyles); console.log( ` tailwind: ${styles.records.length} style record(s), ${styles.anims.length} baked timeline(s), ` + `${Object.keys(styles.ids).length} literal(s) -> framework/src/styles.generated.ts`, @@ -443,11 +448,9 @@ if (!existsSync(frameworkConfig.rendererPath)) { console.warn(" pass 2: renderer missing — wrote the no-op placeholder (js-runtime phase owns the real one)"); } -// NOTE for external app repos (see open-strike): framework-runtime imports -// (solid-js, vue) must resolve to the ONE copy installed next to the -// framework — symlink `node_modules/solid-js` in your repo at the vendored -// framework's copy, or you will bundle a second reactive runtime and break -// reactivity across the two. +// jsxPlugin owns dependency identity as well as framework aliases. In +// particular, external apps and renderer-solid.ts must share PocketJS's +// browser-mode Solid runtime even when the app has its own node_modules. const result = await Bun.build({ entrypoints: [entry], outdir: DIST, @@ -472,7 +475,11 @@ const result = await Bun.build({ }, minify: false, sourcemap: "none", - plugins: [jsxPlugin(framework, { entry, features: buildPlan?.features })], + plugins: [jsxPlugin(framework, { + entry, + features: buildPlan?.features, + generatedStyles, + })], }); if (!result.success) { for (const log of result.logs) console.error(log); diff --git a/tools/cli/README.md b/tools/cli/README.md index 7f646a45..f151df15 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -25,7 +25,7 @@ pocket symbian setup --yes pocket symbian build probe pocket symbian deploy dist/symbian/pocketjs-e7-probe.sis pocket symbian coda usb -pocket symbian coda usb launch +pocket symbian coda usb launch pocket hw my-app # build + run on a real PSP over PSPLINK pocket psplink # interactive multi-app switcher on a real PSP pocket devtools my-app # DevTools panel + USB debug bridge, one command diff --git a/tools/cli/symbian-toolchain.json b/tools/cli/symbian-toolchain.json index 03cee330..faa2d875 100644 --- a/tools/cli/symbian-toolchain.json +++ b/tools/cli/symbian-toolchain.json @@ -83,8 +83,6 @@ "output": "dist/symbian/pocketjs-e7-probe.sis" }, "runtime": { - "uid": "0xE7A11010", - "output": "dist/symbian/pocketjs-e7-runtime.sis", "sisVersion": "1.0.0", "rustToolchain": "nightly-2026-07-02", "frameRate": 30 diff --git a/tools/launcher.ts b/tools/launcher.ts index c8f04594..a3e28eb0 100644 --- a/tools/launcher.ts +++ b/tools/launcher.ts @@ -2,7 +2,7 @@ // The launcher artifact chain (docs/LAUNCHER.md "Build pipeline"). // -// bun tools/launcher.ts scan [--target psp|vita] registry only +// bun tools/launcher.ts scan [--target psp|vita|symbian] registry only // bun tools/launcher.ts covers [--target ...] + render target-neutral covers // bun tools/launcher.ts build [--target ...] + multi-app console package // @@ -14,16 +14,39 @@ // their own output tree and never overwrite the PSP/sim artifacts in dist/. import { + copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, + renameSync, + rmSync, writeFileSync, } from "node:fs"; -import { join, relative } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; import { encodePNG } from "../tests/png.ts"; import { SHOT_W, SHOT_H, downscaleShot } from "../hosts/sim/shot.ts"; +import { + SYMBIAN_E7_DEFAULT_VIEWPORT, + SYMBIAN_E7_DEV_CONTRACTS, + SYMBIAN_E7_DEV_TARGET_ID, + SYMBIAN_E7_MAX_VIEWPORT, + SYMBIAN_E7_MIN_VIEWPORT, +} from "./symbian-profile.ts"; +import { + buildApp as buildSymbianApp, + withSymbianBuildTransaction, + type SymbianBuildTransaction, +} from "./symbian.ts"; +import { + SYMBIAN_TOOLCHAIN, + withSymbianGuestBuildLock, +} from "./symbian-toolchain.ts"; +import { + withArtifactLock, +} from "./psp-toolchain.ts"; const ROOT = new URL("..", import.meta.url).pathname; const APPS_DIR = join(ROOT, "apps"); @@ -32,8 +55,72 @@ const COVERS_DIR = join(LAUNCHER_DIR, "covers"); const IMAGES_JSON = join(LAUNCHER_DIR, "images.json"); const REGISTRY_TS = join(LAUNCHER_DIR, "registry.generated.ts"); const LAUNCHER_MANIFEST = join(LAUNCHER_DIR, "pocket.json"); +const LAUNCHER_APP = join(LAUNCHER_DIR, "app.tsx"); +const LAUNCHER_PSP_DIR = join(LAUNCHER_DIR, "psp"); + +/** + * Serialize one checkout's committed scan output, deterministic cover renders, + * and target artifact directories. Build/pack never swap the shared generated + * sources: each target compiles its own dist/.launcher-source instead. + */ +export async function withLauncherSourceLock( + operation: () => Promise, + // Kept for API compatibility with callers/tests that previously selected a + // cache root. The lock deliberately ignores cache environment now. + _env: NodeJS.ProcessEnv = process.env, +): Promise { + const dotGit = join(ROOT, ".git"); + let checkoutMetadata = join(ROOT, "dist/.locks"); + if (existsSync(dotGit)) { + checkoutMetadata = dotGit; + try { + const marker = readFileSync(dotGit, "utf8").trim(); + if (marker.startsWith("gitdir:")) { + checkoutMetadata = resolve(ROOT, marker.slice("gitdir:".length).trim()); + } + } catch { + // A normal checkout has a .git directory rather than a gitdir marker. + } + } + return await withArtifactLock( + join( + checkoutMetadata, + "pocketjs-locks", + "generated-source.lock", + ), + operation, + { + timeoutMs: 60 * 60_000, + staleMs: 2 * 60 * 60_000, + }, + ); +} + +let generatedSourceWriteSequence = 0; + +function replaceGeneratedSources( + registry: string | Uint8Array, + images: string | Uint8Array, +): void { + const suffix = `${process.pid}-${generatedSourceWriteSequence++}`; + const registryTemp = `${REGISTRY_TS}.tmp-${suffix}`; + const imagesTemp = `${IMAGES_JSON}.tmp-${suffix}`; + try { + // Stage both complete files before publishing. Each individual rename is + // atomic, but the pair is not; only the explicit `scan` command writes + // these shared files, under the checkout-local source lock. Build/pack + // commands compile from an isolated dist source tree instead. + writeFileSync(registryTemp, registry); + writeFileSync(imagesTemp, images); + renameSync(registryTemp, REGISTRY_TS); + renameSync(imagesTemp, IMAGES_JSON); + } finally { + rmSync(registryTemp, { force: true }); + rmSync(imagesTemp, { force: true }); + } +} -export type LauncherTarget = "psp" | "vita"; +export type LauncherTarget = "psp" | "vita" | typeof SYMBIAN_E7_DEV_TARGET_ID; interface LauncherPaths { /** Target-flavored JS/pak output. PSP stays in dist/ for sim/site compatibility. */ @@ -42,6 +129,10 @@ interface LauncherPaths { packages: string; registryJson: string; registryTsv: string; + /** Build-private entry directory for generated registry data and assets. */ + isolatedSource: string; + catalogIndex?: string; + catalogBlob?: string; } function launcherPaths(target: LauncherTarget): LauncherPaths { @@ -51,14 +142,23 @@ function launcherPaths(target: LauncherTarget): LauncherPaths { packages: join(ROOT, "dist/packages"), registryJson: join(ROOT, "dist/launcher-registry.json"), registryTsv: join(ROOT, "dist/launcher-registry.tsv"), + isolatedSource: join(ROOT, "dist/launcher/psp/.launcher-source"), }; } - const output = join(ROOT, "dist/launcher/vita"); + const targetDirectory = target === "vita" ? "vita" : "symbian"; + const output = join(ROOT, "dist/launcher", targetDirectory); return { output, packages: join(output, "packages"), registryJson: join(output, "launcher-registry.json"), registryTsv: join(output, "launcher-registry.tsv"), + isolatedSource: join(output, ".launcher-source"), + ...(target === SYMBIAN_E7_DEV_TARGET_ID + ? { + catalogIndex: join(output, "catalog.tsv"), + catalogBlob: join(output, "catalog.bin"), + } + : {}), }; } @@ -93,18 +193,216 @@ export interface LauncherRegistryEntry { /** Manifest path, repo-root-relative (hosts/psp/build.rs never reads it — * it is for humans and for `covers` to rebuild a stale dist). */ manifest: string; + /** Build root for an explicitly included external manifest. Omitted for + * repository-owned apps so committed/default registries stay portable. */ + projectRoot?: string; } export interface LauncherRegistry { apps: LauncherRegistryEntry[]; } +class LauncherUsageError extends Error {} + function usage(message?: string): never { if (message) console.error(`launcher: ${message}`); console.error( - "usage: bun tools/launcher.ts [--target psp|vita] [--exclude ]... [--force] [-- backend args]", + "usage: bun tools/launcher.ts [--target psp|vita|symbian] [--exclude ]... [--include-manifest ]... [--force] [-- backend args]", + ); + throw new LauncherUsageError(message); +} + +function manifestPath(entry: Pick): string { + return isAbsolute(entry.manifest) + ? entry.manifest + : resolve(entry.projectRoot ?? ROOT, entry.manifest); +} + +function projectRoot(entry: Pick): string { + return resolve(entry.projectRoot ?? ROOT); +} + +function portableRelative(from: string, to: string): string { + return relative(from, to).replaceAll("\\", "/"); +} + +function moduleSpecifier(from: string, to: string): string { + const specifier = portableRelative(from, to); + return specifier.startsWith(".") ? specifier : `./${specifier}`; +} + +function launcherManifestForTarget( + target: LauncherTarget, + entry?: string, +): unknown { + const manifest = JSON.parse(readFileSync(LAUNCHER_MANIFEST, "utf8")) as { + engine: { + capabilities: { + enhances?: string[]; + }; + }; + app: { + entry: string; + viewport: Record; + }; + }; + if (entry) manifest.app.entry = entry; + if (target === SYMBIAN_E7_DEV_TARGET_ID) { + manifest.engine.capabilities.enhances = [ + ...(manifest.engine.capabilities.enhances ?? []), + "display.viewport.live", + "input.touch", + ]; + manifest.app.viewport.dynamic = { + default: SYMBIAN_E7_DEFAULT_VIEWPORT, + min: SYMBIAN_E7_MIN_VIEWPORT, + max: SYMBIAN_E7_MAX_VIEWPORT, + }; + } + return manifest; +} + +function launcherBuildEntry( + target: LauncherTarget, + paths: LauncherPaths, +): Pick { + if (target !== SYMBIAN_E7_DEV_TARGET_ID) { + return { manifest: relative(ROOT, LAUNCHER_MANIFEST) }; + } + const generatedManifest = join( + paths.output, + ".manifests", + "launcher.symbian.pocket.json", + ); + mkdirSync(dirname(generatedManifest), { recursive: true }); + writeFileSync( + generatedManifest, + JSON.stringify(launcherManifestForTarget(target), null, 2) + "\n", + ); + return { manifest: generatedManifest, projectRoot: ROOT }; +} + +/** + * Materialize a compiler-only launcher project under dist. Its generated + * registry and image metadata are private to this build, so a direct + * tools/build.ts process or watcher can keep reading the committed launcher + * sources while target-specific packaging runs. + */ +export function prepareIsolatedLauncherSource( + target: LauncherTarget, + registry: LauncherRegistry, + sourceDirectory: string, +): Pick { + const source = resolve(sourceDirectory); + const distRoot = join(ROOT, "dist"); + const sourceRelativeToDist = relative(distRoot, source); + if ( + !sourceRelativeToDist || + sourceRelativeToDist === "." || + sourceRelativeToDist === ".." || + sourceRelativeToDist.startsWith("../") || + sourceRelativeToDist.startsWith("..\\") || + isAbsolute(sourceRelativeToDist) + ) { + throw new Error( + `launcher: isolated source must stay below ${distRoot}: ${source}`, + ); + } + + rmSync(source, { recursive: true, force: true }); + const covers = join(source, "covers"); + mkdirSync(covers, { recursive: true }); + + const generated = launcherGeneratedSources(registry); + writeFileSync(join(source, "registry.generated.ts"), generated.registryTs); + writeFileSync(join(source, "images.json"), generated.imagesJson); + + const mainPath = join(source, "main.tsx"); + writeFileSync( + mainPath, + [ + "// GENERATED by tools/launcher.ts — isolated target launcher entry.", + "// @title PocketJS: Launcher", + 'import { mount } from "@pocketjs/framework";', + `import Launcher from ${JSON.stringify(moduleSpecifier(source, LAUNCHER_APP))};`, + 'import { REGISTRY } from "./registry.generated.ts";', + "", + "mount(() => );", + "", + ].join("\n"), + ); + + const assets = [ + "launcher-bg.png", + ...registry.apps.flatMap((app) => [ + `cover-${app.output}.png`, + `refl-${app.output}.png`, + ]), + ]; + for (const asset of assets) { + const sourceAsset = join(COVERS_DIR, asset); + if (!existsSync(sourceAsset)) { + throw new Error(`launcher: isolated asset is missing: ${sourceAsset}`); + } + copyFileSync(sourceAsset, join(covers, asset)); + } + + const manifestPath = join(source, "pocket.json"); + writeFileSync( + manifestPath, + JSON.stringify( + launcherManifestForTarget( + target, + portableRelative(ROOT, mainPath), + ), + null, + 2, + ) + "\n", + ); + return { manifest: manifestPath, projectRoot: ROOT }; +} + +function copyIsolatedPspMetadata(source: string): void { + const destination = join(source, "psp"); + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + for (const asset of ["Psp.toml", "icon0.png", "pic1.png"]) { + const path = join(LAUNCHER_PSP_DIR, asset); + if (!existsSync(path)) { + throw new Error(`launcher: PSP metadata is missing: ${path}`); + } + copyFileSync(path, join(destination, asset)); + } +} + +function inferExternalProjectRoot( + absoluteManifest: string, + entry: string, +): string { + let candidate = dirname(absoluteManifest); + while (true) { + if (existsSync(resolve(candidate, entry))) return candidate; + const parent = dirname(candidate); + if (parent === candidate) { + throw new Error( + `launcher: cannot find external entry ${entry} above ${absoluteManifest}`, + ); + } + candidate = parent; + } +} + +function resolveForTarget( + manifest: unknown, + target: LauncherTarget, +): ReturnType { + return validateAndResolveBuildPlan( + manifest, + { target }, + target === SYMBIAN_E7_DEV_TARGET_ID + ? SYMBIAN_E7_DEV_CONTRACTS + : undefined, ); - process.exit(1); } function scanRegistryForTarget( @@ -119,7 +417,7 @@ function scanRegistryForTarget( const manifestPath = join(APPS_DIR, dir, "pocket.json"); if (!existsSync(manifestPath)) continue; const manifest: unknown = JSON.parse(readFileSync(manifestPath, "utf8")); - const resolution = validateAndResolveBuildPlan(manifest, { target }); + const resolution = resolveForTarget(manifest, target); if (!resolution.ok) { const codes = resolution.diagnostics.map((d) => d.code).join(", "); if (logSkips) @@ -160,17 +458,114 @@ export function scanRegistry( return scanRegistryForTarget(exclude, target, true); } +export function includeExternalManifests( + registry: LauncherRegistry, + manifests: readonly string[], + exclude: ReadonlySet, + target: LauncherTarget, + launcher?: Pick, +): LauncherRegistry { + const apps = [...registry.apps]; + const seen = new Map(apps.map((app) => [app.output, app.manifest])); + const seenIds = new Map(apps.map((app) => [app.id, app.manifest])); + const launcherManifest = launcher + ? JSON.parse(readFileSync(manifestPath(launcher), "utf8")) + : launcherManifestForTarget(target); + const launcherResolution = resolveForTarget(launcherManifest, target); + if (!launcherResolution.ok) { + throw new Error(`launcher: launcher manifest does not admit ${target}`); + } + seen.set( + launcherResolution.plan.app.output, + relative(ROOT, LAUNCHER_MANIFEST), + ); + seenIds.set( + launcherResolution.plan.app.id, + relative(ROOT, LAUNCHER_MANIFEST), + ); + for (const requested of manifests) { + const absoluteManifest = resolve(requested); + if (!existsSync(absoluteManifest)) { + throw new Error(`launcher: external manifest is missing: ${absoluteManifest}`); + } + const manifest: unknown = JSON.parse(readFileSync(absoluteManifest, "utf8")); + const resolution = resolveForTarget(manifest, target); + if (!resolution.ok) { + const codes = resolution.diagnostics.map((diagnostic) => diagnostic.code).join(", "); + throw new Error( + `launcher: external manifest ${absoluteManifest} is not ${target}-admissible (${codes})`, + ); + } + const { output, id, title } = resolution.plan.app; + if (exclude.has(output)) continue; + const previous = seen.get(output); + if (previous) { + throw new Error( + `launcher: external output ${output} duplicates ${previous}`, + ); + } + const previousId = seenIds.get(id); + if (previousId) { + throw new Error( + `launcher: external id ${id} duplicates ${previousId}`, + ); + } + seen.set(output, absoluteManifest); + seenIds.set(id, absoluteManifest); + apps.push({ + output, + id, + title, + manifest: absoluteManifest, + projectRoot: inferExternalProjectRoot( + absoluteManifest, + resolution.plan.app.entry, + ), + }); + } + apps.sort((a, b) => + a.title < b.title + ? -1 + : a.title > b.title + ? 1 + : a.output < b.output + ? -1 + : 1, + ); + return { apps }; +} + +function mergeDisplayRegistry( + base: LauncherRegistry, + additions: LauncherRegistry, +): LauncherRegistry { + const apps = new Map(base.apps.map((app) => [app.output, app])); + for (const app of additions.apps) apps.set(app.output, app); + return { + apps: [...apps.values()].sort((a, b) => + a.title < b.title + ? -1 + : a.title > b.title + ? 1 + : a.output < b.output + ? -1 + : 1, + ), + }; +} + /** * Display metadata is target-neutral and committed in one generated module. - * Keep it as the PSP/Vita union so running a Vita build can never leave the - * common source tree in a Vita-only state. Each native host still reports the - * target-admitted subset through appTable(), and the launcher intersects it. + * Keep it as the PSP/Vita/Symbian union so running any target build can never + * leave the common source tree in a target-only state. Each native host still + * reports the target-admitted subset through appTable(), and the launcher + * intersects it. */ export function scanDisplayRegistry( exclude: ReadonlySet, ): LauncherRegistry { const byOutput = new Map(); - for (const target of ["psp", "vita"] as const) { + for (const target of ["psp", "vita", SYMBIAN_E7_DEV_TARGET_ID] as const) { for (const app of scanRegistryForTarget(exclude, target, false).apps) { byOutput.set(app.output, app); } @@ -187,28 +582,13 @@ export function scanDisplayRegistry( return { apps }; } -function writeRegistry( - targetRegistry: LauncherRegistry, - displayRegistry: LauncherRegistry, - paths: LauncherPaths, -): void { - mkdirSync(paths.output, { recursive: true }); - writeFileSync( - paths.registryJson, - JSON.stringify(targetRegistry, null, 2) + "\n", - ); - // The native build's twin (hosts/psp/build.rs): output\tid\ttitle per line — - // no JSON parser inside a build script. - writeFileSync( - paths.registryTsv, - targetRegistry.apps - .map((a) => `${a.output}\t${a.id}\t${a.title}\n`) - .join(""), - ); +export function launcherGeneratedSources( + registry: LauncherRegistry, +): { registryTs: string; imagesJson: string } { const lines = [ "// GENERATED by tools/launcher.ts scan — do not edit by hand; COMMIT", "// the regenerated file (tests/launcher-sim.test.ts asserts freshness).", - "// The display-side PSP/Vita union: the launcher app imports it for", + "// The display-side PSP/Vita/Symbian union: the launcher imports it for", "// titles + cover asset keys; each host's target-specific appTable", "// (spec op 39) stays the runtime truth for what is embedded.", "", @@ -225,7 +605,7 @@ function writeRegistry( "}", "", "export const REGISTRY: readonly RegistryApp[] = [", - ...displayRegistry.apps.map( + ...registry.apps.map( (a) => ` { output: ${JSON.stringify(a.output)}, id: ${JSON.stringify(a.id)}, title: ${JSON.stringify( a.title, @@ -236,47 +616,131 @@ function writeRegistry( "] as const;", "", ]; - mkdirSync(LAUNCHER_DIR, { recursive: true }); - writeFileSync(REGISTRY_TS, lines.join("\n")); - // Static-image meta for tools/build.ts: every cover samples bilinear - // (IMG_FLAG_LINEAR) — the deck rotates and scales them, nearest shimmers. - // Committed alongside registry.generated.ts, same freshness story. const images: Record = { "covers/launcher-bg.png": { linear: true }, }; - for (const a of displayRegistry.apps) { + for (const a of registry.apps) { images[`covers/cover-${a.output}.png`] = { linear: true }; // Reflections stay 8888: their whole point is a smooth alpha ramp, and // PSM_4444's 4-bit alpha gives the 0.3→0 fade only ~5 steps — visible // horizontal banding on hardware. Quarter-res keeps them cheap (32 KB). images[`covers/refl-${a.output}.png`] = { linear: true }; } - writeFileSync(IMAGES_JSON, JSON.stringify(images, null, 2) + "\n"); + return { + registryTs: lines.join("\n"), + imagesJson: JSON.stringify(images, null, 2) + "\n", + }; +} + +function writeLauncherGeneratedSources( + registry: LauncherRegistry, +): void { + const generated = launcherGeneratedSources(registry); + mkdirSync(LAUNCHER_DIR, { recursive: true }); + replaceGeneratedSources(generated.registryTs, generated.imagesJson); +} + +function writeRegistry( + targetRegistry: LauncherRegistry, + displayRegistry: LauncherRegistry, + paths: LauncherPaths, + persistGeneratedSources: boolean, +): void { + mkdirSync(paths.output, { recursive: true }); + writeFileSync( + paths.registryJson, + JSON.stringify({ + apps: targetRegistry.apps.map(({ projectRoot: externalRoot, ...app }) => ({ + ...app, + manifest: externalRoot + ? relative(externalRoot, manifestPath({ ...app, projectRoot: externalRoot })) + : app.manifest, + })), + }, null, 2) + "\n", + ); + // The native build's twin (hosts/psp/build.rs): output\tid\ttitle per line — + // no JSON parser inside a build script. + writeFileSync( + paths.registryTsv, + targetRegistry.apps + .map((a) => `${a.output}\t${a.id}\t${a.title}\n`) + .join(""), + ); + if (persistGeneratedSources) { + writeLauncherGeneratedSources(displayRegistry); + } +} + +export function needsLauncherCompile( + outputName: string, + target: LauncherTarget, + outputDirectory: string, + force: boolean, +): boolean { + if (force || target === SYMBIAN_E7_DEV_TARGET_ID) return true; + return !existsSync(join(outputDirectory, `${outputName}.js`)) || + !existsSync(join(outputDirectory, `${outputName}.pak`)); } async function compileApp( - manifest: string, + app: Pick, target: LauncherTarget, output: string, ): Promise { - const p = Bun.spawnSync( - [ - "bun", - "tools/pocket.ts", - "compile", - "--target", - target, - "--manifest", - manifest, - "--project-root", - ".", - "--outdir", - relative(ROOT, output), - ], - { cwd: ROOT, stdout: "inherit", stderr: "inherit" }, - ); - if (p.exitCode !== 0) - throw new Error(`launcher: compile failed for ${manifest}`); + await withSymbianGuestBuildLock(async () => { + const absoluteManifest = manifestPath(app); + const absoluteProjectRoot = projectRoot(app); + if (target === SYMBIAN_E7_DEV_TARGET_ID) { + const manifestBytes = readFileSync(absoluteManifest); + const resolution = resolveForTarget( + JSON.parse(manifestBytes.toString("utf8")), + target, + ); + if (!resolution.ok) { + throw new Error(`launcher: compile failed to resolve ${absoluteManifest}`); + } + const planDirectory = join(output, ".plans"); + mkdirSync(planDirectory, { recursive: true }); + const planPath = join( + planDirectory, + `${resolution.plan.app.output}.json`, + ); + writeFileSync(planPath, JSON.stringify(resolution.plan, null, 2) + "\n"); + const p = Bun.spawnSync( + [ + "bun", + "tools/build.ts", + `--plan=${planPath}`, + `--project-root=${absoluteProjectRoot}`, + `--outdir=${output}`, + ], + { cwd: ROOT, stdout: "inherit", stderr: "inherit" }, + ); + if (p.exitCode !== 0) { + throw new Error(`launcher: compile failed for ${absoluteManifest}`); + } + return; + } + const p = Bun.spawnSync( + [ + "bun", + "tools/pocket.ts", + "compile", + "--target", + target, + "--manifest", + absoluteManifest, + "--project-root", + absoluteProjectRoot, + "--outdir", + output, + ], + { cwd: ROOT, stdout: "inherit", stderr: "inherit" }, + ); + if (p.exitCode !== 0) { + throw new Error(`launcher: compile failed for ${absoluteManifest}`); + } + }); } /** Deterministic stand-in for apps the sim cannot boot (today: vue-vapor @@ -423,19 +887,21 @@ async function renderCovers( if (force || !existsSync(join(ROOT, "dist", `${app.output}.js`))) { // The deterministic sim is the PSP-flavored 480x272 oracle. Covers are // distribution metadata, not a target raster variant; Vita consumes the - // same 256x128 PNG from its own density-2 launcher pak. Prefer the PSP - // bundle; a future Vita-only app still gets cover metadata through the + // same 256x128 PNG from its own density-2 launcher pak. Prefer PSP, then + // Vita; a Symbian-only dynamic app uses its density-1 bundle through the // injected sim host without making the common registry target-specific. const manifest = JSON.parse( - readFileSync(join(ROOT, app.manifest), "utf8"), + readFileSync(manifestPath(app), "utf8"), ); const coverTarget: LauncherTarget = validateAndResolveBuildPlan( manifest, { target: "psp" }, ).ok ? "psp" - : "vita"; - await compileApp(app.manifest, coverTarget, join(ROOT, "dist")); + : validateAndResolveBuildPlan(manifest, { target: "vita" }).ok + ? "vita" + : SYMBIAN_E7_DEV_TARGET_ID; + await compileApp(app, coverTarget, join(ROOT, "dist")); } let shot: Uint8Array; try { @@ -529,24 +995,24 @@ async function packPackages( registry: LauncherRegistry, target: LauncherTarget, paths: LauncherPaths, + launcher: Pick, ): Promise { const { makeVariant } = await import("./pocket-pack.ts"); const { encodePocketPackage } = await import("../contracts/spec/pocket-package.ts"); const { canonicalJson } = await import("../framework/src/manifest/plan.ts"); - const { validateAndResolveBuildPlan } = - await import("../framework/src/manifest/resolve.ts"); mkdirSync(paths.packages, { recursive: true }); const entries = [ - { manifest: relative(ROOT, LAUNCHER_MANIFEST) }, - ...registry.apps.map((a) => ({ manifest: a.manifest })), + launcher, + ...registry.apps, ]; for (const entry of entries) { - const manifestBytes = readFileSync(join(ROOT, entry.manifest)); + const absoluteManifest = manifestPath(entry); + const manifestBytes = readFileSync(absoluteManifest); const manifest: unknown = JSON.parse(manifestBytes.toString("utf8")); - const resolution = validateAndResolveBuildPlan(manifest, { target }); + const resolution = resolveForTarget(manifest, target); if (!resolution.ok) { throw new Error( - `launcher pack: ${entry.manifest} no longer admits ${target}`, + `launcher pack: ${absoluteManifest} no longer admits ${target}`, ); } const plan = resolution.plan; @@ -585,6 +1051,179 @@ async function packPackages( ); } +export interface SymbianCatalogEntry { + plan: ResolvedBuildPlan; + packageBytes: Uint8Array; + liveViewport: boolean; +} + +export interface EncodedSymbianCatalog { + index: Uint8Array; + blob: Uint8Array; +} + +const alignCatalogOffset = (value: number) => Math.ceil(value / 16) * 16; + +function assertCatalogField(value: string, label: string): void { + if (!value || /[\t\r\n\0]/.test(value)) { + throw new Error(`launcher: unsafe ${label} in Symbian catalog`); + } +} + +/** Concatenate target-thinned `.pocket` files without rewriting them. + * `catalog.tsv` is deliberately tiny enough for the Qt 4 host to parse + * without bringing a JSON implementation into the SIS. */ +export function encodeSymbianCatalog( + entries: readonly SymbianCatalogEntry[], +): EncodedSymbianCatalog { + if ( + entries.length < 2 || + entries[0]?.plan.app.id !== "dev.pocket-stack.launcher" + ) { + throw new Error( + "launcher: Symbian catalog must start with the launcher and contain an app", + ); + } + + let length = 0; + const offsets: number[] = []; + for (const entry of entries) { + length = alignCatalogOffset(length); + if (entry.packageBytes.byteLength > 0x7fffffff - length) { + throw new Error("launcher: Symbian catalog exceeds the 2 GiB host limit"); + } + offsets.push(length); + length += entry.packageBytes.byteLength; + } + + const blob = new Uint8Array(length); + const rows: string[] = []; + entries.forEach((entry, index) => { + const { app, viewport } = entry.plan; + assertCatalogField(app.output, "output"); + assertCatalogField(app.id, "app id"); + assertCatalogField(app.title, "title"); + blob.set(entry.packageBytes, offsets[index]!); + rows.push( + [ + app.output, + app.id, + app.title, + offsets[index], + entry.packageBytes.byteLength, + viewport.logical[0], + viewport.logical[1], + entry.liveViewport ? "live" : "fixed", + ].join("\t"), + ); + }); + return { + index: new TextEncoder().encode(rows.join("\n") + "\n"), + blob, + }; +} + +async function writeSymbianCatalog( + registry: LauncherRegistry, + paths: LauncherPaths, + launcher: Pick, +): Promise { + if (!paths.catalogIndex || !paths.catalogBlob) { + throw new Error("launcher: Symbian catalog paths are missing"); + } + const { + decodeIdentity, + decodePocketPackage, + findVariant, + POCKET_SECTION, + } = + await import("../contracts/spec/pocket-package.ts"); + const manifests = [ + launcher, + ...registry.apps, + ]; + const entries: SymbianCatalogEntry[] = []; + for (const app of manifests) { + const absoluteManifest = manifestPath(app); + const manifest: unknown = JSON.parse( + readFileSync(absoluteManifest, "utf8"), + ); + const resolution = resolveForTarget( + manifest, + SYMBIAN_E7_DEV_TARGET_ID, + ); + if (!resolution.ok) { + throw new Error( + `launcher: ${absoluteManifest} no longer admits ${SYMBIAN_E7_DEV_TARGET_ID}`, + ); + } + const packagePath = join( + paths.packages, + `${resolution.plan.app.output}.pocket`, + ); + const packageBytes = new Uint8Array(readFileSync(packagePath)); + const decoded = decodePocketPackage(packageBytes); + const variant = findVariant(decoded, SYMBIAN_E7_DEV_TARGET_ID); + if (!variant || variant.hostAbi !== resolution.plan.target.hostAbi) { + throw new Error( + `launcher: ${packagePath} does not match its Symbian build plan`, + ); + } + const identitySection = variant.sections.find( + (section) => section.kind === POCKET_SECTION.identity, + ); + if (!identitySection) { + throw new Error(`launcher: ${packagePath} has no identity section`); + } + const identity = decodeIdentity(identitySection.bytes); + if ( + identity.output !== resolution.plan.app.output || + identity.id !== resolution.plan.app.id || + identity.title !== resolution.plan.app.title + ) { + throw new Error( + `launcher: ${packagePath} identity does not match its Symbian plan`, + ); + } + entries.push({ + plan: resolution.plan, + packageBytes, + liveViewport: + resolution.plan.features["display.viewport.live"] === true, + }); + } + const catalog = encodeSymbianCatalog(entries); + writeFileSync(paths.catalogIndex, catalog.index); + writeFileSync(paths.catalogBlob, catalog.blob); + console.log( + `launcher: ${entries.length} package(s) -> ${relative(ROOT, paths.catalogBlob)}`, + ); +} + +function symbianBackendOptions(args: readonly string[]): { + sisVersion: string; + uid?: string; +} { + let sisVersion = SYMBIAN_TOOLCHAIN.runtime.sisVersion; + let uid: string | undefined; + for (let index = 0; index < args.length; ++index) { + const argument = args[index]!; + if (argument === "--sis-version" || argument === "--uid") { + const value = args[++index]; + if (!value) usage(`${argument} requires a value`); + if (argument === "--sis-version") sisVersion = value; + else uid = value; + } else if (argument.startsWith("--sis-version=")) { + sisVersion = argument.slice("--sis-version=".length); + } else if (argument.startsWith("--uid=")) { + uid = argument.slice("--uid=".length); + } else { + usage(`unknown Symbian backend option ${argument}`); + } + } + return { sisVersion, uid }; +} + async function main(): Promise { const argv = Bun.argv.slice(2); const command = argv.shift(); @@ -596,6 +1235,7 @@ async function main(): Promise { ) usage(); const exclude = new Set(); + const externalManifests: string[] = []; let force = false; let target: LauncherTarget = "psp"; const separator = argv.indexOf("--"); @@ -607,16 +1247,34 @@ async function main(): Promise { const value = argv.shift(); if (!value) usage("--exclude requires an output name"); exclude.add(value); + } else if (arg === "--include-manifest") { + const value = argv.shift(); + if (!value) usage("--include-manifest requires a pocket.json path"); + externalManifests.push(value); + } else if (arg.startsWith("--include-manifest=")) { + const value = arg.slice("--include-manifest=".length); + if (!value) usage("--include-manifest requires a pocket.json path"); + externalManifests.push(value); } else if (arg === "--target") { const value = argv.shift(); - if (value !== "psp" && value !== "vita") - usage("--target must be psp or vita"); - target = value; + if ( + value !== "psp" && + value !== "vita" && + value !== "symbian" && + value !== SYMBIAN_E7_DEV_TARGET_ID + ) + usage("--target must be psp, vita, or symbian"); + target = value === "symbian" ? SYMBIAN_E7_DEV_TARGET_ID : value; } else if (arg.startsWith("--target=")) { const value = arg.slice("--target=".length); - if (value !== "psp" && value !== "vita") - usage("--target must be psp or vita"); - target = value; + if ( + value !== "psp" && + value !== "vita" && + value !== "symbian" && + value !== SYMBIAN_E7_DEV_TARGET_ID + ) + usage("--target must be psp, vita, or symbian"); + target = value === "symbian" ? SYMBIAN_E7_DEV_TARGET_ID : value; } else if (arg === "--force") { force = true; } else { @@ -625,84 +1283,171 @@ async function main(): Promise { } const paths = launcherPaths(target); - console.log( - `launcher: scanning apps/*/pocket.json against target ${target}`, - ); - const registry = scanRegistry(exclude, target); - const displayRegistry = scanDisplayRegistry(exclude); - writeRegistry(registry, displayRegistry, paths); - console.log( - `launcher: ${registry.apps.length} ${target} app(s) admitted -> ${relative(ROOT, paths.registryJson)}`, - ); - for (const app of registry.apps) { - const js = join(paths.output, `${app.output}.js`); - const pak = join(paths.output, `${app.output}.pak`); - const size = (p: string) => (existsSync(p) ? Bun.file(p).size : 0); - const total = size(js) + size(pak); + if ( + externalManifests.length > 0 && + target !== SYMBIAN_E7_DEV_TARGET_ID + ) { + usage("--include-manifest is currently limited to the Symbian launcher"); + } + // Validate target-specific backend arguments before launcherBuildEntry() or + // writeRegistry() can emit any build metadata. + const symbianBackend = + target === SYMBIAN_E7_DEV_TARGET_ID + ? symbianBackendOptions(backendArgs) + : undefined; + const execute = async ( + symbianTransaction?: SymbianBuildTransaction, + ): Promise => { + let launcher = launcherBuildEntry(target, paths); console.log( - ` ${app.output.padEnd(24)} ${app.title.padEnd(28)} ${total ? (total / 1024).toFixed(0) + " KB" : "(not built)"}`, + `launcher: scanning apps/*/pocket.json against target ${target}`, ); - } - if (command === "scan") return; + const registry = includeExternalManifests( + scanRegistry(exclude, target), + externalManifests, + exclude, + target, + launcher, + ); + const displayRegistry = mergeDisplayRegistry( + scanDisplayRegistry(exclude), + registry, + ); + // Only a plain scan updates the committed display union. External scans + // and every build-like command write dist metadata only. + writeRegistry( + registry, + displayRegistry, + paths, + command === "scan" && externalManifests.length === 0, + ); + console.log( + `launcher: ${registry.apps.length} ${target} app(s) admitted -> ${relative(ROOT, paths.registryJson)}`, + ); + for (const app of registry.apps) { + const js = join(paths.output, `${app.output}.js`); + const pak = join(paths.output, `${app.output}.pak`); + const size = (p: string) => (existsSync(p) ? Bun.file(p).size : 0); + const total = size(js) + size(pak); + console.log( + ` ${app.output.padEnd(24)} ${app.title.padEnd(28)} ${total ? (total / 1024).toFixed(0) + " KB" : "(not built)"}`, + ); + } + if (command === "scan") return; - console.log( - "launcher: rendering common covers (PSP-flavored sim, deterministic)", - ); - await renderCovers(displayRegistry, force); - if (command === "covers") return; + console.log( + "launcher: rendering common covers (PSP-flavored sim, deterministic)", + ); + await renderCovers(displayRegistry, force); + if (command === "covers") return; - console.log( - `launcher: compiling ${target} app dists -> ${relative(ROOT, paths.output)}/`, - ); - for (const app of registry.apps) { - if (force || !existsSync(join(paths.output, `${app.output}.js`))) { - await compileApp(app.manifest, target, paths.output); + console.log( + `launcher: compiling ${target} app dists -> ${relative(ROOT, paths.output)}/`, + ); + for (const app of registry.apps) { + if (needsLauncherCompile(app.output, target, paths.output, force)) { + await compileApp(app, target, paths.output); + } } - } - console.log(`launcher: compiling the ${target} launcher app`); - await compileApp(relative(ROOT, LAUNCHER_MANIFEST), target, paths.output); - console.log(`launcher: packing ${target} .pocket files`); - await packPackages(registry, target, paths); - if (command === "pack") return; - - if (target === "psp") { - console.log("launcher: rendering XMB art"); - await renderXmbArt(); - } - console.log( - `launcher: building the multi-app ${target === "psp" ? "EBOOT" : "VPK"}`, - ); - const targetBackendArgs = - target === "vita" - ? [ - `--launcher-packages=${relative(ROOT, paths.packages)}`, - `--package-outdir=${relative(ROOT, join(ROOT, "dist/vita"))}`, - ] - : []; - const p = Bun.spawnSync( - [ - "bun", - "tools/pocket.ts", - "build", - "--target", + // PSP/Vita retain the historical display union and let appTable() filter + // it at runtime. Symbian bakes only target-admitted apps to keep E7 texture + // memory bounded. + launcher = prepareIsolatedLauncherSource( target, - "--manifest", - relative(ROOT, LAUNCHER_MANIFEST), - "--project-root", - ".", - "--outdir", - relative(ROOT, paths.output), - "--", - `--launcher-registry=${relative(ROOT, paths.registryTsv)}`, - ...targetBackendArgs, - ...backendArgs, - ], - { cwd: ROOT, stdout: "inherit", stderr: "inherit" }, - ); - if (p.exitCode !== 0) - throw new Error(`launcher: ${target} backend build failed`); + target === SYMBIAN_E7_DEV_TARGET_ID ? registry : displayRegistry, + paths.isolatedSource, + ); + console.log(`launcher: compiling the ${target} launcher app`); + await compileApp(launcher, target, paths.output); + console.log(`launcher: packing ${target} .pocket files`); + await packPackages(registry, target, paths, launcher); + if (target === SYMBIAN_E7_DEV_TARGET_ID) { + console.log("launcher: assembling the Symbian package catalog"); + await writeSymbianCatalog(registry, paths, launcher); + } + if (command === "pack") return; + + if (target === "psp") { + console.log("launcher: rendering XMB art"); + await renderXmbArt(); + copyIsolatedPspMetadata(paths.isolatedSource); + } + console.log(`launcher: building the multi-app ${ + target === "psp" + ? "EBOOT" + : target === "vita" + ? "VPK" + : "SIS" + }`); + if (target === SYMBIAN_E7_DEV_TARGET_ID) { + if (!symbianBackend) { + throw new Error("launcher: missing validated Symbian backend options"); + } + const sis = await buildSymbianApp( + manifestPath(launcher), + symbianBackend.sisVersion, + { + projectRoot: ROOT, + outputRoot: paths.output, + uid: symbianBackend.uid, + catalogIndex: paths.catalogIndex!, + catalogBlob: paths.catalogBlob!, + transaction: symbianTransaction, + }, + ); + console.log(`PocketJS Symbian launcher: ${sis}`); + return; + } + const targetBackendArgs = + target === "vita" + ? [ + `--launcher-packages=${relative(ROOT, paths.packages)}`, + `--package-outdir=${relative(ROOT, join(ROOT, "dist/vita"))}`, + ] + : []; + const p = Bun.spawnSync( + [ + "bun", + "tools/pocket.ts", + "build", + "--target", + target, + "--manifest", + manifestPath(launcher), + "--project-root", + projectRoot(launcher), + "--outdir", + paths.output, + "--", + `--launcher-registry=${relative(ROOT, paths.registryTsv)}`, + ...targetBackendArgs, + ...backendArgs, + ], + { cwd: ROOT, stdout: "inherit", stderr: "inherit" }, + ); + if (p.exitCode !== 0) + throw new Error(`launcher: ${target} backend build failed`); + }; + + await withLauncherSourceLock(async () => { + if (target === SYMBIAN_E7_DEV_TARGET_ID) { + await withSymbianBuildTransaction(paths.output, async (transaction) => { + await execute(transaction); + }); + } else { + await execute(); + } + }); } if (import.meta.main) { - await main(); + try { + await main(); + } catch (error) { + if (error instanceof LauncherUsageError) { + process.exitCode = 1; + } else { + throw error; + } + } } diff --git a/tools/symbian-package.ts b/tools/symbian-package.ts new file mode 100644 index 00000000..8ac4cd24 --- /dev/null +++ b/tools/symbian-package.ts @@ -0,0 +1,119 @@ +import { createHash } from "node:crypto"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; + +const DEVELOPMENT_UID = /^0xE[0-9A-F]{7}$/i; +const SAFE_OUTPUT = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; +const SAFE_EXECUTABLE = /^[A-Za-z][A-Za-z0-9]{0,30}$/; +const DEFAULT_DATA_BASE = 0x400000; +const DATA_BASE_ALIGNMENT = 0x100000; +const MAX_DATA_BASE = 0x10000000; + +export interface SymbianPackageIdentity { + readonly appId: string; + readonly appOutput: string; + readonly title: string; + readonly uid: string; + readonly executable: string; + readonly sisFile: string; + readonly receiptFile: string; +} + +/** + * GCCE's stock Qt mkspec puts writable data at 0x400000. Large qrc payloads + * (DeepZoom tiles or a multi-app catalog) can extend read-only data past that + * address, so reserve the raw embedded byte count on top of the historical + * 4 MiB baseline and round to a 1 MiB boundary. The linker remains the final + * overlap check; the upper bound rejects artifacts too large for this E7 host. + */ +export function symbianDataBaseForEmbeddedBytes( + embeddedBytes: number, +): string { + if ( + !Number.isSafeInteger(embeddedBytes) || + embeddedBytes < 0 + ) { + throw new Error("Symbian embedded byte count must be a non-negative safe integer"); + } + const dataBase = Math.ceil( + (DEFAULT_DATA_BASE + embeddedBytes) / DATA_BASE_ALIGNMENT, + ) * DATA_BASE_ALIGNMENT; + if (dataBase > MAX_DATA_BASE) { + throw new Error( + `Symbian embedded payload requires data base 0x${dataBase.toString(16)}, above the E7 limit`, + ); + } + return `0x${dataBase.toString(16)}`; +} + +/** + * Stable private-range UID3 for local development packages. + * + * Symbian's 0xE0000000..0xEFFFFFFF range is intentionally unprotected. The + * reverse-DNS Pocket id is the durable input so independent checkouts build + * upgrade-compatible packages without adding private Symbian fields to + * pocket.json. + */ +export function symbianUidForAppId(appId: string): string { + const digest = createHash("sha256").update(appId, "utf8").digest("hex"); + return `0xE${digest.slice(0, 7).toUpperCase()}`; +} + +export function validateSymbianDevelopmentUid(uid: string): string { + if (!DEVELOPMENT_UID.test(uid)) { + throw new Error( + `Symbian UID3 must be in the unprotected development range 0xE0000000..0xEFFFFFFF, got ${JSON.stringify(uid)}`, + ); + } + return `0x${uid.slice(2).toUpperCase()}`; +} + +/** + * qmake/EKA2 target names are deliberately ASCII and at most 31 characters. + * The UID suffix prevents two similarly truncated output names from sharing + * sys/bin and resource paths. + */ +export function symbianExecutableName( + appOutput: string, + uid: string, +): string { + const stem = appOutput + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join("") + .replace(/[^A-Za-z0-9]/g, "") + .slice(0, 15) || "App"; + const executable = `PocketJs${stem}${validateSymbianDevelopmentUid(uid).slice(2)}`; + if (!SAFE_EXECUTABLE.test(executable)) { + throw new Error(`unsafe Symbian executable name ${JSON.stringify(executable)}`); + } + return executable; +} + +export function symbianPackageIdentity( + plan: ResolvedBuildPlan, + uidOverride?: string, +): SymbianPackageIdentity { + if (!SAFE_OUTPUT.test(plan.app.output)) { + throw new Error( + `unsafe Symbian app output name ${JSON.stringify(plan.app.output)}`, + ); + } + if (!/^[A-Za-z0-9][A-Za-z0-9 ._+&():-]{0,127}$/.test(plan.app.title)) { + throw new Error( + "Symbian package title must be 1..128 safe ASCII title characters", + ); + } + const uid = validateSymbianDevelopmentUid( + uidOverride ?? symbianUidForAppId(plan.app.id), + ); + return { + appId: plan.app.id, + appOutput: plan.app.output, + title: plan.app.title, + uid, + executable: symbianExecutableName(plan.app.output, uid), + sisFile: `${plan.app.output}.sis`, + receiptFile: `${plan.app.output}.receipt.json`, + }; +} diff --git a/tools/symbian-profile.ts b/tools/symbian-profile.ts index 83c2689a..94808b3f 100644 --- a/tools/symbian-profile.ts +++ b/tools/symbian-profile.ts @@ -39,6 +39,7 @@ export const SYMBIAN_E7_DEV_CONTRACTS = definePlatformContractRegistry( }, capabilities: [ "input.buttons", + "input.touch", "display.viewport.live", "text.glyphs.baked", ], diff --git a/tools/symbian-toolchain.ts b/tools/symbian-toolchain.ts index 20b2f1ab..2191f2cd 100644 --- a/tools/symbian-toolchain.ts +++ b/tools/symbian-toolchain.ts @@ -64,8 +64,6 @@ export interface SymbianToolchainManifest { readonly output: string; }; readonly runtime: { - readonly uid: string; - readonly output: string; readonly sisVersion: string; readonly rustToolchain: string; readonly frameRate: number; @@ -174,6 +172,25 @@ export async function withSymbianRuntimeBuildLock( }); } +/** + * Current tools/build.ts injects its STYLE_IDS module per build, but it also + * keeps an ignored on-disk mirror for docs and older consumers. Serialize + * Symbian callers so that mirror and any vendored legacy builder stay stable. + */ +export async function withSymbianGuestBuildLock( + operation: () => Promise, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const lock = join( + pocketStackCacheRoot(env), + "symbian/.locks/guest-build.lock", + ); + return await withArtifactLock(lock, operation, { + timeoutMs: 60 * 60_000, + staleMs: 2 * 60 * 60_000, + }); +} + export function symbianDownloadsRoot(env: NodeJS.ProcessEnv = process.env): string { const explicit = env.POCKETJS_SYMBIAN_DOWNLOADS?.trim(); if (explicit) return resolve(explicit); diff --git a/tools/symbian.ts b/tools/symbian.ts index 4b1695dd..4e463de2 100644 --- a/tools/symbian.ts +++ b/tools/symbian.ts @@ -7,9 +7,10 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { deploySis, isExpectedMtpDevice, @@ -18,6 +19,10 @@ import { type CommandRunner, } from "./symbian-device.ts"; import { setupSymbianToolchain } from "./symbian-bootstrap.ts"; +import { + symbianDataBaseForEmbeddedBytes, + symbianPackageIdentity, +} from "./symbian-package.ts"; import { resolveSymbianE7BuildPlan } from "./symbian-profile.ts"; import { SYMBIAN_DOWNLOADS, @@ -29,12 +34,12 @@ import { symbianDownloadPath, symbianDownloadsRoot, symbianImplementationDigest, + withSymbianGuestBuildLock, withSymbianRuntimeBuildLock, } from "./symbian-toolchain.ts"; import { pocketStackCacheRoot, withArtifactLock } from "./psp-toolchain.ts"; const root = new URL("..", import.meta.url).pathname; -const DEFAULT_CODA_EXECUTABLE = "PocketJsE7Runtime.exe"; async function spawn( command: string, @@ -306,9 +311,41 @@ function flagValue(args: readonly string[], name: string): string | undefined { return index >= 0 ? args[index + 1] : undefined; } -async function buildApp( +export interface SymbianBuildTransaction { + readonly outputRoot: string; +} + +export interface SymbianBuildAppOptions { + projectRoot?: string; + outputRoot?: string; + uid?: string; + catalogIndex?: string; + catalogBlob?: string; + transaction?: SymbianBuildTransaction; +} + +const activeBuildTransactions = new WeakSet(); + +export async function withSymbianBuildTransaction( + outputRoot: string, + operation: (transaction: SymbianBuildTransaction) => Promise, +): Promise { + const output = resolve(outputRoot); + return await withSymbianRuntimeBuildLock(output, async () => { + const transaction = Object.freeze({ outputRoot: output }); + activeBuildTransactions.add(transaction); + try { + return await operation(transaction); + } finally { + activeBuildTransactions.delete(transaction); + } + }); +} + +export async function buildApp( manifestPath: string, sisVersion: string, + options: SymbianBuildAppOptions = {}, ): Promise { const version = sisVersion.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)$/); if (!version || version.slice(1).some((part) => Number(part) > 32767)) { @@ -347,34 +384,104 @@ async function buildApp( const absoluteManifest = resolve(manifestPath); const manifest = JSON.parse(readFileSync(absoluteManifest, "utf8")) as unknown; const plan = resolveSymbianE7BuildPlan(manifest); + const packageIdentity = symbianPackageIdentity(plan, options.uid); + const manifestRelativeToPocketJs = relative(root, absoluteManifest); + const defaultProjectRoot = + manifestRelativeToPocketJs !== ".." && + !manifestRelativeToPocketJs.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && + !isAbsolute(manifestRelativeToPocketJs) + ? root + : dirname(absoluteManifest); + const projectRoot = resolve(options.projectRoot ?? defaultProjectRoot); + const manifestRelativeToProject = relative(projectRoot, absoluteManifest); if ( - !/^[A-Za-z0-9._-]+$/.test(plan.app.output) || - plan.app.output === "." || - plan.app.output === ".." + manifestRelativeToProject === ".." || + manifestRelativeToProject.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || + isAbsolute(manifestRelativeToProject) ) { - throw new Error(`unsafe Symbian app output name ${JSON.stringify(plan.app.output)}`); + throw new Error( + `Symbian manifest ${absoluteManifest} is outside project root ${projectRoot}`, + ); + } + const entry = resolve(projectRoot, plan.app.entry); + const entryRelativeToProject = relative(projectRoot, entry); + if ( + entryRelativeToProject === ".." || + entryRelativeToProject.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || + isAbsolute(entryRelativeToProject) || + !existsSync(entry) + ) { + throw new Error( + `Symbian app entry ${plan.app.entry} is missing or outside project root ${projectRoot}`, + ); } - const outputRoot = resolve(root, "dist/symbian"); + const outputRoot = resolve( + options.outputRoot ?? resolve(projectRoot, "dist/symbian"), + ); + if ((options.catalogIndex === undefined) !== (options.catalogBlob === undefined)) { + throw new Error( + "Symbian app catalog requires both --catalog-index and --catalog-blob", + ); + } + const catalogIndex = options.catalogIndex === undefined + ? undefined + : resolve(options.catalogIndex); + const catalogBlob = options.catalogBlob === undefined + ? undefined + : resolve(options.catalogBlob); + for (const [label, path] of [ + ["catalog index", catalogIndex], + ["catalog blob", catalogBlob], + ] as const) { + if (path !== undefined && (!existsSync(path) || !statSync(path).isFile() || + statSync(path).size === 0)) { + throw new Error(`Symbian ${label} is missing or empty: ${path}`); + } + } const payload = resolve(outputRoot, "build", plan.app.output); const rustTarget = resolve(outputRoot, ".cargo-symbian"); - return await withSymbianRuntimeBuildLock(outputRoot, async () => { + const transaction = async () => { rmSync(payload, { recursive: true, force: true }); mkdirSync(payload, { recursive: true }); await Bun.write( resolve(payload, "plan.json"), JSON.stringify(plan, null, 2) + "\n", ); + await Bun.write( + resolve(payload, "package.json"), + JSON.stringify(packageIdentity, null, 2) + "\n", + ); - const appBuild = await spawn("bun", [ - "tools/build.ts", - `--plan=${resolve(payload, "plan.json")}`, - `--project-root=${root}`, - `--outdir=${payload}`, - ], { inherit: true, cwd: root }); - if (appBuild.exitCode !== 0) throw new Error("PocketJS Symbian guest build failed"); - copyFileSync(resolve(payload, `${plan.app.output}.js`), resolve(payload, "app.js")); - copyFileSync(resolve(payload, `${plan.app.output}.pak`), resolve(payload, "app.pak")); + await withSymbianGuestBuildLock(async () => { + const appBuild = await spawn("bun", [ + "tools/build.ts", + `--plan=${resolve(payload, "plan.json")}`, + `--project-root=${projectRoot}`, + `--outdir=${payload}`, + ], { inherit: true, cwd: root }); + if (appBuild.exitCode !== 0) { + throw new Error("PocketJS Symbian guest build failed"); + } + copyFileSync(resolve(payload, `${plan.app.output}.js`), resolve(payload, "app.js")); + copyFileSync(resolve(payload, `${plan.app.output}.pak`), resolve(payload, "app.pak")); + }); + if (catalogIndex !== undefined && catalogBlob !== undefined) { + copyFileSync(catalogIndex, resolve(payload, "catalog.tsv")); + copyFileSync(catalogBlob, resolve(payload, "catalog.bin")); + } + const embeddedPaths = [ + resolve(payload, "app.js"), + resolve(payload, "app.pak"), + ...(catalogIndex !== undefined + ? [resolve(payload, "catalog.tsv"), resolve(payload, "catalog.bin")] + : []), + ]; + const embeddedBytes = embeddedPaths.reduce( + (total, path) => total + statSync(path).size, + 0, + ); + const dataBase = symbianDataBaseForEmbeddedBytes(embeddedBytes); const coreDirectory = resolve(root, "engine/symbian"); const rustBuild = await spawn(rustHost.rustupPath!, [ @@ -408,7 +515,12 @@ async function buildApp( const built = await spawn("docker", symbianDockerRunArguments( "/usr/local/bin/pocketjs-symbian-build-app", - [plan.app.output, sisVersion], + [ + plan.app.output, + sisVersion, + dataBase, + String(embeddedBytes), + ], { repository: root, output: outputRoot, @@ -417,10 +529,20 @@ async function buildApp( ), { inherit: true, cwd: root }); if (built.exitCode !== 0) throw new Error("Symbian PocketJS runtime build failed"); - const sis = resolve(root, SYMBIAN_TOOLCHAIN.runtime.output); + const sis = resolve(outputRoot, packageIdentity.sisFile); if (!existsSync(sis)) throw new Error(`runtime build did not produce ${sis}`); return sis; - }); + }; + if (options.transaction !== undefined) { + if ( + !activeBuildTransactions.has(options.transaction) || + options.transaction.outputRoot !== outputRoot + ) { + throw new Error("invalid or inactive Symbian build transaction"); + } + return await transaction(); + } + return await withSymbianRuntimeBuildLock(outputRoot, transaction); } async function deploy(path: string): Promise { @@ -446,8 +568,6 @@ async function deploy(path: string): Promise { console.log(" copied and read back byte-for-byte; installation still requires confirmation on the E7"); } -const args = Bun.argv.slice(2); -const command = args[0] ?? "help"; const HELP = `PocketJS Nokia E7 / Symbian toolchain pocket symbian doctor [--device] inspect the isolated build chain and optional USB device @@ -455,14 +575,21 @@ const HELP = `PocketJS Nokia E7 / Symbian toolchain pocket symbian setup --yes fetch pinned SDK inputs and build the amd64 toolchain pocket symbian build probe build and self-sign the visible Qt probe SIS pocket symbian build app --manifest [--sis-version 1.0.0] - build a target-bound PocketJS E7 runtime SIS + [--project-root ] [--outdir ] [--uid 0xE.......] + [--catalog-index --catalog-blob ] + build an independently installable PocketJS E7 SIS pocket symbian deploy copy to Mass memory/Installs and verify by MTP readback pocket symbian coda usb run the CODA USB ping + Locator handshake - pocket symbian coda usb launch remotely launch PocketJsE7Runtime.exe + pocket symbian coda usb launch + remotely launch an installed app from its receipt `; -try { - switch (command) { +export async function symbianMain( + args: readonly string[] = Bun.argv.slice(2), +): Promise { + const command = args[0] ?? "help"; + try { + switch (command) { case "doctor": if (!await doctor( args.includes("--device"), @@ -491,7 +618,17 @@ try { } const sisVersion = flagValue(args.slice(2), "--sis-version") ?? SYMBIAN_TOOLCHAIN.runtime.sisVersion; - console.log(`PocketJS Symbian runtime: ${await buildApp(manifest, sisVersion)}`); + console.log(`PocketJS Symbian runtime: ${await buildApp( + manifest, + sisVersion, + { + projectRoot: flagValue(args.slice(2), "--project-root"), + outputRoot: flagValue(args.slice(2), "--outdir"), + uid: flagValue(args.slice(2), "--uid"), + catalogIndex: flagValue(args.slice(2), "--catalog-index"), + catalogBlob: flagValue(args.slice(2), "--catalog-blob"), + }, + )}`); break; } throw new Error( @@ -516,8 +653,13 @@ try { } const launchRequested = action === "launch"; const executable = launchRequested - ? args[3] ?? DEFAULT_CODA_EXECUTABLE + ? args[3] : undefined; + if (launchRequested && !executable) { + throw new Error( + "usage: pocket symbian coda usb launch ", + ); + } const coda = await runCodaUsbProbe(executable); if (coda.stdout) process.stdout.write(sanitizeDeviceOutput(coda.stdout)); if (coda.stderr) process.stderr.write(sanitizeDeviceOutput(coda.stderr)); @@ -538,8 +680,13 @@ try { default: console.error(HELP); throw new Error(`unknown Symbian command ${JSON.stringify(command)}`); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; } -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; +} + +if (import.meta.main) { + await symbianMain(); } diff --git a/tools/symbian/container/pocketjs-symbian-build-app b/tools/symbian/container/pocketjs-symbian-build-app index 9611697f..d7403ff6 100755 --- a/tools/symbian/container/pocketjs-symbian-build-app +++ b/tools/symbian/container/pocketjs-symbian-build-app @@ -10,12 +10,14 @@ if test "$POCKETJS_SYMBIAN_IMAGE_IMPLEMENTATION_SHA256" != \ exit 1 fi -if test "$#" != 2; then - echo "usage: pocketjs-symbian-build-app " >&2 +if test "$#" != 4; then + echo "usage: pocketjs-symbian-build-app " >&2 exit 1 fi app_output=$1 sis_version=$2 +data_base=$3 +embedded_bytes=$4 if [[ ! "$app_output" =~ ^[A-Za-z0-9._-]+$ ]]; then echo "Unsafe PocketJS app output name: $app_output" >&2 exit 1 @@ -33,19 +35,73 @@ for component in "$sis_major" "$sis_minor" "$sis_build"; do exit 1 fi done +if [[ ! "$data_base" =~ ^0x[0-9a-f]+$ ]] || + (( data_base < 0x400000 )) || + (( data_base > 0x10000000 )) || + (( data_base % 0x100000 != 0 )); then + echo "Symbian data base must be an aligned lowercase hex address from 0x400000 through 0x10000000." >&2 + exit 1 +fi +if [[ ! "$embedded_bytes" =~ ^[0-9]+$ ]]; then + echo "Symbian embedded byte count must be a decimal integer." >&2 + exit 1 +fi toolchain=/toolchain/current payload="/out/build/$app_output" runtime_source=/workspace/hosts/symbian/runtime rust_library="$payload/libpocketjs_symbian_core.a" +package_json="$payload/package.json" quickjs_asset="/downloads/$(jq -r .quickjs.asset "$manifest")" quickjs_sha256=$(jq -r .quickjs.sha256 "$manifest") quickjs_version=$(jq -r .quickjs.version "$manifest") quickjs_revision=$(jq -r .quickjs.rev "$manifest") -uid=$(jq -r .runtime.uid "$manifest") -output_name=$(basename "$(jq -r .runtime.output "$manifest")") +if test ! -s "$package_json"; then + echo "Missing Symbian package identity: $package_json" >&2 + exit 1 +fi +uid=$(jq -er '.uid | strings | select(test("^0xE[0-9A-F]{7}$"))' "$package_json") +package_title=$(jq -er \ + '.title | strings | + select(test("^[A-Za-z0-9][A-Za-z0-9 ._+&():-]{0,127}$"))' \ + "$package_json") +executable=$(jq -er \ + '.executable | strings | select(test("^[A-Za-z][A-Za-z0-9]{0,30}$"))' \ + "$package_json") +output_name=$(jq -er \ + '.sisFile | strings | select(test("^[a-z][a-z0-9]*(?:-[a-z0-9]+)*\\.sis$"))' \ + "$package_json") output="/out/$output_name" -receipt="/out/${output_name%.sis}.receipt.json" +receipt_name=$(jq -er \ + '.receiptFile | strings | select(test("^[a-z][a-z0-9]*(?:-[a-z0-9]+)*\\.receipt\\.json$"))' \ + "$package_json") +receipt="/out/$receipt_name" +catalog_index="$payload/catalog.tsv" +catalog_blob="$payload/catalog.bin" +catalog_enabled=false +if test -e "$catalog_index" || test -e "$catalog_blob"; then + if test ! -s "$catalog_index" || test ! -s "$catalog_blob"; then + echo "Symbian app catalog requires non-empty catalog.tsv and catalog.bin." >&2 + exit 1 + fi + catalog_enabled=true +fi +actual_embedded_bytes=$( + stat -c %s "$payload/app.js" "$payload/app.pak" | + awk '{ total += $1 } END { print total + 0 }' +) +if test "$catalog_enabled" = true; then + actual_embedded_bytes=$( + { + printf '%s\n' "$actual_embedded_bytes" + stat -c %s "$catalog_index" "$catalog_blob" + } | awk '{ total += $1 } END { print total + 0 }' + ) +fi +if test "$actual_embedded_bytes" != "$embedded_bytes"; then + echo "Symbian embedded payload changed after link layout resolution." >&2 + exit 1 +fi certificate="/signing/$(jq -r .signing.certificate "$manifest")" private_key="/signing/$(jq -r .signing.privateKey "$manifest")" lock=/toolchain/.toolchain.lock @@ -54,6 +110,7 @@ for required in \ "$payload/app.js" \ "$payload/app.pak" \ "$payload/plan.json" \ + "$package_json" \ "$rust_library"; do if test ! -s "$required"; then echo "Missing Symbian runtime input: $required" >&2 @@ -101,7 +158,7 @@ quickjs_root="$build/quickjs-rs" quickjs_objects="$build/quickjs-objects" output_stage=$(mktemp -d /out/.pocketjs-symbian-runtime.XXXXXX) candidate="$output_stage/$output_name" -candidate_receipt="$output_stage/${output_name%.sis}.receipt.json" +candidate_receipt="$output_stage/$receipt_name" cleanup() { rm -rf "$build" "$output_stage" @@ -166,11 +223,17 @@ arm-none-symbianelf-ar cqs "$build/libquickjs.a" "$quickjs_objects"/*.o cp "$runtime_source"/* "$build/" cp "$payload/app.js" "$payload/app.pak" "$build/" +if test "$catalog_enabled" = true; then + cp "$catalog_index" "$build/catalog.tsv" + cp "$catalog_blob" "$build/catalog.bin" +fi cd "$build" qmake \ -spec "$toolchain/sdk/mkspecs/symbian-gcce" \ "POCKETJS_SYMBIAN_UID=$uid" \ + "POCKETJS_SYMBIAN_TARGET=$executable" \ + "POCKETJS_SYMBIAN_CAPTION=$package_title" \ "POCKETJS_QUICKJS_INCLUDE=$quickjs_source" \ "POCKETJS_QUICKJS_LIBRARY=$build/libquickjs.a" \ "POCKETJS_CORE_LIBRARY=$rust_library" \ @@ -178,26 +241,27 @@ qmake \ "POCKETJS_HOST_ABI=$host_abi" \ "POCKETJS_INITIAL_LOGICAL_WIDTH=$initial_logical_width" \ "POCKETJS_INITIAL_LOGICAL_HEIGHT=$initial_logical_height" \ + "QMAKE_${executable}_LFLAGS=-Ttext 0x80000 -Tdata $data_base" \ pocketjs-e7-runtime.pro make -j2 expected_uid=$(tr "[:upper:]" "[:lower:]" <<<"${uid#0x}" | tr -d "[:space:]") -actual_uid=$(od -An -tx4 -j8 -N4 PocketJsE7Runtime.exe | tr -d "[:space:]") +actual_uid=$(od -An -tx4 -j8 -N4 "$executable.exe" | tr -d "[:space:]") test "$actual_uid" = "$expected_uid" -file PocketJsE7Runtime.exe | grep -Fq "Psion Series 5 executable" +file "$executable.exe" | grep -Fq "Psion Series 5 executable" cat > pocketjs-e7-runtime.pkg < "$candidate_receipt" mv -f "$candidate" "$output" mv -f "$candidate_receipt" "$receipt" -file PocketJsE7Runtime.exe "$output" +file "$executable.exe" "$output" sha256sum "$output" diff --git a/tools/symbian/container/pocketjs-symbian-doctor b/tools/symbian/container/pocketjs-symbian-doctor index 3f8eadf8..3112f2db 100644 --- a/tools/symbian/container/pocketjs-symbian-doctor +++ b/tools/symbian/container/pocketjs-symbian-doctor @@ -117,6 +117,14 @@ grep -Fq "Qt Resource Compiler" <<<"$rcc_version" uic -version >/dev/null 2>&1 mifconv_output=$(mifconv 2>&1 || true) grep -Fq "No MIF file name specified" <<<"$mifconv_output" +test "$(readlink "$root/sdk/epoc32/include/GLES2")" = gles2 +test "$(readlink "$root/sdk/epoc32/include/EGL")" = egl +test -s "$root/sdk/include/QtOpenGL/QGLWidget" +test -s "$root/sdk/epoc32/include/gles2/gl2.h" +test -s "$root/sdk/epoc32/include/egl/egl.h" +test -s "$root/sdk/epoc32/release/armv5/lib/QtOpenGL.dso" +test -s "$root/sdk/epoc32/release/armv5/lib/libGLESv2.dso" +test -s "$root/sdk/epoc32/release/armv5/lib/libEGL.dso" echo "Running Qt code-generator smoke checks" smoke=$(mktemp -d /tmp/pocketjs-symbian-doctor.XXXXXX) @@ -154,19 +162,31 @@ test -s "$smoke/ui-smoke.h" echo "Building a GCCE/E32 smoke executable" cat > "$smoke/main.cpp" <<'EOF' #include -#include +#include +class PocketJsGlSmoke : public QGLWidget +{ +protected: + void initializeGL() + { + glClearColor(0.05f, 0.1f, 0.2f, 1.0f); + } + void paintGL() + { + glClear(GL_COLOR_BUFFER_BIT); + } +}; int main(int argc, char *argv[]) { QApplication app(argc, argv); - QLabel label("PocketJS Symbian doctor"); - label.show(); + PocketJsGlSmoke widget; + widget.show(); return 0; } EOF cat > "$smoke/smoke.pro" <