Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions hosts/vita/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,15 @@ 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"));

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}");

Expand Down
41 changes: 40 additions & 1 deletion hosts/vita/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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::<u32>().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::<u8>(), x.parse::<u16>(), y.parse::<u16>()) else {
continue;
};
contacts.push((id, x, y));
}
pocketjs_vita::input::TouchSnapshot::from_logical(&contacts)
}

#[cfg(feature = "capture")]
fn capture_frames() -> Vec<u32> {
CAPTURE_FRAMES
Expand Down Expand Up @@ -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) = {
Expand Down
9 changes: 6 additions & 3 deletions tests/e2e/vita3k.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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 });
Expand All @@ -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()
Expand All @@ -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;
Expand Down
86 changes: 85 additions & 1 deletion tests/golden-specs.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
);
}
18 changes: 11 additions & 7 deletions tests/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -103,22 +103,26 @@ async function runDemo(spec: GoldenSpec): Promise<Map<number, Uint8Array>> {
// A fresh wasm instance per demo: fresh core, zero cross-demo state.
const wasm = await createWasmUi(wasmBytes);
const g = globalThis as Record<string, unknown>;
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<number, Uint8Array>();
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());
}
Expand Down
Binary file added tests/goldens/vita/im-touch.124.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/goldens/vita/im-touch.180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/goldens/vita/im-touch.300.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/goldens/web/im-touch.124.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/goldens/web/im-touch.180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/goldens/web/im-touch.300.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tools/vita.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
Expand Down