diff --git a/docs/runtime/polkavm-app-abi-v1.md b/docs/runtime/polkavm-app-abi-v1.md index 1758c57..6cb2ba9 100644 --- a/docs/runtime/polkavm-app-abi-v1.md +++ b/docs/runtime/polkavm-app-abi-v1.md @@ -79,6 +79,14 @@ input and audio. A Host call made outside its declared capability MUST fail with that call's unavailable or invalid-state result. The Host MUST NOT silently reinterpret a submission as another graphics profile. +Device-input ABI v1 may gain additive Host imports behind declared feature +names. Adding a feature does not change the existing input-record layout or the +semantics of existing imports. A Host that does not recognize a requested +feature MUST reject the manifest before instantiating the guest. A Host that +recognizes an optional feature MUST still define its import when the current +device cannot provide data; the import reports unavailability as specified +below. + ## Host imports ### Framebuffer presentation @@ -265,6 +273,60 @@ The device-input contract defines code values, coordinate interpretation, and surface-metric scaling. ABI v1 does not define touch, wheel, UTF-8 text, IME, or focus events. +#### Optional motion tilt + +An App requests fused, display-relative tilt without making it a launch +requirement by declaring: + +```json +{ + "deviceInput": { + "abiVersion": 1, + "requiredFeatures": ["pointer"], + "optionalFeatures": ["motion-tilt"] + } +} +``` + +The feature adds: + +```text +host_motion_read(pointer: u32, capacity: u32) -> i32 +``` + +The Host retains only the newest calibrated sample. The call returns `40` after +writing one complete sample, `0` when motion is unavailable, inactive, stale, +or not authorized, and `-40` when `capacity` is too small. It never writes a +partial sample. + +Every ABI v1 Host MUST resolve `host_motion_read`, including Hosts without a +motion source. An unavailable Host returns `0`; it does not reject the import or +trap the guest. This keeps pointer fallback deterministic across desktop and +sensor-capable Hosts. + +The 40-byte `PMT1` sample is: + +```text +offset type field +0 [u8;4] magic "PMT1" +4 u16 version 1 +6 u16 flags +8 u32 byte length 40 +12 u32 nonzero sequence +16 u64 monotonic timestamp in microseconds +24 f32 normalized horizontal tilt in [-1, 1] +28 f32 normalized vertical tilt in [-1, 1] +32 f32 azimuth in radians, or zero when unavailable +36 u32 zero +``` + +Flag bit 0 means the sample is calibrated and MUST be set. Bit 1 means azimuth +is valid. All other bits are zero. Float fields are finite. Motion tilt is +lossy state, not an event stream: Hosts SHOULD sample near display cadence, +coalesce updates, stop sampling when the App is not visible, and MUST NOT +persist samples. Pointer input remains available as the fallback and MAY +temporarily override tilt while a pointer gesture is active. + ### Time ```text diff --git a/js/packages/pvm-browser-runtime/src/pvm-runtime-core.js b/js/packages/pvm-browser-runtime/src/pvm-runtime-core.js index 87d89da..7c1c3ab 100644 --- a/js/packages/pvm-browser-runtime/src/pvm-runtime-core.js +++ b/js/packages/pvm-browser-runtime/src/pvm-runtime-core.js @@ -525,6 +525,50 @@ globalThis.createPvmRuntime = endpoint => { ); } + function sendMotionTilt(bytes) { + if (!running || bytes.byteLength !== 40) { + return; + } + if (translated) { + translated.sendMotionTilt(bytes); + return; + } + stage(bytes); + check( + pvm.pvm_browser_set_motion_tilt(), + "set PolkaVM browser motion-tilt sample" + ); + } + + function clearMotionTilt() { + if (!running) { + return; + } + if (translated) { + translated.clearMotionTilt(); + return; + } + check( + pvm.pvm_browser_clear_motion_tilt(), + "clear PolkaVM browser motion-tilt sample" + ); + } + + function sendGpuCapabilities(bytes) { + if (!running || bytes.byteLength < 56 || bytes.byteLength > 4096) { + return; + } + if (translated) { + translated.setGpuCapabilities(bytes); + return; + } + stage(bytes); + check( + pvm.pvm_browser_set_gpu_capabilities(), + "update PolkaVM browser GPU capabilities" + ); + } + function sendGpuEvent(bytes) { if (!running || !pvm || !bytes.byteLength) { return; @@ -568,6 +612,30 @@ globalThis.createPvmRuntime = endpoint => { postMessage({ type: "error", message: error.message }); postMessage({ type: "terminated" }); } + } else if (message?.type === "motion-tilt") { + try { + sendMotionTilt(new Uint8Array(message.bytes)); + } catch (error) { + stopRuntime(); + postMessage({ type: "error", message: error.message }); + postMessage({ type: "terminated" }); + } + } else if (message?.type === "motion-tilt-clear") { + try { + clearMotionTilt(); + } catch (error) { + stopRuntime(); + postMessage({ type: "error", message: error.message }); + postMessage({ type: "terminated" }); + } + } else if (message?.type === "gpu-capabilities") { + try { + sendGpuCapabilities(new Uint8Array(message.bytes)); + } catch (error) { + stopRuntime(); + postMessage({ type: "error", message: error.message }); + postMessage({ type: "terminated" }); + } } else if (message?.type === "gpu-event") { try { sendGpuEvent(new Uint8Array(message.bytes)); diff --git a/js/packages/pvm-browser-runtime/src/pvm-wasm-translated.js b/js/packages/pvm-browser-runtime/src/pvm-wasm-translated.js index 8887c73..63a9f4b 100644 --- a/js/packages/pvm-browser-runtime/src/pvm-wasm-translated.js +++ b/js/packages/pvm-browser-runtime/src/pvm-wasm-translated.js @@ -10,6 +10,7 @@ const STATUS_TRAP = -3; const STATUS_OUT_OF_GAS = -4; const INPUT_EVENT_BYTES = 8; + const MOTION_TILT_BYTES = 40; const MAX_INPUT_EVENTS = 4096; const MAX_HOSTCALLS_PER_INIT = 1024 * 1024; const MAX_HOSTCALLS_PER_UPDATE = 8192; @@ -48,6 +49,33 @@ const decoder = new TextDecoder(); const encoder = new TextEncoder(); + function validMotionTilt(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== MOTION_TILT_BYTES) { + return false; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const flags = view.getUint16(6, true); + const tiltX = view.getFloat32(24, true); + const tiltY = view.getFloat32(28, true); + const azimuth = view.getFloat32(32, true); + return ( + decoder.decode(bytes.subarray(0, 4)) === "PMT1" && + view.getUint16(4, true) === 1 && + (flags & ~3) === 0 && + (flags & 1) !== 0 && + view.getUint32(8, true) === MOTION_TILT_BYTES && + view.getUint32(12, true) !== 0 && + Number.isFinite(tiltX) && + tiltX >= -1 && + tiltX <= 1 && + Number.isFinite(tiltY) && + tiltY >= -1 && + tiltY <= 1 && + ((flags & 2) === 0 || Number.isFinite(azimuth)) && + view.getUint32(36, true) === 0 + ); + } + function readMetadata(module) { const sections = WebAssembly.Module.customSections( module, @@ -258,6 +286,7 @@ this.tri2dSubmitted = false; this.maxGas = BigInt(maxGas); this.input = []; + this.motionTilt = null; this.coreInput = []; this.epocaInput = []; this.pointer = null; @@ -390,6 +419,29 @@ } } + sendMotionTilt(bytes) { + if (this.stopped || !validMotionTilt(bytes)) { + throw new Error("invalid translated motion-tilt sample"); + } + this.motionTilt = bytes.slice(); + } + + clearMotionTilt() { + this.motionTilt = null; + } + + setGpuCapabilities(bytes) { + if ( + this.stopped || + !(bytes instanceof Uint8Array) || + bytes.byteLength < 56 || + bytes.byteLength > 4096 + ) { + throw new Error("invalid translated WebGPU capabilities"); + } + this.gpuCapabilities = bytes.slice(); + } + sendGpuEvent(bytes) { if ( this.stopped || @@ -428,6 +480,7 @@ stop() { this.stopped = true; this.input.length = 0; + this.motionTilt = null; this.coreInput.length = 0; this.truapiRequests = 0; this.truapiRequestBytes = 0; @@ -744,6 +797,20 @@ this.#setReg(7, BigInt(event.byteLength)); return false; } + case "host_motion_read": { + if (this.motionTilt === null) { + this.#setReg(7, 0n); + return false; + } + const capacity = this.#u32(a1); + if (capacity < MOTION_TILT_BYTES) { + this.#setReg(7, BigInt(-MOTION_TILT_BYTES)); + return false; + } + this.#write(this.#u32(a0), this.motionTilt); + this.#setReg(7, BigInt(MOTION_TILT_BYTES)); + return false; + } case "host_poll_input": { const capacity = this.#u32(a1); const count = Math.min( diff --git a/rust/crates/pvm-runtime-assets/assets/SHA256SUMS b/rust/crates/pvm-runtime-assets/assets/SHA256SUMS index a1def63..6401137 100644 --- a/rust/crates/pvm-runtime-assets/assets/SHA256SUMS +++ b/rust/crates/pvm-runtime-assets/assets/SHA256SUMS @@ -1,6 +1,6 @@ -14c19701d27b037935fb8bd1f1ac2ad350d769da87783e746a030d5b3b9e9fc2 pvm-browser-runtime.wasm -7d9ebbe1189a37dcbd80cc2817a526a801424c82489cfbb9387a8535717607ab pvm-worker.js +2da1dbca9ca067064ba4311c01d627fd6dcec0ebbcf539941b01b611a29eb59c pvm-browser-runtime.wasm +a6b8052746d7b30e6790e54fee6a7a0f35386934f7eefc61d35757f32370564a pvm-worker.js 33418e2c81c117539569eb4cf91af4d058cdfae7dd4556b13f3687f6d6b3bae4 pvm-gpu-worker.js -a49f337b87c614d0427c9bb2845e88ed2fc33c34b878cc36fbda95cf2ceb3e4d pvm-wasm-translated.js -f527c1f530ce326e4d40840a5c0456dc9dad5798818038c8f0fe865040742c50 pvm-runtime-core.js +7b41037579c861aabcc04e28f66650c88cb78c5e77a66bf354f9474c26157715 pvm-wasm-translated.js +11e8df14cb719da9fa29921441c19820a7076dde28a2318f775254d18a960312 pvm-runtime-core.js 9c929f5d5c64a1b75e7e48485d7c3944ed6838112177ea778827a2c407c2d820 pvm-wasm-worker-entry.js diff --git a/rust/crates/pvm-runtime-assets/assets/pvm-browser-runtime.wasm b/rust/crates/pvm-runtime-assets/assets/pvm-browser-runtime.wasm index fa2bef2..be82106 100755 Binary files a/rust/crates/pvm-runtime-assets/assets/pvm-browser-runtime.wasm and b/rust/crates/pvm-runtime-assets/assets/pvm-browser-runtime.wasm differ diff --git a/rust/crates/pvm-runtime-assets/assets/pvm-runtime-core.js b/rust/crates/pvm-runtime-assets/assets/pvm-runtime-core.js index 87d89da..7c1c3ab 100644 --- a/rust/crates/pvm-runtime-assets/assets/pvm-runtime-core.js +++ b/rust/crates/pvm-runtime-assets/assets/pvm-runtime-core.js @@ -525,6 +525,50 @@ globalThis.createPvmRuntime = endpoint => { ); } + function sendMotionTilt(bytes) { + if (!running || bytes.byteLength !== 40) { + return; + } + if (translated) { + translated.sendMotionTilt(bytes); + return; + } + stage(bytes); + check( + pvm.pvm_browser_set_motion_tilt(), + "set PolkaVM browser motion-tilt sample" + ); + } + + function clearMotionTilt() { + if (!running) { + return; + } + if (translated) { + translated.clearMotionTilt(); + return; + } + check( + pvm.pvm_browser_clear_motion_tilt(), + "clear PolkaVM browser motion-tilt sample" + ); + } + + function sendGpuCapabilities(bytes) { + if (!running || bytes.byteLength < 56 || bytes.byteLength > 4096) { + return; + } + if (translated) { + translated.setGpuCapabilities(bytes); + return; + } + stage(bytes); + check( + pvm.pvm_browser_set_gpu_capabilities(), + "update PolkaVM browser GPU capabilities" + ); + } + function sendGpuEvent(bytes) { if (!running || !pvm || !bytes.byteLength) { return; @@ -568,6 +612,30 @@ globalThis.createPvmRuntime = endpoint => { postMessage({ type: "error", message: error.message }); postMessage({ type: "terminated" }); } + } else if (message?.type === "motion-tilt") { + try { + sendMotionTilt(new Uint8Array(message.bytes)); + } catch (error) { + stopRuntime(); + postMessage({ type: "error", message: error.message }); + postMessage({ type: "terminated" }); + } + } else if (message?.type === "motion-tilt-clear") { + try { + clearMotionTilt(); + } catch (error) { + stopRuntime(); + postMessage({ type: "error", message: error.message }); + postMessage({ type: "terminated" }); + } + } else if (message?.type === "gpu-capabilities") { + try { + sendGpuCapabilities(new Uint8Array(message.bytes)); + } catch (error) { + stopRuntime(); + postMessage({ type: "error", message: error.message }); + postMessage({ type: "terminated" }); + } } else if (message?.type === "gpu-event") { try { sendGpuEvent(new Uint8Array(message.bytes)); diff --git a/rust/crates/pvm-runtime-assets/assets/pvm-wasm-translated.js b/rust/crates/pvm-runtime-assets/assets/pvm-wasm-translated.js index 8887c73..63a9f4b 100644 --- a/rust/crates/pvm-runtime-assets/assets/pvm-wasm-translated.js +++ b/rust/crates/pvm-runtime-assets/assets/pvm-wasm-translated.js @@ -10,6 +10,7 @@ const STATUS_TRAP = -3; const STATUS_OUT_OF_GAS = -4; const INPUT_EVENT_BYTES = 8; + const MOTION_TILT_BYTES = 40; const MAX_INPUT_EVENTS = 4096; const MAX_HOSTCALLS_PER_INIT = 1024 * 1024; const MAX_HOSTCALLS_PER_UPDATE = 8192; @@ -48,6 +49,33 @@ const decoder = new TextDecoder(); const encoder = new TextEncoder(); + function validMotionTilt(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== MOTION_TILT_BYTES) { + return false; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const flags = view.getUint16(6, true); + const tiltX = view.getFloat32(24, true); + const tiltY = view.getFloat32(28, true); + const azimuth = view.getFloat32(32, true); + return ( + decoder.decode(bytes.subarray(0, 4)) === "PMT1" && + view.getUint16(4, true) === 1 && + (flags & ~3) === 0 && + (flags & 1) !== 0 && + view.getUint32(8, true) === MOTION_TILT_BYTES && + view.getUint32(12, true) !== 0 && + Number.isFinite(tiltX) && + tiltX >= -1 && + tiltX <= 1 && + Number.isFinite(tiltY) && + tiltY >= -1 && + tiltY <= 1 && + ((flags & 2) === 0 || Number.isFinite(azimuth)) && + view.getUint32(36, true) === 0 + ); + } + function readMetadata(module) { const sections = WebAssembly.Module.customSections( module, @@ -258,6 +286,7 @@ this.tri2dSubmitted = false; this.maxGas = BigInt(maxGas); this.input = []; + this.motionTilt = null; this.coreInput = []; this.epocaInput = []; this.pointer = null; @@ -390,6 +419,29 @@ } } + sendMotionTilt(bytes) { + if (this.stopped || !validMotionTilt(bytes)) { + throw new Error("invalid translated motion-tilt sample"); + } + this.motionTilt = bytes.slice(); + } + + clearMotionTilt() { + this.motionTilt = null; + } + + setGpuCapabilities(bytes) { + if ( + this.stopped || + !(bytes instanceof Uint8Array) || + bytes.byteLength < 56 || + bytes.byteLength > 4096 + ) { + throw new Error("invalid translated WebGPU capabilities"); + } + this.gpuCapabilities = bytes.slice(); + } + sendGpuEvent(bytes) { if ( this.stopped || @@ -428,6 +480,7 @@ stop() { this.stopped = true; this.input.length = 0; + this.motionTilt = null; this.coreInput.length = 0; this.truapiRequests = 0; this.truapiRequestBytes = 0; @@ -744,6 +797,20 @@ this.#setReg(7, BigInt(event.byteLength)); return false; } + case "host_motion_read": { + if (this.motionTilt === null) { + this.#setReg(7, 0n); + return false; + } + const capacity = this.#u32(a1); + if (capacity < MOTION_TILT_BYTES) { + this.#setReg(7, BigInt(-MOTION_TILT_BYTES)); + return false; + } + this.#write(this.#u32(a0), this.motionTilt); + this.#setReg(7, BigInt(MOTION_TILT_BYTES)); + return false; + } case "host_poll_input": { const capacity = this.#u32(a1); const count = Math.min( diff --git a/rust/crates/pvm-runtime-assets/assets/pvm-worker.js b/rust/crates/pvm-runtime-assets/assets/pvm-worker.js index 9a48a1e..6352945 100644 --- a/rust/crates/pvm-runtime-assets/assets/pvm-worker.js +++ b/rust/crates/pvm-runtime-assets/assets/pvm-worker.js @@ -10,6 +10,7 @@ const STATUS_TRAP = -3; const STATUS_OUT_OF_GAS = -4; const INPUT_EVENT_BYTES = 8; + const MOTION_TILT_BYTES = 40; const MAX_INPUT_EVENTS = 4096; const MAX_HOSTCALLS_PER_INIT = 1024 * 1024; const MAX_HOSTCALLS_PER_UPDATE = 8192; @@ -48,6 +49,33 @@ const decoder = new TextDecoder(); const encoder = new TextEncoder(); + function validMotionTilt(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== MOTION_TILT_BYTES) { + return false; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const flags = view.getUint16(6, true); + const tiltX = view.getFloat32(24, true); + const tiltY = view.getFloat32(28, true); + const azimuth = view.getFloat32(32, true); + return ( + decoder.decode(bytes.subarray(0, 4)) === "PMT1" && + view.getUint16(4, true) === 1 && + (flags & ~3) === 0 && + (flags & 1) !== 0 && + view.getUint32(8, true) === MOTION_TILT_BYTES && + view.getUint32(12, true) !== 0 && + Number.isFinite(tiltX) && + tiltX >= -1 && + tiltX <= 1 && + Number.isFinite(tiltY) && + tiltY >= -1 && + tiltY <= 1 && + ((flags & 2) === 0 || Number.isFinite(azimuth)) && + view.getUint32(36, true) === 0 + ); + } + function readMetadata(module) { const sections = WebAssembly.Module.customSections( module, @@ -258,6 +286,7 @@ this.tri2dSubmitted = false; this.maxGas = BigInt(maxGas); this.input = []; + this.motionTilt = null; this.coreInput = []; this.epocaInput = []; this.pointer = null; @@ -390,6 +419,29 @@ } } + sendMotionTilt(bytes) { + if (this.stopped || !validMotionTilt(bytes)) { + throw new Error("invalid translated motion-tilt sample"); + } + this.motionTilt = bytes.slice(); + } + + clearMotionTilt() { + this.motionTilt = null; + } + + setGpuCapabilities(bytes) { + if ( + this.stopped || + !(bytes instanceof Uint8Array) || + bytes.byteLength < 56 || + bytes.byteLength > 4096 + ) { + throw new Error("invalid translated WebGPU capabilities"); + } + this.gpuCapabilities = bytes.slice(); + } + sendGpuEvent(bytes) { if ( this.stopped || @@ -428,6 +480,7 @@ stop() { this.stopped = true; this.input.length = 0; + this.motionTilt = null; this.coreInput.length = 0; this.truapiRequests = 0; this.truapiRequestBytes = 0; @@ -744,6 +797,20 @@ this.#setReg(7, BigInt(event.byteLength)); return false; } + case "host_motion_read": { + if (this.motionTilt === null) { + this.#setReg(7, 0n); + return false; + } + const capacity = this.#u32(a1); + if (capacity < MOTION_TILT_BYTES) { + this.#setReg(7, BigInt(-MOTION_TILT_BYTES)); + return false; + } + this.#write(this.#u32(a0), this.motionTilt); + this.#setReg(7, BigInt(MOTION_TILT_BYTES)); + return false; + } case "host_poll_input": { const capacity = this.#u32(a1); const count = Math.min( @@ -1847,6 +1914,50 @@ globalThis.createPvmRuntime = endpoint => { ); } + function sendMotionTilt(bytes) { + if (!running || bytes.byteLength !== 40) { + return; + } + if (translated) { + translated.sendMotionTilt(bytes); + return; + } + stage(bytes); + check( + pvm.pvm_browser_set_motion_tilt(), + "set PolkaVM browser motion-tilt sample" + ); + } + + function clearMotionTilt() { + if (!running) { + return; + } + if (translated) { + translated.clearMotionTilt(); + return; + } + check( + pvm.pvm_browser_clear_motion_tilt(), + "clear PolkaVM browser motion-tilt sample" + ); + } + + function sendGpuCapabilities(bytes) { + if (!running || bytes.byteLength < 56 || bytes.byteLength > 4096) { + return; + } + if (translated) { + translated.setGpuCapabilities(bytes); + return; + } + stage(bytes); + check( + pvm.pvm_browser_set_gpu_capabilities(), + "update PolkaVM browser GPU capabilities" + ); + } + function sendGpuEvent(bytes) { if (!running || !pvm || !bytes.byteLength) { return; @@ -1890,6 +2001,30 @@ globalThis.createPvmRuntime = endpoint => { postMessage({ type: "error", message: error.message }); postMessage({ type: "terminated" }); } + } else if (message?.type === "motion-tilt") { + try { + sendMotionTilt(new Uint8Array(message.bytes)); + } catch (error) { + stopRuntime(); + postMessage({ type: "error", message: error.message }); + postMessage({ type: "terminated" }); + } + } else if (message?.type === "motion-tilt-clear") { + try { + clearMotionTilt(); + } catch (error) { + stopRuntime(); + postMessage({ type: "error", message: error.message }); + postMessage({ type: "terminated" }); + } + } else if (message?.type === "gpu-capabilities") { + try { + sendGpuCapabilities(new Uint8Array(message.bytes)); + } catch (error) { + stopRuntime(); + postMessage({ type: "error", message: error.message }); + postMessage({ type: "terminated" }); + } } else if (message?.type === "gpu-event") { try { sendGpuEvent(new Uint8Array(message.bytes)); diff --git a/rust/crates/pvm-runtime-assets/src/lib.rs b/rust/crates/pvm-runtime-assets/src/lib.rs index 0a4e65f..a7da3ee 100644 --- a/rust/crates/pvm-runtime-assets/src/lib.rs +++ b/rust/crates/pvm-runtime-assets/src/lib.rs @@ -26,13 +26,13 @@ const ASSETS: [BrowserAsset; 7] = [ path: "pvm-browser-runtime.wasm", content_type: "application/wasm", bytes: include_bytes!("../assets/pvm-browser-runtime.wasm"), - sha256: "14c19701d27b037935fb8bd1f1ac2ad350d769da87783e746a030d5b3b9e9fc2", + sha256: "2da1dbca9ca067064ba4311c01d627fd6dcec0ebbcf539941b01b611a29eb59c", }, BrowserAsset { path: "pvm-worker.js", content_type: "text/javascript", bytes: include_bytes!("../assets/pvm-worker.js"), - sha256: "7d9ebbe1189a37dcbd80cc2817a526a801424c82489cfbb9387a8535717607ab", + sha256: "a6b8052746d7b30e6790e54fee6a7a0f35386934f7eefc61d35757f32370564a", }, BrowserAsset { path: "pvm-gpu-worker.js", @@ -44,13 +44,13 @@ const ASSETS: [BrowserAsset; 7] = [ path: "pvm-wasm-translated.js", content_type: "text/javascript", bytes: include_bytes!("../assets/pvm-wasm-translated.js"), - sha256: "a49f337b87c614d0427c9bb2845e88ed2fc33c34b878cc36fbda95cf2ceb3e4d", + sha256: "7b41037579c861aabcc04e28f66650c88cb78c5e77a66bf354f9474c26157715", }, BrowserAsset { path: "pvm-runtime-core.js", content_type: "text/javascript", bytes: include_bytes!("../assets/pvm-runtime-core.js"), - sha256: "f527c1f530ce326e4d40840a5c0456dc9dad5798818038c8f0fe865040742c50", + sha256: "11e8df14cb719da9fa29921441c19820a7076dde28a2318f775254d18a960312", }, BrowserAsset { path: "pvm-wasm-worker-entry.js", @@ -62,7 +62,7 @@ const ASSETS: [BrowserAsset; 7] = [ path: "SHA256SUMS", content_type: "text/plain", bytes: include_bytes!("../assets/SHA256SUMS"), - sha256: "e86ec9a00c60b2a71a0be7c0a6626b33aa4b7512a5da2f51f9d3a1109eb51436", + sha256: "0be2f6cb2d3b59783c4fe02e86e3763c642932b48c11783ad54f8b51d9710686", }, ]; diff --git a/rust/crates/pvm-runtime/src/application.rs b/rust/crates/pvm-runtime/src/application.rs index 4a28f9b..3f40acf 100644 --- a/rust/crates/pvm-runtime/src/application.rs +++ b/rust/crates/pvm-runtime/src/application.rs @@ -4,8 +4,8 @@ use crate::corevm::{Interruption, Vm}; use crate::{ - AudioChunk, Frame, GpuBatch, InputEvent, InputEventType, PresentationProfile, Runtime, - Tri2dFrame, MAX_FRAME_BYTES, + AudioChunk, Frame, GpuBatch, InputEvent, InputEventType, MotionTiltSample, PresentationProfile, + Runtime, Tri2dFrame, MAX_FRAME_BYTES, }; use anyhow::{anyhow, Context, Result}; use polkavm::ProgramBlob; @@ -137,6 +137,16 @@ impl ApplicationRuntime { } } + pub fn set_motion_tilt(&mut self, sample: Option) -> Result<()> { + match self { + Self::Cooperative(runtime) => runtime.set_motion_tilt(sample), + Self::CoreVm(runtime) => runtime + .vm + .set_motion_tilt(sample) + .map_err(anyhow::Error::msg), + } + } + pub fn gpu_ready(&self) -> bool { match self { Self::Cooperative(runtime) => runtime.gpu_ready(), diff --git a/rust/crates/pvm-runtime/src/corevm.rs b/rust/crates/pvm-runtime/src/corevm.rs index 530c467..6ab92d4 100644 --- a/rust/crates/pvm-runtime/src/corevm.rs +++ b/rust/crates/pvm-runtime/src/corevm.rs @@ -65,6 +65,7 @@ pub struct Vm { input_events: VecDeque, audio_channels: u32, epoca_input_events: VecDeque<[u8; crate::INPUT_EVENT_BYTES]>, + motion_tilt: Option<[u8; crate::MOTION_TILT_BYTES]>, #[cfg(not(target_arch = "wasm32"))] started: Instant, #[cfg(target_arch = "wasm32")] @@ -87,6 +88,7 @@ pub struct Vm { import_log: Option, import_yield: Option, import_truapi_send: Option, + import_motion_read: Option, import_truapi_poll: Option, } @@ -263,6 +265,7 @@ impl Vm { let mut import_log = None; let mut import_yield = None; let mut import_truapi_send = None; + let mut import_motion_read = None; let mut import_truapi_poll = None; for (import_index, import) in module.imports().into_iter().enumerate() { @@ -285,6 +288,7 @@ impl Vm { b"host_log" => import_log = Some(import_index), b"pvm_yield" => import_yield = Some(import_index), b"host_truapi_send" => import_truapi_send = Some(import_index), + b"host_motion_read" => import_motion_read = Some(import_index), b"host_truapi_poll" => import_truapi_poll = Some(import_index), _ => return Err(format!("unsupported import: {}", import).into()), } @@ -301,6 +305,7 @@ impl Vm { input_events: VecDeque::with_capacity(MAX_QUEUED_INPUT_EVENTS), audio_channels: 0, epoca_input_events: VecDeque::with_capacity(MAX_QUEUED_INPUT_EVENTS), + motion_tilt: None, #[cfg(not(target_arch = "wasm32"))] started: Instant::now(), #[cfg(target_arch = "wasm32")] @@ -322,6 +327,7 @@ impl Vm { import_log, import_yield, import_truapi_send, + import_motion_read, import_truapi_poll, }) } @@ -367,6 +373,17 @@ impl Vm { Ok(()) } + pub fn set_motion_tilt( + &mut self, + sample: Option, + ) -> Result<(), String> { + self.motion_tilt = sample + .map(crate::MotionTiltSample::encode) + .transpose() + .map_err(|error| error.to_string())?; + Ok(()) + } + pub fn set_gas(&mut self, gas: u64) { self.instance.set_gas(gas.min(i64::MAX as u64) as i64); } @@ -653,6 +670,27 @@ impl Vm { self.instance.set_reg(Reg::A0, written as u64); continue; } + InterruptKind::Ecalli(hostcall) if Some(hostcall) == self.import_motion_read => { + let Some(sample) = self.motion_tilt else { + self.instance.set_reg(Reg::A0, 0); + continue; + }; + let capacity = + usize::try_from(self.instance.reg(Reg::A1)).unwrap_or(usize::MAX); + if capacity < crate::MOTION_TILT_BYTES { + self.instance.set_reg( + Reg::A0, + i64::from(-(crate::MOTION_TILT_BYTES as i32)) as u64, + ); + continue; + } + let address = u32::try_from(self.instance.reg(Reg::A0)) + .map_err(|_| "motion-tilt address is out of range".to_owned())?; + self.instance.write_memory(address, &sample)?; + self.instance + .set_reg(Reg::A0, crate::MOTION_TILT_BYTES as u64); + continue; + } InterruptKind::Ecalli(hostcall) if Some(hostcall) == self.import_truapi_send => { let address = u32::try_from(self.instance.reg(Reg::A0)) .map_err(|_| "TrUAPI request address is out of range".to_owned())?; diff --git a/rust/crates/pvm-runtime/src/lib.rs b/rust/crates/pvm-runtime/src/lib.rs index 09aa1f9..837f4d2 100644 --- a/rust/crates/pvm-runtime/src/lib.rs +++ b/rust/crates/pvm-runtime/src/lib.rs @@ -42,6 +42,79 @@ pub use tri2d::{ pub const ABI_VERSION: u32 = 1; +pub const MOTION_TILT_MAGIC: [u8; 4] = *b"PMT1"; +pub const MOTION_TILT_VERSION: u16 = 1; +pub const MOTION_TILT_BYTES: usize = 40; +pub const MOTION_TILT_FLAG_CALIBRATED: u16 = 1 << 0; +pub const MOTION_TILT_FLAG_AZIMUTH_VALID: u16 = 1 << 1; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MotionTiltSample { + pub sequence: u32, + pub timestamp_us: u64, + pub tilt_x: f32, + pub tilt_y: f32, + pub azimuth: Option, +} + +impl MotionTiltSample { + pub fn encode(self) -> Result<[u8; MOTION_TILT_BYTES]> { + if self.sequence == 0 + || !self.tilt_x.is_finite() + || !self.tilt_y.is_finite() + || !(-1.0..=1.0).contains(&self.tilt_x) + || !(-1.0..=1.0).contains(&self.tilt_y) + || self.azimuth.is_some_and(|value| !value.is_finite()) + { + return Err(anyhow!("invalid motion-tilt sample")); + } + let mut bytes = [0; MOTION_TILT_BYTES]; + bytes[..4].copy_from_slice(&MOTION_TILT_MAGIC); + bytes[4..6].copy_from_slice(&MOTION_TILT_VERSION.to_le_bytes()); + let flags = MOTION_TILT_FLAG_CALIBRATED + | if self.azimuth.is_some() { + MOTION_TILT_FLAG_AZIMUTH_VALID + } else { + 0 + }; + bytes[6..8].copy_from_slice(&flags.to_le_bytes()); + bytes[8..12].copy_from_slice(&(MOTION_TILT_BYTES as u32).to_le_bytes()); + bytes[12..16].copy_from_slice(&self.sequence.to_le_bytes()); + bytes[16..24].copy_from_slice(&self.timestamp_us.to_le_bytes()); + bytes[24..28].copy_from_slice(&self.tilt_x.to_le_bytes()); + bytes[28..32].copy_from_slice(&self.tilt_y.to_le_bytes()); + bytes[32..36].copy_from_slice(&self.azimuth.unwrap_or(0.0).to_le_bytes()); + Ok(bytes) + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() != MOTION_TILT_BYTES + || bytes[..4] != MOTION_TILT_MAGIC + || u16::from_le_bytes(bytes[4..6].try_into().unwrap()) != MOTION_TILT_VERSION + || u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize != MOTION_TILT_BYTES + || bytes[36..40] != [0; 4] + { + return Err(anyhow!("invalid motion-tilt encoding")); + } + let flags = u16::from_le_bytes(bytes[6..8].try_into().unwrap()); + if flags & !(MOTION_TILT_FLAG_CALIBRATED | MOTION_TILT_FLAG_AZIMUTH_VALID) != 0 + || flags & MOTION_TILT_FLAG_CALIBRATED == 0 + { + return Err(anyhow!("invalid motion-tilt flags")); + } + let sample = Self { + sequence: u32::from_le_bytes(bytes[12..16].try_into().unwrap()), + timestamp_us: u64::from_le_bytes(bytes[16..24].try_into().unwrap()), + tilt_x: f32::from_le_bytes(bytes[24..28].try_into().unwrap()), + tilt_y: f32::from_le_bytes(bytes[28..32].try_into().unwrap()), + azimuth: (flags & MOTION_TILT_FLAG_AZIMUTH_VALID != 0) + .then(|| f32::from_le_bytes(bytes[32..36].try_into().unwrap())), + }; + sample.encode()?; + Ok(sample) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PresentationProfile { Framebuffer, @@ -291,6 +364,7 @@ struct HostState { audio: VecDeque, audio_samples: usize, input: VecDeque, + motion_tilt: Option<[u8; MOTION_TILT_BYTES]>, assets: HashMap>, clock: HostClock, logs: VecDeque, @@ -326,6 +400,7 @@ impl HostState { audio: VecDeque::new(), audio_samples: 0, input: VecDeque::new(), + motion_tilt: None, assets, clock: HostClock::new(), logs: VecDeque::new(), @@ -751,6 +826,30 @@ impl Runtime { ) .context("define host_truapi_poll")?; + linker + .define_typed( + "host_motion_read", + |caller: polkavm::Caller<'_, HostState>, + pointer: u32, + capacity: u32| + -> Result { + caller.user_data.charge_hostcall(0)?; + let Some(sample) = caller.user_data.motion_tilt else { + return Ok(0); + }; + if (capacity as usize) < MOTION_TILT_BYTES { + return Ok(-(MOTION_TILT_BYTES as i32)); + } + caller.user_data.charge_hostcall_bytes(MOTION_TILT_BYTES)?; + caller + .instance + .write_memory(pointer, &sample) + .map_err(|error| anyhow!("write motion-tilt sample: {error:?}"))?; + Ok(MOTION_TILT_BYTES as i32) + }, + ) + .context("define host_motion_read")?; + linker .define_typed( "host_poll_input", @@ -1004,6 +1103,11 @@ impl Runtime { self.state.queue_input(event); } + pub fn set_motion_tilt(&mut self, sample: Option) -> Result<()> { + self.state.motion_tilt = sample.map(MotionTiltSample::encode).transpose()?; + Ok(()) + } + pub fn gpu_ready(&self) -> bool { self.state.presentation != PresentationProfile::WebGpuRaster || self.state.gpu_capabilities.is_some() @@ -1266,6 +1370,32 @@ mod tests { ); } + #[test] + fn motion_tilt_encoding_roundtrips_and_rejects_invalid_samples() { + let sample = MotionTiltSample { + sequence: 7, + timestamp_us: 123_456, + tilt_x: -0.25, + tilt_y: 0.75, + azimuth: Some(1.5), + }; + let bytes = sample.encode().expect("valid sample should encode"); + assert_eq!(bytes.len(), MOTION_TILT_BYTES); + assert_eq!(MotionTiltSample::decode(&bytes).unwrap(), sample); + assert!(MotionTiltSample { + sequence: 0, + ..sample + } + .encode() + .is_err()); + assert!(MotionTiltSample { + tilt_x: f32::NAN, + ..sample + } + .encode() + .is_err()); + } + #[test] fn input_queue_coalesces_pointer_motion_and_stays_bounded() { let mut state = HostState::new(HashMap::new(), PresentationProfile::Framebuffer, false); diff --git a/rust/crates/pvm-runtime/src/manifest.rs b/rust/crates/pvm-runtime/src/manifest.rs index 906639b..3c7ed88 100644 --- a/rust/crates/pvm-runtime/src/manifest.rs +++ b/rust/crates/pvm-runtime/src/manifest.rs @@ -20,6 +20,8 @@ pub struct AppDescriptor { pub audio_enabled: bool, /// Required device-input features. pub input_features: Vec, + /// Optional device-input features requested when the host can provide them. + pub optional_input_features: Vec, /// Required WebGPU limits, empty for other profiles. pub gpu_limits: BTreeMap, } @@ -73,6 +75,8 @@ struct DeviceInput { abi_version: u32, #[serde(rename = "requiredFeatures", default)] required_features: Vec, + #[serde(rename = "optionalFeatures", default)] + optional_features: Vec, } #[derive(Deserialize)] @@ -133,18 +137,43 @@ impl AppDescriptor { } else if !gpu_limits.is_empty() { bail!("non-WebGPU graphics profile declares required limits"); } - let input_features = if let Some(input) = manifest.capabilities.device_input { + let (input_features, optional_input_features) = if let Some(input) = + manifest.capabilities.device_input + { if input.abi_version != 1 { bail!("device input capability must use ABI version 1"); } + if input.required_features.len() + != input + .required_features + .iter() + .collect::>() + .len() + || input.optional_features.len() + != input + .optional_features + .iter() + .collect::>() + .len() + { + bail!("device input features must be unique"); + } for feature in &input.required_features { if feature != "pointer" && feature != "keyboard" { - bail!("unsupported device input feature {feature}"); + bail!("unsupported required device input feature {feature}"); + } + } + for feature in &input.optional_features { + if feature != "motion-tilt" { + bail!("unsupported optional device input feature {feature}"); + } + if input.required_features.contains(feature) { + bail!("device input feature {feature} cannot be both required and optional"); } } - input.required_features + (input.required_features, input.optional_features) } else { - Vec::new() + (Vec::new(), Vec::new()) }; let audio_enabled = if let Some(audio) = manifest.capabilities.audio { if audio.abi_version != 1 || !audio.required_features.is_empty() { @@ -160,6 +189,7 @@ impl AppDescriptor { presentation, audio_enabled, input_features, + optional_input_features, gpu_limits, }) } @@ -195,6 +225,8 @@ mod tests { assert!(descriptor.audio_enabled); } + const MOTION: &[u8] = br#"{"$v":2,"kind":"app","appVersion":[1,2,3],"runtime":{"kind":"polkavm","abiVersion":1,"entrypoint":"app.polkavm"},"capabilities":{"graphics":{"abiVersion":1,"profile":"webgpu-raster","requiredFeatures":[]},"deviceInput":{"abiVersion":1,"requiredFeatures":["pointer"],"optionalFeatures":["motion-tilt"]}}}"#; + #[test] fn parses_exact_strict_manifest() { let descriptor = AppDescriptor::parse_exact(FRAMEBUFFER, FRAMEBUFFER).unwrap(); @@ -203,6 +235,13 @@ mod tests { assert!(descriptor.audio_enabled); } + #[test] + fn parses_optional_motion_tilt_without_changing_abi_version() { + let descriptor = AppDescriptor::parse_exact(MOTION, MOTION).unwrap(); + assert_eq!(descriptor.input_features, ["pointer"]); + assert_eq!(descriptor.optional_input_features, ["motion-tilt"]); + } + #[test] fn rejects_external_byte_mismatch() { let mut changed = FRAMEBUFFER.to_vec(); diff --git a/rust/crates/pvm-runtime/src/native_ffi.rs b/rust/crates/pvm-runtime/src/native_ffi.rs index 64eb7ba..313148b 100644 --- a/rust/crates/pvm-runtime/src/native_ffi.rs +++ b/rust/crates/pvm-runtime/src/native_ffi.rs @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ use crate::{ - ApplicationRuntime, AudioChunk, Frame, GpuBatch, InputEvent, InputEventType, + ApplicationRuntime, AudioChunk, Frame, GpuBatch, InputEvent, InputEventType, MotionTiltSample, PresentationProfile, Tri2dFrame, }; use std::collections::HashMap; @@ -57,6 +57,27 @@ pub struct NativePvmAsset { pub bytes: Vec, } +#[derive(Clone, Debug, uniffi::Record)] +pub struct NativePvmMotionTiltSample { + pub sequence: u32, + pub timestamp_us: u64, + pub tilt_x: f32, + pub tilt_y: f32, + pub azimuth: Option, +} + +impl From for MotionTiltSample { + fn from(sample: NativePvmMotionTiltSample) -> Self { + Self { + sequence: sample.sequence, + timestamp_us: sample.timestamp_us, + tilt_x: sample.tilt_x, + tilt_y: sample.tilt_y, + azimuth: sample.azimuth, + } + } +} + #[derive(Clone, Debug, uniffi::Record)] pub struct NativePvmFrame { pub width: u32, @@ -219,6 +240,18 @@ impl NativePvmRuntime { Ok(()) } + pub fn set_motion_tilt(&self, sample: NativePvmMotionTiltSample) -> Result<(), NativePvmError> { + self.lock()? + .set_motion_tilt(Some(sample.into())) + .map_err(NativePvmError::runtime) + } + + pub fn clear_motion_tilt(&self) -> Result<(), NativePvmError> { + self.lock()? + .set_motion_tilt(None) + .map_err(NativePvmError::runtime) + } + pub fn gpu_ready(&self) -> Result { Ok(self.lock()?.gpu_ready()) } diff --git a/rust/crates/pvm-runtime/src/wasm.rs b/rust/crates/pvm-runtime/src/wasm.rs index a9287b6..9d9acda 100644 --- a/rust/crates/pvm-runtime/src/wasm.rs +++ b/rust/crates/pvm-runtime/src/wasm.rs @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ use crate::{ - ApplicationRuntime, AudioChunk, Frame, GpuBatch, InputEvent, InputEventType, + ApplicationRuntime, AudioChunk, Frame, GpuBatch, InputEvent, InputEventType, MotionTiltSample, PresentationProfile, Tri2dFrame, MAX_ASSET_BYTES, MAX_ASSET_FILES, MAX_ASSET_FILE_BYTES, MAX_PROGRAM_BYTES, }; @@ -317,6 +317,20 @@ pub extern "C" fn pvm_browser_send_input(event_type: u32, code: u32, x: u32, y: }) } +#[no_mangle] +pub extern "C" fn pvm_browser_set_motion_tilt() -> u32 { + status(|host| { + let bytes = std::mem::take(&mut host.staging); + let sample = MotionTiltSample::decode(&bytes)?; + host.running()?.set_motion_tilt(Some(sample)) + }) +} + +#[no_mangle] +pub extern "C" fn pvm_browser_clear_motion_tilt() -> u32 { + status(|host| host.running()?.set_motion_tilt(None)) +} + #[no_mangle] pub extern "C" fn pvm_browser_take_frame() -> u32 { HOST.with(|host| {