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
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]>
308 lines
12 KiB
Rust
308 lines
12 KiB
Rust
//! 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 §ions {
|
||
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 §ions {
|
||
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());
|
||
}
|
||
}
|