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.
This commit is contained in:
Till Wegmueller 2026-04-07 15:42:54 +02:00
parent 0762cb1fa3
commit f394d8cd7d
9 changed files with 252 additions and 13 deletions

29
Cargo.lock generated
View file

@ -2192,6 +2192,7 @@ dependencies = [
"postcard", "postcard",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
"zstd",
] ]
[[package]] [[package]]
@ -2597,3 +2598,31 @@ name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 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",
]

View file

@ -8,3 +8,4 @@ license.workspace = true
serde.workspace = true serde.workspace = true
postcard.workspace = true postcard.workspace = true
thiserror.workspace = true thiserror.workspace = true
zstd.workspace = true

View file

@ -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<u8> {
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, &region);
assert_eq!(framebuffer, original);
}
}

View file

@ -5,6 +5,7 @@
//! length prefix for transmission over QUIC streams. //! length prefix for transmission over QUIC streams.
pub mod codec; pub mod codec;
pub mod encoding;
pub mod messages; pub mod messages;
/// Current protocol version. Incremented on breaking changes. /// Current protocol version. Incremented on breaking changes.

View file

@ -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, &region);
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, &region);
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, &region1);
apply_region(&mut reconstructed, stride, &region2);
assert_eq!(reconstructed, curr);
}

View file

@ -1,9 +1,6 @@
use smithay::{ use smithay::{
delegate_compositor, delegate_shm, delegate_compositor, delegate_shm,
reexports::wayland_server::{ reexports::wayland_server::{Client, protocol::wl_surface::WlSurface},
Client,
protocol::wl_surface::WlSurface,
},
wayland::{ wayland::{
buffer::BufferHandler, buffer::BufferHandler,
compositor::{CompositorClientState, CompositorHandler, CompositorState}, compositor::{CompositorClientState, CompositorHandler, CompositorState},

View file

@ -1,8 +1,4 @@
use smithay::{ use smithay::{delegate_output, output::Output, wayland::output::OutputHandler};
delegate_output,
output::Output,
wayland::output::OutputHandler,
};
use crate::state::WayRay; use crate::state::WayRay;

View file

@ -2,8 +2,7 @@ use smithay::{
delegate_xdg_decoration, delegate_xdg_shell, delegate_xdg_decoration, delegate_xdg_shell,
desktop::Window, desktop::Window,
reexports::{ reexports::{
wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode,
as DecorationMode,
wayland_server::protocol::wl_seat, wayland_server::protocol::wl_seat,
}, },
utils::Serial, utils::Serial,

View file

@ -41,8 +41,8 @@ fn main() -> Result<()> {
info!("wrsrvd starting"); info!("wrsrvd starting");
// Create the Wayland display. // Create the Wayland display.
let mut display = Display::<WayRay>::new() let mut display =
.map_err(|e| errors::WayRayError::DisplayInit(Box::new(e)))?; Display::<WayRay>::new().map_err(|e| errors::WayRayError::DisplayInit(Box::new(e)))?;
// Initialize the Winit backend (opens a window, creates a GlesRenderer). // Initialize the Winit backend (opens a window, creates a GlesRenderer).
let (backend, winit_event_loop) = winit::init::<GlesRenderer>().map_err(|e| { let (backend, winit_event_loop) = winit::init::<GlesRenderer>().map_err(|e| {