diff --git a/hosts/vita/build.rs b/hosts/vita/build.rs index f9a78fc..c7b9a01 100644 --- a/hosts/vita/build.rs +++ b/hosts/vita/build.rs @@ -108,6 +108,7 @@ fn main() { fs::write(Path::new(&out_dir).join("app.pak"), pak).unwrap(); let capture_input = env::var("POCKETJS_CAPTURE_INPUT").unwrap_or_default(); + let capture_touch = env::var("POCKETJS_CAPTURE_TOUCH").unwrap_or_default(); let capture_frames = env::var("POCKETJS_CAPTURE_FRAMES").unwrap_or_default(); let capture_dir = env::var("POCKETJS_CAPTURE_DIR") .unwrap_or_else(|_| String::from("ux0:data/pocketjs-captures")); @@ -115,6 +116,7 @@ fn main() { println!("cargo:rustc-env=POCKETJS_TARGET={target}"); println!("cargo:rustc-env=POCKETJS_HOST_ABI={host_abi}"); println!("cargo:rustc-env=POCKETJS_CAPTURE_INPUT={capture_input}"); + println!("cargo:rustc-env=POCKETJS_CAPTURE_TOUCH={capture_touch}"); println!("cargo:rustc-env=POCKETJS_CAPTURE_FRAMES={capture_frames}"); println!("cargo:rustc-env=POCKETJS_CAPTURE_DIR={capture_dir}"); diff --git a/hosts/vita/src/main.rs b/hosts/vita/src/main.rs index 2a0f247..91de425 100644 --- a/hosts/vita/src/main.rs +++ b/hosts/vita/src/main.rs @@ -8,6 +8,8 @@ static APP_PAK: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/app.pak")); #[cfg(feature = "capture")] static CAPTURE_INPUT: &str = env!("POCKETJS_CAPTURE_INPUT"); #[cfg(feature = "capture")] +static CAPTURE_TOUCH: &str = env!("POCKETJS_CAPTURE_TOUCH"); +#[cfg(feature = "capture")] static CAPTURE_FRAMES: &str = env!("POCKETJS_CAPTURE_FRAMES"); #[cfg(feature = "capture")] static CAPTURE_DIR: &str = env!("POCKETJS_CAPTURE_DIR"); @@ -39,6 +41,43 @@ fn scripted_buttons(frame: u32) -> i32 { buttons } +/// Level-triggered touch script, the threshold convention of +/// `scripted_buttons`: entries `frame:id,x,y[+id,x,y…]` joined by `;`, +/// `frame:-` releases (tests/golden-specs.ts `encodeTouchInput`). +#[cfg(feature = "capture")] +fn scripted_touches(frame: u32) -> pocketjs_vita::input::TouchSnapshot { + let mut latest: Option<(u32, &str)> = None; + for item in CAPTURE_TOUCH.split(';') { + let Some((at, spec)) = item.split_once(':') else { + continue; + }; + let Some(at) = at.parse::().ok().filter(|at| *at <= frame) else { + continue; + }; + if latest.is_none_or(|(previous, _)| at >= previous) { + latest = Some((at, spec)); + } + } + let Some((_, spec)) = latest else { + return pocketjs_vita::input::TouchSnapshot::EMPTY; + }; + if spec == "-" { + return pocketjs_vita::input::TouchSnapshot::EMPTY; + } + let mut contacts: Vec<(u8, u16, u16)> = Vec::new(); + for triple in spec.split('+') { + let mut parts = triple.split(','); + let (Some(id), Some(x), Some(y)) = (parts.next(), parts.next(), parts.next()) else { + continue; + }; + let (Ok(id), Ok(x), Ok(y)) = (id.parse::(), x.parse::(), y.parse::()) else { + continue; + }; + contacts.push((id, x, y)); + } + pocketjs_vita::input::TouchSnapshot::from_logical(&contacts) +} + #[cfg(feature = "capture")] fn capture_frames() -> Vec { CAPTURE_FRAMES @@ -75,7 +114,7 @@ fn main() { let (buttons, analog, touches) = ( scripted_buttons(frame), pocketjs_core::spec::ANALOG_CENTER as i32, - pocketjs_vita::input::TouchSnapshot::EMPTY, + scripted_touches(frame), ); #[cfg(not(feature = "capture"))] let (buttons, analog, touches) = { diff --git a/tests/e2e/vita3k.ts b/tests/e2e/vita3k.ts index 10c4109..394605c 100644 --- a/tests/e2e/vita3k.ts +++ b/tests/e2e/vita3k.ts @@ -20,7 +20,7 @@ import { } from "../../tools/vita-package.ts"; import { vitaTitleId } from "../../framework/src/manifest/vita-package.ts"; import { encodePNG } from "../png.ts"; -import { encodeThresholdInput, GOLDEN_SPECS } from "../golden-specs.ts"; +import { encodeThresholdInput, encodeTouchInput, GOLDEN_SPECS } from "../golden-specs.ts"; const ROOT = new URL("../..", import.meta.url).pathname; const OUT = `${ROOT}dist/e2e-vita3k`; @@ -220,7 +220,9 @@ if (specs.length === 0) { for (const spec of specs) { const input = encodeThresholdInput(spec); - const { path: manifest, titleId } = writeDemoManifest(spec.name); + const touch = encodeTouchInput(spec); + const bundle = spec.app ?? spec.name; + const { path: manifest, titleId } = writeDemoManifest(bundle); const appDir = `${VITAFS}/ux0/app/${titleId}`; const globalTitleStub = `${globalVitaFs}/ux0/app/${titleId}`; mkdirSync(`${appDir}/sce_sys`, { recursive: true }); @@ -239,6 +241,7 @@ for (const spec of specs) { ...process.env, VITASDK: process.env.VITASDK ?? `${homedir()}/vitasdk`, POCKETJS_CAPTURE_INPUT: input, + POCKETJS_CAPTURE_TOUCH: touch, POCKETJS_CAPTURE_FRAMES: spec.capture.join(","), }) .quiet() @@ -250,7 +253,7 @@ for (const spec of specs) { } try { - assertPackagedDefaultLiveArea(`${ROOT}dist/vita/${spec.name}.vpk`); + assertPackagedDefaultLiveArea(`${ROOT}dist/vita/${bundle}.vpk`); } catch (error) { console.error(`FAIL ${spec.name}: ${(error as Error).message}`); failed += spec.capture.length; diff --git a/tests/golden-specs.ts b/tests/golden-specs.ts index 3e31466..cb7ca47 100644 --- a/tests/golden-specs.ts +++ b/tests/golden-specs.ts @@ -1,10 +1,47 @@ -import { BTN } from "../contracts/spec/spec.ts"; +import { BTN, SCREEN_H, SCREEN_W } from "../contracts/spec/spec.ts"; +import { + layoutRows, + OSK_GAP, + OSK_H, + OSK_LAYERS, + OSK_PAD, + OSK_ROW_H, +} from "../framework/src/osk-layout.ts"; + +export interface TouchPoint { + id: number; + x: number; + y: number; +} export interface GoldenSpec { name: string; + /** App bundle to build/run — defaults to `name`. Set it when several specs + * exercise one app (golden files keep the spec's own name). */ + app?: string; frames: number; capture: number[]; input?: (frame: number) => number; + /** Scripted front-panel contacts for the frame, logical px. Undefined or + * [] = no contacts. Runs on hosts that deliver touch (vita, wasm oracle). */ + touch?: (frame: number) => readonly TouchPoint[]; +} + +/** Center of an OSK key on the given layer, in screen px (panel docked at + * the bottom of the screen — the system convention). */ +function oskKeyCenter(ch: string, layer: keyof typeof OSK_LAYERS = "lower"): { x: number; y: number } { + const rows = layoutRows(OSK_LAYERS[layer], SCREEN_W - 2 * OSK_PAD); + for (const row of rows) { + for (const k of row) { + if (k.key.ch === ch) { + return { + x: Math.round(OSK_PAD + k.x + k.w / 2), + y: Math.round(SCREEN_H - OSK_H + OSK_PAD + k.row * (OSK_ROW_H + OSK_GAP) + OSK_ROW_H / 2), + }; + } + } + } + throw new Error(`golden-specs: no ${JSON.stringify(ch)} key on layer ${String(layer)}`); } // Shared by the headless WASM oracle and Vita3K E2E so inputs/frame indices @@ -167,6 +204,24 @@ export const GOLDEN_SPECS: GoldenSpec[] = [ ? BTN.START : 0, }, + { + // im, driven by TOUCH — the first touch golden. CIRCLE@60 opens MAYA + // CHEN, TRIANGLE@90 opens the OSK. A finger lands on 'h' at f120 and + // HOLDS — f124 captures the pressed key (the native active: variant, a + // state no button tape can show under touch). Release at f128 commits + // (the modern press model); a tap types 'i' at f150..152. f180 shows + // the live "hi" draft. START@210 sends; f300 has the delivered bubble. + name: "im-touch", + app: "im-main", + frames: 310, + capture: [124, 180, 300], + input: (f) => (f === 60 ? BTN.CIRCLE : f === 90 ? BTN.TRIANGLE : f === 210 ? BTN.START : 0), + touch: (f) => { + if (f >= 120 && f < 128) return [{ id: 0, ...oskKeyCenter("h") }]; + if (f >= 150 && f < 152) return [{ id: 0, ...oskKeyCenter("i") }]; + return []; + }, + }, ]; export function encodeThresholdInput(spec: GoldenSpec): string { @@ -182,3 +237,32 @@ export function encodeThresholdInput(spec: GoldenSpec): string { } return entries.join(","); } + +/** Level-triggered touch script for the capture hosts: `frame:id,x,y[+…]` + * entries joined by `;`, `frame:-` releases. Touch-free specs encode to "" + * so button-only builds stay byte-identical. */ +export function encodeTouchInput(spec: GoldenSpec): string { + if (!spec.touch) return ""; + const lastFrame = Math.max(...spec.capture); + const entries: string[] = []; + let previous = ""; + for (let frame = 0; frame <= lastFrame; frame++) { + const contacts = spec.touch(frame); + const encoded = + contacts.length === 0 ? "-" : contacts.map((c) => `${c.id},${c.x},${c.y}`).join("+"); + if (frame === 0 || encoded !== previous) { + entries.push(`${frame}:${encoded}`); + previous = encoded; + } + } + return entries.length === 1 && entries[0] === "0:-" ? "" : entries.join(";"); +} + +/** Packed contacts (touch.ts wire words) for one frame, or undefined. */ +export function packedTouchFor(spec: GoldenSpec, frame: number): number[] | undefined { + const contacts = spec.touch?.(frame); + if (!contacts || contacts.length === 0) return undefined; + return contacts.map( + (c) => (((c.id & 0xff) << 18) | ((c.y & 0x1ff) << 9) | (c.x & 0x1ff)) >>> 0, + ); +} diff --git a/tests/golden.ts b/tests/golden.ts index b9de9a8..d4ef32d 100644 --- a/tests/golden.ts +++ b/tests/golden.ts @@ -20,7 +20,7 @@ import { fileURLToPath } from "node:url"; import { join, resolve } from "node:path"; import { createWasmUi } from "../hosts/web/wasm-ops.js"; import { SCREEN_H, SCREEN_W } from "../contracts/spec/spec.ts"; -import { GOLDEN_SPECS, type GoldenSpec } from "./golden-specs.ts"; +import { GOLDEN_SPECS, packedTouchFor, type GoldenSpec } from "./golden-specs.ts"; const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); // PocketJS/ // Goldens never consume the shared dist/ directory: it may contain ignored, @@ -94,7 +94,7 @@ const SPECS = GOLDEN_SPECS; ensureBuilt(WASM_PATH, [process.execPath, "tools/wasm.ts"]); rmSync(DIST, { recursive: true, force: true }); mkdirSync(DIST, { recursive: true }); -for (const spec of SPECS) buildDemo(spec.name); +for (const bundle of new Set(SPECS.map((spec) => spec.app ?? spec.name))) buildDemo(bundle); mkdirSync(GOLDEN_DIR, { recursive: true }); const wasmBytes = await Bun.file(WASM_PATH).arrayBuffer(); @@ -103,22 +103,26 @@ async function runDemo(spec: GoldenSpec): Promise> { // A fresh wasm instance per demo: fresh core, zero cross-demo state. const wasm = await createWasmUi(wasmBytes); const g = globalThis as Record; + const bundle = spec.app ?? spec.name; g.ui = wasm.ops; // the host contract: HostOps BEFORE eval - g.__pak = existsSync(DIST + spec.name + ".pak") - ? await Bun.file(DIST + spec.name + ".pak").arrayBuffer() + g.__pak = existsSync(DIST + bundle + ".pak") + ? await Bun.file(DIST + bundle + ".pak").arrayBuffer() : undefined; g.frame = undefined; try { - const src = await Bun.file(DIST + spec.name + ".js").text(); + const src = await Bun.file(DIST + bundle + ".js").text(); (0, eval)(src); // IIFE mounts the app and installs globalThis.frame - const frame = g.frame as ((buttons: number) => void) | undefined; + const frame = g.frame as + | ((buttons: number, analog?: number, touches?: readonly number[]) => void) + | undefined; if (typeof frame !== "function") { throw new Error("bundle did not install globalThis.frame (does the entry call render()?)"); } const captures = new Map(); const want = new Set(spec.capture); for (let f = 0; f < spec.frames; f++) { - frame(spec.input ? spec.input(f) : 0); // input + effects + sweep + // input + effects + sweep (touch rides the third frame argument) + frame(spec.input ? spec.input(f) : 0, undefined, packedTouchFor(spec, f)); wasm.tick(); // anims + layout, exactly 1/60 s if (want.has(f)) captures.set(f, wasm.render().slice()); } diff --git a/tests/goldens/vita/im-touch.124.png b/tests/goldens/vita/im-touch.124.png new file mode 100644 index 0000000..5117758 Binary files /dev/null and b/tests/goldens/vita/im-touch.124.png differ diff --git a/tests/goldens/vita/im-touch.180.png b/tests/goldens/vita/im-touch.180.png new file mode 100644 index 0000000..db27d85 Binary files /dev/null and b/tests/goldens/vita/im-touch.180.png differ diff --git a/tests/goldens/vita/im-touch.300.png b/tests/goldens/vita/im-touch.300.png new file mode 100644 index 0000000..c0f393f Binary files /dev/null and b/tests/goldens/vita/im-touch.300.png differ diff --git a/tests/goldens/web/im-touch.124.png b/tests/goldens/web/im-touch.124.png new file mode 100644 index 0000000..f6ac199 Binary files /dev/null and b/tests/goldens/web/im-touch.124.png differ diff --git a/tests/goldens/web/im-touch.180.png b/tests/goldens/web/im-touch.180.png new file mode 100644 index 0000000..4e6d7f2 Binary files /dev/null and b/tests/goldens/web/im-touch.180.png differ diff --git a/tests/goldens/web/im-touch.300.png b/tests/goldens/web/im-touch.300.png new file mode 100644 index 0000000..4ddce71 Binary files /dev/null and b/tests/goldens/web/im-touch.300.png differ diff --git a/tools/vita.ts b/tools/vita.ts index 5c92395..a0cb9c4 100644 --- a/tools/vita.ts +++ b/tools/vita.ts @@ -217,6 +217,7 @@ const env = { TARGET_CXX: 'arm-vita-eabi-g++', CXX_armv7_sony_vita_newlibeabihf: 'arm-vita-eabi-g++', POCKETJS_CAPTURE_INPUT: process.env.POCKETJS_CAPTURE_INPUT ?? "", + POCKETJS_CAPTURE_TOUCH: process.env.POCKETJS_CAPTURE_TOUCH ?? "", POCKETJS_CAPTURE_FRAMES: process.env.POCKETJS_CAPTURE_FRAMES ?? "", POCKETJS_CAPTURE_DIR: process.env.POCKETJS_CAPTURE_DIR ?? "ux0:data/pocketjs-captures", };