From f394d8cd7d5f9ebcd1e65ca558e2af26ea9c16ff Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Tue, 7 Apr 2026 15:42:54 +0200 Subject: [PATCH] Add XOR diff + zstd frame encoding in wayray-protocol Encoder: xor_diff + encode_region (per damage rectangle). Decoder: apply_region (decompress + XOR apply to framebuffer). Both live in wayray-protocol::encoding for shared access. 14 tests including 3 end-to-end round-trip integration tests. --- Cargo.lock | 29 ++++ crates/wayray-protocol/Cargo.toml | 1 + crates/wayray-protocol/src/encoding.rs | 134 ++++++++++++++++++ crates/wayray-protocol/src/lib.rs | 1 + .../tests/encoding_roundtrip.rs | 82 +++++++++++ crates/wrsrvd/src/handlers/compositor.rs | 5 +- crates/wrsrvd/src/handlers/output.rs | 6 +- crates/wrsrvd/src/handlers/xdg_shell.rs | 3 +- crates/wrsrvd/src/main.rs | 4 +- 9 files changed, 252 insertions(+), 13 deletions(-) create mode 100644 crates/wayray-protocol/src/encoding.rs create mode 100644 crates/wayray-protocol/tests/encoding_roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index 7359475..ce68a68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2192,6 +2192,7 @@ dependencies = [ "postcard", "serde", "thiserror 2.0.18", + "zstd", ] [[package]] @@ -2597,3 +2598,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/crates/wayray-protocol/Cargo.toml b/crates/wayray-protocol/Cargo.toml index 78bf046..2aa75cc 100644 --- a/crates/wayray-protocol/Cargo.toml +++ b/crates/wayray-protocol/Cargo.toml @@ -8,3 +8,4 @@ license.workspace = true serde.workspace = true postcard.workspace = true thiserror.workspace = true +zstd.workspace = true diff --git a/crates/wayray-protocol/src/encoding.rs b/crates/wayray-protocol/src/encoding.rs new file mode 100644 index 0000000..1943528 --- /dev/null +++ b/crates/wayray-protocol/src/encoding.rs @@ -0,0 +1,134 @@ +//! Frame encoding and decoding: XOR diff + zstd compression. +//! +//! The encoder (server-side) produces `DamageRegion` messages from +//! framebuffer changes. The decoder (client-side) applies them to +//! reconstruct the framebuffer. + +use crate::messages::DamageRegion; + +// ── Encoder (server side) ─────────────────────────────────────────── + +/// Compute XOR diff between two ARGB8888 framebuffers. +/// +/// Returns a new buffer where unchanged pixels are zero. +/// Both buffers must have the same length. +pub fn xor_diff(current: &[u8], previous: &[u8]) -> Vec { + assert_eq!( + current.len(), + previous.len(), + "framebuffer size mismatch: {} vs {}", + current.len(), + previous.len() + ); + current + .iter() + .zip(previous.iter()) + .map(|(a, b)| a ^ b) + .collect() +} + +/// Extract a rectangular region from an XOR diff buffer and compress it. +/// +/// `stride` is the number of bytes per row in the full framebuffer. +/// The region is compressed with zstd at level 1 (fast). +pub fn encode_region( + diff: &[u8], + stride: usize, + x: u32, + y: u32, + width: u32, + height: u32, +) -> DamageRegion { + let bpp = 4; // ARGB8888 + let region_stride = width as usize * bpp; + let mut region_data = Vec::with_capacity(region_stride * height as usize); + + for row in 0..height as usize { + let src_offset = (y as usize + row) * stride + x as usize * bpp; + region_data.extend_from_slice(&diff[src_offset..src_offset + region_stride]); + } + + let compressed = zstd::encode_all(region_data.as_slice(), 1).expect("zstd encode failed"); + + DamageRegion { + x, + y, + width, + height, + data: compressed, + } +} + +// ── Decoder (client side) ─────────────────────────────────────────── + +/// Apply a compressed XOR diff region onto a framebuffer. +/// +/// Decompresses the region data with zstd, then XOR-applies it at the +/// specified position. Modifies `framebuffer` in place. +/// +/// `stride` is the number of bytes per row in the full framebuffer. +pub fn apply_region(framebuffer: &mut [u8], stride: usize, region: &DamageRegion) { + let bpp = 4; + let decompressed = zstd::decode_all(region.data.as_slice()).expect("zstd decode failed"); + let region_stride = region.width as usize * bpp; + + for row in 0..region.height as usize { + let dst_offset = (region.y as usize + row) * stride + region.x as usize * bpp; + let src_offset = row * region_stride; + let dst_row = &mut framebuffer[dst_offset..dst_offset + region_stride]; + let src_row = &decompressed[src_offset..src_offset + region_stride]; + for (d, s) in dst_row.iter_mut().zip(src_row.iter()) { + *d ^= s; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xor_diff_identical_frames() { + let frame = vec![100u8; 400]; + let diff = xor_diff(&frame, &frame); + assert!(diff.iter().all(|&b| b == 0)); + } + + #[test] + fn xor_diff_changed_pixel() { + let prev = vec![0u8; 16]; + let mut curr = vec![0u8; 16]; + curr[0..4].copy_from_slice(&[255, 128, 64, 255]); + let diff = xor_diff(&curr, &prev); + assert_eq!(&diff[0..4], &[255, 128, 64, 255]); + assert!(diff[4..].iter().all(|&b| b == 0)); + } + + #[test] + fn encode_region_compresses_zeros() { + let width = 100usize; + let height = 100usize; + let stride = width * 4; + let diff = vec![0u8; stride * height]; + let region = encode_region(&diff, stride, 0, 0, width as u32, height as u32); + assert!(region.data.len() < 1000); + } + + #[test] + fn apply_region_zeros_is_noop() { + let stride = 40; // 10 pixels * 4 bpp + let mut framebuffer = vec![42u8; stride * 10]; + let original = framebuffer.clone(); + let zero_data = vec![0u8; stride * 10]; + let compressed = zstd::encode_all(zero_data.as_slice(), 1).unwrap(); + let region = DamageRegion { + x: 0, + y: 0, + width: 10, + height: 10, + data: compressed, + }; + apply_region(&mut framebuffer, stride, ®ion); + assert_eq!(framebuffer, original); + } +} diff --git a/crates/wayray-protocol/src/lib.rs b/crates/wayray-protocol/src/lib.rs index 9f77b66..032694b 100644 --- a/crates/wayray-protocol/src/lib.rs +++ b/crates/wayray-protocol/src/lib.rs @@ -5,6 +5,7 @@ //! length prefix for transmission over QUIC streams. pub mod codec; +pub mod encoding; pub mod messages; /// Current protocol version. Incremented on breaking changes. diff --git a/crates/wayray-protocol/tests/encoding_roundtrip.rs b/crates/wayray-protocol/tests/encoding_roundtrip.rs new file mode 100644 index 0000000..6096d1a --- /dev/null +++ b/crates/wayray-protocol/tests/encoding_roundtrip.rs @@ -0,0 +1,82 @@ +//! Integration test: encode → compress → decompress → apply. +//! +//! Verifies the full XOR diff → zstd → XOR apply pipeline +//! reconstructs the original framebuffer correctly. + +use wayray_protocol::encoding::{apply_region, encode_region, xor_diff}; + +#[test] +fn roundtrip_with_subregion() { + let width = 100u32; + let height = 100u32; + let stride = width as usize * 4; + let prev = vec![0u8; stride * height as usize]; + let mut curr = prev.clone(); + + // Paint a 10x10 red rectangle at (5, 5) + for y in 5..15u32 { + for x in 5..15u32 { + let offset = y as usize * stride + x as usize * 4; + curr[offset..offset + 4].copy_from_slice(&[255, 0, 0, 255]); + } + } + + let diff = xor_diff(&curr, &prev); + let region = encode_region(&diff, stride, 5, 5, 10, 10); + + let mut reconstructed = prev; + apply_region(&mut reconstructed, stride, ®ion); + assert_eq!(reconstructed, curr); +} + +#[test] +fn roundtrip_unchanged_frame() { + let width = 50u32; + let height = 50u32; + let stride = width as usize * 4; + let frame = vec![128u8; stride * height as usize]; + + let diff = xor_diff(&frame, &frame); + let region = encode_region(&diff, stride, 0, 0, width, height); + + assert!( + region.data.len() < 200, + "all-zero diff should compress well" + ); + + let mut reconstructed = frame.clone(); + apply_region(&mut reconstructed, stride, ®ion); + assert_eq!(reconstructed, frame); +} + +#[test] +fn roundtrip_multiple_regions() { + let width = 200u32; + let height = 200u32; + let stride = width as usize * 4; + let prev = vec![0u8; stride * height as usize]; + let mut curr = prev.clone(); + + for y in 0..10u32 { + for x in 0..10u32 { + let offset = y as usize * stride + x as usize * 4; + curr[offset..offset + 4].copy_from_slice(&[255, 0, 0, 255]); + } + } + + for y in 100..120u32 { + for x in 150..180u32 { + let offset = y as usize * stride + x as usize * 4; + curr[offset..offset + 4].copy_from_slice(&[0, 255, 0, 255]); + } + } + + let diff = xor_diff(&curr, &prev); + let region1 = encode_region(&diff, stride, 0, 0, 10, 10); + let region2 = encode_region(&diff, stride, 150, 100, 30, 20); + + let mut reconstructed = prev; + apply_region(&mut reconstructed, stride, ®ion1); + apply_region(&mut reconstructed, stride, ®ion2); + assert_eq!(reconstructed, curr); +} diff --git a/crates/wrsrvd/src/handlers/compositor.rs b/crates/wrsrvd/src/handlers/compositor.rs index eea96d1..f3c8881 100644 --- a/crates/wrsrvd/src/handlers/compositor.rs +++ b/crates/wrsrvd/src/handlers/compositor.rs @@ -1,9 +1,6 @@ use smithay::{ delegate_compositor, delegate_shm, - reexports::wayland_server::{ - Client, - protocol::wl_surface::WlSurface, - }, + reexports::wayland_server::{Client, protocol::wl_surface::WlSurface}, wayland::{ buffer::BufferHandler, compositor::{CompositorClientState, CompositorHandler, CompositorState}, diff --git a/crates/wrsrvd/src/handlers/output.rs b/crates/wrsrvd/src/handlers/output.rs index d8ce53d..5a4d7dc 100644 --- a/crates/wrsrvd/src/handlers/output.rs +++ b/crates/wrsrvd/src/handlers/output.rs @@ -1,8 +1,4 @@ -use smithay::{ - delegate_output, - output::Output, - wayland::output::OutputHandler, -}; +use smithay::{delegate_output, output::Output, wayland::output::OutputHandler}; use crate::state::WayRay; diff --git a/crates/wrsrvd/src/handlers/xdg_shell.rs b/crates/wrsrvd/src/handlers/xdg_shell.rs index 5d8b1c1..5efe25e 100644 --- a/crates/wrsrvd/src/handlers/xdg_shell.rs +++ b/crates/wrsrvd/src/handlers/xdg_shell.rs @@ -2,8 +2,7 @@ use smithay::{ delegate_xdg_decoration, delegate_xdg_shell, desktop::Window, reexports::{ - wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode - as DecorationMode, + wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode, wayland_server::protocol::wl_seat, }, utils::Serial, diff --git a/crates/wrsrvd/src/main.rs b/crates/wrsrvd/src/main.rs index d8ec2c9..1ac10cf 100644 --- a/crates/wrsrvd/src/main.rs +++ b/crates/wrsrvd/src/main.rs @@ -41,8 +41,8 @@ fn main() -> Result<()> { info!("wrsrvd starting"); // Create the Wayland display. - let mut display = Display::::new() - .map_err(|e| errors::WayRayError::DisplayInit(Box::new(e)))?; + let mut display = + Display::::new().map_err(|e| errors::WayRayError::DisplayInit(Box::new(e)))?; // Initialize the Winit backend (opens a window, creates a GlesRenderer). let (backend, winit_event_loop) = winit::init::().map_err(|e| {