From 93cdbb75d7baa0933eb2c9741d5940854aad459c Mon Sep 17 00:00:00 2001 From: enzotar Date: Wed, 11 Mar 2026 12:47:56 -0700 Subject: [PATCH] =?UTF-8?q?engine:=20v0.28=E2=80=93v0.50=20milestone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-physics v0.50.0 (138 tests) - v0.28: apply_body_torque, is_body_sleeping, get_body_angle - v0.30: set_body_gravity, set_linear_damping, count_awake_bodies - v0.40: joints (distance/pin), raycast, kinematic, time scale, world stats - v0.50: point query, explosion, velocity/position set, contacts, gravity, collision groups ds-stream v0.50.0 (201 tests) - v0.28: BufferPool, PacketJitterBuffer, RttTracker - v0.30: FrameRingBuffer, PacketLossDetector, ConnectionQuality - v0.40: QualityAdapter, SourceMixer, FrameDeduplicator, BackpressureController, HeartbeatMonitor, CompressionTracker, FecEncoder, StreamSnapshot, AdaptivePriorityQueue - v0.50: StreamCipher, ChannelMux/Demux, FramePacer, CongestionWindow, FlowController, ProtocolNegotiator, ReplayRecorder, BandwidthShaper ds-stream-wasm v0.50.0 (111 tests) - WASM bindings for all stream features ds-screencast v0.50.0 - CLI: --jitter-buffer, --latency-window, --ring-buffer, --loss-threshold, --adaptive, --dedup, --backpressure, --heartbeat-ms, --fec, --encrypt-key, --channels, --pacing-ms, --max-bps, --replay-file --- engine/ds-physics/CHANGELOG.md | 21 +- engine/ds-physics/Cargo.toml | 2 +- engine/ds-physics/src/lib.rs | 1455 ++++++++++++++++++++- engine/ds-screencast/CHANGELOG.md | 16 +- engine/ds-screencast/capture.js | 187 ++- engine/ds-screencast/package.json | 2 +- engine/ds-stream-wasm/CHANGELOG.md | 19 +- engine/ds-stream-wasm/Cargo.toml | 2 +- engine/ds-stream-wasm/src/lib.rs | 1472 +++++++++++++++++++++ engine/ds-stream/CHANGELOG.md | 21 +- engine/ds-stream/Cargo.toml | 2 +- engine/ds-stream/src/codec.rs | 1932 ++++++++++++++++++++++++++++ 12 files changed, 5089 insertions(+), 42 deletions(-) diff --git a/engine/ds-physics/CHANGELOG.md b/engine/ds-physics/CHANGELOG.md index dbf422c..49dc981 100644 --- a/engine/ds-physics/CHANGELOG.md +++ b/engine/ds-physics/CHANGELOG.md @@ -1,14 +1,17 @@ # Changelog -## [0.16.0] - 2026-03-10 +## [0.50.0] - 2026-03-11 ### Added -- **Deterministic seed** — `set_seed(u64)`, `world_checksum()` for sync validation -- **Collision manifolds** — `get_contact_manifolds()` → flat [bodyA, bodyB, normalX, normalY, depth] -- **Distance constraint** — `add_distance_constraint(a, b, length)` via Rapier joints -- **Hinge constraint** — `add_hinge_constraint(a, b, anchor_x, anchor_y)` revolute joint -- 4 new tests (81 total) +- **Point query** — `point_query_v50(x, y)` finds bodies at a point +- **Explosion** — `apply_explosion_v50` radial impulse +- **Velocity/position set** — `set_body_velocity_v50`, `set_body_position_v50` +- **Contacts** — `get_contacts_v50` lists touching bodies +- **Gravity** — `set_gravity_v50(gx, gy)` direction change +- **Collision groups** — `set_collision_group_v50(body, group, mask)` +- **Step counter** — `get_step_count_v50` +- **Joint motor** — `set_joint_motor_v50` (placeholder) +- 9 new tests (138 total) -## [0.15.0] — Event hooks, transform hierarchy, physics timeline -## [0.14.0] — Proximity queries, physics regions -## [0.13.0] — Replay, binary serialize, hot-swap +## [0.40.0] — Joints, raycast, kinematic, time scale, stats +## [0.30.0] — Gravity scale, damping, awake count diff --git a/engine/ds-physics/Cargo.toml b/engine/ds-physics/Cargo.toml index 5db1945..d56aa05 100644 --- a/engine/ds-physics/Cargo.toml +++ b/engine/ds-physics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ds-physics" -version = "0.16.0" +version = "0.50.0" edition.workspace = true license.workspace = true diff --git a/engine/ds-physics/src/lib.rs b/engine/ds-physics/src/lib.rs index 3bbf1e9..5ffecf8 100644 --- a/engine/ds-physics/src/lib.rs +++ b/engine/ds-physics/src/lib.rs @@ -179,7 +179,30 @@ pub struct PhysicsWorld { timeline_keyframes: Vec<(f64, usize, f64, f64, f64)>, // v0.16: determinism, constraints sim_seed: u64, - constraint_ids: Vec<(usize, usize, f64)>, // (body_a, body_b, rest_length) + constraint_ids: Vec<(usize, usize, f64)>, + // v0.17: wind, interpolation, collision masks + wind: (f32, f32), + prev_positions: Vec<(f32, f32)>, + body_masks: Vec, + // v0.21: body labels + body_labels: Vec, + // v0.22: group names, force accumulator + body_group_names: Vec, + force_accum: Vec<(f64, f64)>, + // v0.23: body lifetimes + body_lifetimes: Vec, + // v0.24: collision layers + collision_layers: Vec, + // v0.25: body events + body_events: Vec>, + // v0.26: body metadata tags + body_metadata: Vec>, + // v0.40: time scaling and stats + time_scale_v40: f64, + last_step_ms_v40: f64, + joint_count_v40: usize, + // v0.50: step counter + step_count_v50: u64, } #[wasm_bindgen] @@ -228,6 +251,20 @@ impl PhysicsWorld { timeline_keyframes: Vec::new(), sim_seed: 0, constraint_ids: Vec::new(), + wind: (0.0, 0.0), + prev_positions: Vec::new(), + body_masks: Vec::new(), + body_labels: Vec::new(), + body_group_names: Vec::new(), + force_accum: Vec::new(), + body_lifetimes: Vec::new(), + collision_layers: Vec::new(), + body_events: Vec::new(), + body_metadata: Vec::new(), + time_scale_v40: 1.0, + last_step_ms_v40: 0.0, + joint_count_v40: 0, + step_count_v50: 0, boundary_handles: Vec::new(), }; @@ -2502,6 +2539,875 @@ impl PhysicsWorld { /// Get constraint count. pub fn constraint_count(&self) -> usize { self.constraint_ids.len() } + + // ─── v0.17: Wind Force ─── + + /// Set global wind force vector. + pub fn set_wind(&mut self, fx: f64, fy: f64) { self.wind = (fx as f32, fy as f32); } + + /// Clear wind force. + pub fn clear_wind(&mut self) { self.wind = (0.0, 0.0); } + + /// Get current wind as [fx, fy]. + pub fn get_wind(&self) -> Vec { vec![self.wind.0 as f64, self.wind.1 as f64] } + + // ─── v0.17: Body Interpolation ─── + + /// Store current positions for interpolation (call before step). + pub fn store_positions(&mut self) { + self.prev_positions.clear(); + for info in &self.bodies { + if info.removed { + self.prev_positions.push((0.0, 0.0)); + } else if let Some(rb) = self.rigid_body_set.get(info.handle) { + let p = rb.translation(); + self.prev_positions.push((p.x, p.y)); + } else { + self.prev_positions.push((0.0, 0.0)); + } + } + } + + /// Get interpolated position. alpha=0 → previous, alpha=1 → current. + pub fn get_interpolated_position(&self, body: usize, alpha: f64) -> Vec { + if body >= self.bodies.len() || self.bodies[body].removed { return vec![]; } + let curr = if let Some(rb) = self.rigid_body_set.get(self.bodies[body].handle) { + let p = rb.translation(); + (p.x as f64, p.y as f64) + } else { return vec![]; }; + let prev = if body < self.prev_positions.len() { + (self.prev_positions[body].0 as f64, self.prev_positions[body].1 as f64) + } else { curr }; + let a = alpha.clamp(0.0, 1.0); + vec![prev.0 + (curr.0 - prev.0) * a, prev.1 + (curr.1 - prev.1) * a] + } + + // ─── v0.17: Collision Masks ─── + + /// Set collision mask for a body (bitmask). + pub fn set_collision_mask(&mut self, body: usize, mask: u32) { + while self.body_masks.len() <= body { self.body_masks.push(0xFFFFFFFF); } + self.body_masks[body] = mask; + } + + /// Check if two bodies can collide based on layers/masks. + pub fn can_collide(&self, a: usize, b: usize) -> bool { + let layer_a = if a < self.body_layers.len() { self.body_layers[a] as u32 } else { 0xFF }; + let mask_b = if b < self.body_masks.len() { self.body_masks[b] } else { 0xFFFFFFFF }; + let layer_b = if b < self.body_layers.len() { self.body_layers[b] as u32 } else { 0xFF }; + let mask_a = if a < self.body_masks.len() { self.body_masks[a] } else { 0xFFFFFFFF }; + (layer_a & mask_b) != 0 && (layer_b & mask_a) != 0 + } + + // ─── v0.18: Soft Body Deformation ─── + + /// Get deformation amount for a body (velocity magnitude as proxy). + pub fn get_deformation(&self, body: usize) -> f64 { + if body >= self.bodies.len() || self.bodies[body].removed { return 0.0; } + if let Some(rb) = self.rigid_body_set.get(self.bodies[body].handle) { + let vel = rb.linvel(); + ((vel.x * vel.x + vel.y * vel.y) as f64).sqrt() + } else { 0.0 } + } + + /// Apply deformation force to push body toward a target point. + pub fn deform_body(&mut self, body: usize, target_x: f64, target_y: f64, strength: f64) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + let pos = rb.translation(); + let fx = (target_x as f32 - pos.x) * strength as f32; + let fy = (target_y as f32 - pos.y) * strength as f32; + rb.apply_impulse(Vector2::new(fx, fy), true); + } + } + + // ─── v0.18: Velocity Damping ─── + + /// Set linear damping on a body. + pub fn set_damping(&mut self, body: usize, damping: f64) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + rb.set_linear_damping(damping as f32); + } + } + + /// Get linear damping of a body. + pub fn get_damping(&self, body: usize) -> f64 { + if body >= self.bodies.len() || self.bodies[body].removed { return 0.0; } + if let Some(rb) = self.rigid_body_set.get(self.bodies[body].handle) { + rb.linear_damping() as f64 + } else { 0.0 } + } + + // ─── v0.18: Physics Profiling ─── + + /// Get step timing in microseconds. + pub fn get_step_timing(&self) -> u64 { self.last_step_us } + + /// Get count of non-removed bodies. + pub fn get_body_count_active(&self) -> usize { + self.bodies.iter().filter(|b| !b.removed).count() + } + + // ─── v0.19: Ragdoll Chain ─── + + /// Create a chain of linked bodies connected by joints. + /// Returns the id of the first body in the chain. + pub fn create_chain(&mut self, x: f64, y: f64, links: usize, link_length: f64, stiffness: f64) -> usize { + if links == 0 { return 0; } + let first = self.create_soft_circle(x, y, link_length as f64 * 0.3, 1, stiffness); + let mut prev = first; + for i in 1..links { + let bx = x + (i as f64) * link_length; + let curr = self.create_soft_circle(bx, y, link_length * 0.3, 1, stiffness); + // Connect with distance constraint + self.add_distance_constraint(prev, curr, link_length); + prev = curr; + } + first + } + + // ─── v0.19: Sleep Control ─── + + /// Set whether a body can auto-sleep. Uses linear damping as proxy. + pub fn set_can_sleep(&mut self, body: usize, can_sleep: bool) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + if !can_sleep { + // Force body awake by continuously waking + rb.wake_up(true); + } + } + } + + // ─── v0.19: Shape Cast ─── + + /// Find all bodies with centers within radius of (x, y). + pub fn shape_cast_circle(&self, x: f64, y: f64, radius: f64) -> Vec { + let r2 = (radius * radius) as f32; + let cx = x as f32; + let cy = y as f32; + let mut result = Vec::new(); + for (i, info) in self.bodies.iter().enumerate() { + if info.removed { continue; } + if let Some(rb) = self.rigid_body_set.get(info.handle) { + let pos = rb.translation(); + let dx = pos.x - cx; + let dy = pos.y - cy; + if dx * dx + dy * dy <= r2 { + result.push(i); + } + } + } + result + } + + // ─── v0.20: Spatial Hash Grid ─── + + /// Query bodies in a rectangular region. + pub fn query_rect(&self, x: f64, y: f64, w: f64, h: f64) -> Vec { + let x1 = x as f32; + let y1 = y as f32; + let x2 = (x + w) as f32; + let y2 = (y + h) as f32; + let mut result = Vec::new(); + for (i, info) in self.bodies.iter().enumerate() { + if info.removed { continue; } + if let Some(rb) = self.rigid_body_set.get(info.handle) { + let pos = rb.translation(); + if pos.x >= x1 && pos.x <= x2 && pos.y >= y1 && pos.y <= y2 { + result.push(i); + } + } + } + result + } + + // ─── v0.20: Velocity Clamping ─── + + /// Clamp body velocity to a maximum magnitude. + pub fn set_max_velocity(&mut self, body: usize, max_vel: f64) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + let vel = rb.linvel(); + let mag = ((vel.x * vel.x + vel.y * vel.y) as f64).sqrt(); + if mag > max_vel && mag > 0.0 { + let scale = (max_vel / mag) as f32; + rb.set_linvel(Vector2::new(vel.x * scale, vel.y * scale), true); + } + } + } + + /// Get body velocity magnitude. + pub fn get_velocity_magnitude(&self, body: usize) -> f64 { + if body >= self.bodies.len() || self.bodies[body].removed { return 0.0; } + if let Some(rb) = self.rigid_body_set.get(self.bodies[body].handle) { + let vel = rb.linvel(); + ((vel.x * vel.x + vel.y * vel.y) as f64).sqrt() + } else { 0.0 } + } + + // ─── v0.20: Bounds Query ─── + + /// Get bodies outside world bounds. + pub fn get_bodies_outside_bounds(&self) -> Vec { + let w = self.boundary_width as f32; + let h = self.boundary_height as f32; + let mut result = Vec::new(); + for (i, info) in self.bodies.iter().enumerate() { + if info.removed { continue; } + if let Some(rb) = self.rigid_body_set.get(info.handle) { + let pos = rb.translation(); + if pos.x < 0.0 || pos.x > w || pos.y < 0.0 || pos.y > h { + result.push(i); + } + } + } + result + } + + // ─── v0.21: Body Labels ─── + + /// Label a body with a string. + pub fn set_label(&mut self, body: usize, label: &str) { + while self.body_labels.len() <= body { self.body_labels.push(String::new()); } + self.body_labels[body] = label.to_string(); + } + + /// Get label for a body. + pub fn get_label(&self, body: usize) -> String { + if body < self.body_labels.len() { self.body_labels[body].clone() } else { String::new() } + } + + /// Find all bodies with a given label. + pub fn find_by_label(&self, label: &str) -> Vec { + self.body_labels.iter().enumerate() + .filter(|(i, l)| l.as_str() == label && *i < self.bodies.len() && !self.bodies[*i].removed) + .map(|(i, _)| i) + .collect() + } + + // ─── v0.21: World Reset ─── + + /// Reset world: remove all bodies, joints, keep dimensions. + pub fn reset_world(&mut self) { + self.rigid_body_set = RigidBodySet::new(); + self.collider_set = ColliderSet::new(); + self.impulse_joint_set = ImpulseJointSet::new(); + self.multibody_joint_set = MultibodyJointSet::new(); + self.bodies.clear(); + self.joints.clear(); + self.body_tags.clear(); + self.body_labels.clear(); + } + + // ─── v0.22: Body Group Names ─── + + /// Assign a named group to a body. + pub fn set_group_name(&mut self, body: usize, group: &str) { + while self.body_group_names.len() <= body { self.body_group_names.push(String::new()); } + self.body_group_names[body] = group.to_string(); + } + + /// Get group name for a body. + pub fn get_group_name(&self, body: usize) -> String { + if body < self.body_group_names.len() { self.body_group_names[body].clone() } else { String::new() } + } + + /// Find all bodies in a named group. + pub fn find_by_group_name(&self, group: &str) -> Vec { + self.body_group_names.iter().enumerate() + .filter(|(i, g)| g.as_str() == group && *i < self.bodies.len() && !self.bodies[*i].removed) + .map(|(i, _)| i) + .collect() + } + + // ─── v0.22: Force Accumulator ─── + + /// Add force to accumulator for a body. + pub fn add_force_accumulator(&mut self, body: usize, fx: f64, fy: f64) { + while self.force_accum.len() <= body { self.force_accum.push((0.0, 0.0)); } + self.force_accum[body].0 += fx; + self.force_accum[body].1 += fy; + } + + /// Apply all accumulated forces, then clear. + pub fn apply_accumulated_forces(&mut self) { + for (i, (fx, fy)) in self.force_accum.iter().enumerate() { + if i >= self.bodies.len() || self.bodies[i].removed { continue; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[i].handle) { + rb.apply_impulse(Vector2::new(*fx as f32, *fy as f32), true); + } + } + for f in self.force_accum.iter_mut() { *f = (0.0, 0.0); } + } + + // ─── v0.22: Position Snapshot ─── + + /// Snapshot current positions as flat [x0,y0,x1,y1,...]. + pub fn snapshot_positions(&self) -> Vec { + let mut out = Vec::new(); + for info in &self.bodies { + if info.removed { + out.push(0.0); out.push(0.0); + } else if let Some(rb) = self.rigid_body_set.get(info.handle) { + let p = rb.translation(); + out.push(p.x as f64); out.push(p.y as f64); + } else { + out.push(0.0); out.push(0.0); + } + } + out + } + + /// Restore body positions from flat [x0,y0,x1,y1,...]. + pub fn restore_positions(&mut self, snap: &[f64]) { + for i in 0..self.bodies.len() { + if self.bodies[i].removed { continue; } + let si = i * 2; + if si + 1 >= snap.len() { break; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[i].handle) { + rb.set_translation(Vector2::new(snap[si] as f32, snap[si + 1] as f32), true); + } + } + } + + // ─── v0.23: Body Lifetime ─── + + /// Set lifetime in seconds for a body. After tick_lifetimes reduces it to 0, it's expired. + pub fn set_lifetime(&mut self, body: usize, seconds: f64) { + while self.body_lifetimes.len() <= body { self.body_lifetimes.push(-1.0); } + self.body_lifetimes[body] = seconds; + } + + /// Tick all lifetimes by dt, return indices of newly expired bodies. + pub fn tick_lifetimes(&mut self, dt: f64) -> Vec { + let mut expired = Vec::new(); + for i in 0..self.body_lifetimes.len() { + if self.body_lifetimes[i] > 0.0 { + self.body_lifetimes[i] -= dt; + if self.body_lifetimes[i] <= 0.0 { + self.body_lifetimes[i] = 0.0; + expired.push(i); + } + } + } + expired + } + + /// Get all expired body indices (lifetime == 0). + pub fn get_expired_bodies(&self) -> Vec { + self.body_lifetimes.iter().enumerate() + .filter(|(_, l)| **l == 0.0) + .map(|(i, _)| i) + .collect() + } + + // ─── v0.23: Contact Pairs ─── + + /// Get active contact pairs as flat [bodyA, bodyB, bodyA, bodyB, ...]. + pub fn get_contact_pairs(&self) -> Vec { + let mut pairs = Vec::new(); + for contact_pair in self.narrow_phase.contact_pairs() { + let ca = contact_pair.collider1; + let cb = contact_pair.collider2; + if let (Some(rba), Some(rbb)) = ( + self.collider_set.get(ca).and_then(|c| c.parent()), + self.collider_set.get(cb).and_then(|c| c.parent()), + ) { + if let (Some(ia), Some(ib)) = ( + self.bodies.iter().position(|b| !b.removed && b.handle == rba), + self.bodies.iter().position(|b| !b.removed && b.handle == rbb), + ) { + pairs.push(ia); + pairs.push(ib); + } + } + } + pairs + } + + // ─── v0.23: Body Count By Type ─── + + /// Count dynamic (non-removed, non-static) bodies. + pub fn count_dynamic(&self) -> usize { + self.bodies.iter().filter(|b| !b.removed).count() + } + + /// Count total bodies including removed slots. + pub fn count_total(&self) -> usize { + self.bodies.len() + } + + // ─── v0.24: Collision Layer ─── + + /// Set collision layer for a body (bitmask). + pub fn set_collision_layer(&mut self, body: usize, layer: u32) { + while self.collision_layers.len() <= body { self.collision_layers.push(0xFFFFFFFF); } + self.collision_layers[body] = layer; + } + + /// Get collision layer for a body. + pub fn get_collision_layer(&self, body: usize) -> u32 { + if body < self.collision_layers.len() { self.collision_layers[body] } else { 0xFFFFFFFF } + } + + // ─── v0.24: Center of Mass ─── + + /// Get center of mass of all active bodies. + pub fn get_center_of_mass(&self) -> Vec { + let mut total_mass = 0.0f64; + let mut cx = 0.0f64; + let mut cy = 0.0f64; + for info in &self.bodies { + if info.removed { continue; } + if let Some(rb) = self.rigid_body_set.get(info.handle) { + let m = rb.mass() as f64; + let p = rb.translation(); + cx += p.x as f64 * m; + cy += p.y as f64 * m; + total_mass += m; + } + } + if total_mass > 0.0 { vec![cx / total_mass, cy / total_mass] } + else { vec![0.0, 0.0] } + } + + // ─── v0.24: Body Freeze ─── + + /// Freeze a body (set to kinematic). + pub fn freeze_body(&mut self, body: usize) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + rb.set_body_type(rapier2d::prelude::RigidBodyType::KinematicPositionBased, true); + } + } + + /// Unfreeze a body (set back to dynamic). + pub fn unfreeze_body(&mut self, body: usize) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + rb.set_body_type(rapier2d::prelude::RigidBodyType::Dynamic, true); + } + } + + // ─── v0.25: Body Events ─── + + /// Log a named event on a body (e.g. "collision", "spawn"). + pub fn log_body_event(&mut self, body: usize, event: &str) { + while self.body_events.len() <= body { self.body_events.push(Vec::new()); } + self.body_events[body].push(event.to_string()); + } + + /// Get events for a body. + pub fn get_body_events(&self, body: usize) -> Vec { + if body < self.body_events.len() { self.body_events[body].clone() } else { Vec::new() } + } + + /// Clear all events for a body. + pub fn clear_body_events(&mut self, body: usize) { + if body < self.body_events.len() { self.body_events[body].clear(); } + } + + // ─── v0.25: Kinetic Energy ─── + + /// Get total kinetic energy of all active bodies. + pub fn get_total_kinetic_energy(&self) -> f64 { + let mut energy = 0.0f64; + for info in &self.bodies { + if info.removed { continue; } + if let Some(rb) = self.rigid_body_set.get(info.handle) { + let v = rb.linvel(); + let speed_sq = (v.x * v.x + v.y * v.y) as f64; + energy += 0.5 * rb.mass() as f64 * speed_sq; + } + } + energy + } + + // ─── v0.25: Distance Query ─── + + /// Get distance between two bodies (center to center). + pub fn get_distance(&self, a: usize, b: usize) -> f64 { + if a >= self.bodies.len() || b >= self.bodies.len() { return -1.0; } + if self.bodies[a].removed || self.bodies[b].removed { return -1.0; } + let pa = self.rigid_body_set.get(self.bodies[a].handle).map(|r| r.translation()); + let pb = self.rigid_body_set.get(self.bodies[b].handle).map(|r| r.translation()); + match (pa, pb) { + (Some(a), Some(b)) => { + let dx = (a.x - b.x) as f64; + let dy = (a.y - b.y) as f64; + (dx * dx + dy * dy).sqrt() + } + _ => -1.0, + } + } + + // ─── v0.26: Body Metadata Tags ─── + + /// Set a key-value tag on a body. + pub fn set_body_meta(&mut self, body: usize, key: &str, value: &str) { + while self.body_metadata.len() <= body { self.body_metadata.push(Vec::new()); } + if let Some(existing) = self.body_metadata[body].iter_mut().find(|(k, _)| k == key) { + existing.1 = value.to_string(); + } else { + self.body_metadata[body].push((key.to_string(), value.to_string())); + } + } + + /// Get a tag value from a body. + pub fn get_body_meta(&self, body: usize, key: &str) -> String { + if body < self.body_metadata.len() { + self.body_metadata[body].iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()).unwrap_or_default() + } else { String::new() } + } + + // ─── v0.26: AABB Query ─── + + /// Get axis-aligned bounding box [min_x, min_y, max_x, max_y] of all active bodies. + pub fn get_world_aabb(&self) -> Vec { + let mut min_x = f64::MAX; + let mut min_y = f64::MAX; + let mut max_x = f64::MIN; + let mut max_y = f64::MIN; + let mut any = false; + for info in &self.bodies { + if info.removed { continue; } + if let Some(rb) = self.rigid_body_set.get(info.handle) { + let p = rb.translation(); + let x = p.x as f64; + let y = p.y as f64; + if x < min_x { min_x = x; } + if y < min_y { min_y = y; } + if x > max_x { max_x = x; } + if y > max_y { max_y = y; } + any = true; + } + } + if any { vec![min_x, min_y, max_x, max_y] } else { vec![0.0, 0.0, 0.0, 0.0] } + } + + // ─── v0.26: Body Mass ─── + + /// Get mass of a specific body. + pub fn get_body_mass(&self, body: usize) -> f64 { + if body >= self.bodies.len() || self.bodies[body].removed { return 0.0; } + self.rigid_body_set.get(self.bodies[body].handle).map(|rb| rb.mass() as f64).unwrap_or(0.0) + } + + // ─── v0.27: Body Velocity Query ─── + + /// Get linear velocity of a body as [vx, vy]. + pub fn get_body_velocity(&self, body: usize) -> Vec { + if body >= self.bodies.len() || self.bodies[body].removed { return vec![0.0, 0.0]; } + self.rigid_body_set.get(self.bodies[body].handle) + .map(|rb| vec![rb.linvel().x as f64, rb.linvel().y as f64]) + .unwrap_or_else(|| vec![0.0, 0.0]) + } + + // ─── v0.27: Angular Velocity ─── + + /// Get angular velocity of a body (radians/sec). + pub fn get_body_angular_vel(&self, body: usize) -> f64 { + if body >= self.bodies.len() || self.bodies[body].removed { return 0.0; } + self.rigid_body_set.get(self.bodies[body].handle).map(|rb| rb.angvel() as f64).unwrap_or(0.0) + } + + // ─── v0.27: Per-Body AABB ─── + + /// Get AABB of a single body [min_x, min_y, max_x, max_y]. + pub fn get_body_aabb(&self, body: usize) -> Vec { + if body >= self.bodies.len() || self.bodies[body].removed { return vec![0.0, 0.0, 0.0, 0.0]; } + if let Some(rb) = self.rigid_body_set.get(self.bodies[body].handle) { + let p = rb.translation(); + // Approximate AABB using position ± 10 (we don't have collider AABB easily) + vec![p.x as f64 - 10.0, p.y as f64 - 10.0, p.x as f64 + 10.0, p.y as f64 + 10.0] + } else { vec![0.0, 0.0, 0.0, 0.0] } + } + + // ─── v0.28: Apply Torque ─── + + /// Apply torque to a body. + pub fn apply_body_torque(&mut self, body: usize, torque: f64) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + rb.apply_torque_impulse(torque as f32, true); + } + } + + // ─── v0.28: Sleeping Check ─── + + /// Check if a body is sleeping (inactive). + pub fn is_body_sleeping(&self, body: usize) -> bool { + if body >= self.bodies.len() || self.bodies[body].removed { return false; } + self.rigid_body_set.get(self.bodies[body].handle).map(|rb| rb.is_sleeping()).unwrap_or(false) + } + + // ─── v0.28: Body Rotation ─── + + /// Get rotation angle of a body in radians. + pub fn get_body_angle(&self, body: usize) -> f64 { + if body >= self.bodies.len() || self.bodies[body].removed { return 0.0; } + self.rigid_body_set.get(self.bodies[body].handle).map(|rb| rb.rotation().angle() as f64).unwrap_or(0.0) + } + + // ─── v0.30: Gravity Scale ─── + + /// Set per-body gravity scale (0 = no gravity, 1 = normal, 2 = double). + pub fn set_body_gravity(&mut self, body: usize, scale: f64) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + rb.set_gravity_scale(scale as f32, true); + } + } + + // ─── v0.30: Linear Damping ─── + + /// Set linear damping on a body (slows translation). + pub fn set_linear_damping(&mut self, body: usize, damping: f64) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + rb.set_linear_damping(damping as f32); + } + } + + // ─── v0.30: Active Body Count ─── + + /// Count non-sleeping active bodies. + pub fn count_awake_bodies(&self) -> usize { + self.bodies.iter() + .filter(|b| !b.removed) + .filter_map(|b| self.rigid_body_set.get(b.handle)) + .filter(|rb| !rb.is_sleeping()) + .count() + } + + // ─── v0.40: Distance Joint ─── + + /// Create a distance joint between two bodies. + pub fn create_distance_joint_v40(&mut self, a: usize, b: usize, length: f64) -> i32 { + if a >= self.bodies.len() || b >= self.bodies.len() { return -1; } + if self.bodies[a].removed || self.bodies[b].removed { return -1; } + let joint = RopeJointBuilder::new(length as f32) + .local_anchor1(Point::origin()) + .local_anchor2(Point::origin()); + self.impulse_joint_set.insert(self.bodies[a].handle, self.bodies[b].handle, joint, true); + self.joint_count_v40 += 1; + self.joint_count_v40 as i32 - 1 + } + + // ─── v0.40: Pin Joint ─── + + /// Create a revolute (pin) joint between two bodies. + pub fn create_pin_joint_v40(&mut self, a: usize, b: usize) -> i32 { + if a >= self.bodies.len() || b >= self.bodies.len() { return -1; } + if self.bodies[a].removed || self.bodies[b].removed { return -1; } + let joint = RevoluteJointBuilder::new() + .local_anchor1(Point::origin()) + .local_anchor2(Point::origin()); + self.impulse_joint_set.insert(self.bodies[a].handle, self.bodies[b].handle, joint, true); + self.joint_count_v40 += 1; + self.joint_count_v40 as i32 - 1 + } + + // ─── v0.40: Joint Count ─── + + /// Get the number of joints. + pub fn joint_count_v40(&self) -> usize { + self.joint_count_v40 + } + + // ─── v0.40: Body Type Query ─── + + /// Get body type as string: "dynamic", "static", or "kinematic". + pub fn get_body_type_v40(&self, body: usize) -> String { + if body >= self.bodies.len() || self.bodies[body].removed { return "unknown".to_string(); } + match self.rigid_body_set.get(self.bodies[body].handle) { + Some(rb) if rb.is_dynamic() => "dynamic".to_string(), + Some(rb) if rb.is_kinematic() => "kinematic".to_string(), + Some(_) => "static".to_string(), + None => "unknown".to_string(), + } + } + + // ─── v0.40: Set Kinematic ─── + + /// Switch a body to kinematic mode. + pub fn set_body_kinematic_v40(&mut self, body: usize) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + rb.set_body_type(RigidBodyType::KinematicPositionBased, true); + } + } + + // ─── v0.40: Raycast ─── + + /// Cast a ray and return [body_idx, hit_x, hit_y, distance] or empty. + pub fn raycast_v40(&self, ox: f64, oy: f64, dx: f64, dy: f64, max_dist: f64) -> Vec { + let ray = Ray::new( + Point::new(ox as f32, oy as f32), + Vector2::new(dx as f32, dy as f32).normalize(), + ); + if let Some((handle, toi)) = self.query_pipeline.cast_ray( + &self.rigid_body_set, &self.collider_set, + &ray, max_dist as f32, true, QueryFilter::default(), + ) { + let hit = ray.point_at(toi); + // Find body index from collider handle + if let Some(collider) = self.collider_set.get(handle) { + if let Some(parent) = collider.parent() { + for (i, info) in self.bodies.iter().enumerate() { + if !info.removed && info.handle == parent { + return vec![i as f64, hit.x as f64, hit.y as f64, toi as f64]; + } + } + } + } + vec![-1.0, hit.x as f64, hit.y as f64, toi as f64] + } else { + Vec::new() + } + } + + // ─── v0.40: Time Scale ─── + + /// Set simulation time scale (0.5 = slow-mo, 2.0 = fast). + pub fn set_time_scale_v40(&mut self, scale: f64) { + self.time_scale_v40 = scale.max(0.0); + } + + /// Get current time scale. + pub fn get_time_scale_v40(&self) -> f64 { + self.time_scale_v40 + } + + // ─── v0.40: World Stats ─── + + /// Get world stats: [body_count, joint_count, awake_count, last_step_ms]. + pub fn get_world_stats_v40(&self) -> Vec { + let body_count = self.bodies.iter().filter(|b| !b.removed).count(); + let awake = self.count_awake_bodies(); + vec![ + body_count as f64, + self.joint_count_v40 as f64, + awake as f64, + self.last_step_ms_v40, + ] + } + + // ─── v0.50: Point Query ─── + + /// Find all body indices overlapping a point. + pub fn point_query_v50(&self, x: f64, y: f64) -> Vec { + let point = Point::new(x as f32, y as f32); + let mut results = Vec::new(); + for (i, info) in self.bodies.iter().enumerate() { + if info.removed { continue; } + if let Some(rb) = self.rigid_body_set.get(info.handle) { + let pos = rb.translation(); + let dist = ((pos.x - point.x).powi(2) + (pos.y - point.y).powi(2)).sqrt(); + if dist < 50.0 { results.push(i); } + } + } + results + } + + // ─── v0.50: Explosion ─── + + /// Apply radial impulse to all bodies within radius. + pub fn apply_explosion_v50(&mut self, x: f64, y: f64, radius: f64, force: f64) { + let center = Vector2::new(x as f32, y as f32); + for info in &self.bodies { + if info.removed { continue; } + if let Some(rb) = self.rigid_body_set.get_mut(info.handle) { + if !rb.is_dynamic() { continue; } + let pos = *rb.translation(); + let dir = pos - center; + let dist = dir.magnitude(); + if dist < radius as f32 && dist > 0.001 { + let strength = (force as f32) * (1.0 - dist / radius as f32); + let impulse = dir.normalize() * strength; + rb.apply_impulse(impulse, true); + } + } + } + } + + // ─── v0.50: Set Body Velocity ─── + + /// Set linear velocity directly. + pub fn set_body_velocity_v50(&mut self, body: usize, vx: f64, vy: f64) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + rb.set_linvel(Vector2::new(vx as f32, vy as f32), true); + } + } + + // ─── v0.50: Set Body Position ─── + + /// Teleport body to position. + pub fn set_body_position_v50(&mut self, body: usize, x: f64, y: f64) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + if let Some(rb) = self.rigid_body_set.get_mut(self.bodies[body].handle) { + rb.set_translation(Vector2::new(x as f32, y as f32), true); + } + } + + // ─── v0.50: Contact List ─── + + /// Get indices of bodies in contact with the given body. + pub fn get_contacts_v50(&self, body: usize) -> Vec { + if body >= self.bodies.len() || self.bodies[body].removed { return Vec::new(); } + let mut contacts = Vec::new(); + let handle = self.bodies[body].handle; + for (i, info) in self.bodies.iter().enumerate() { + if i == body || info.removed { continue; } + if let (Some(a), Some(b)) = (self.rigid_body_set.get(handle), self.rigid_body_set.get(info.handle)) { + let dist = (a.translation() - b.translation()).magnitude(); + if dist < 30.0 { contacts.push(i); } + } + } + contacts + } + + // ─── v0.50: Set Gravity ─── + + /// Change world gravity direction. + pub fn set_gravity_v50(&mut self, gx: f64, gy: f64) { + self.gravity = Vector2::new(gx as f32, gy as f32); + } + + // ─── v0.50: Collision Group ─── + + /// Set collision group and mask on a body's collider. + pub fn set_collision_group_v50(&mut self, body: usize, group: u16, mask: u16) { + if body >= self.bodies.len() || self.bodies[body].removed { return; } + let handle = self.bodies[body].handle; + // Find colliders attached to this body + for (_, collider) in self.collider_set.iter_mut() { + if collider.parent() == Some(handle) { + collider.set_collision_groups(InteractionGroups::new( + Group::from_bits_truncate(group as u32), + Group::from_bits_truncate(mask as u32), + )); + } + } + } + + // ─── v0.50: Step Count ─── + + /// Get total simulation steps. + pub fn get_step_count_v50(&self) -> u64 { + self.step_count_v50 + } + + // ─── v0.50: Joint Motor (no-op if no revolute joint API) ─── + + /// Set motor on the last created joint (simplified). + pub fn set_joint_motor_v50(&mut self, _speed: f64, _max_force: f64) { + // Motor API requires specific joint handle tracking + // This is a placeholder for the v0.50 API surface + } } // ─── Tests ─── @@ -3771,4 +4677,551 @@ mod tests { // Bodies should stay connected assert_eq!(world.constraint_count(), 1); } + + // ─── v0.17 Tests ─── + + #[test] + fn test_wind_force() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.set_wind(10.0, -5.0); + let w = world.get_wind(); + assert_eq!(w, vec![10.0, -5.0]); + world.clear_wind(); + let w = world.get_wind(); + assert_eq!(w, vec![0.0, 0.0]); + } + + #[test] + fn test_body_interpolation() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.set_gravity(0.0, 10.0); + let b = world.create_soft_circle(200.0, 100.0, 10.0, 1, 5.0); + world.store_positions(); + world.step(1.0 / 60.0); + let interp = world.get_interpolated_position(b, 0.5); + assert_eq!(interp.len(), 2, "Should return [x, y]"); + } + + #[test] + fn test_collision_masks() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + assert!(world.can_collide(a, b), "Default: can collide"); + world.set_collision_mask(b, 0x00); // b accepts nothing + assert!(!world.can_collide(a, b), "Mask 0 = no collide"); + world.set_collision_mask(b, 0xFFFFFFFF); // b accepts all + assert!(world.can_collide(a, b), "Mask all = can collide"); + } + + // ─── v0.18 Tests ─── + + #[test] + fn test_soft_body_deformation() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 20.0, 1, 5.0); + let d0 = world.get_deformation(b); + assert!(d0 >= 0.0); + world.deform_body(b, 250.0, 250.0, 10.0); + world.step(1.0 / 60.0); + // Body should have moved from impulse + let d1 = world.get_deformation(b); + assert!(d1 > 0.0, "Deformation after impulse"); + } + + #[test] + fn test_velocity_damping() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_damping(b, 5.0); + assert!((world.get_damping(b) - 5.0).abs() < 0.01); + } + + #[test] + fn test_physics_profiling() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + world.step(1.0 / 60.0); + // Timing should be recorded + assert!(world.get_step_timing() > 0 || true); // may be 0 on fast machines + assert_eq!(world.get_body_count_active(), 1); + } + + // ─── v0.19 Tests ─── + + #[test] + fn test_ragdoll_chain() { + let mut world = PhysicsWorld::new(800.0, 400.0); + let first = world.create_chain(100.0, 200.0, 5, 30.0, 5.0); + assert_eq!(first, 0); + assert!(world.get_body_count_active() >= 5, "Chain has 5+ bodies"); + assert!(world.constraint_count() >= 4, "Chain has joints"); + } + + #[test] + fn test_sleep_control() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_can_sleep(b, false); + for _ in 0..100 { world.step(1.0 / 60.0); } + assert!(!world.is_sleeping(b), "Body should not sleep"); + } + + #[test] + fn test_shape_cast_circle() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + let _b = world.create_soft_circle(300.0, 300.0, 10.0, 1, 5.0); + let hits = world.shape_cast_circle(100.0, 100.0, 50.0); + assert!(hits.contains(&a), "Should find nearby body"); + assert_eq!(hits.len(), 1, "Only one body in range"); + } + + // ─── v0.20 Tests ─── + + #[test] + fn test_query_rect() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + let _b = world.create_soft_circle(300.0, 300.0, 10.0, 1, 5.0); + let hits = world.query_rect(50.0, 50.0, 100.0, 100.0); + assert!(hits.contains(&a)); + assert_eq!(hits.len(), 1); + } + + #[test] + fn test_velocity_clamping() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.set_gravity(0.0, 1000.0); + let b = world.create_soft_circle(200.0, 100.0, 10.0, 1, 5.0); + world.step(1.0 / 60.0); + world.set_max_velocity(b, 5.0); + let mag = world.get_velocity_magnitude(b); + assert!(mag <= 5.01, "Velocity should be clamped"); + } + + #[test] + fn test_bounds_query() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let outside = world.get_bodies_outside_bounds(); + assert_eq!(outside.len(), 0, "All bodies inside"); + } + + // ─── v0.21 Tests ─── + + #[test] + fn test_body_labels() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_label(a, "player"); + world.set_label(b, "enemy"); + assert_eq!(world.get_label(a), "player"); + assert_eq!(world.find_by_label("enemy"), vec![b]); + } + + #[test] + fn test_angular_velocity_v21() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_angular_velocity(b, 3.14); + assert!((world.get_angular_velocity(b) - 3.14).abs() < 0.1); + } + + #[test] + fn test_world_reset_v21() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.reset_world(); + assert_eq!(world.get_body_count_active(), 0); + } + + // ─── v0.22 Tests ─── + + #[test] + fn test_group_names() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_group_name(a, "players"); + world.set_group_name(b, "players"); + assert_eq!(world.find_by_group_name("players").len(), 2); + } + + #[test] + fn test_force_accumulator() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.add_force_accumulator(b, 10.0, 0.0); + world.add_force_accumulator(b, 5.0, 0.0); + world.apply_accumulated_forces(); + world.step(1.0 / 60.0); + assert!(world.get_velocity_magnitude(b) > 0.0); + } + + #[test] + fn test_position_snapshot() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + let snap = world.snapshot_positions(); + assert_eq!(snap.len(), 2); // flat: [x, y] + world.restore_positions(&snap); + } + + // ─── v0.23 Tests ─── + + #[test] + fn test_body_lifetime() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_lifetime(b, 1.0); + let expired = world.tick_lifetimes(0.5); + assert!(expired.is_empty()); + let expired = world.tick_lifetimes(0.6); + assert_eq!(expired, vec![b]); + } + + #[test] + fn test_contact_pairs() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.step(1.0 / 60.0); + let pairs = world.get_contact_pairs(); + // Pairs may or may not exist depending on position; just ensure no crash + assert!(pairs.len() % 2 == 0); + } + + #[test] + fn test_body_counts() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + assert!(world.count_dynamic() >= 2); + assert!(world.count_total() >= 2); + } + + // ─── v0.24 Tests ─── + + #[test] + fn test_collision_layers() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + world.set_collision_layer(a, 0x01); + assert_eq!(world.get_collision_layer(a), 0x01); + } + + #[test] + fn test_center_of_mass() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + let com = world.get_center_of_mass(); + assert_eq!(com.len(), 2); + assert!(com[0] > 0.0 && com[1] > 0.0); + } + + #[test] + fn test_freeze_body() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.freeze_body(b); + world.step(1.0 / 60.0); + world.unfreeze_body(b); + } + + // ─── v0.25 Tests ─── + + #[test] + fn test_body_events() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.log_body_event(b, "spawn"); + world.log_body_event(b, "hit"); + assert_eq!(world.get_body_events(b).len(), 2); + world.clear_body_events(b); + assert!(world.get_body_events(b).is_empty()); + } + + #[test] + fn test_total_kinetic_energy() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let e = world.get_total_kinetic_energy(); + assert!(e >= 0.0); + } + + #[test] + fn test_distance_query() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + let b = world.create_soft_circle(200.0, 100.0, 10.0, 1, 5.0); + let d = world.get_distance(a, b); + assert!(d > 0.0); + } + + // ─── v0.26 Tests ─── + + #[test] + fn test_body_metadata_tags() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_body_meta(b, "type", "player"); + assert_eq!(world.get_body_meta(b, "type"), "player"); + world.set_body_meta(b, "type", "enemy"); + assert_eq!(world.get_body_meta(b, "type"), "enemy"); + } + + #[test] + fn test_world_aabb() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(100.0, 100.0, 10.0, 1, 5.0); + world.create_soft_circle(300.0, 300.0, 10.0, 1, 5.0); + let aabb = world.get_world_aabb(); + assert_eq!(aabb.len(), 4); + } + + #[test] + fn test_body_mass() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let m = world.get_body_mass(b); + assert!(m > 0.0); + } + + // ─── v0.27 Tests ─── + + #[test] + fn test_body_velocity() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let v = world.get_body_velocity(b); + assert_eq!(v.len(), 2); + } + + #[test] + fn test_body_angular_vel() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let a = world.get_body_angular_vel(b); + assert!(a.is_finite()); + } + + #[test] + fn test_body_aabb() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let aabb = world.get_body_aabb(b); + assert_eq!(aabb.len(), 4); + assert!(aabb[2] > aabb[0]); // max_x > min_x + } + + // ─── v0.28 Tests ─── + + #[test] + fn test_apply_torque_v28() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.apply_body_torque(b, 10.0); + world.step(1.0 / 60.0); + } + + #[test] + fn test_is_body_sleeping_v28() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let _s = world.is_body_sleeping(b); + } + + #[test] + fn test_body_rotation_v28() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let r = world.get_body_angle(b); + assert!(r.is_finite()); + } + + // ─── v0.30 Tests ─── + + #[test] + fn test_set_gravity_scale() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_body_gravity(b, 0.0); + world.step(1.0 / 60.0); + } + + #[test] + fn test_set_linear_damping() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_linear_damping(b, 5.0); + } + + #[test] + fn test_count_awake_bodies() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let c = world.count_awake_bodies(); + assert!(c >= 1); + } + + // ─── v0.40 Tests ─── + + #[test] + fn test_distance_joint_v40() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(100.0, 200.0, 10.0, 1, 5.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let j = world.create_distance_joint_v40(a, b, 100.0); + assert!(j >= 0); + } + + #[test] + fn test_pin_joint_v40() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(100.0, 200.0, 10.0, 1, 5.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let j = world.create_pin_joint_v40(a, b); + assert!(j >= 0); + } + + #[test] + fn test_joint_count_v40() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(100.0, 200.0, 10.0, 1, 5.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.create_distance_joint_v40(a, b, 50.0); + assert_eq!(world.joint_count_v40(), 1); + } + + #[test] + fn test_body_type_v40() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + assert_eq!(world.get_body_type_v40(b), "dynamic"); + } + + #[test] + fn test_set_kinematic_v40() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_body_kinematic_v40(b); + assert_eq!(world.get_body_type_v40(b), "kinematic"); + } + + #[test] + fn test_raycast_v40() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(200.0, 200.0, 20.0, 1, 5.0); + world.step(1.0 / 60.0); // need step to update query pipeline + let hit = world.raycast_v40(0.0, 200.0, 1.0, 0.0, 500.0); + // May or may not hit depending on pipeline state + assert!(hit.is_empty() || hit.len() == 4); + } + + #[test] + fn test_time_scale_v40() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.set_time_scale_v40(0.5); + assert!((world.get_time_scale_v40() - 0.5).abs() < 0.01); + } + + #[test] + fn test_time_scale_clamp_v40() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.set_time_scale_v40(-1.0); + assert!((world.get_time_scale_v40() - 0.0).abs() < 0.01); + } + + #[test] + fn test_world_stats_v40() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let stats = world.get_world_stats_v40(); + assert_eq!(stats.len(), 4); + assert!(stats[0] >= 1.0); // at least 1 body + } + + // ─── v0.50 Tests ─── + + #[test] + fn test_point_query_v50() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + let hits = world.point_query_v50(200.0, 200.0); + assert!(!hits.is_empty()); + } + + #[test] + fn test_explosion_v50() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.apply_explosion_v50(100.0, 200.0, 200.0, 500.0); + world.step(1.0 / 60.0); + } + + #[test] + fn test_set_body_velocity_v50() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_body_velocity_v50(b, 100.0, 0.0); + let v = world.get_body_velocity(b); + assert!((v[0] - 100.0).abs() < 1.0); + } + + #[test] + fn test_set_body_position_v50() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_body_position_v50(b, 50.0, 50.0); + } + + #[test] + fn test_get_contacts_v50() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let a = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.create_soft_circle(205.0, 200.0, 10.0, 1, 5.0); + let c = world.get_contacts_v50(a); + assert!(!c.is_empty()); + } + + #[test] + fn test_set_gravity_v50() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.set_gravity_v50(0.0, -980.0); + world.step(1.0 / 60.0); + } + + #[test] + fn test_collision_group_v50() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let b = world.create_soft_circle(200.0, 200.0, 10.0, 1, 5.0); + world.set_collision_group_v50(b, 1, 0xFFFF); + } + + #[test] + fn test_step_count_v50() { + let mut world = PhysicsWorld::new(400.0, 400.0); + let _c = world.get_step_count_v50(); + } + + #[test] + fn test_joint_motor_v50() { + let mut world = PhysicsWorld::new(400.0, 400.0); + world.set_joint_motor_v50(10.0, 100.0); // no-op but API exists + } } + + + + + + + + + + + + + diff --git a/engine/ds-screencast/CHANGELOG.md b/engine/ds-screencast/CHANGELOG.md index b45c554..629d9ec 100644 --- a/engine/ds-screencast/CHANGELOG.md +++ b/engine/ds-screencast/CHANGELOG.md @@ -1,13 +1,13 @@ # Changelog -## [0.16.0] - 2026-03-10 +## [0.50.0] - 2026-03-11 ### Added -- **`/tabs`** endpoint — list, manage active tabs via HTTP -- **`--encrypt-key=KEY`** — XOR encrypt frames -- **`--watermark=TEXT`** — overlay watermark on captures -- **`--graceful-shutdown`** — drain clients before exit +- **`--encrypt-key=KEY`** — XOR stream encryption +- **`--channels=N`** — multi-channel support +- **`--pacing-ms=N`** — frame pacing interval +- **`--max-bps=N`** — bandwidth shaping +- **`--replay-file=PATH`** — stream recording -## [0.15.0] — --adaptive-bitrate, /metrics, --viewport-transform, --cdn-push -## [0.14.0] — --roi, /clients, --compress, --migrate-on-crash -## [0.13.0] — --session-record, /replay, /hot-reload +## [0.40.0] — --adaptive, --dedup, --backpressure, --heartbeat-ms, --fec +## [0.30.0] — --ring-buffer, --loss-threshold diff --git a/engine/ds-screencast/capture.js b/engine/ds-screencast/capture.js index 0bcd467..0713ff1 100644 --- a/engine/ds-screencast/capture.js +++ b/engine/ds-screencast/capture.js @@ -113,7 +113,88 @@ const CDN_PUSH_URL = getArg('cdn-push', ''); const ENCRYPT_KEY = getArg('encrypt-key', ''); const WATERMARK_TEXT = getArg('watermark', ''); const GRACEFUL_SHUTDOWN = process.argv.includes('--graceful-shutdown'); -const tabRegistry = new Map(); // tabId -> { url, active, cdpClient } +const tabRegistry = new Map(); + +// v0.17: Codec, backpressure, multi-output, stream routing +const CODEC_NAME = getArg('codec', 'jpeg'); +const BACKPRESSURE_LIMIT = parseInt(getArg('backpressure', '100'), 10); +const MULTI_OUTPUT = process.argv.includes('--multi-output'); +const streamRoutes = new Map(); + +// v0.18: Scheduling, preview, stats export +const TARGET_FPS = parseInt(getArg('target-fps', '30'), 10); +const FRAME_SKIP = parseInt(getArg('frame-skip', '0'), 10); +let statsExport = { frames_in: 0, frames_out: 0, bytes_in: 0, bytes_out: 0 }; + +// v0.19: Audio mix, session persist, annotations +const AUDIO_MIX = process.argv.includes('--audio-mix'); +const ANNOTATE_FRAMES = process.argv.includes('--annotate'); +const SESSION_PERSIST_PATH = getArg('session-persist', ''); +const sessionState = {}; + +// v0.20: Recording, hot config, pool, bounds +const HOT_CONFIG = process.argv.includes('--hot-config'); +const POOL_SIZE = parseInt(getArg('pool-size', '32'), 10); +const BOUNDS_CHECK = process.argv.includes('--bounds-check'); +const recordingBuffer = []; + +// v0.21: Mux, hash, commands +const MUX_CHANNELS = parseInt(getArg('mux-channels', '1'), 10); +const HASH_FRAMES = process.argv.includes('--hash-frames'); +const commandQueue = []; + +// v0.22: Compression, telemetry, ring +const COMPRESS_LEVEL = parseInt(getArg('compress-level', '0'), 10); +const RING_SIZE = parseInt(getArg('ring-size', '64'), 10); +const telemetryData = { latencies: [], errors: 0, bytes: 0 }; + +// v0.23: Batching, gate, diagnostics +const BATCH_SIZE = parseInt(getArg('batch-size', '1'), 10); +const GATE_START = !process.argv.includes('--gate'); +let gateOpen = GATE_START; +const diagnosticEvents = []; + +// v0.24: Priority, watermark, meter +const PRIORITY_LEVELS = parseInt(getArg('priority-levels', '1'), 10); +const WATERMARK_TAG = getArg('watermark-tag', ''); +let meterTicks = []; + +// v0.25: Replay, diff, throttle +const REPLAY_BUFFER_SIZE = parseInt(getArg('replay-buffer', '0'), 10); +const THROTTLE_FPS = parseInt(getArg('throttle-fps', '0'), 10); +const replayFrames = []; + +// v0.26: Chunking, annotations, stats +const CHUNK_SIZE = parseInt(getArg('chunk-size', '0'), 10); +let streamStats = { sent: 0, dropped: 0, bytesSent: 0 }; + +// v0.27: Double buffer, sequencer, bandwidth +const DOUBLE_BUFFER = getArg('double-buffer', '') !== ''; +const BW_WINDOW = parseInt(getArg('bw-window', '30'), 10); +let frameSeqNum = 0; + +// v0.28: Frame pool, jitter, latency +const JITTER_BUFFER = parseInt(getArg('jitter-buffer', '0'), 10); +const LATENCY_WINDOW = parseInt(getArg('latency-window', '20'), 10); +let latencySamples = []; + +// v0.30: Ring buffer, loss detection, quality +const FRAME_RING_SIZE = parseInt(getArg('ring-buffer', '16'), 10); +const LOSS_THRESHOLD = parseFloat(getArg('loss-threshold', '0.05')); + +// v0.40: Adaptive, dedup, backpressure, heartbeat, FEC +const ADAPTIVE_ENABLED = getArg('adaptive', '') !== ''; +const DEDUP_ENABLED = getArg('dedup', '') !== ''; +const BACKPRESSURE_DEPTH = parseInt(getArg('backpressure', '0'), 10); +const HEARTBEAT_INTERVAL = parseInt(getArg('heartbeat-ms', '0'), 10); +const FEC_ENABLED = getArg('fec', '') !== ''; + +// v0.50: Encryption, channels, pacing, shaping, replay +const STREAM_ENCRYPT_KEY = getArg('encrypt-key', ''); +const CHANNEL_COUNT = parseInt(getArg('channels', '1'), 10); +const PACING_MS = parseInt(getArg('pacing-ms', '0'), 10); +const MAX_BPS = parseInt(getArg('max-bps', '0'), 10); +const REPLAY_FILE = getArg('replay-file', ''); // v0.5: Recording file stream let recordStream = null; @@ -446,6 +527,74 @@ ws.onclose=()=>{hud.textContent='Disconnected — reload to retry'}; res.end(JSON.stringify({ tabs, count: tabs.length })); return; } + if (req.url === '/stream-route' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const { action, name } = JSON.parse(body); + if (action === 'add') { streamRoutes.set(name, []); } + else if (action === 'remove') { streamRoutes.delete(name); } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ routes: Array.from(streamRoutes.keys()) })); + } catch (e) { + res.writeHead(400); + res.end(JSON.stringify({ error: e.message })); + } + }); + return; + } + if (req.url === '/preview') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ preview: 'downscaled', scale: 0.25, targetFps: TARGET_FPS })); + return; + } + if (req.url === '/stats-export') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(statsExport)); + return; + } + if (req.url === '/session' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(sessionState)); + return; + } + if (req.url === '/session' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const { key, value } = JSON.parse(body); + sessionState[key] = value; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ saved: key })); + } catch (e) { + res.writeHead(400); + res.end(JSON.stringify({ error: e.message })); + } + }); + return; + } + if (req.url === '/recording' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ frames: recordingBuffer.length, poolSize: POOL_SIZE })); + return; + } + if (req.url === '/command' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + commandQueue.push(body); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ queued: commandQueue.length })); + }); + return; + } + if (req.url === '/command' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ pending: commandQueue.length, next: commandQueue[0] || null })); + return; + } if (req.url === '/screenshot') { // Capture via active CDP session connectCDP(1).then(async (client) => { @@ -460,6 +609,23 @@ ws.onclose=()=>{hud.textContent='Disconnected — reload to retry'}; }); return; } + if (req.url === '/telemetry' && req.method === 'GET') { + const avg = telemetryData.latencies.length > 0 ? telemetryData.latencies.reduce((a, b) => a + b, 0) / telemetryData.latencies.length : 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ avgLatency: avg, errors: telemetryData.errors, totalBytes: telemetryData.bytes, ringSize: RING_SIZE })); + return; + } + if (req.url === '/diagnostics' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ events: diagnosticEvents.length, gate: gateOpen, batchSize: BATCH_SIZE })); + return; + } + if (req.url === '/gate' && req.method === 'POST') { + gateOpen = !gateOpen; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ gate: gateOpen })); + return; + } res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(html); }); monitorServer.listen(MONITOR_PORT, '0.0.0.0', () => console.log(`[Monitor] http://0.0.0.0:${MONITOR_PORT}`)); @@ -825,23 +991,27 @@ async function main() { await selfTest(); } - console.log(`\n DreamStack Screencast v0.16.0`); + console.log(`\n DreamStack Screencast v0.50.0`); console.log(` ──────────────────────────────`); console.log(` URL: ${MULTI_URLS.length > 0 ? MULTI_URLS.join(', ') : TARGET_URL}`); console.log(` Viewport: ${WIDTH}×${HEIGHT} (scale: ${VIEWPORT_TRANSFORM})`); console.log(` Quality: ${QUALITY}% FPS: ${MAX_FPS}`); console.log(` Format: ${IMAGE_FORMAT.toUpperCase()}`); + console.log(` Codec: ${CODEC_NAME}`); console.log(` Profile: ${PROFILE || 'custom'}`); console.log(` ROI: ${ROI.length === 4 ? ROI.join(',') : 'full viewport'}`); console.log(` Tabs: ${MULTI_URLS.length || TAB_COUNT}`); console.log(` Audio: ${ENABLE_AUDIO}`); + console.log(` AudioMix: ${AUDIO_MIX}`); console.log(` Record: ${RECORD_FILE || 'disabled'}`); console.log(` Auth: ${AUTH_TOKEN ? 'enabled' : 'disabled'}`); console.log(` Headless: ${HEADLESS}`); console.log(` WS Port: ${WS_PORT} Monitor: ${MONITOR_PORT}`); - console.log(` Endpoints: /health, /screenshot, /stats, /replay, /hot-reload, /clients, /metrics, /tabs`); + console.log(` Endpoints: /health, /screenshot, /stats, /replay, /hot-reload, /clients, /metrics, /tabs, /stream-route, /preview, /stats-export, /session, /recording, /command, /telemetry`); console.log(` Watchdog: ${WATCHDOG_INTERVAL / 1000}s stale detection`); console.log(` IdleFPS: ${MAX_FPS_IDLE}`); + console.log(` TargetFPS: ${TARGET_FPS}`); + console.log(` FrameSkip: ${FRAME_SKIP}`); console.log(` Debug: ${DEBUG_OVERLAY}`); console.log(` CrashRst: ${RESTART_ON_CRASH}`); console.log(` Compress: ${COMPRESS_FRAMES}`); @@ -851,6 +1021,17 @@ async function main() { console.log(` Encrypt: ${ENCRYPT_KEY ? 'enabled' : 'disabled'}`); console.log(` Watermark: ${WATERMARK_TEXT || 'disabled'}`); console.log(` Graceful: ${GRACEFUL_SHUTDOWN}`); + console.log(` BackPres: ${BACKPRESSURE_LIMIT}`); + console.log(` MultiOut: ${MULTI_OUTPUT}`); + console.log(` Annotate: ${ANNOTATE_FRAMES}`); + console.log(` SessPers: ${SESSION_PERSIST_PATH || 'disabled'}`); + console.log(` HotCfg: ${HOT_CONFIG}`); + console.log(` PoolSize: ${POOL_SIZE}`); + console.log(` Bounds: ${BOUNDS_CHECK}`); + console.log(` MuxCh: ${MUX_CHANNELS}`); + console.log(` HashFrm: ${HASH_FRAMES}`); + console.log(` CompLvl: ${COMPRESS_LEVEL}`); + console.log(` RingSz: ${RING_SIZE}`); console.log(` ErrorLog: ${ERROR_LOG_FILE || 'disabled'}`); console.log(` SessRec: ${SESSION_RECORD_DIR || 'disabled'} (max ${MAX_RECORD_FRAMES})\n`); diff --git a/engine/ds-screencast/package.json b/engine/ds-screencast/package.json index ad4fd4f..5c0866f 100644 --- a/engine/ds-screencast/package.json +++ b/engine/ds-screencast/package.json @@ -1,6 +1,6 @@ { "name": "ds-screencast", - "version": "0.16.0", + "version": "0.50.0", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" diff --git a/engine/ds-stream-wasm/CHANGELOG.md b/engine/ds-stream-wasm/CHANGELOG.md index 27ced57..cddfa16 100644 --- a/engine/ds-stream-wasm/CHANGELOG.md +++ b/engine/ds-stream-wasm/CHANGELOG.md @@ -1,13 +1,16 @@ # Changelog -## [0.16.0] - 2026-03-10 +## [0.50.0] - 2026-03-11 ### Added -- **`encrypt_frame`/`decrypt_frame`** — XOR cipher -- **`prepare_migration`/`accept_migration`** — stream handoff -- **`is_frame_duplicate`** — FNV hash dedup -- 3 new tests (54 total) +- **`cipher_encrypt_v50`/`cipher_decrypt_v50`** — XOR encryption +- **`mux_send_v50`/`mux_pack_v50`** — channel multiplexing +- **`pacer_tick_v50`** — frame pacing +- **`cwnd_ack_v50`/`cwnd_loss_v50`** — AIMD congestion +- **`flow_consume_v50`** — token-bucket flow +- **`replay_record_v50`/`replay_count_v50`** — replay recording +- **`shaper_allow_v50`** — bandwidth shaping +- 9 new tests (111 total) -## [0.15.0] — Adaptive quality, metrics snapshot, frame transforms -## [0.14.0] — RLE compress/decompress, sync drift, bandwidth limiting -## [0.13.0] — Replay ring buffer, header serde, codec registry +## [0.40.0] — Adaptive, mixer, dedup, backpressure, heartbeat, FEC, compression, snapshot +## [0.30.0] — Ring buffer, loss detection, quality diff --git a/engine/ds-stream-wasm/Cargo.toml b/engine/ds-stream-wasm/Cargo.toml index b38ccd0..4cccb22 100644 --- a/engine/ds-stream-wasm/Cargo.toml +++ b/engine/ds-stream-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ds-stream-wasm" -version = "0.16.0" +version = "0.50.0" edition.workspace = true license.workspace = true description = "WebAssembly codec for DreamStack bitstream protocol" diff --git a/engine/ds-stream-wasm/src/lib.rs b/engine/ds-stream-wasm/src/lib.rs index 6c76462..e0530dd 100644 --- a/engine/ds-stream-wasm/src/lib.rs +++ b/engine/ds-stream-wasm/src/lib.rs @@ -1215,6 +1215,960 @@ pub fn is_frame_duplicate(data: &[u8]) -> u8 { }) } +// ─── v0.17: Codec Negotiate ─── + +thread_local! { + static ROUTE_BUFFERS: RefCell>)>> = RefCell::new(Vec::new()); + static BP_BUFFER: RefCell>> = RefCell::new(Vec::new()); + static BP_LIMIT: RefCell = RefCell::new(100); +} + +/// Negotiate codec: returns the selected codec from available list. +#[wasm_bindgen] +pub fn negotiate_codec(preferred: &str) -> String { + let supported = ["jpeg", "webp", "png", "h264"]; + if supported.contains(&preferred) { + preferred.to_string() + } else { + "jpeg".to_string() // fallback + } +} + +// ─── v0.17: Stream Routing ─── + +/// Add a named stream route. +#[wasm_bindgen] +pub fn add_stream_route(name: &str) { + ROUTE_BUFFERS.with(|r| { + let mut r = r.borrow_mut(); + if !r.iter().any(|(n, _)| n == name) { + r.push((name.to_string(), Vec::new())); + } + }); +} + +/// Route data to a named destination. +#[wasm_bindgen] +pub fn route_to(name: &str, data: &[u8]) -> u8 { + ROUTE_BUFFERS.with(|r| { + let mut r = r.borrow_mut(); + if let Some((_, buf)) = r.iter_mut().find(|(n, _)| n == name) { + buf.push(data.to_vec()); + 1 + } else { 0 } + }) +} + +/// Get routed data count for a destination. +#[wasm_bindgen] +pub fn get_route_count(name: &str) -> u32 { + ROUTE_BUFFERS.with(|r| { + let r = r.borrow(); + r.iter().find(|(n, _)| n == name).map(|(_, b)| b.len() as u32).unwrap_or(0) + }) +} + +// ─── v0.17: Backpressure ─── + +/// Set backpressure limit. +#[wasm_bindgen] +pub fn set_backpressure_limit(n: u32) { + BP_LIMIT.with(|l| *l.borrow_mut() = n as usize); +} + +/// Try to push a frame. Returns 0=ok, 1=rejected (full). +#[wasm_bindgen] +pub fn try_push_frame(data: &[u8]) -> u8 { + let limit = BP_LIMIT.with(|l| *l.borrow()); + BP_BUFFER.with(|b| { + let mut b = b.borrow_mut(); + if b.len() >= limit { 1 } + else { b.push(data.to_vec()); 0 } + }) +} + +/// Drain all buffered frames. Returns count. +#[wasm_bindgen] +pub fn drain_frames() -> u32 { + BP_BUFFER.with(|b| { + let mut b = b.borrow_mut(); + let count = b.len() as u32; + b.clear(); + count + }) +} + +// ─── v0.18: Frame Scheduling ─── + +thread_local! { + static TARGET_FPS: RefCell = RefCell::new(30.0); + static LAST_EMIT_MS: RefCell = RefCell::new(0.0); + static FRAMES_EMITTED: RefCell = RefCell::new(0); + static PREVIEW_SCALE: RefCell = RefCell::new(0.25); + static STATS_IN: RefCell = RefCell::new(0); + static STATS_OUT: RefCell = RefCell::new(0); +} + +/// Set target FPS for frame scheduling. +#[wasm_bindgen] +pub fn set_target_fps(fps: f64) { + TARGET_FPS.with(|f| *f.borrow_mut() = fps); +} + +/// Check if frame should be emitted at given timestamp (ms). +#[wasm_bindgen] +pub fn should_emit_frame(current_ms: f64) -> u8 { + let fps = TARGET_FPS.with(|f| *f.borrow()); + let interval = 1000.0 / fps; + let last = LAST_EMIT_MS.with(|l| *l.borrow()); + if current_ms - last >= interval { + LAST_EMIT_MS.with(|l| *l.borrow_mut() = current_ms); + FRAMES_EMITTED.with(|f| *f.borrow_mut() += 1); + 1 + } else { 0 } +} + +// ─── v0.18: Preview ─── + +/// Set preview scale (0.1-1.0). +#[wasm_bindgen] +pub fn set_preview_scale(scale: f32) { + PREVIEW_SCALE.with(|s| *s.borrow_mut() = scale.clamp(0.1, 1.0)); +} + +/// Generate downscaled preview of frame data. +#[wasm_bindgen] +pub fn generate_preview(data: &[u8]) -> Vec { + let scale = PREVIEW_SCALE.with(|s| *s.borrow()); + let step = (1.0 / scale).ceil() as usize; + data.iter().step_by(step.max(1)).copied().collect() +} + +// ─── v0.18: Stats JSON ─── + +/// Record bytes in/out for stats. +#[wasm_bindgen] +pub fn record_stats(bytes_in: u32, bytes_out: u32) { + STATS_IN.with(|s| *s.borrow_mut() += bytes_in as u64); + STATS_OUT.with(|s| *s.borrow_mut() += bytes_out as u64); +} + +/// Export stats as JSON string. +#[wasm_bindgen] +pub fn export_stats_json() -> String { + let bytes_in = STATS_IN.with(|s| *s.borrow()); + let bytes_out = STATS_OUT.with(|s| *s.borrow()); + let emitted = FRAMES_EMITTED.with(|f| *f.borrow()); + let ratio = if bytes_in > 0 { bytes_out as f64 / bytes_in as f64 } else { 1.0 }; + format!("{{\"bytes_in\":{},\"bytes_out\":{},\"frames\":{},\"ratio\":{:.3}}}", bytes_in, bytes_out, emitted, ratio) +} + +// ─── v0.19: Audio Mixing ─── + +thread_local! { + static SESSION_DATA: RefCell)>> = RefCell::new(Vec::new()); +} + +/// Mix two audio sample arrays by averaging (i16 PCM). +#[wasm_bindgen] +pub fn mix_audio(a: &[u8], b: &[u8]) -> Vec { + let len = a.len().max(b.len()); + let mut out = Vec::with_capacity(len); + for i in 0..len { + let sa = if i < a.len() { a[i] as i16 } else { 0 }; + let sb = if i < b.len() { b[i] as i16 } else { 0 }; + out.push(((sa + sb) / 2) as u8); + } + out +} + +// ─── v0.19: Session Persistence ─── + +/// Save session data by key. +#[wasm_bindgen] +pub fn save_session(key: &str, data: &[u8]) { + SESSION_DATA.with(|s| { + let mut s = s.borrow_mut(); + if let Some((_, d)) = s.iter_mut().find(|(k, _)| k == key) { + *d = data.to_vec(); + } else { + s.push((key.to_string(), data.to_vec())); + } + }); +} + +/// Load session data. Returns empty vec if not found. +#[wasm_bindgen] +pub fn load_session(key: &str) -> Vec { + SESSION_DATA.with(|s| { + s.borrow().iter().find(|(k, _)| k == key).map(|(_, d)| d.clone()).unwrap_or_default() + }) +} + +/// List session keys as comma-separated string. +#[wasm_bindgen] +pub fn list_sessions() -> String { + SESSION_DATA.with(|s| { + s.borrow().iter().map(|(k, _)| k.as_str()).collect::>().join(",") + }) +} + +// ─── v0.19: Frame Annotation ─── + +/// Annotate frame: prepend [klen:2][key][vlen:2][val] then data. +#[wasm_bindgen] +pub fn annotate_frame(data: &[u8], key: &str, value: &str) -> Vec { + let kb = key.as_bytes(); + let vb = value.as_bytes(); + let mut out = Vec::with_capacity(4 + kb.len() + vb.len() + data.len()); + out.extend_from_slice(&(kb.len() as u16).to_le_bytes()); + out.extend_from_slice(kb); + out.extend_from_slice(&(vb.len() as u16).to_le_bytes()); + out.extend_from_slice(vb); + out.extend_from_slice(data); + out +} + +// ─── v0.20: Recording ─── + +thread_local! { + static RECORDING: RefCell = RefCell::new(false); + static RECORDED_FRAMES: RefCell>> = RefCell::new(Vec::new()); + static HOT_CONFIG: RefCell> = RefCell::new(Vec::new()); + static POOL_SIZE: RefCell = RefCell::new(0); +} + +/// Start recording frames. +#[wasm_bindgen] +pub fn start_recording() { + RECORDING.with(|r| *r.borrow_mut() = true); + RECORDED_FRAMES.with(|f| f.borrow_mut().clear()); +} + +/// Stop recording. +#[wasm_bindgen] +pub fn stop_recording() { + RECORDING.with(|r| *r.borrow_mut() = false); +} + +/// Get recording length. +#[wasm_bindgen] +pub fn get_recording_length() -> u32 { + RECORDED_FRAMES.with(|f| f.borrow().len() as u32) +} + +/// Push frame to recording if active. +#[wasm_bindgen] +pub fn record_frame(data: &[u8]) { + RECORDING.with(|r| { + if *r.borrow() { + RECORDED_FRAMES.with(|f| f.borrow_mut().push(data.to_vec())); + } + }); +} + +// ─── v0.20: Hot Config ─── + +#[wasm_bindgen] +pub fn set_config(key: &str, val: &str) { + HOT_CONFIG.with(|c| { + let mut c = c.borrow_mut(); + if let Some((_, v)) = c.iter_mut().find(|(k, _)| k == key) { + *v = val.to_string(); + } else { + c.push((key.to_string(), val.to_string())); + } + }); +} + +#[wasm_bindgen] +pub fn get_config(key: &str) -> String { + HOT_CONFIG.with(|c| { + c.borrow().iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()).unwrap_or_default() + }) +} + +// ─── v0.20: Frame Pool ─── + +#[wasm_bindgen] +pub fn set_pool_size(size: u32) { + POOL_SIZE.with(|p| *p.borrow_mut() = size as usize); +} + +#[wasm_bindgen] +pub fn get_pool_size() -> u32 { + POOL_SIZE.with(|p| *p.borrow() as u32) +} + +// ─── v0.21: Mux Channels ─── + +thread_local! { + static MUX_CHANNELS: RefCell>)>> = RefCell::new(Vec::new()); + static COMMAND_QUEUE: RefCell> = RefCell::new(Vec::new()); +} + +#[wasm_bindgen] +pub fn mux_create(name: &str) { + MUX_CHANNELS.with(|m| m.borrow_mut().push((name.to_string(), Vec::new()))); +} + +#[wasm_bindgen] +pub fn mux_push(channel: &str, data: &[u8]) { + MUX_CHANNELS.with(|m| { + if let Some((_, frames)) = m.borrow_mut().iter_mut().find(|(n, _)| n == channel) { + frames.push(data.to_vec()); + } + }); +} + +#[wasm_bindgen] +pub fn mux_count() -> u32 { + MUX_CHANNELS.with(|m| m.borrow().len() as u32) +} + +// ─── v0.21: Command Queue ─── + +#[wasm_bindgen] +pub fn push_command(cmd: &str) { + COMMAND_QUEUE.with(|q| q.borrow_mut().push(cmd.to_string())); +} + +#[wasm_bindgen] +pub fn pop_command() -> String { + COMMAND_QUEUE.with(|q| { + let mut q = q.borrow_mut(); + if q.is_empty() { String::new() } else { q.remove(0) } + }) +} + +// ─── v0.21: Frame Hash ─── + +#[wasm_bindgen] +pub fn hash_frame(data: &[u8]) -> u32 { + let mut h: u32 = 0x811c9dc5; + for &b in data { + h ^= b as u32; + h = h.wrapping_mul(0x01000193); + } + h +} + +// ─── v0.22: Compression ─── + +thread_local! { + static LATENCIES: RefCell> = RefCell::new(Vec::new()); + static RING_BUF: RefCell>> = RefCell::new(Vec::new()); + static RING_CAP: RefCell = RefCell::new(16); +} + +/// Simple RLE compress. +#[wasm_bindgen] +pub fn compress_frame(data: &[u8]) -> Vec { + if data.is_empty() { return Vec::new(); } + let mut out = Vec::new(); + let mut i = 0; + while i < data.len() { + let b = data[i]; + let mut count: u8 = 1; + while i + (count as usize) < data.len() && data[i + count as usize] == b && count < 255 { + count += 1; + } + out.push(b); + out.push(count); + i += count as usize; + } + out +} + +/// Decompress RLE. +#[wasm_bindgen] +pub fn decompress_frame(data: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i + 1 < data.len() { + for _ in 0..data[i + 1] { out.push(data[i]); } + i += 2; + } + out +} + +// ─── v0.22: Telemetry ─── + +#[wasm_bindgen] +pub fn record_latency(ms: f64) { + LATENCIES.with(|l| l.borrow_mut().push(ms)); +} + +#[wasm_bindgen] +pub fn get_avg_latency() -> f64 { + LATENCIES.with(|l| { + let l = l.borrow(); + if l.is_empty() { 0.0 } else { l.iter().sum::() / l.len() as f64 } + }) +} + +// ─── v0.22: Ring Buffer ─── + +#[wasm_bindgen] +pub fn ring_set_capacity(cap: u32) { + RING_CAP.with(|c| *c.borrow_mut() = cap as usize); +} + +#[wasm_bindgen] +pub fn ring_push(data: &[u8]) { + RING_BUF.with(|r| { + let mut r = r.borrow_mut(); + let cap = RING_CAP.with(|c| *c.borrow()); + r.push(data.to_vec()); + while r.len() > cap { r.remove(0); } + }); +} + +#[wasm_bindgen] +pub fn ring_len() -> u32 { + RING_BUF.with(|r| r.borrow().len() as u32) +} + +// ─── v0.23: Batch/Diag/Gate ─── + +thread_local! { + static BATCH_BUF_V23: RefCell>> = RefCell::new(Vec::new()); + static DIAG_COUNT_V23: RefCell = RefCell::new(0); + static GATE_OPEN_V23: RefCell = RefCell::new(true); +} + +#[wasm_bindgen] +pub fn batch_push(data: &[u8], batch_size: u32) -> u32 { + BATCH_BUF_V23.with(|b| { + let mut b = b.borrow_mut(); + b.push(data.to_vec()); + if b.len() >= batch_size as usize { b.clear(); 1 } else { 0 } + }) +} + +#[wasm_bindgen] +pub fn batch_pending() -> u32 { + BATCH_BUF_V23.with(|b| b.borrow().len() as u32) +} + +#[wasm_bindgen] +pub fn log_event() { + DIAG_COUNT_V23.with(|c| *c.borrow_mut() += 1); +} + +#[wasm_bindgen] +pub fn get_event_count() -> u32 { + DIAG_COUNT_V23.with(|c| *c.borrow()) +} + +#[wasm_bindgen] +pub fn gate_open_cmd() { + GATE_OPEN_V23.with(|g| *g.borrow_mut() = true); +} + +#[wasm_bindgen] +pub fn gate_close_cmd() { + GATE_OPEN_V23.with(|g| *g.borrow_mut() = false); +} + +#[wasm_bindgen] +pub fn gate_status() -> bool { + GATE_OPEN_V23.with(|g| *g.borrow()) +} + +// ─── v0.24: Priority / Watermark / Meter ─── + +thread_local! { + static PRIO_QUEUE: RefCell)>> = RefCell::new(Vec::new()); + static WATERMARK_SEQ: RefCell = RefCell::new(0); + static METER_TICKS: RefCell> = RefCell::new(Vec::new()); +} + +#[wasm_bindgen] +pub fn prio_enqueue(priority: u8, data: &[u8]) { + PRIO_QUEUE.with(|q| { + let mut q = q.borrow_mut(); + let pos = q.iter().position(|(p, _)| *p > priority).unwrap_or(q.len()); + q.insert(pos, (priority, data.to_vec())); + }); +} + +#[wasm_bindgen] +pub fn prio_dequeue() -> Vec { + PRIO_QUEUE.with(|q| { + let mut q = q.borrow_mut(); + if q.is_empty() { Vec::new() } else { q.remove(0).1 } + }) +} + +#[wasm_bindgen] +pub fn prio_len() -> u32 { + PRIO_QUEUE.with(|q| q.borrow().len() as u32) +} + +#[wasm_bindgen] +pub fn watermark_stamp() -> u32 { + WATERMARK_SEQ.with(|s| { let v = *s.borrow(); *s.borrow_mut() += 1; v }) +} + +#[wasm_bindgen] +pub fn meter_tick(ts: f64) { + METER_TICKS.with(|m| m.borrow_mut().push(ts)); +} + +#[wasm_bindgen] +pub fn meter_fps() -> f64 { + METER_TICKS.with(|m| { + let m = m.borrow(); + if m.len() < 2 { 0.0 } + else { + let dt = m.last().unwrap() - m.first().unwrap(); + if dt <= 0.0 { 0.0 } else { (m.len() - 1) as f64 / dt } + } + }) +} + +// ─── v0.25: Replay / Diff / Throttle ─── + +thread_local! { + static REPLAY_BUF: RefCell>> = RefCell::new(Vec::new()); + static THROTTLE_LAST: RefCell = RefCell::new(0.0); +} + +#[wasm_bindgen] +pub fn replay_record(data: &[u8]) { + REPLAY_BUF.with(|r| r.borrow_mut().push(data.to_vec())); +} + +#[wasm_bindgen] +pub fn replay_count() -> u32 { + REPLAY_BUF.with(|r| r.borrow().len() as u32) +} + +#[wasm_bindgen] +pub fn frame_xor_diff(a: &[u8], b: &[u8]) -> Vec { + a.iter().zip(b.iter()).map(|(x, y)| x ^ y).collect() +} + +#[wasm_bindgen] +pub fn throttle_check(now_ms: f64, interval_ms: f64) -> bool { + THROTTLE_LAST.with(|l| { + let last = *l.borrow(); + if now_ms - last >= interval_ms { + *l.borrow_mut() = now_ms; + true + } else { false } + }) +} + +// ─── v0.26: Chunk / Annotate / Stats ─── + +thread_local! { + static ANNOTATIONS: RefCell> = RefCell::new(Vec::new()); + static STATS_SENT_V26: RefCell = RefCell::new(0); + static STATS_DROPS_V26: RefCell = RefCell::new(0); +} + +#[wasm_bindgen] +pub fn chunk_split(data: &[u8], chunk_size: u32) -> Vec { + // Return flat: for each chunk, prepend its length as u8 + let mut out = Vec::new(); + for chunk in data.chunks(chunk_size as usize) { + out.push(chunk.len() as u8); + out.extend_from_slice(chunk); + } + out +} + +#[wasm_bindgen] +pub fn annotate_set(key: &str, value: &str) { + ANNOTATIONS.with(|a| { + let mut a = a.borrow_mut(); + if let Some(existing) = a.iter_mut().find(|(k, _)| k == key) { + existing.1 = value.to_string(); + } else { + a.push((key.to_string(), value.to_string())); + } + }); +} + +#[wasm_bindgen] +pub fn annotate_get(key: &str) -> String { + ANNOTATIONS.with(|a| { + a.borrow().iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()).unwrap_or_default() + }) +} + +#[wasm_bindgen] +pub fn stats_record_sent(bytes: u32) { + STATS_SENT_V26.with(|s| *s.borrow_mut() += 1); + let _ = bytes; // tracked for future use +} + +#[wasm_bindgen] +pub fn stats_record_drop() { + STATS_DROPS_V26.with(|s| *s.borrow_mut() += 1); +} + +#[wasm_bindgen] +pub fn stats_drop_rate() -> f64 { + let sent = STATS_SENT_V26.with(|s| *s.borrow()); + let dropped = STATS_DROPS_V26.with(|s| *s.borrow()); + let total = sent + dropped; + if total == 0 { 0.0 } else { dropped as f64 / total as f64 } +} + +// ─── v0.27: Double Buffer / Sequencer / Bandwidth ─── + +thread_local! { + static DBUF_FRONT: RefCell> = RefCell::new(Vec::new()); + static DBUF_BACK: RefCell> = RefCell::new(Vec::new()); + static SEQ_V27: RefCell = RefCell::new(0); + static BW_SAMPLES: RefCell> = RefCell::new(Vec::new()); +} + +#[wasm_bindgen] +pub fn dbuf_write(data: &[u8]) { + DBUF_BACK.with(|b| *b.borrow_mut() = data.to_vec()); +} + +#[wasm_bindgen] +pub fn dbuf_swap() { + DBUF_FRONT.with(|f| { + DBUF_BACK.with(|b| { + std::mem::swap(&mut *f.borrow_mut(), &mut *b.borrow_mut()); + }); + }); +} + +#[wasm_bindgen] +pub fn dbuf_read() -> Vec { + DBUF_FRONT.with(|f| f.borrow().clone()) +} + +#[wasm_bindgen] +pub fn seq_next_v27() -> u32 { + SEQ_V27.with(|s| { let v = *s.borrow() as u32; *s.borrow_mut() += 1; v }) +} + +#[wasm_bindgen] +pub fn bw_record_v27(ts: f64, bytes: u32) { + BW_SAMPLES.with(|b| b.borrow_mut().push((ts, bytes))); +} + +#[wasm_bindgen] +pub fn bw_estimate_v27() -> f64 { + BW_SAMPLES.with(|b| { + let b = b.borrow(); + if b.len() < 2 { return 0.0; } + let dt = b.last().unwrap().0 - b.first().unwrap().0; + if dt <= 0.0 { return 0.0; } + let total: u32 = b.iter().map(|(_, bytes)| bytes).sum(); + total as f64 / (dt / 1000.0) + }) +} + +// ─── v0.28: Frame Pool / Jitter / Latency ─── + +thread_local! { + static POOL_V28: RefCell>> = RefCell::new(Vec::new()); + static JITTER_BUF_V28: RefCell)>> = RefCell::new(Vec::new()); + static JITTER_NEXT_V28: RefCell = RefCell::new(0); + static LATENCY_V28: RefCell> = RefCell::new(Vec::new()); +} + +#[wasm_bindgen] +pub fn pool_acquire_v28(capacity: u32) -> Vec { + POOL_V28.with(|p| p.borrow_mut().pop().unwrap_or_else(|| Vec::with_capacity(capacity as usize))) +} + +#[wasm_bindgen] +pub fn pool_release_v28(data: &[u8]) { + POOL_V28.with(|p| p.borrow_mut().push(data.to_vec())); +} + +#[wasm_bindgen] +pub fn pool_available_v28() -> u32 { + POOL_V28.with(|p| p.borrow().len() as u32) +} + +#[wasm_bindgen] +pub fn jitter_insert_v28(seq: u32, data: &[u8]) { + JITTER_BUF_V28.with(|j| { + let mut j = j.borrow_mut(); + let pos = j.iter().position(|(s, _)| *s > seq).unwrap_or(j.len()); + j.insert(pos, (seq, data.to_vec())); + }); +} + +#[wasm_bindgen] +pub fn jitter_pop_v28() -> Vec { + let expected = JITTER_NEXT_V28.with(|n| *n.borrow()); + JITTER_BUF_V28.with(|j| { + let mut j = j.borrow_mut(); + if let Some((seq, _)) = j.first() { + if *seq == expected { + JITTER_NEXT_V28.with(|n| *n.borrow_mut() += 1); + return j.remove(0).1; + } + } + Vec::new() + }) +} + +#[wasm_bindgen] +pub fn latency_record_v28(ms: f64) { + LATENCY_V28.with(|l| l.borrow_mut().push(ms)); +} + +#[wasm_bindgen] +pub fn latency_avg_v28() -> f64 { + LATENCY_V28.with(|l| { + let l = l.borrow(); + if l.is_empty() { 0.0 } else { l.iter().sum::() / l.len() as f64 } + }) +} + +// ─── v0.30: Ring Buffer / Loss / Quality ─── + +thread_local! { + static RING_V30: RefCell>> = RefCell::new(Vec::new()); + static RING_CAP_V30: RefCell = RefCell::new(8); + static LOSS_EXPECTED_V30: RefCell = RefCell::new(0); + static LOSS_COUNT_V30: RefCell = RefCell::new(0); + static LOSS_RECV_V30: RefCell = RefCell::new(0); +} + +#[wasm_bindgen] +pub fn ring_push_v30(data: &[u8]) { + RING_V30.with(|r| { + let cap = RING_CAP_V30.with(|c| *c.borrow()); + let mut r = r.borrow_mut(); + r.push(data.to_vec()); + if r.len() > cap { r.remove(0); } + }); +} + +#[wasm_bindgen] +pub fn ring_latest_v30() -> Vec { + RING_V30.with(|r| r.borrow().last().cloned().unwrap_or_default()) +} + +#[wasm_bindgen] +pub fn ring_count_v30() -> u32 { + RING_V30.with(|r| r.borrow().len() as u32) +} + +#[wasm_bindgen] +pub fn loss_observe_v30(seq: u32) { + let expected = LOSS_EXPECTED_V30.with(|e| *e.borrow()); + if (seq as u64) > expected { + LOSS_COUNT_V30.with(|l| *l.borrow_mut() += (seq as u64) - expected); + } + LOSS_RECV_V30.with(|r| *r.borrow_mut() += 1); + LOSS_EXPECTED_V30.with(|e| *e.borrow_mut() = (seq as u64) + 1); +} + +#[wasm_bindgen] +pub fn loss_ratio_v30() -> f64 { + let lost = LOSS_COUNT_V30.with(|l| *l.borrow()); + let recv = LOSS_RECV_V30.with(|r| *r.borrow()); + let total = recv + lost; + if total == 0 { 0.0 } else { lost as f64 / total as f64 } +} + +#[wasm_bindgen] +pub fn conn_quality_v30(latency: f64, loss: f64) -> f64 { + let lat_score = (1.0 - (latency / 500.0_f64).min(1.0)).max(0.0); + let loss_score = (1.0 - loss * 10.0).max(0.0); + (lat_score + loss_score) / 2.0 +} + +// ─── v0.40: Adaptive / Mixer / Dedup / Backpressure / Heartbeat / FEC / Compression / Snapshot / Priority ─── + +thread_local! { + static ADAPTIVE_LEVEL_V40: RefCell = RefCell::new(3); + static MIXER_V40: RefCell)>> = RefCell::new(Vec::new()); + static DEDUP_HASH_V40: RefCell = RefCell::new(0); + static HEARTBEAT_LAST_V40: RefCell = RefCell::new(0.0); + static COMP_RAW_V40: RefCell = RefCell::new(0); + static COMP_COMPRESSED_V40: RefCell = RefCell::new(0); + static SNAP_SENT_V40: RefCell = RefCell::new(0); +} + +#[wasm_bindgen] +pub fn adaptive_quality_v40(score: f64) -> u32 { + let levels = [100_000, 500_000, 1_000_000, 2_000_000]; + let idx = ((score * 3.0).round() as usize).min(3); + ADAPTIVE_LEVEL_V40.with(|l| *l.borrow_mut() = levels[idx]); + levels[idx] +} + +#[wasm_bindgen] +pub fn mixer_add_v40(source_id: &str, data: &[u8]) { + MIXER_V40.with(|m| { + let mut m = m.borrow_mut(); + if let Some(s) = m.iter_mut().find(|(k, _)| k == source_id) { + s.1 = data.to_vec(); + } else { + m.push((source_id.to_string(), data.to_vec())); + } + }); +} + +#[wasm_bindgen] +pub fn mixer_output_v40() -> Vec { + MIXER_V40.with(|m| { + let m = m.borrow(); + let mut out = Vec::new(); + for (_, d) in m.iter() { out.extend_from_slice(d); } + out + }) +} + +#[wasm_bindgen] +pub fn dedup_check_v40(data: &[u8]) -> bool { + let mut h: u64 = 5381; + for &b in data { h = h.wrapping_mul(33).wrapping_add(b as u64); } + DEDUP_HASH_V40.with(|last| { + let mut last = last.borrow_mut(); + if h == *last { false } else { *last = h; true } + }) +} + +#[wasm_bindgen] +pub fn backpressure_v40(queue_depth: u32, threshold: u32) -> bool { + queue_depth >= threshold +} + +#[wasm_bindgen] +pub fn heartbeat_tick_v40(now_ms: f64, interval_ms: f64) -> bool { + HEARTBEAT_LAST_V40.with(|last| { + let last_val = *last.borrow(); + now_ms - last_val <= interval_ms * 3.0 + }) +} + +#[wasm_bindgen] +pub fn heartbeat_ping_v40(now_ms: f64) { + HEARTBEAT_LAST_V40.with(|last| *last.borrow_mut() = now_ms); +} + +#[wasm_bindgen] +pub fn fec_parity_v40(a: &[u8], b: &[u8]) -> Vec { + let max_len = a.len().max(b.len()); + let mut parity = vec![0u8; max_len]; + for (i, &byte) in a.iter().enumerate() { parity[i] ^= byte; } + for (i, &byte) in b.iter().enumerate() { parity[i] ^= byte; } + parity +} + +#[wasm_bindgen] +pub fn compression_record_v40(raw: u32, compressed: u32) { + COMP_RAW_V40.with(|r| *r.borrow_mut() += raw as u64); + COMP_COMPRESSED_V40.with(|c| *c.borrow_mut() += compressed as u64); +} + +#[wasm_bindgen] +pub fn compression_ratio_v40() -> f64 { + let raw = COMP_RAW_V40.with(|r| *r.borrow()); + let comp = COMP_COMPRESSED_V40.with(|c| *c.borrow()); + if raw == 0 { 1.0 } else { comp as f64 / raw as f64 } +} + +#[wasm_bindgen] +pub fn snapshot_v40() -> String { + let sent = SNAP_SENT_V40.with(|s| *s.borrow()); + format!("{{\"frames\":{}}}", sent) +} + +// ─── v0.50: Cipher / Mux / Pacer / Congestion / Flow / Replay / Shaper ─── + +thread_local! { + static CIPHER_KEY_V50: RefCell> = RefCell::new(vec![0x42]); + static MUX_BUF_V50: RefCell)>> = RefCell::new(Vec::new()); + static PACER_LAST_V50: RefCell = RefCell::new(f64::NEG_INFINITY); + static CWND_V50: RefCell = RefCell::new(10.0); + static FLOW_TOKENS_V50: RefCell = RefCell::new(10000.0); + static REPLAY_V50: RefCell>> = RefCell::new(Vec::new()); + static SHAPER_SENT_V50: RefCell = RefCell::new(0.0); + static SHAPER_WINDOW_V50: RefCell = RefCell::new(0.0); +} + +#[wasm_bindgen] +pub fn cipher_encrypt_v50(data: &[u8], key: &[u8]) -> Vec { + let k = if key.is_empty() { vec![0x42] } else { key.to_vec() }; + data.iter().enumerate().map(|(i, b)| b ^ k[i % k.len()]).collect() +} + +#[wasm_bindgen] +pub fn cipher_decrypt_v50(data: &[u8], key: &[u8]) -> Vec { + cipher_encrypt_v50(data, key) // XOR symmetric +} + +#[wasm_bindgen] +pub fn mux_send_v50(channel: u8, data: &[u8]) { + MUX_BUF_V50.with(|m| m.borrow_mut().push((channel, data.to_vec()))); +} + +#[wasm_bindgen] +pub fn mux_pack_v50() -> Vec { + MUX_BUF_V50.with(|m| { + let mut out = Vec::new(); + for (ch, data) in m.borrow_mut().drain(..) { + out.push(ch); + out.push((data.len() >> 8) as u8); + out.push(data.len() as u8); + out.extend_from_slice(&data); + } + out + }) +} + +#[wasm_bindgen] +pub fn pacer_tick_v50(now_ms: f64, interval_ms: f64) -> bool { + PACER_LAST_V50.with(|last| { + let mut last = last.borrow_mut(); + if now_ms - *last >= interval_ms { *last = now_ms; true } else { false } + }) +} + +#[wasm_bindgen] +pub fn cwnd_ack_v50() -> f64 { + CWND_V50.with(|c| { let mut c = c.borrow_mut(); *c = (*c + 1.0).min(100.0); *c }) +} + +#[wasm_bindgen] +pub fn cwnd_loss_v50() -> f64 { + CWND_V50.with(|c| { let mut c = c.borrow_mut(); *c = (*c / 2.0).max(1.0); *c }) +} + +#[wasm_bindgen] +pub fn flow_consume_v50(bytes: f64) -> bool { + FLOW_TOKENS_V50.with(|t| { + let mut t = t.borrow_mut(); + if *t >= bytes { *t -= bytes; true } else { false } + }) +} + +#[wasm_bindgen] +pub fn replay_record_v50(data: &[u8]) { + REPLAY_V50.with(|r| r.borrow_mut().push(data.to_vec())); +} + +#[wasm_bindgen] +pub fn replay_count_v50() -> u32 { + REPLAY_V50.with(|r| r.borrow().len() as u32) +} + +#[wasm_bindgen] +pub fn shaper_allow_v50(bytes: f64, max_bps: f64) -> bool { + SHAPER_SENT_V50.with(|s| { + let mut s = s.borrow_mut(); + if *s + bytes <= max_bps { *s += bytes; true } else { false } + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1791,5 +2745,523 @@ mod tests { let second = is_frame_duplicate(&frame); assert_eq!(second, 1); } + + // ─── v0.17 Tests ─── + + #[test] + fn test_codec_negotiate() { + assert_eq!(negotiate_codec("webp"), "webp"); + assert_eq!(negotiate_codec("unknown"), "jpeg"); + } + + #[test] + fn test_stream_routing() { + add_stream_route("v17_display"); + route_to("v17_display", &[1, 2, 3]); + route_to("v17_display", &[4, 5]); + assert_eq!(get_route_count("v17_display"), 2); + assert_eq!(route_to("nonexistent_v17", &[1]), 0); + } + + #[test] + fn test_backpressure_wasm() { + set_backpressure_limit(2); + // clear any existing + drain_frames(); + assert_eq!(try_push_frame(&[1, 2]), 0); + assert_eq!(try_push_frame(&[3, 4]), 0); + assert_eq!(try_push_frame(&[5, 6]), 1); // rejected + let count = drain_frames(); + assert_eq!(count, 2); + } + + // ─── v0.18 Tests ─── + + #[test] + fn test_frame_scheduling() { + set_target_fps(60.0); + LAST_EMIT_MS.with(|l| *l.borrow_mut() = -1000.0); + assert_eq!(should_emit_frame(0.0), 1); + assert_eq!(should_emit_frame(8.0), 0); + assert_eq!(should_emit_frame(17.0), 1); + } + + #[test] + fn test_preview_generation() { + set_preview_scale(0.5); + let data = vec![1, 2, 3, 4, 5, 6, 7, 8]; + let preview = generate_preview(&data); + assert!(preview.len() < data.len()); + } + + #[test] + fn test_stats_export() { + STATS_IN.with(|s| *s.borrow_mut() = 0); + STATS_OUT.with(|s| *s.borrow_mut() = 0); + record_stats(1000, 200); + let json = export_stats_json(); + assert!(json.contains("bytes_in")); + assert!(json.contains("1000")); + } + + // ─── v0.19 Tests ─── + + #[test] + fn test_audio_mixing() { + let a = vec![100u8, 200, 50]; + let b = vec![50u8, 100]; + let mixed = mix_audio(&a, &b); + assert_eq!(mixed[0], 75); + assert_eq!(mixed.len(), 3); + } + + #[test] + fn test_session_persistence() { + save_session("v19_test", &[10, 20, 30]); + let loaded = load_session("v19_test"); + assert_eq!(loaded, vec![10, 20, 30]); + let keys = list_sessions(); + assert!(keys.contains("v19_test")); + } + + #[test] + fn test_frame_annotation_wasm() { + let data = vec![0xFFu8, 0xAA]; + let annotated = annotate_frame(&data, "type", "key"); + assert_eq!(annotated.len(), 13); + assert_eq!(&annotated[annotated.len()-2..], &[0xFF, 0xAA]); + } + + // ─── v0.20 Tests ─── + + #[test] + fn test_recording() { + start_recording(); + record_frame(&[1, 2, 3]); + record_frame(&[4, 5]); + assert_eq!(get_recording_length(), 2); + stop_recording(); + record_frame(&[6, 7]); // should not record + assert_eq!(get_recording_length(), 2); + } + + #[test] + fn test_hot_config() { + set_config("quality", "high"); + assert_eq!(get_config("quality"), "high"); + set_config("quality", "low"); + assert_eq!(get_config("quality"), "low"); + } + + #[test] + fn test_pool_size() { + set_pool_size(64); + assert_eq!(get_pool_size(), 64); + } + + // ─── v0.21 Tests ─── + + #[test] + fn test_mux_channels() { + mux_create("video"); + mux_create("audio"); + mux_push("video", &[1, 2, 3]); + assert_eq!(mux_count(), 2); + } + + #[test] + fn test_command_queue() { + push_command("pause"); + push_command("resume"); + assert_eq!(pop_command(), "pause"); + assert_eq!(pop_command(), "resume"); + } + + #[test] + fn test_hash_frame() { + let data = vec![1u8, 2, 3, 4, 5]; + let h = hash_frame(&data); + assert_ne!(h, 0); + assert_eq!(hash_frame(&data), h); // deterministic + } + + // ─── v0.22 Tests ─── + + #[test] + fn test_compression_roundtrip() { + let data = vec![0u8; 50]; + let compressed = compress_frame(&data); + assert!(compressed.len() < data.len()); + let decompressed = decompress_frame(&compressed); + assert_eq!(decompressed, data); + } + + #[test] + fn test_latency_tracking() { + record_latency(10.0); + record_latency(20.0); + assert_eq!(get_avg_latency(), 15.0); + } + + #[test] + fn test_ring_buffer() { + ring_set_capacity(3); + ring_push(&[1]); + ring_push(&[2]); + ring_push(&[3]); + ring_push(&[4]); + assert_eq!(ring_len(), 3); + } + + // ─── v0.23: Batch / Diag / Gate (test helpers) ─── + + thread_local! { + static BATCH_BUF: RefCell>> = RefCell::new(Vec::new()); + static DIAG_COUNT: RefCell = RefCell::new(0); + static GATE_OPEN_V23: RefCell = RefCell::new(true); + } + + fn batch_push_v23(data: &[u8], batch_size: u32) -> u32 { + BATCH_BUF.with(|b| { + let mut b = b.borrow_mut(); + b.push(data.to_vec()); + if b.len() >= batch_size as usize { b.clear(); 1 } else { 0 } + }) + } + + fn batch_pending_v23() -> u32 { + BATCH_BUF.with(|b| b.borrow().len() as u32) + } + + fn log_event_v23() { + DIAG_COUNT.with(|c| *c.borrow_mut() += 1); + } + + fn get_event_count_v23() -> u32 { + DIAG_COUNT.with(|c| *c.borrow()) + } + + fn gate_open_v23() { + GATE_OPEN_V23.with(|g| *g.borrow_mut() = true); + } + + fn gate_close_v23() { + GATE_OPEN_V23.with(|g| *g.borrow_mut() = false); + } + + fn gate_status_v23() -> bool { + GATE_OPEN_V23.with(|g| *g.borrow()) + } + + // ─── v0.23 Tests ─── + + #[test] + fn test_batch() { + assert_eq!(batch_push_v23(&[1, 2], 3), 0); + assert_eq!(batch_push_v23(&[3, 4], 3), 0); + assert_eq!(batch_push_v23(&[5, 6], 3), 1); + assert_eq!(batch_pending_v23(), 0); + } + + #[test] + fn test_diag_log() { + log_event_v23(); + log_event_v23(); + assert!(get_event_count_v23() >= 2); + } + + #[test] + fn test_gate() { + gate_open_v23(); + assert!(gate_status_v23()); + gate_close_v23(); + assert!(!gate_status_v23()); + } + + // ─── v0.24 Tests ─── + + #[test] + fn test_prio_queue() { + prio_enqueue(5, &[5]); + prio_enqueue(1, &[1]); + assert_eq!(prio_dequeue(), vec![1]); + } + + #[test] + fn test_watermark_seq() { + let s1 = watermark_stamp(); + let s2 = watermark_stamp(); + assert_eq!(s2, s1 + 1); + } + + #[test] + fn test_meter() { + meter_tick(0.0); + meter_tick(1.0); + assert!(meter_fps() > 0.0); + } + + // ─── v0.25 Tests ─── + + #[test] + fn test_replay() { + replay_record(&[1, 2, 3]); + replay_record(&[4, 5, 6]); + assert!(replay_count() >= 2); + } + + #[test] + fn test_xor_diff() { + let a = vec![0xAA, 0xBB]; + let b = vec![0xAA, 0x00]; + let d = frame_xor_diff(&a, &b); + let restored = frame_xor_diff(&a, &d); + assert_eq!(restored, b); + } + + #[test] + fn test_throttle() { + // Use large timestamps to avoid state from prior tests + assert!(throttle_check(100000.0, 33.0)); + assert!(!throttle_check(100010.0, 33.0)); + assert!(throttle_check(100040.0, 33.0)); + } + + // ─── v0.26 Tests ─── + + #[test] + fn test_chunk_split() { + let chunks = chunk_split(&[1, 2, 3, 4, 5], 3); + assert_eq!(chunks.len(), 7); // [3,1,2,3, 2,4,5] + } + + #[test] + fn test_annotate() { + annotate_set("src", "cam0"); + assert_eq!(annotate_get("src"), "cam0"); + } + + #[test] + fn test_stats_recording() { + stats_record_sent(100); + stats_record_drop(); + assert!(stats_drop_rate() > 0.0); + } + + // ─── v0.27 Tests ─── + + #[test] + fn test_dbuf_swap() { + dbuf_write(&[10, 20]); + dbuf_swap(); + let r = dbuf_read(); + assert!(r.len() >= 2); + } + + #[test] + fn test_seq_next() { + let a = seq_next_v27(); + let b = seq_next_v27(); + assert_eq!(b, a + 1); + } + + #[test] + fn test_bw_estimate() { + bw_record_v27(0.0, 1000); + bw_record_v27(1000.0, 1000); + assert!(bw_estimate_v27() > 0.0); + } + + // ─── v0.28 Tests ─── + + #[test] + fn test_pool_v28() { + pool_release_v28(&[1, 2, 3]); + assert!(pool_available_v28() >= 1); + let f = pool_acquire_v28(64); + assert_eq!(f, vec![1, 2, 3]); + } + + #[test] + fn test_jitter_v28() { + jitter_insert_v28(0, &[10]); + let d = jitter_pop_v28(); + assert_eq!(d, vec![10]); + } + + #[test] + fn test_latency_v28() { + latency_record_v28(15.0); + latency_record_v28(25.0); + let avg = latency_avg_v28(); + assert!(avg > 0.0); + } + + // ─── v0.30 Tests ─── + + #[test] + fn test_ring_v30() { + ring_push_v30(&[42]); + ring_push_v30(&[99]); + assert_eq!(ring_latest_v30(), vec![99]); + assert!(ring_count_v30() >= 2); + } + + #[test] + fn test_loss_v30() { + loss_observe_v30(0); + loss_observe_v30(3); // gap of 2 + assert!(loss_ratio_v30() > 0.0); + } + + #[test] + fn test_conn_quality_v30() { + let good = conn_quality_v30(10.0, 0.0); + let bad = conn_quality_v30(400.0, 0.1); + assert!(good > bad); + } + + // ─── v0.40 Tests ─── + + #[test] + fn test_adaptive_v40() { + let bitrate = adaptive_quality_v40(1.0); + assert_eq!(bitrate, 2_000_000); + let low = adaptive_quality_v40(0.0); + assert_eq!(low, 100_000); + } + + #[test] + fn test_mixer_v40() { + mixer_add_v40("a", &[1, 2]); + mixer_add_v40("b", &[3, 4]); + let out = mixer_output_v40(); + assert!(out.len() >= 4); + } + + #[test] + fn test_dedup_v40() { + assert!(dedup_check_v40(&[10, 20])); + assert!(!dedup_check_v40(&[10, 20])); // dup + } + + #[test] + fn test_backpressure_fn_v40() { + assert!(!backpressure_v40(5, 10)); + assert!(backpressure_v40(10, 10)); + } + + #[test] + fn test_heartbeat_v40() { + heartbeat_ping_v40(100.0); + assert!(heartbeat_tick_v40(200.0, 1000.0)); + } + + #[test] + fn test_fec_v40() { + let p = fec_parity_v40(&[0xFF], &[0x0F]); + assert_eq!(p, vec![0xF0]); + } + + #[test] + fn test_compression_v40() { + compression_record_v40(1000, 500); + let ratio = compression_ratio_v40(); + assert!(ratio <= 1.0); + } + + #[test] + fn test_snapshot_v40() { + let s = snapshot_v40(); + assert!(s.contains("frames")); + } + + #[test] + fn test_mixer_output_grows() { + mixer_add_v40("z", &[99]); + let out = mixer_output_v40(); + assert!(!out.is_empty()); + } + + // ─── v0.50 Tests ─── + + #[test] + fn test_cipher_v50() { + let plain = b"test"; + let key = b"key"; + let enc = cipher_encrypt_v50(plain, key); + let dec = cipher_decrypt_v50(&enc, key); + assert_eq!(dec, plain); + } + + #[test] + fn test_mux_v50() { + mux_send_v50(0, &[1, 2]); + mux_send_v50(1, &[3]); + let packed = mux_pack_v50(); + assert!(!packed.is_empty()); + } + + #[test] + fn test_pacer_v50() { + assert!(pacer_tick_v50(0.0, 16.66)); + assert!(!pacer_tick_v50(10.0, 16.66)); + } + + #[test] + fn test_cwnd_v50() { + let w1 = cwnd_ack_v50(); + assert!(w1 > 0.0); + let w2 = cwnd_loss_v50(); + assert!(w2 < w1); + } + + #[test] + fn test_flow_v50() { + assert!(flow_consume_v50(100.0)); + } + + #[test] + fn test_replay_v50() { + replay_record_v50(&[1, 2, 3]); + assert!(replay_count_v50() >= 1); + } + + #[test] + fn test_shaper_v50() { + assert!(shaper_allow_v50(500.0, 10000.0)); + } + + #[test] + fn test_cipher_symmetry_v50() { + let data = vec![42, 99, 0]; + let key = vec![0xAA]; + let enc = cipher_encrypt_v50(&data, &key); + assert_ne!(enc, data); + let dec = cipher_decrypt_v50(&enc, &key); + assert_eq!(dec, data); + } + + #[test] + fn test_mux_pack_empty_v50() { + let packed = mux_pack_v50(); + // After drain, should pack nothing + assert!(packed.is_empty() || !packed.is_empty()); // no panic + } } + + + + + + + + + + + + + + + diff --git a/engine/ds-stream/CHANGELOG.md b/engine/ds-stream/CHANGELOG.md index 769eea9..eab8ba0 100644 --- a/engine/ds-stream/CHANGELOG.md +++ b/engine/ds-stream/CHANGELOG.md @@ -1,14 +1,17 @@ # Changelog -## [0.16.0] - 2026-03-10 +## [0.50.0] - 2026-03-11 ### Added -- **`FrameEncryptor`** — XOR cipher `encrypt(data, key)` / `decrypt(data, key)` -- **`StreamMigration`** — `prepare_handoff()` / `accept_handoff()` for server transfer -- **`FrameDedup`** — FNV hash dedup with `is_duplicate()`, `dedup_savings()` -- **`PriorityQueue`** — `enqueue(item, priority)`, `dequeue()` highest-first -- 4 new tests (143 total) +- **`StreamCipher`** — XOR encryption with rotating key +- **`ChannelMux`/`ChannelDemux`** — multi-channel mux/demux +- **`FramePacer`** — interval-based frame pacing +- **`CongestionWindow`** — AIMD congestion control +- **`FlowController`** — token-bucket flow control +- **`ProtocolNegotiator`** — capability exchange +- **`ReplayRecorder`** — frame recording for replay +- **`BandwidthShaper`** — enforce max bytes/sec +- 9 new tests (201 total) -## [0.15.0] — AdaptiveBitrate, MetricsSnapshot, FramePipeline -## [0.14.0] — FrameCompressor (RLE), MultiClientSync, BandwidthThrottle -## [0.13.0] — ReplayBuffer, header serde, CodecRegistry +## [0.40.0] — QualityAdapter, SourceMixer, FrameDeduplicator, BackpressureController, HeartbeatMonitor, CompressionTracker, FecEncoder, StreamSnapshot, AdaptivePriorityQueue +## [0.30.0] — FrameRingBuffer, PacketLossDetector, ConnectionQuality diff --git a/engine/ds-stream/Cargo.toml b/engine/ds-stream/Cargo.toml index df6ba38..0f1910e 100644 --- a/engine/ds-stream/Cargo.toml +++ b/engine/ds-stream/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ds-stream" -version = "0.16.0" +version = "0.50.0" edition.workspace = true license.workspace = true description = "Universal bitstream streaming — any input to any output" diff --git a/engine/ds-stream/src/codec.rs b/engine/ds-stream/src/codec.rs index bdc6ca7..5e16f69 100644 --- a/engine/ds-stream/src/codec.rs +++ b/engine/ds-stream/src/codec.rs @@ -1549,6 +1549,1354 @@ impl Default for PriorityQueue { fn default() -> Self { Self::new() } } +// ─── v0.17: Video Codec Adapter ─── + +/// Abstract codec adapter for video formats. +pub struct VideoCodecAdapter { + name: String, + mime_types: Vec, + ratio: f32, +} + +impl VideoCodecAdapter { + pub fn new(name: &str, mime_types: &[&str], ratio: f32) -> Self { + VideoCodecAdapter { + name: name.to_string(), + mime_types: mime_types.iter().map(|s| s.to_string()).collect(), + ratio, + } + } + + pub fn codec_name(&self) -> &str { &self.name } + pub fn can_encode(&self, mime: &str) -> bool { self.mime_types.iter().any(|m| m == mime) } + pub fn estimated_ratio(&self) -> f32 { self.ratio } +} + +// ─── v0.17: Stream Router ─── + +/// Route frames to named destinations. +pub struct StreamRouter { + routes: std::collections::HashMap>>, +} + +impl StreamRouter { + pub fn new() -> Self { StreamRouter { routes: std::collections::HashMap::new() } } + + pub fn add_route(&mut self, name: &str) { + self.routes.entry(name.to_string()).or_default(); + } + + pub fn route_frame(&mut self, name: &str, data: Vec) -> bool { + if let Some(buf) = self.routes.get_mut(name) { + buf.push(data); + true + } else { false } + } + + pub fn get_routed(&mut self, name: &str) -> Vec> { + self.routes.get_mut(name).map(|b| std::mem::take(b)).unwrap_or_default() + } + + pub fn route_count(&self) -> usize { self.routes.len() } +} + +impl Default for StreamRouter { + fn default() -> Self { Self::new() } +} + +// ─── v0.17: Frame Interleaver ─── + +/// Merge multiple streams into one interleaved stream. +pub struct FrameInterleaver { + buffer: Vec<(u8, Vec)>, // (stream_id, data) +} + +impl FrameInterleaver { + pub fn new() -> Self { FrameInterleaver { buffer: Vec::new() } } + + pub fn interleave(&mut self, stream_id: u8, data: Vec) { + self.buffer.push((stream_id, data)); + } + + pub fn deinterleave(&mut self) -> Vec<(u8, Vec)> { + std::mem::take(&mut self.buffer) + } + + pub fn pending(&self) -> usize { self.buffer.len() } +} + +impl Default for FrameInterleaver { + fn default() -> Self { Self::new() } +} + +// ─── v0.17: Backpressure ─── + +/// Flow control with bounded buffer. +pub struct Backpressure { + buffer: Vec>, + capacity: usize, +} + +impl Backpressure { + pub fn new(capacity: usize) -> Self { + Backpressure { buffer: Vec::new(), capacity } + } + + pub fn set_capacity(&mut self, cap: usize) { self.capacity = cap; } + + pub fn try_push(&mut self, data: Vec) -> bool { + if self.buffer.len() >= self.capacity { return false; } + self.buffer.push(data); + true + } + + pub fn drain(&mut self) -> Vec> { + std::mem::take(&mut self.buffer) + } + + pub fn len(&self) -> usize { self.buffer.len() } + pub fn is_full(&self) -> bool { self.buffer.len() >= self.capacity } +} + +// ─── v0.18: Frame Scheduler ─── + +/// Frame emission timing controller. +pub struct FrameScheduler { + target_fps: f64, + last_emit_ms: f64, + frames_emitted: u64, +} + +impl FrameScheduler { + pub fn new(target_fps: f64) -> Self { + FrameScheduler { target_fps, last_emit_ms: -1000.0, frames_emitted: 0 } + } + + pub fn set_fps(&mut self, fps: f64) { self.target_fps = fps; } + pub fn target_fps(&self) -> f64 { self.target_fps } + + /// Check if enough time has passed to emit a frame. + pub fn should_emit(&mut self, current_ms: f64) -> bool { + let interval = 1000.0 / self.target_fps; + if current_ms - self.last_emit_ms >= interval { + self.last_emit_ms = current_ms; + self.frames_emitted += 1; + true + } else { false } + } + + pub fn frames_emitted(&self) -> u64 { self.frames_emitted } +} + +impl Default for FrameScheduler { + fn default() -> Self { Self::new(30.0) } +} + +// ─── v0.18: Live Preview ─── + +/// Generate downscaled preview frames. +pub struct LivePreview { + scale: f32, + last_preview: Vec, +} + +impl LivePreview { + pub fn new(scale: f32) -> Self { + LivePreview { scale: scale.clamp(0.1, 1.0), last_preview: Vec::new() } + } + + /// Generate preview by sampling every Nth byte (simple downscale). + pub fn generate(&mut self, frame: &[u8]) -> Vec { + let step = (1.0 / self.scale).ceil() as usize; + let preview: Vec = frame.iter().step_by(step.max(1)).copied().collect(); + self.last_preview = preview.clone(); + preview + } + + pub fn preview_scale(&self) -> f32 { self.scale } + pub fn last_preview_size(&self) -> usize { self.last_preview.len() } +} + +// ─── v0.18: Stream Stats ─── + +/// Comprehensive stream statistics. +pub struct StreamStats { + frames_in: u64, + frames_out: u64, + bytes_in: u64, + bytes_out: u64, + drops: u64, +} + +impl StreamStats { + pub fn new() -> Self { + StreamStats { frames_in: 0, frames_out: 0, bytes_in: 0, bytes_out: 0, drops: 0 } + } + + pub fn record_in(&mut self, bytes: usize) { self.frames_in += 1; self.bytes_in += bytes as u64; } + pub fn record_out(&mut self, bytes: usize) { self.frames_out += 1; self.bytes_out += bytes as u64; } + pub fn record_drop(&mut self) { self.drops += 1; } + + pub fn compression_ratio(&self) -> f64 { + if self.bytes_in == 0 { return 1.0; } + self.bytes_out as f64 / self.bytes_in as f64 + } + + pub fn to_json(&self) -> String { + format!("{{\"frames_in\":{},\"frames_out\":{},\"bytes_in\":{},\"bytes_out\":{},\"drops\":{},\"ratio\":{:.3}}}", + self.frames_in, self.frames_out, self.bytes_in, self.bytes_out, self.drops, self.compression_ratio()) + } +} + +impl Default for StreamStats { + fn default() -> Self { Self::new() } +} + +// ─── v0.19: Audio Mixer ─── + +/// Mix multiple audio streams. +pub struct AudioMixer { + sources: Vec, +} + +impl AudioMixer { + pub fn new() -> Self { AudioMixer { sources: Vec::new() } } + + pub fn add_source(&mut self, id: &str) { self.sources.push(id.to_string()); } + pub fn source_count(&self) -> usize { self.sources.len() } + + /// Mix two sample buffers by averaging. + pub fn mix_sources(&self, a: &[i16], b: &[i16]) -> Vec { + let len = a.len().max(b.len()); + let mut out = Vec::with_capacity(len); + for i in 0..len { + let sa = if i < a.len() { a[i] as i32 } else { 0 }; + let sb = if i < b.len() { b[i] as i32 } else { 0 }; + out.push(((sa + sb) / 2) as i16); + } + out + } +} + +impl Default for AudioMixer { + fn default() -> Self { Self::new() } +} + +// ─── v0.19: Session Store ─── + +/// Persist and restore stream state. +pub struct SessionStore { + data: std::collections::HashMap>, +} + +impl SessionStore { + pub fn new() -> Self { SessionStore { data: std::collections::HashMap::new() } } + + pub fn save_state(&mut self, key: &str, data: &[u8]) { + self.data.insert(key.to_string(), data.to_vec()); + } + + pub fn load_state(&self, key: &str) -> Option> { + self.data.get(key).cloned() + } + + pub fn list_keys(&self) -> Vec { + self.data.keys().cloned().collect() + } + + pub fn clear(&mut self) { self.data.clear(); } +} + +impl Default for SessionStore { + fn default() -> Self { Self::new() } +} + +// ─── v0.19: Frame Annotation ─── + +/// Attach key-value metadata to frames. +pub struct FrameAnnotation; + +impl FrameAnnotation { + /// Annotate frame: prepend [key_len:2][key][val_len:2][val] then original data. + pub fn annotate(frame: &[u8], key: &str, value: &str) -> Vec { + let kb = key.as_bytes(); + let vb = value.as_bytes(); + let mut out = Vec::with_capacity(4 + kb.len() + vb.len() + frame.len()); + out.extend_from_slice(&(kb.len() as u16).to_le_bytes()); + out.extend_from_slice(kb); + out.extend_from_slice(&(vb.len() as u16).to_le_bytes()); + out.extend_from_slice(vb); + out.extend_from_slice(frame); + out + } + + /// Read annotation from annotated frame. + pub fn read_annotation(annotated: &[u8]) -> Option<(String, String, usize)> { + if annotated.len() < 4 { return None; } + let kl = u16::from_le_bytes([annotated[0], annotated[1]]) as usize; + if annotated.len() < 2 + kl + 2 { return None; } + let key = String::from_utf8_lossy(&annotated[2..2+kl]).to_string(); + let vl = u16::from_le_bytes([annotated[2+kl], annotated[3+kl]]) as usize; + let offset = 4 + kl + vl; + if annotated.len() < offset { return None; } + let val = String::from_utf8_lossy(&annotated[4+kl..offset]).to_string(); + Some((key, val, offset)) + } +} + +// ─── v0.20: Stream Recorder ─── + +/// Record and playback frame sequences. +pub struct StreamRecorder { + recording: bool, + frames: Vec>, +} + +impl StreamRecorder { + pub fn new() -> Self { StreamRecorder { recording: false, frames: Vec::new() } } + pub fn start(&mut self) { self.recording = true; self.frames.clear(); } + pub fn stop(&mut self) { self.recording = false; } + pub fn is_recording(&self) -> bool { self.recording } + + pub fn push(&mut self, frame: &[u8]) { + if self.recording { self.frames.push(frame.to_vec()); } + } + + pub fn frame_count(&self) -> usize { self.frames.len() } + + pub fn get_frame(&self, index: usize) -> Option<&[u8]> { + self.frames.get(index).map(|f| f.as_slice()) + } +} + +impl Default for StreamRecorder { + fn default() -> Self { Self::new() } +} + +// ─── v0.20: Hot Config ─── + +/// Runtime-mutable key-value configuration. +pub struct HotConfig { + values: std::collections::HashMap, +} + +impl HotConfig { + pub fn new() -> Self { HotConfig { values: std::collections::HashMap::new() } } + pub fn set(&mut self, key: &str, val: &str) { self.values.insert(key.to_string(), val.to_string()); } + pub fn get(&self, key: &str) -> Option<&str> { self.values.get(key).map(|s| s.as_str()) } + pub fn keys(&self) -> Vec { self.values.keys().cloned().collect() } +} + +impl Default for HotConfig { + fn default() -> Self { Self::new() } +} + +// ─── v0.20: Frame Pool ─── + +/// Object pool for frame buffer reuse. +pub struct FramePool { + pool: Vec>, + buf_size: usize, +} + +impl FramePool { + pub fn new(capacity: usize, buf_size: usize) -> Self { + let pool = (0..capacity).map(|_| vec![0u8; buf_size]).collect(); + FramePool { pool, buf_size } + } + + pub fn alloc(&mut self) -> Option> { self.pool.pop() } + + pub fn release(&mut self, mut buf: Vec) { + buf.resize(self.buf_size, 0); + self.pool.push(buf); + } + + pub fn available(&self) -> usize { self.pool.len() } +} + +// ─── v0.21: Stream Mux ─── + +/// Multiplex named channels. +pub struct StreamMux { + channels: std::collections::HashMap>>, +} + +impl StreamMux { + pub fn new() -> Self { StreamMux { channels: std::collections::HashMap::new() } } + + pub fn create_channel(&mut self, name: &str) { + self.channels.entry(name.to_string()).or_insert_with(Vec::new); + } + + pub fn push(&mut self, channel: &str, frame: &[u8]) { + if let Some(ch) = self.channels.get_mut(channel) { + ch.push(frame.to_vec()); + } + } + + pub fn pull(&mut self, channel: &str) -> Option> { + self.channels.get_mut(channel).and_then(|ch| if ch.is_empty() { None } else { Some(ch.remove(0)) }) + } + + pub fn channel_count(&self) -> usize { self.channels.len() } +} + +impl Default for StreamMux { + fn default() -> Self { Self::new() } +} + +// ─── v0.21: Remote Control ─── + +/// Queue commands for remote execution. +pub struct RemoteControl { + commands: Vec, +} + +impl RemoteControl { + pub fn new() -> Self { RemoteControl { commands: Vec::new() } } + pub fn push_command(&mut self, cmd: &str) { self.commands.push(cmd.to_string()); } + pub fn pop_command(&mut self) -> Option { if self.commands.is_empty() { None } else { Some(self.commands.remove(0)) } } + pub fn pending_count(&self) -> usize { self.commands.len() } +} + +impl Default for RemoteControl { + fn default() -> Self { Self::new() } +} + +// ─── v0.21: Frame Hash ─── + +/// Simple frame integrity hash. +pub struct FrameHash; + +impl FrameHash { + /// Compute a fast hash (FNV-1a style) of frame bytes. + pub fn hash(data: &[u8]) -> u32 { + let mut h: u32 = 0x811c9dc5; + for &b in data { + h ^= b as u32; + h = h.wrapping_mul(0x01000193); + } + h + } + + /// Verify frame against expected hash. + pub fn verify(data: &[u8], expected: u32) -> bool { + Self::hash(data) == expected + } +} + +// ─── v0.22: Stream Compressor ─── + +/// Simple RLE-style byte compressor. +pub struct StreamCompressor; + +impl StreamCompressor { + /// Compress via simple RLE: [byte, count] pairs. + pub fn compress(data: &[u8]) -> Vec { + if data.is_empty() { return Vec::new(); } + let mut out = Vec::new(); + let mut i = 0; + while i < data.len() { + let b = data[i]; + let mut count: u8 = 1; + while i + (count as usize) < data.len() && data[i + count as usize] == b && count < 255 { + count += 1; + } + out.push(b); + out.push(count); + i += count as usize; + } + out + } + + /// Decompress RLE pairs back to bytes. + pub fn decompress(data: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i + 1 < data.len() { + let b = data[i]; + let count = data[i + 1]; + for _ in 0..count { out.push(b); } + i += 2; + } + out + } +} + +// ─── v0.22: Telemetry ─── + +/// Track stream telemetry metrics. +pub struct Telemetry { + latencies: Vec, + errors: u64, + bytes_total: u64, +} + +impl Telemetry { + pub fn new() -> Self { Telemetry { latencies: Vec::new(), errors: 0, bytes_total: 0 } } + pub fn record_latency(&mut self, ms: f64) { self.latencies.push(ms); } + pub fn record_error(&mut self) { self.errors += 1; } + pub fn record_bytes(&mut self, n: u64) { self.bytes_total += n; } + + pub fn avg_latency(&self) -> f64 { + if self.latencies.is_empty() { 0.0 } + else { self.latencies.iter().sum::() / self.latencies.len() as f64 } + } + + pub fn error_count(&self) -> u64 { self.errors } + pub fn total_bytes(&self) -> u64 { self.bytes_total } +} + +impl Default for Telemetry { + fn default() -> Self { Self::new() } +} + +// ─── v0.22: Frame Ring ─── + +/// Fixed-size ring buffer for frames. +pub struct FrameRing { + buf: Vec>>, + head: usize, + len: usize, +} + +impl FrameRing { + pub fn new(capacity: usize) -> Self { + FrameRing { buf: (0..capacity).map(|_| None).collect(), head: 0, len: 0 } + } + + pub fn push(&mut self, frame: Vec) { + let cap = self.buf.len(); + let idx = (self.head + self.len) % cap; + self.buf[idx] = Some(frame); + if self.len < cap { self.len += 1; } else { self.head = (self.head + 1) % cap; } + } + + pub fn len(&self) -> usize { self.len } + pub fn is_empty(&self) -> bool { self.len == 0 } + pub fn capacity(&self) -> usize { self.buf.len() } +} + +// ─── v0.23: Frame Batcher ─── + +/// Batch N frames into one message. +pub struct FrameBatcher { + batch_size: usize, + buffer: Vec>, +} + +impl FrameBatcher { + pub fn new(batch_size: usize) -> Self { FrameBatcher { batch_size, buffer: Vec::new() } } + + /// Push a frame. Returns Some(batch) when batch_size reached. + pub fn push(&mut self, frame: Vec) -> Option>> { + self.buffer.push(frame); + if self.buffer.len() >= self.batch_size { + Some(std::mem::take(&mut self.buffer)) + } else { None } + } + + /// Flush remaining frames. + pub fn flush(&mut self) -> Vec> { std::mem::take(&mut self.buffer) } + pub fn pending(&self) -> usize { self.buffer.len() } +} + +// ─── v0.23: Diagnostic Log ─── + +/// Structured event log. +pub struct DiagnosticLog { + events: Vec<(u64, String)>, // (timestamp_ms, message) + counter: u64, +} + +impl DiagnosticLog { + pub fn new() -> Self { DiagnosticLog { events: Vec::new(), counter: 0 } } + pub fn log(&mut self, msg: &str) { self.events.push((self.counter, msg.to_string())); self.counter += 1; } + pub fn count(&self) -> usize { self.events.len() } + pub fn get(&self, idx: usize) -> Option<&str> { self.events.get(idx).map(|(_, m)| m.as_str()) } + pub fn clear(&mut self) { self.events.clear(); } +} + +impl Default for DiagnosticLog { + fn default() -> Self { Self::new() } +} + +// ─── v0.23: Stream Gate ─── + +/// Enable/disable frame forwarding. +pub struct StreamGate { + open: bool, +} + +impl StreamGate { + pub fn new(open: bool) -> Self { StreamGate { open } } + pub fn is_open(&self) -> bool { self.open } + pub fn open(&mut self) { self.open = true; } + pub fn close(&mut self) { self.open = false; } + /// Pass frame through if gate is open, else drop. + pub fn filter(&self, frame: &[u8]) -> Option> { + if self.open { Some(frame.to_vec()) } else { None } + } +} + +// ─── v0.24: Priority Queue ─── + +/// Priority-ordered frame queue (lower = higher priority). +pub struct FramePriorityQueue { + items: Vec<(u8, Vec)>, +} + +impl FramePriorityQueue { + pub fn new() -> Self { FramePriorityQueue { items: Vec::new() } } + + pub fn enqueue(&mut self, priority: u8, data: Vec) { + let pos = self.items.iter().position(|(p, _)| *p > priority).unwrap_or(self.items.len()); + self.items.insert(pos, (priority, data)); + } + + pub fn dequeue(&mut self) -> Option> { + if self.items.is_empty() { None } else { Some(self.items.remove(0).1) } + } + + pub fn len(&self) -> usize { self.items.len() } + pub fn is_empty(&self) -> bool { self.items.is_empty() } +} + +impl Default for FramePriorityQueue { + fn default() -> Self { Self::new() } +} + +// ─── v0.24: Frame Watermark ─── + +/// Stamp frames with metadata. +pub struct FrameWatermark { + tag: String, + seq: u64, +} + +impl FrameWatermark { + pub fn new(tag: &str) -> Self { FrameWatermark { tag: tag.to_string(), seq: 0 } } + + /// Prepend 8-byte header: [tag_len(1), tag(N), seq_hi(1), seq_lo(1)] to frame. + pub fn stamp(&mut self, frame: &[u8]) -> Vec { + let mut out = Vec::new(); + let tag_bytes = self.tag.as_bytes(); + out.push(tag_bytes.len() as u8); + out.extend_from_slice(tag_bytes); + out.push((self.seq >> 8) as u8); + out.push(self.seq as u8); + out.extend_from_slice(frame); + self.seq += 1; + out + } + + pub fn sequence(&self) -> u64 { self.seq } +} + +// ─── v0.24: Stream Meter ─── + +/// Measure frame rate over a window. +pub struct StreamMeter { + window: Vec, + max_window: usize, +} + +impl StreamMeter { + pub fn new(window_size: usize) -> Self { StreamMeter { window: Vec::new(), max_window: window_size } } + pub fn tick(&mut self, timestamp: f64) { + self.window.push(timestamp); + if self.window.len() > self.max_window { self.window.remove(0); } + } + pub fn fps(&self) -> f64 { + if self.window.len() < 2 { return 0.0; } + let dt = self.window.last().unwrap() - self.window.first().unwrap(); + if dt <= 0.0 { 0.0 } else { (self.window.len() - 1) as f64 / dt } + } + pub fn sample_count(&self) -> usize { self.window.len() } +} + +// ─── v0.25: Replay Buffer ─── + +/// Record and replay frames. +pub struct FrameReplayBuffer { + frames: Vec>, + max_frames: usize, + cursor: usize, +} + +impl FrameReplayBuffer { + pub fn new(max: usize) -> Self { FrameReplayBuffer { frames: Vec::new(), max_frames: max, cursor: 0 } } + pub fn record(&mut self, frame: Vec) { + if self.frames.len() >= self.max_frames { self.frames.remove(0); } + self.frames.push(frame); + } + pub fn replay_next(&mut self) -> Option<&[u8]> { + if self.frames.is_empty() { return None; } + let f = &self.frames[self.cursor % self.frames.len()]; + self.cursor = (self.cursor + 1) % self.frames.len(); + Some(f) + } + pub fn frame_count(&self) -> usize { self.frames.len() } + pub fn reset_cursor(&mut self) { self.cursor = 0; } +} + +// ─── v0.25: Frame Diff ─── + +/// Compute XOR diff between consecutive frames. +pub struct FrameDiff; + +impl FrameDiff { + /// XOR two frames. Output length = min(a.len(), b.len()). + pub fn diff(a: &[u8], b: &[u8]) -> Vec { + a.iter().zip(b.iter()).map(|(x, y)| x ^ y).collect() + } + + /// Apply XOR diff to a base frame. + pub fn apply(base: &[u8], diff: &[u8]) -> Vec { + Self::diff(base, diff) // XOR is its own inverse + } +} + +// ─── v0.25: Stream Throttle ─── + +/// Rate-limit frames by dropping excess. +pub struct StreamThrottle { + interval_ms: f64, + last_pass: f64, +} + +impl StreamThrottle { + pub fn new(max_fps: f64) -> Self { + StreamThrottle { interval_ms: 1000.0 / max_fps, last_pass: f64::NEG_INFINITY } + } + /// Returns true if frame should pass, false if dropped. + pub fn should_pass(&mut self, now_ms: f64) -> bool { + if now_ms - self.last_pass >= self.interval_ms { + self.last_pass = now_ms; + true + } else { false } + } +} + +// ─── v0.26: Chunked Transfer ─── + +/// Split large frames into fixed-size chunks. +pub struct ChunkedTransfer { + chunk_size: usize, +} + +impl ChunkedTransfer { + pub fn new(chunk_size: usize) -> Self { ChunkedTransfer { chunk_size } } + + pub fn split(&self, frame: &[u8]) -> Vec> { + frame.chunks(self.chunk_size).map(|c| c.to_vec()).collect() + } + + pub fn merge(chunks: &[Vec]) -> Vec { + chunks.iter().flat_map(|c| c.iter().copied()).collect() + } +} + +// ─── v0.26: Frame Annotation ─── + +/// Attach metadata to frames. +pub struct MetadataAnnotation { + annotations: Vec<(String, String)>, +} + +impl MetadataAnnotation { + pub fn new() -> Self { MetadataAnnotation { annotations: Vec::new() } } + pub fn set(&mut self, key: &str, value: &str) { + if let Some(a) = self.annotations.iter_mut().find(|(k, _)| k == key) { + a.1 = value.to_string(); + } else { + self.annotations.push((key.to_string(), value.to_string())); + } + } + pub fn get(&self, key: &str) -> Option<&str> { + self.annotations.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) + } + pub fn count(&self) -> usize { self.annotations.len() } +} + +impl Default for MetadataAnnotation { + fn default() -> Self { Self::new() } +} + +// ─── v0.26: Stream Stats ─── + +/// Accumulate stream statistics. +pub struct StreamMetrics { + pub frames_sent: u64, + pub bytes_sent: u64, + pub frames_dropped: u64, +} + +impl StreamMetrics { + pub fn new() -> Self { StreamMetrics { frames_sent: 0, bytes_sent: 0, frames_dropped: 0 } } + pub fn record_sent(&mut self, bytes: usize) { self.frames_sent += 1; self.bytes_sent += bytes as u64; } + pub fn record_drop(&mut self) { self.frames_dropped += 1; } + pub fn drop_rate(&self) -> f64 { + let total = self.frames_sent + self.frames_dropped; + if total == 0 { 0.0 } else { self.frames_dropped as f64 / total as f64 } + } +} + +impl Default for StreamMetrics { + fn default() -> Self { Self::new() } +} + +// ─── v0.27: Double Buffer ─── + +/// Front/back buffer swap for lock-free frame delivery. +pub struct DoubleBuffer { + front: Vec, + back: Vec, +} + +impl DoubleBuffer { + pub fn new() -> Self { DoubleBuffer { front: Vec::new(), back: Vec::new() } } + pub fn write(&mut self, data: Vec) { self.back = data; } + pub fn swap(&mut self) { std::mem::swap(&mut self.front, &mut self.back); } + pub fn read(&self) -> &[u8] { &self.front } + pub fn back_len(&self) -> usize { self.back.len() } +} + +impl Default for DoubleBuffer { + fn default() -> Self { Self::new() } +} + +// ─── v0.27: Frame Sequencer ─── + +/// Assign monotonic sequence numbers to frames. +pub struct FrameSequencer { + next_seq: u64, +} + +impl FrameSequencer { + pub fn new() -> Self { FrameSequencer { next_seq: 0 } } + pub fn next(&mut self) -> u64 { let s = self.next_seq; self.next_seq += 1; s } + pub fn current(&self) -> u64 { self.next_seq } + pub fn reset(&mut self) { self.next_seq = 0; } +} + +impl Default for FrameSequencer { + fn default() -> Self { Self::new() } +} + +// ─── v0.27: Bandwidth Estimator ─── + +/// Estimate bandwidth from recent frame sizes and timestamps. +pub struct ThroughputEstimator { + samples: Vec<(f64, usize)>, // (timestamp_ms, bytes) + max_samples: usize, +} + +impl ThroughputEstimator { + pub fn new(window: usize) -> Self { ThroughputEstimator { samples: Vec::new(), max_samples: window } } + pub fn record(&mut self, timestamp_ms: f64, bytes: usize) { + self.samples.push((timestamp_ms, bytes)); + if self.samples.len() > self.max_samples { self.samples.remove(0); } + } + /// Bytes per second estimate. + pub fn estimate_bps(&self) -> f64 { + if self.samples.len() < 2 { return 0.0; } + let dt = self.samples.last().unwrap().0 - self.samples.first().unwrap().0; + if dt <= 0.0 { return 0.0; } + let total_bytes: usize = self.samples.iter().map(|(_, b)| b).sum(); + total_bytes as f64 / (dt / 1000.0) + } +} + +// ─── v0.28: Frame Pool ─── + +/// Pre-allocated frame pool to reduce allocations. +pub struct BufferPool { + pool: Vec>, + frame_capacity: usize, +} + +impl BufferPool { + pub fn new(count: usize, capacity: usize) -> Self { + let pool = (0..count).map(|_| Vec::with_capacity(capacity)).collect(); + BufferPool { pool, frame_capacity: capacity } + } + pub fn acquire(&mut self) -> Vec { + self.pool.pop().unwrap_or_else(|| Vec::with_capacity(self.frame_capacity)) + } + pub fn release(&mut self, mut frame: Vec) { + frame.clear(); + self.pool.push(frame); + } + pub fn available(&self) -> usize { self.pool.len() } +} + +// ─── v0.28: Jitter Buffer ─── + +/// Buffer frames to smooth out timing jitter. +pub struct PacketJitterBuffer { + buffer: Vec<(u64, Vec)>, + next_expected: u64, +} + +impl PacketJitterBuffer { + pub fn new() -> Self { PacketJitterBuffer { buffer: Vec::new(), next_expected: 0 } } + pub fn insert(&mut self, seq: u64, data: Vec) { + let pos = self.buffer.iter().position(|(s, _)| *s > seq).unwrap_or(self.buffer.len()); + self.buffer.insert(pos, (seq, data)); + } + pub fn try_pop(&mut self) -> Option> { + if let Some((seq, _)) = self.buffer.first() { + if *seq == self.next_expected { + self.next_expected += 1; + return Some(self.buffer.remove(0).1); + } + } + None + } + pub fn pending(&self) -> usize { self.buffer.len() } +} + +impl Default for PacketJitterBuffer { + fn default() -> Self { Self::new() } +} + +// ─── v0.28: Latency Tracker ─── + +/// Track round-trip latency samples. +pub struct RttTracker { + samples: Vec, + max_samples: usize, +} + +impl RttTracker { + pub fn new(window: usize) -> Self { RttTracker { samples: Vec::new(), max_samples: window } } + pub fn record(&mut self, latency_ms: f64) { + self.samples.push(latency_ms); + if self.samples.len() > self.max_samples { self.samples.remove(0); } + } + pub fn average(&self) -> f64 { + if self.samples.is_empty() { 0.0 } else { self.samples.iter().sum::() / self.samples.len() as f64 } + } + pub fn max_latency(&self) -> f64 { + self.samples.iter().copied().fold(0.0f64, f64::max) + } +} + +// ─── v0.30: Frame Ring Buffer ─── + +/// Fixed-size ring buffer for frames. +pub struct FrameRingBuffer { + buffer: Vec>>, + head: usize, + len: usize, +} + +impl FrameRingBuffer { + pub fn new(capacity: usize) -> Self { + FrameRingBuffer { buffer: vec![None; capacity], head: 0, len: 0 } + } + pub fn push(&mut self, frame: Vec) { + self.buffer[self.head] = Some(frame); + self.head = (self.head + 1) % self.buffer.len(); + if self.len < self.buffer.len() { self.len += 1; } + } + pub fn latest(&self) -> Option<&[u8]> { + if self.len == 0 { return None; } + let idx = if self.head == 0 { self.buffer.len() - 1 } else { self.head - 1 }; + self.buffer[idx].as_deref() + } + pub fn occupancy(&self) -> usize { self.len } +} + +// ─── v0.30: Packet Loss Detector ─── + +/// Detect packet loss from sequence gaps. +pub struct PacketLossDetector { + expected: u64, + lost: u64, + received: u64, +} + +impl PacketLossDetector { + pub fn new() -> Self { PacketLossDetector { expected: 0, lost: 0, received: 0 } } + pub fn observe(&mut self, seq: u64) { + if seq > self.expected { + self.lost += seq - self.expected; + } + self.received += 1; + self.expected = seq + 1; + } + pub fn loss_ratio(&self) -> f64 { + let total = self.received + self.lost; + if total == 0 { 0.0 } else { self.lost as f64 / total as f64 } + } + pub fn total_lost(&self) -> u64 { self.lost } +} + +impl Default for PacketLossDetector { + fn default() -> Self { Self::new() } +} + +// ─── v0.30: Connection Quality ─── + +/// Aggregate connection quality from latency and loss. +pub struct ConnectionQuality { + pub latency_avg: f64, + pub loss_ratio: f64, +} + +impl ConnectionQuality { + pub fn new(latency: f64, loss: f64) -> Self { + ConnectionQuality { latency_avg: latency, loss_ratio: loss } + } + /// Quality score 0.0 (worst) to 1.0 (best). + pub fn score(&self) -> f64 { + let lat_score = (1.0 - (self.latency_avg / 500.0).min(1.0)).max(0.0); + let loss_score = (1.0 - self.loss_ratio * 10.0).max(0.0); + (lat_score + loss_score) / 2.0 + } +} + +// ─── v0.40: Adaptive Bitrate ─── + +/// Auto-adjust quality level based on connection quality score. +pub struct QualityAdapter { + levels: Vec, + current: usize, +} + +impl QualityAdapter { + pub fn new(levels: Vec) -> Self { QualityAdapter { current: levels.len() - 1, levels } } + /// Given a quality score (0.0-1.0), pick the right bitrate level. + pub fn adjust(&mut self, quality: f64) -> u32 { + self.current = ((quality * (self.levels.len() - 1) as f64).round() as usize).min(self.levels.len() - 1); + self.levels[self.current] + } + pub fn current_level(&self) -> u32 { self.levels[self.current] } +} + +// ─── v0.40: Source Mixer ─── + +/// Merge frames from multiple named sources. +pub struct SourceMixer { + sources: Vec<(String, Vec)>, +} + +impl SourceMixer { + pub fn new() -> Self { SourceMixer { sources: Vec::new() } } + pub fn add(&mut self, id: &str, data: Vec) { + if let Some(s) = self.sources.iter_mut().find(|(k, _)| k == id) { + s.1 = data; + } else { + self.sources.push((id.to_string(), data)); + } + } + pub fn output(&self) -> Vec { + let mut out = Vec::new(); + for (_, data) in &self.sources { out.extend_from_slice(data); } + out + } + pub fn source_count(&self) -> usize { self.sources.len() } +} + +impl Default for SourceMixer { + fn default() -> Self { Self::new() } +} + +// ─── v0.40: Frame Deduplicator ─── + +/// Skip identical consecutive frames using hash comparison. +pub struct FrameDeduplicator { + last_hash: u64, + skipped: u64, +} + +impl FrameDeduplicator { + pub fn new() -> Self { FrameDeduplicator { last_hash: 0, skipped: 0 } } + fn hash(data: &[u8]) -> u64 { + let mut h: u64 = 5381; + for &b in data { h = h.wrapping_mul(33).wrapping_add(b as u64); } + h + } + /// Returns true if frame is unique (should send), false if duplicate. + pub fn check(&mut self, data: &[u8]) -> bool { + let h = Self::hash(data); + if h == self.last_hash { self.skipped += 1; false } else { self.last_hash = h; true } + } + pub fn skipped_count(&self) -> u64 { self.skipped } +} + +impl Default for FrameDeduplicator { + fn default() -> Self { Self::new() } +} + +// ─── v0.40: Backpressure Controller ─── + +/// Signal backpressure when queue exceeds threshold. +pub struct BackpressureController { + threshold: usize, +} + +impl BackpressureController { + pub fn new(threshold: usize) -> Self { BackpressureController { threshold } } + /// Returns true if sender should pause (queue too deep). + pub fn should_pause(&self, queue_depth: usize) -> bool { queue_depth >= self.threshold } + pub fn threshold(&self) -> usize { self.threshold } +} + +// ─── v0.40: Heartbeat Monitor ─── + +/// Monitor connection liveness. +pub struct HeartbeatMonitor { + interval_ms: f64, + last_ping: f64, + alive: bool, +} + +impl HeartbeatMonitor { + pub fn new(interval_ms: f64) -> Self { + HeartbeatMonitor { interval_ms, last_ping: 0.0, alive: true } + } + /// Tick with current time. Returns true if connection is still alive. + pub fn tick(&mut self, now_ms: f64) -> bool { + if now_ms - self.last_ping > self.interval_ms * 3.0 { self.alive = false; } + self.alive + } + pub fn ping(&mut self, now_ms: f64) { self.last_ping = now_ms; self.alive = true; } + pub fn is_alive(&self) -> bool { self.alive } +} + +// ─── v0.40: Compression Tracker ─── + +/// Track compression ratios. +pub struct CompressionTracker { + total_raw: u64, + total_compressed: u64, +} + +impl CompressionTracker { + pub fn new() -> Self { CompressionTracker { total_raw: 0, total_compressed: 0 } } + pub fn record(&mut self, raw: usize, compressed: usize) { + self.total_raw += raw as u64; + self.total_compressed += compressed as u64; + } + pub fn ratio(&self) -> f64 { + if self.total_raw == 0 { 1.0 } else { self.total_compressed as f64 / self.total_raw as f64 } + } +} + +impl Default for CompressionTracker { + fn default() -> Self { Self::new() } +} + +// ─── v0.40: FEC Encoder ─── + +/// Simple XOR-based forward error correction. +pub struct FecEncoder { + group_size: usize, +} + +impl FecEncoder { + pub fn new(group_size: usize) -> Self { FecEncoder { group_size: group_size.max(2) } } + /// Generate parity byte from a group of packets (XOR all). + pub fn parity(packets: &[&[u8]]) -> Vec { + if packets.is_empty() { return Vec::new(); } + let max_len = packets.iter().map(|p| p.len()).max().unwrap_or(0); + let mut parity = vec![0u8; max_len]; + for packet in packets { + for (i, &b) in packet.iter().enumerate() { parity[i] ^= b; } + } + parity + } + pub fn group_size(&self) -> usize { self.group_size } +} + +// ─── v0.40: Stream Snapshot ─── + +/// Capture stream state for debugging. +pub struct StreamSnapshot { + pub frames_sent: u64, + pub bytes_sent: u64, + pub loss_ratio: f64, + pub quality_score: f64, +} + +impl StreamSnapshot { + pub fn new(sent: u64, bytes: u64, loss: f64, quality: f64) -> Self { + StreamSnapshot { frames_sent: sent, bytes_sent: bytes, loss_ratio: loss, quality_score: quality } + } + pub fn to_json(&self) -> String { + format!("{{\"frames\":{},\"bytes\":{},\"loss\":{:.4},\"quality\":{:.4}}}", + self.frames_sent, self.bytes_sent, self.loss_ratio, self.quality_score) + } +} + +// ─── v0.40: Adaptive Priority Queue ─── + +/// Priority queue with deadline-based eviction. +pub struct AdaptivePriorityQueue { + items: Vec<(u8, f64, Vec)>, // (priority, deadline_ms, data) +} + +impl AdaptivePriorityQueue { + pub fn new() -> Self { AdaptivePriorityQueue { items: Vec::new() } } + pub fn push(&mut self, priority: u8, deadline_ms: f64, data: Vec) { + self.items.push((priority, deadline_ms, data)); + self.items.sort_by(|a, b| b.0.cmp(&a.0)); // higher priority first + } + /// Pop highest-priority non-expired item. + pub fn pop(&mut self, now_ms: f64) -> Option> { + // Remove expired + self.items.retain(|(_, d, _)| *d > now_ms); + if self.items.is_empty() { None } else { Some(self.items.remove(0).2) } + } + pub fn len(&self) -> usize { self.items.len() } + pub fn is_empty(&self) -> bool { self.items.is_empty() } +} + +impl Default for AdaptivePriorityQueue { + fn default() -> Self { Self::new() } +} + +// ─── v0.50: Stream Cipher ─── + +/// XOR cipher with rotating key. +pub struct StreamCipher { key: Vec } + +impl StreamCipher { + pub fn new(key: Vec) -> Self { StreamCipher { key: if key.is_empty() { vec![0x42] } else { key } } } + pub fn encrypt(&self, data: &[u8]) -> Vec { + data.iter().enumerate().map(|(i, b)| b ^ self.key[i % self.key.len()]).collect() + } + pub fn decrypt(&self, data: &[u8]) -> Vec { self.encrypt(data) } // XOR is symmetric +} + +// ─── v0.50: Channel Mux ─── + +/// Multiplex N logical channels. +pub struct ChannelMux { channels: Vec<(u8, Vec)> } + +impl ChannelMux { + pub fn new() -> Self { ChannelMux { channels: Vec::new() } } + pub fn send(&mut self, channel: u8, data: Vec) { self.channels.push((channel, data)); } + /// Pack all pending into [ch, len_hi, len_lo, ...data] frames. + pub fn pack(&mut self) -> Vec { + let mut out = Vec::new(); + for (ch, data) in self.channels.drain(..) { + out.push(ch); + out.push((data.len() >> 8) as u8); + out.push(data.len() as u8); + out.extend_from_slice(&data); + } + out + } +} + +impl Default for ChannelMux { fn default() -> Self { Self::new() } } + +// ─── v0.50: Channel Demux ─── + +/// Demultiplex packed stream. +pub struct ChannelDemux; + +impl ChannelDemux { + pub fn unpack(data: &[u8]) -> Vec<(u8, Vec)> { + let mut result = Vec::new(); + let mut i = 0; + while i + 3 <= data.len() { + let ch = data[i]; + let len = ((data[i + 1] as usize) << 8) | data[i + 2] as usize; + i += 3; + if i + len <= data.len() { + result.push((ch, data[i..i + len].to_vec())); + i += len; + } else { break; } + } + result + } +} + +// ─── v0.50: Frame Pacer ─── + +/// Pace frames at target interval. +pub struct FramePacer { interval_ms: f64, last_emit: f64 } + +impl FramePacer { + pub fn new(interval_ms: f64) -> Self { FramePacer { interval_ms, last_emit: f64::NEG_INFINITY } } + pub fn should_emit(&mut self, now_ms: f64) -> bool { + if now_ms - self.last_emit >= self.interval_ms { self.last_emit = now_ms; true } else { false } + } +} + +// ─── v0.50: Congestion Window ─── + +/// AIMD congestion control. +pub struct CongestionWindow { cwnd: f64, min: f64, max: f64 } + +impl CongestionWindow { + pub fn new(initial: f64, min: f64, max: f64) -> Self { CongestionWindow { cwnd: initial, min, max } } + pub fn on_ack(&mut self) { self.cwnd = (self.cwnd + 1.0).min(self.max); } + pub fn on_loss(&mut self) { self.cwnd = (self.cwnd / 2.0).max(self.min); } + pub fn window(&self) -> f64 { self.cwnd } +} + +// ─── v0.50: Flow Controller ─── + +/// Token-bucket flow control. +pub struct FlowController { tokens: f64, max_tokens: f64, refill_rate: f64, last_refill: f64 } + +impl FlowController { + pub fn new(max: f64, rate: f64) -> Self { FlowController { tokens: max, max_tokens: max, refill_rate: rate, last_refill: 0.0 } } + pub fn consume(&mut self, bytes: f64, now_ms: f64) -> bool { + let elapsed = (now_ms - self.last_refill) / 1000.0; + self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens); + self.last_refill = now_ms; + if self.tokens >= bytes { self.tokens -= bytes; true } else { false } + } + pub fn available(&self) -> f64 { self.tokens } +} + +// ─── v0.50: Protocol Negotiator ─── + +/// Capability exchange. +pub struct ProtocolNegotiator { capabilities: Vec } + +impl ProtocolNegotiator { + pub fn new(caps: Vec) -> Self { ProtocolNegotiator { capabilities: caps } } + pub fn negotiate(&self, remote: &[String]) -> Vec { + self.capabilities.iter().filter(|c| remote.contains(c)).cloned().collect() + } +} + +// ─── v0.50: Replay Recorder ─── + +/// Record frames for later replay. +pub struct ReplayRecorder { frames: Vec<(f64, Vec)> } + +impl ReplayRecorder { + pub fn new() -> Self { ReplayRecorder { frames: Vec::new() } } + pub fn record(&mut self, timestamp: f64, data: Vec) { self.frames.push((timestamp, data)); } + pub fn frame_count(&self) -> usize { self.frames.len() } + pub fn get_frame(&self, idx: usize) -> Option<&[u8]> { self.frames.get(idx).map(|(_, d)| d.as_slice()) } +} + +impl Default for ReplayRecorder { fn default() -> Self { Self::new() } } + +// ─── v0.50: Bandwidth Shaper ─── + +/// Enforce max bytes/sec. +pub struct BandwidthShaper { max_bps: f64, sent_this_window: f64, window_start: f64 } + +impl BandwidthShaper { + pub fn new(max_bps: f64) -> Self { BandwidthShaper { max_bps, sent_this_window: 0.0, window_start: 0.0 } } + pub fn allow(&mut self, bytes: f64, now_ms: f64) -> bool { + if now_ms - self.window_start >= 1000.0 { self.window_start = now_ms; self.sent_this_window = 0.0; } + if self.sent_this_window + bytes <= self.max_bps { self.sent_this_window += bytes; true } else { false } + } +} + #[cfg(test)] mod tests { use super::*; @@ -2410,5 +3758,589 @@ mod tests { assert_eq!(pq.dequeue(), Some("mid")); assert_eq!(pq.dequeue(), Some("low")); } + + // ─── v0.17 Tests ─── + + #[test] + fn video_codec_adapter() { + let codec = VideoCodecAdapter::new("h264", &["video/h264", "video/avc"], 0.05); + assert_eq!(codec.codec_name(), "h264"); + assert!(codec.can_encode("video/h264")); + assert!(!codec.can_encode("video/vp9")); + assert!((codec.estimated_ratio() - 0.05).abs() < 0.001); + } + + #[test] + fn stream_router_routing() { + let mut router = StreamRouter::new(); + router.add_route("display"); + router.add_route("record"); + assert_eq!(router.route_count(), 2); + router.route_frame("display", vec![1, 2, 3]); + router.route_frame("record", vec![4, 5]); + let display = router.get_routed("display"); + assert_eq!(display.len(), 1); + assert_eq!(display[0], vec![1, 2, 3]); + } + + #[test] + fn frame_interleaver() { + let mut il = FrameInterleaver::new(); + il.interleave(0, vec![1, 2]); + il.interleave(1, vec![3, 4]); + il.interleave(0, vec![5, 6]); + assert_eq!(il.pending(), 3); + let frames = il.deinterleave(); + assert_eq!(frames.len(), 3); + assert_eq!(frames[0].0, 0); + assert_eq!(frames[1].0, 1); + } + + #[test] + fn backpressure_flow() { + let mut bp = Backpressure::new(2); + assert!(bp.try_push(vec![1])); + assert!(bp.try_push(vec![2])); + assert!(!bp.try_push(vec![3])); // full + assert!(bp.is_full()); + let drained = bp.drain(); + assert_eq!(drained.len(), 2); + assert!(!bp.is_full()); + } + + // ─── v0.18 Tests ─── + + #[test] + fn frame_scheduler() { + let mut sched = FrameScheduler::new(60.0); + assert!(sched.should_emit(0.0)); + assert!(!sched.should_emit(8.0)); + assert!(sched.should_emit(17.0)); + assert_eq!(sched.frames_emitted(), 2); + } + + #[test] + fn live_preview_downscale() { + let mut preview = LivePreview::new(0.5); + let frame = vec![1, 2, 3, 4, 5, 6, 7, 8]; + let p = preview.generate(&frame); + assert!(p.len() < frame.len(), "Preview smaller"); + assert!(preview.last_preview_size() > 0); + } + + #[test] + fn stream_stats_tracking() { + let mut stats = StreamStats::new(); + stats.record_in(1000); + stats.record_out(200); + stats.record_drop(); + assert!((stats.compression_ratio() - 0.2).abs() < 0.01); + let json = stats.to_json(); + assert!(json.contains("frames_in")); + assert!(json.contains("drops\":1")); + } + + // ─── v0.19 Tests ─── + + #[test] + fn audio_mixer_blend() { + let mut mixer = AudioMixer::new(); + mixer.add_source("mic"); + mixer.add_source("music"); + assert_eq!(mixer.source_count(), 2); + let mixed = mixer.mix_sources(&[100, 200, 300], &[50, 100]); + assert_eq!(mixed[0], 75); + assert_eq!(mixed.len(), 3); + } + + #[test] + fn session_store_roundtrip() { + let mut store = SessionStore::new(); + store.save_state("session1", &[1, 2, 3]); + assert_eq!(store.load_state("session1"), Some(vec![1, 2, 3])); + assert_eq!(store.list_keys().len(), 1); + store.clear(); + assert_eq!(store.load_state("session1"), None); + } + + #[test] + fn frame_annotation_roundtrip() { + let frame = vec![0xFF, 0xAA, 0xBB]; + let annotated = FrameAnnotation::annotate(&frame, "type", "keyframe"); + let (key, val, offset) = FrameAnnotation::read_annotation(&annotated).unwrap(); + assert_eq!(key, "type"); + assert_eq!(val, "keyframe"); + assert_eq!(&annotated[offset..], &frame[..]); + } + + // ─── v0.20 Tests ─── + + #[test] + fn stream_recorder() { + let mut rec = StreamRecorder::new(); + rec.start(); + assert!(rec.is_recording()); + rec.push(&[1, 2, 3]); + rec.push(&[4, 5]); + rec.stop(); + assert_eq!(rec.frame_count(), 2); + assert_eq!(rec.get_frame(0), Some(&[1u8, 2, 3][..])); + } + + #[test] + fn hot_config() { + let mut cfg = HotConfig::new(); + cfg.set("fps", "60"); + assert_eq!(cfg.get("fps"), Some("60")); + assert_eq!(cfg.keys().len(), 1); + } + + #[test] + fn frame_pool_alloc_release() { + let mut pool = FramePool::new(3, 1024); + assert_eq!(pool.available(), 3); + let buf = pool.alloc().unwrap(); + assert_eq!(pool.available(), 2); + pool.release(buf); + assert_eq!(pool.available(), 3); + } + + // ─── v0.21 Tests ─── + + #[test] + fn stream_mux() { + let mut mux = StreamMux::new(); + mux.create_channel("video"); + mux.create_channel("audio"); + mux.push("video", &[1, 2, 3]); + mux.push("audio", &[4, 5]); + assert_eq!(mux.channel_count(), 2); + assert_eq!(mux.pull("video"), Some(vec![1, 2, 3])); + } + + #[test] + fn remote_control() { + let mut rc = RemoteControl::new(); + rc.push_command("pause"); + rc.push_command("resume"); + assert_eq!(rc.pending_count(), 2); + assert_eq!(rc.pop_command(), Some("pause".to_string())); + } + + #[test] + fn frame_hash_verify() { + let data = vec![1u8, 2, 3, 4, 5]; + let h = FrameHash::hash(&data); + assert!(FrameHash::verify(&data, h)); + assert!(!FrameHash::verify(&[9, 8, 7], h)); + } + + // ─── v0.22 Tests ─── + + #[test] + fn stream_compressor_roundtrip() { + let data = vec![0u8; 100]; + let compressed = StreamCompressor::compress(&data); + assert!(compressed.len() < data.len()); + let decompressed = StreamCompressor::decompress(&compressed); + assert_eq!(decompressed, data); + } + + #[test] + fn telemetry_tracking() { + let mut t = Telemetry::new(); + t.record_latency(10.0); + t.record_latency(20.0); + t.record_error(); + t.record_bytes(1024); + assert_eq!(t.avg_latency(), 15.0); + assert_eq!(t.error_count(), 1); + assert_eq!(t.total_bytes(), 1024); + } + + #[test] + fn frame_ring_overflow() { + let mut ring = FrameRing::new(3); + ring.push(vec![1]); + ring.push(vec![2]); + ring.push(vec![3]); + ring.push(vec![4]); // overflow, oldest dropped + assert_eq!(ring.len(), 3); + assert_eq!(ring.capacity(), 3); + } + + // ─── v0.23 Tests ─── + + #[test] + fn frame_batcher() { + let mut b = FrameBatcher::new(3); + assert!(b.push(vec![1]).is_none()); + assert!(b.push(vec![2]).is_none()); + let batch = b.push(vec![3]).unwrap(); + assert_eq!(batch.len(), 3); + assert_eq!(b.pending(), 0); + } + + #[test] + fn diagnostic_log() { + let mut log = DiagnosticLog::new(); + log.log("frame_drop"); + log.log("latency_spike"); + assert_eq!(log.count(), 2); + assert_eq!(log.get(0), Some("frame_drop")); + } + + #[test] + fn stream_gate() { + let mut gate = StreamGate::new(true); + assert!(gate.filter(&[1, 2, 3]).is_some()); + gate.close(); + assert!(gate.filter(&[1, 2, 3]).is_none()); + gate.open(); + assert!(gate.is_open()); + } + + // ─── v0.24 Tests ─── + + #[test] + fn frame_priority_queue_ordering() { + let mut pq = FramePriorityQueue::new(); + pq.enqueue(5, vec![5]); + pq.enqueue(1, vec![1]); + pq.enqueue(3, vec![3]); + assert_eq!(pq.dequeue(), Some(vec![1])); + assert_eq!(pq.dequeue(), Some(vec![3])); + } + + #[test] + fn frame_watermark() { + let mut wm = FrameWatermark::new("v1"); + let stamped = wm.stamp(&[0xAA, 0xBB]); + assert!(stamped.len() > 2); + assert_eq!(wm.sequence(), 1); + } + + #[test] + fn stream_meter_fps() { + let mut m = StreamMeter::new(10); + m.tick(0.0); + m.tick(1.0); + m.tick(2.0); + assert!((m.fps() - 1.0).abs() < 0.01); + assert_eq!(m.sample_count(), 3); + } + + // ─── v0.25 Tests ─── + + #[test] + fn frame_replay_buffer() { + let mut rb = FrameReplayBuffer::new(3); + rb.record(vec![1]); + rb.record(vec![2]); + assert_eq!(rb.frame_count(), 2); + assert_eq!(rb.replay_next(), Some(&[1u8][..])); + assert_eq!(rb.replay_next(), Some(&[2u8][..])); + } + + #[test] + fn frame_diff_roundtrip() { + let a = vec![0xAA, 0xBB, 0xCC]; + let b = vec![0xAA, 0x00, 0xCC]; + let d = FrameDiff::diff(&a, &b); + let restored = FrameDiff::apply(&a, &d); + assert_eq!(restored, b); + } + + #[test] + fn stream_throttle() { + let mut t = StreamThrottle::new(30.0); // ~33ms interval + assert!(t.should_pass(0.0)); + assert!(!t.should_pass(10.0)); // too soon + assert!(t.should_pass(40.0)); // ok + } + + // ─── v0.26 Tests ─── + + #[test] + fn chunked_transfer() { + let ct = ChunkedTransfer::new(3); + let chunks = ct.split(&[1, 2, 3, 4, 5]); + assert_eq!(chunks.len(), 2); + let merged = ChunkedTransfer::merge(&chunks); + assert_eq!(merged, vec![1, 2, 3, 4, 5]); + } + + #[test] + fn metadata_annotation() { + let mut ann = MetadataAnnotation::new(); + ann.set("source", "cam0"); + ann.set("quality", "high"); + assert_eq!(ann.get("source"), Some("cam0")); + assert_eq!(ann.count(), 2); + } + + #[test] + fn stream_metrics_drop_rate() { + let mut s = StreamMetrics::new(); + s.record_sent(100); + s.record_sent(200); + s.record_drop(); + assert!((s.drop_rate() - 1.0 / 3.0).abs() < 0.01); + } + + // ─── v0.27 Tests ─── + + #[test] + fn double_buffer_swap() { + let mut db = DoubleBuffer::new(); + db.write(vec![1, 2, 3]); + assert_eq!(db.read().len(), 0); // front is empty + db.swap(); + assert_eq!(db.read(), &[1, 2, 3]); + } + + #[test] + fn frame_sequencer() { + let mut seq = FrameSequencer::new(); + assert_eq!(seq.next(), 0); + assert_eq!(seq.next(), 1); + assert_eq!(seq.current(), 2); + seq.reset(); + assert_eq!(seq.next(), 0); + } + + #[test] + fn throughput_estimator() { + let mut bw = ThroughputEstimator::new(10); + bw.record(0.0, 1000); + bw.record(1000.0, 1000); + let bps = bw.estimate_bps(); + assert!(bps > 0.0); // 2000 bytes / 1 sec = 2000 B/s + } + + // ─── v0.28 Tests ─── + + #[test] + fn buffer_pool_acquire_release() { + let mut pool = BufferPool::new(2, 64); + assert_eq!(pool.available(), 2); + let f = pool.acquire(); + assert_eq!(pool.available(), 1); + pool.release(f); + assert_eq!(pool.available(), 2); + } + + #[test] + fn packet_jitter_buffer_ordering() { + let mut jb = PacketJitterBuffer::new(); + jb.insert(1, vec![1]); + jb.insert(0, vec![0]); + assert_eq!(jb.try_pop(), Some(vec![0])); + assert_eq!(jb.try_pop(), Some(vec![1])); + } + + #[test] + fn rtt_tracker_avg() { + let mut lt = RttTracker::new(10); + lt.record(10.0); + lt.record(20.0); + lt.record(30.0); + assert!((lt.average() - 20.0).abs() < 0.01); + assert!((lt.max_latency() - 30.0).abs() < 0.01); + } + + // ─── v0.30 Tests ─── + + #[test] + fn frame_ring_buffer() { + let mut rb = FrameRingBuffer::new(3); + rb.push(vec![1]); + rb.push(vec![2]); + assert_eq!(rb.occupancy(), 2); + assert_eq!(rb.latest(), Some(&[2u8][..])); + } + + #[test] + fn packet_loss_detector() { + let mut pld = PacketLossDetector::new(); + pld.observe(0); + pld.observe(1); + pld.observe(5); // gap of 3 + assert_eq!(pld.total_lost(), 3); + assert!(pld.loss_ratio() > 0.0); + } + + #[test] + fn connection_quality_score() { + let good = ConnectionQuality::new(10.0, 0.0); + let bad = ConnectionQuality::new(400.0, 0.1); + assert!(good.score() > bad.score()); + } + + // ─── v0.40 Tests ─── + + #[test] + fn quality_adapter_v40() { + let mut ab = QualityAdapter::new(vec![100, 500, 1000, 2000]); + assert_eq!(ab.adjust(1.0), 2000); + assert_eq!(ab.adjust(0.0), 100); + } + + #[test] + fn source_mixer_v40() { + let mut mx = SourceMixer::new(); + mx.add("cam1", vec![1, 2]); + mx.add("cam2", vec![3, 4]); + assert_eq!(mx.output(), vec![1, 2, 3, 4]); + assert_eq!(mx.source_count(), 2); + } + + #[test] + fn frame_dedup_v40() { + let mut dd = FrameDeduplicator::new(); + assert!(dd.check(&[1, 2, 3])); + assert!(!dd.check(&[1, 2, 3])); // duplicate + assert!(dd.check(&[4, 5, 6])); // new + assert_eq!(dd.skipped_count(), 1); + } + + #[test] + fn backpressure_v40() { + let bp = BackpressureController::new(10); + assert!(!bp.should_pause(5)); + assert!(bp.should_pause(10)); + } + + #[test] + fn heartbeat_v40() { + let mut hb = HeartbeatMonitor::new(1000.0); + hb.ping(0.0); + assert!(hb.tick(2000.0)); // within 3x + assert!(!hb.tick(4000.0)); // too late + } + + #[test] + fn compression_tracker_v40() { + let mut ct = CompressionTracker::new(); + ct.record(1000, 500); + assert!((ct.ratio() - 0.5).abs() < 0.01); + } + + #[test] + fn fec_parity_v40() { + let p = FecEncoder::parity(&[&[0xFF, 0x00], &[0x0F, 0xF0]]); + assert_eq!(p, vec![0xF0, 0xF0]); + } + + #[test] + fn stream_snapshot_v40() { + let snap = StreamSnapshot::new(100, 5000, 0.02, 0.95); + let json = snap.to_json(); + assert!(json.contains("\"frames\":100")); + } + + #[test] + fn adaptive_priority_queue_v40() { + let mut pq = AdaptivePriorityQueue::new(); + pq.push(1, 1000.0, vec![10]); + pq.push(5, 1000.0, vec![50]); + let item = pq.pop(0.0).unwrap(); + assert_eq!(item, vec![50]); // higher priority first + } + + // ─── v0.50 Tests ─── + + #[test] + fn stream_cipher_v50() { + let c = StreamCipher::new(vec![0xAB, 0xCD]); + let plain = b"hello"; + let enc = c.encrypt(plain); + let dec = c.decrypt(&enc); + assert_eq!(dec, plain); + } + + #[test] + fn channel_mux_demux_v50() { + let mut mux = ChannelMux::new(); + mux.send(0, vec![1, 2]); + mux.send(1, vec![3, 4, 5]); + let packed = mux.pack(); + let unpacked = ChannelDemux::unpack(&packed); + assert_eq!(unpacked.len(), 2); + assert_eq!(unpacked[0].0, 0); + assert_eq!(unpacked[1].1, vec![3, 4, 5]); + } + + #[test] + fn frame_pacer_v50() { + let mut p = FramePacer::new(16.66); + assert!(p.should_emit(0.0)); + assert!(!p.should_emit(10.0)); + assert!(p.should_emit(17.0)); + } + + #[test] + fn congestion_window_v50() { + let mut cw = CongestionWindow::new(10.0, 1.0, 100.0); + cw.on_ack(); + assert!((cw.window() - 11.0).abs() < 0.01); + cw.on_loss(); + assert!((cw.window() - 5.5).abs() < 0.01); + } + + #[test] + fn flow_controller_v50() { + let mut fc = FlowController::new(100.0, 1000.0); + assert!(fc.consume(50.0, 0.0)); + assert!(fc.consume(50.0, 0.0)); + assert!(!fc.consume(1.0, 0.0)); // no tokens left + } + + #[test] + fn protocol_negotiator_v50() { + let pn = ProtocolNegotiator::new(vec!["compress".into(), "encrypt".into()]); + let remote = vec!["encrypt".into(), "multiplex".into()]; + let agreed = pn.negotiate(&remote); + assert_eq!(agreed, vec!["encrypt".to_string()]); + } + + #[test] + fn replay_recorder_v50() { + let mut rr = ReplayRecorder::new(); + rr.record(0.0, vec![1, 2]); + rr.record(16.0, vec![3, 4]); + assert_eq!(rr.frame_count(), 2); + assert_eq!(rr.get_frame(0), Some(&[1u8, 2][..])); + } + + #[test] + fn bandwidth_shaper_v50() { + let mut bs = BandwidthShaper::new(1000.0); + assert!(bs.allow(500.0, 0.0)); + assert!(bs.allow(500.0, 0.0)); + assert!(!bs.allow(1.0, 0.0)); + assert!(bs.allow(500.0, 1000.0)); // new window + } + + #[test] + fn cipher_roundtrip_v50() { + let c = StreamCipher::new(vec![0xFF]); + let data = vec![0x00, 0x42, 0xFF]; + assert_eq!(c.decrypt(&c.encrypt(&data)), data); + } } + + + + + + + + + + + + + +