open-standards/wai/wai-rs/src/haptic/mod.rs
Transaction Science 161d147767
Some checks failed
wai-quantum WebGPU member / member · discrete GPU, Vulkan (jetson-hub) (pull_request) Successful in 9m59s
spec citations resolve / Code cites no section its SPEC lacks (pull_request) Successful in 4s
standards workspaces build + test / arl-rs (pull_request) Successful in 1m12s
standards workspaces build + test / eoc-rs (pull_request) Successful in 2m15s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m48s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 1m10s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 1m13s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m35s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m14s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m35s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Has been cancelled
no-vanity-metrics / WAI public text names no comparison (pull_request) Has been cancelled
no-vanity-metrics / WAI public text names no company or product (pull_request) Has been cancelled
no-vanity-metrics / No new orphan source files (pull_request) Has been cancelled
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
tests in CI / every test file is run by a job or listed with its reason (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · macos (pull_request) Has been cancelled
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Has been cancelled
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Has been cancelled
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Has been cancelled
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · macos (pull_request) Has been cancelled
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Has been cancelled
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux (pull_request) Has been cancelled
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Has been cancelled
WAI energy receipts / energy receipts · macos (pull_request) Has been cancelled
WAI HTTP delivery / WAI HTTP · does this change touch it (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Has been cancelled
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Has been cancelled
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Has been cancelled
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Has been cancelled
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Has been cancelled
wai-pack / wai-pack · does this change touch it (pull_request) Has been cancelled
wai-pack / wai-pack · linux (pull_request) Has been cancelled
wai-pack / wai-pack · linux-arm64 (pull_request) Has been cancelled
wai-pack / wai-pack · macos (pull_request) Has been cancelled
wai-pack / wai-pack · independent verifier (Python) (pull_request) Has been cancelled
wai-pack / wai-pack · the floor through a media prober (pull_request) Has been cancelled
wai-pack / wai-pack · dependency licences (pull_request) Has been cancelled
wai-quantum WebGPU member / shader checks · browser build · wasip2 inertness · MSRV (pull_request) Has been cancelled
wai-quantum WebGPU member / member · software Vulkan (linux) (pull_request) Has been cancelled
wai-quantum WebGPU member / member · Metal (macos) (pull_request) Has been cancelled
wai-quantum WebGPU member / member · browser WebGPU (macos) (pull_request) Has been cancelled
WAI section references / cited sections exist (pull_request) Has been cancelled
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Has been cancelled
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Has been cancelled
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Has been cancelled
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Has been cancelled
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Has been cancelled
workflow hygiene / every workflow supersedes its own queue (pull_request) Has been cancelled
lockfiles / every Cargo.lock is current (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · linux (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
wai: reader limits for the extension formats' durations
A score, haptic signal, audio scene, splat4d clip or film declares its length in its
contract, and rendering allocates in proportion: a score's mixdown allocated `duration`
samples outright, a haptic channel `duration` × 2 bytes, a scene's stereo mix 20 bytes a
sample, and the splat4d and film receipts a hash a frame. Nothing bounded the
declaration, so a few bytes could ask a renderer for more memory than any machine has.
SECURITY.md recorded it as a gap when the fuzzing PR found the reader-side reservations.

Each extension's contract section (score, haptics, spatial-audio, volumetric,
linear-film and its reel) now has a reader limit shared with SPEC §2, through a new
always-on module `wai::limits`:
- score and haptics: `duration × channels` at most 2^27 samples;
- audio scene: `duration` at most 2^26, so the stereo floor is at most 2^27 samples;
- splat4d `n_frames`, film `frames`, a reel's shots together: at most 2^20 frames.
A reader may refuse past them (`duration_too_long`) when it reads the contract, before
anything is rendered; a conforming encoder writes none.

The film schedule's tick, `start_tick + f × ticks_per_frame`, wrapped in release builds
and panicked in debug ones. A reader must now refuse a schedule whose last tick passes
2^64 - 1 (a film's, and each shot's of a reel); `Film::frame_tick` saturates and
`Reel::frame_tick` uses checked arithmetic.

tests/extension_duration_limits.rs rewrites a committed vector of each format to declare
exactly its limit (it reads) and one past it (refused by name), and checks the film
schedule ending at 2^64 - 1 (reads) and past it (refused). Falsified: with the readers
as they were, all three tests fail. The world, audio, haptic, splat4d, score and film
corpora verify unchanged; the 275 lib tests with the container features pass. CI runs
the test beside container_reservations on every leg.

SECURITY.md drops the gap and keeps the one this exposes: replaying a world to a far
tick costs time in proportion (memory is bounded, time is not).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 11:07:16 -04:00

308 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Haptics — `extensions/haptics.md` (Phase 2). Touch as instructions.
//!
//! A `wai.haptic.signal` is a per-channel keyframed envelope of
//! **intensity** and **sharpness** (the pair modern actuator APIs map
//! to). The sink reconstructs a per-sample `u8` buffer in the
//! `wai.det.fixed64` floor and plays it on whatever actuators it has.
//! Conformance is **sample-equivalence**: same signal → identical
//! reconstructed buffer → identical BLAKE3. Trig-free (amplitude +
//! sharpness, not a modulated carrier), so the floor reconstructs it
//! exactly with `+,−,×,÷` alone — the simplest cargo class to make
//! bit-exact.
use crate::world::Fx;
mod receipt;
pub use receipt::{ChannelHash, HapticReceipt};
pub const CAP_HAPTIC_SIGNAL: &str = "wai.haptic.signal";
pub const DOMAIN_SIGNAL: &[u8] = b"wai:haptic-signal\x01";
pub const MAGIC_WHAP: &[u8; 4] = b"WHAP";
const SECTION_CONTRACT: u8 = 0x01;
const SECTION_CHANNELS: u8 = 0x02;
#[derive(Debug)]
pub enum HapticError {
BadMagic,
Truncated,
MissingSection(u8),
BadContract(String),
}
impl std::fmt::Display for HapticError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadMagic => write!(f, "payload is not a WHAP container"),
Self::Truncated => write!(f, "byte stream truncated"),
Self::MissingSection(k) => write!(f, "required section 0x{k:02x} missing"),
Self::BadContract(w) => write!(f, "bad haptic contract: {w}"),
}
}
}
impl std::error::Error for HapticError {}
#[derive(Clone, Debug, PartialEq)]
pub struct HapticContract {
pub sample_rate: u32,
pub numeric: String,
pub channels: u32,
pub duration: u64,
}
impl HapticContract {
pub fn to_canonical_json(&self) -> Vec<u8> {
format!(
"{{\"channels\":{},\"duration\":{},\"numeric\":\"{}\",\"sample_rate\":{}}}",
self.channels, self.duration, self.numeric, self.sample_rate
)
.into_bytes()
}
pub fn from_json(b: &[u8]) -> Result<HapticContract, HapticError> {
let v: serde_json::Value =
serde_json::from_slice(b).map_err(|e| HapticError::BadContract(e.to_string()))?;
let o = v.as_object().ok_or_else(|| HapticError::BadContract("not an object".into()))?;
let u = |k: &str| o.get(k).and_then(|x| x.as_u64()).ok_or_else(|| HapticError::BadContract(format!("missing `{k}`")));
let s = |k: &str| o.get(k).and_then(|x| x.as_str()).map(str::to_owned).ok_or_else(|| HapticError::BadContract(format!("missing `{k}`")));
let sr = u("sample_rate")?;
if sr == 0 || sr > u32::MAX as u64 {
return Err(HapticError::BadContract("sample_rate out of range".into()));
}
let ch = u("channels")?;
let duration = u("duration")?;
let samples = duration.saturating_mul(ch.max(1));
if samples > crate::limits::RENDER_MAX_SAMPLES {
return Err(HapticError::BadContract(crate::limits::duration_too_long(
"samples (duration × channels)", samples, crate::limits::RENDER_MAX_SAMPLES)));
}
Ok(HapticContract { sample_rate: sr as u32, numeric: s("numeric")?, channels: ch as u32, duration })
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HapticKey {
pub pos_sample: u64,
pub intensity: Fx,
pub sharpness: Fx,
}
#[derive(Clone, Debug, PartialEq)]
pub struct HapticSignal {
pub contract: HapticContract,
pub contract_bytes: Vec<u8>,
/// One keyframe list per channel.
pub channels: Vec<Vec<HapticKey>>,
}
#[inline]
fn clamp01(v: Fx) -> Fx {
if v.to_bits() < 0 { Fx::ZERO } else if v > Fx::ONE { Fx::ONE } else { v }
}
/// `Fx` [0,1] → u8, round half away from zero in integer space.
#[inline]
fn quant_u8(v: Fx) -> u8 {
let scaled = clamp01(v) * Fx::from_int(255) + (Fx::ONE / Fx::from_int(2));
(scaled.to_bits() / (1i64 << 32)).clamp(0, 255) as u8
}
fn interp(keys: &[HapticKey], t: u64) -> (Fx, Fx) {
match keys {
[] => (Fx::ZERO, Fx::ZERO),
[k] => (k.intensity, k.sharpness),
_ => {
if t <= keys[0].pos_sample {
return (keys[0].intensity, keys[0].sharpness);
}
let last = keys.last().unwrap();
if t >= last.pos_sample {
return (last.intensity, last.sharpness);
}
let mut a = &keys[0];
for k in &keys[1..] {
if t < k.pos_sample {
let span = (k.pos_sample - a.pos_sample) as i32;
let into = (t - a.pos_sample) as i32;
let frac = Fx::from_int(into) / Fx::from_int(span);
let l = |p: Fx, q: Fx| p + (q - p) * frac;
return (l(a.intensity, k.intensity), l(a.sharpness, k.sharpness));
}
a = k;
}
(last.intensity, last.sharpness)
}
}
}
impl HapticSignal {
/// Reconstruct channel `c` to interleaved `[intensity, sharpness]` u8.
pub fn reconstruct_channel(&self, c: usize) -> Vec<u8> {
let keys = &self.channels[c];
let n = self.contract.duration as usize;
let mut out = Vec::with_capacity(n * 2);
for t in 0..n {
let (i, s) = interp(keys, t as u64);
out.push(quant_u8(i));
out.push(quant_u8(s));
}
out
}
/// `BLAKE3(domain || per-channel u8 pairs)` — the sample-equivalence
/// quantity.
pub fn signal_hash(&self) -> [u8; 32] {
let mut h = blake3::Hasher::new();
h.update(DOMAIN_SIGNAL);
for c in 0..self.channels.len() {
h.update(&self.reconstruct_channel(c));
}
*h.finalize().as_bytes()
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut chans = Vec::new();
chans.extend_from_slice(&(self.channels.len() as u32).to_le_bytes());
for ch in &self.channels {
chans.extend_from_slice(&(ch.len() as u16).to_le_bytes());
for k in ch {
chans.extend_from_slice(&k.pos_sample.to_le_bytes());
chans.extend_from_slice(&k.intensity.to_bits().to_le_bytes());
chans.extend_from_slice(&k.sharpness.to_bits().to_le_bytes());
}
}
let sections = [(SECTION_CONTRACT, &self.contract_bytes[..]), (SECTION_CHANNELS, &chans[..])];
let mut out = Vec::new();
out.extend_from_slice(MAGIC_WHAP);
out.extend_from_slice(&(sections.len() as u16).to_le_bytes());
let mut off = 0u32;
let mut offs = Vec::new();
for (_, b) in &sections {
offs.push(off);
off += b.len().div_ceil(8) as u32 * 8;
}
for ((kind, b), o) in sections.iter().zip(&offs) {
out.push(*kind);
out.extend_from_slice(&o.to_le_bytes());
out.extend_from_slice(&(b.len() as u32).to_le_bytes());
}
for (_, b) in &sections {
out.extend_from_slice(b);
out.extend_from_slice(&[0u8; 8][..b.len().div_ceil(8) * 8 - b.len()]);
}
out
}
pub fn from_bytes(bytes: &[u8]) -> Result<HapticSignal, HapticError> {
if bytes.len() < 6 {
return Err(HapticError::Truncated);
}
if &bytes[..4] != MAGIC_WHAP {
return Err(HapticError::BadMagic);
}
let n = u16::from_le_bytes(bytes[4..6].try_into().unwrap()) as usize;
let table_end = 6 + n * 9;
if bytes.len() < table_end {
return Err(HapticError::Truncated);
}
let pay = &bytes[table_end..];
let (mut contract_bytes, mut chbytes) = (None, None);
for i in 0..n {
let e = &bytes[6 + i * 9..6 + (i + 1) * 9];
let off = u32::from_le_bytes(e[1..5].try_into().unwrap()) as usize;
let len = u32::from_le_bytes(e[5..9].try_into().unwrap()) as usize;
let end = off.checked_add(len).ok_or(HapticError::Truncated)?;
if end > pay.len() {
return Err(HapticError::Truncated);
}
match e[0] {
SECTION_CONTRACT => contract_bytes = Some(pay[off..end].to_vec()),
SECTION_CHANNELS => chbytes = Some(&pay[off..end]),
_ => {}
}
}
let contract_bytes = contract_bytes.ok_or(HapticError::MissingSection(SECTION_CONTRACT))?;
let chbytes = chbytes.ok_or(HapticError::MissingSection(SECTION_CHANNELS))?;
let contract = HapticContract::from_json(&contract_bytes)?;
let channels = parse_channels(chbytes)?;
Ok(HapticSignal { contract, contract_bytes, channels })
}
}
fn parse_channels(b: &[u8]) -> Result<Vec<Vec<HapticKey>>, HapticError> {
let rd = |p: usize, n: usize| -> Result<&[u8], HapticError> {
if p + n > b.len() { Err(HapticError::Truncated) } else { Ok(&b[p..p + n]) }
};
let nc = u32::from_le_bytes(rd(0, 4)?.try_into().unwrap()) as usize;
let mut p = 4usize;
// Reserve no more entries than the bytes left could hold: the count is the input's,
// and a reservation sized by it alone let a few bytes ask for gigabytes.
let mut chans = Vec::with_capacity(nc.min(b.len() / 2));
for _ in 0..nc {
let nk = u16::from_le_bytes(rd(p, 2)?.try_into().unwrap()) as usize;
p += 2;
let mut keys = Vec::with_capacity(nk);
for _ in 0..nk {
let pos = u64::from_le_bytes(rd(p, 8)?.try_into().unwrap());
let inten = i64::from_le_bytes(rd(p + 8, 8)?.try_into().unwrap());
let sharp = i64::from_le_bytes(rd(p + 16, 8)?.try_into().unwrap());
p += 24;
keys.push(HapticKey { pos_sample: pos, intensity: Fx::from_bits(inten), sharpness: Fx::from_bits(sharp) });
}
chans.push(keys);
}
Ok(chans)
}
#[cfg(test)]
mod tests {
use super::*;
fn sig(keys: Vec<HapticKey>, dur: u64) -> HapticSignal {
let c = HapticContract { sample_rate: 1000, numeric: crate::world::NUMERIC_FIXED64.into(), channels: 1, duration: dur };
HapticSignal { contract_bytes: c.to_canonical_json(), contract: c, channels: vec![keys] }
}
fn f(n: i32, d: i32) -> Fx {
Fx::from_int(n) / Fx::from_int(d)
}
#[test]
fn ramp_reconstructs_monotonically_and_hashes_stably() {
let s = sig(
vec![
HapticKey { pos_sample: 0, intensity: Fx::ZERO, sharpness: f(1, 2) },
HapticKey { pos_sample: 100, intensity: Fx::ONE, sharpness: f(1, 2) },
],
100,
);
let buf = s.reconstruct_channel(0);
assert_eq!(buf.len(), 200); // 100 samples × (intensity,sharpness)
assert_eq!(buf[0], 0); // intensity starts at 0
assert!(buf[198] > buf[0], "intensity ramps up");
assert!(buf.iter().step_by(2).is_sorted(), "intensity is monotonic non-decreasing");
assert_eq!(s.signal_hash(), s.signal_hash());
}
#[test]
fn full_buzz_is_max_intensity() {
let s = sig(vec![HapticKey { pos_sample: 0, intensity: Fx::ONE, sharpness: Fx::ONE }], 50);
let buf = s.reconstruct_channel(0);
assert!(buf.iter().all(|&b| b == 255), "constant full effect is 255 everywhere");
}
#[test]
fn container_round_trips_and_distinct_signals_differ() {
let s = sig(
vec![
HapticKey { pos_sample: 0, intensity: Fx::ZERO, sharpness: Fx::ZERO },
HapticKey { pos_sample: 40, intensity: Fx::ONE, sharpness: f(3, 4) },
HapticKey { pos_sample: 80, intensity: Fx::ZERO, sharpness: Fx::ZERO },
],
80,
);
let back = HapticSignal::from_bytes(&s.to_bytes()).unwrap();
assert_eq!(back.channels, s.channels);
assert_eq!(back.contract, s.contract);
assert_eq!(back.signal_hash(), s.signal_hash());
// a different envelope → different hash
let s2 = sig(vec![HapticKey { pos_sample: 0, intensity: f(1, 2), sharpness: Fx::ZERO }], 80);
assert_ne!(s.signal_hash(), s2.signal_hash());
}
}