rfb_encodings/
zrle.rs

1// Copyright 2025 Dustin McAfee
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! ZRLE (Zlib Run-Length Encoding) implementation for VNC.
16//!
17//! ZRLE is a highly efficient encoding that combines tiling, palette-based compression,
18//! run-length encoding, and zlib compression. It is effective for a wide range of
19//! screen content.
20//!
21//! # Encoding Process
22//!
23//! 1. The framebuffer region is divided into 64x64 pixel tiles.
24//! 2. Each tile is compressed independently.
25//! 3. The compressed data for all tiles is concatenated and then compressed as a whole
26//!    using zlib.
27//!
28//! # Tile Sub-encodings
29//!
30//! Each tile is analyzed and compressed using one of the following methods:
31//! - **Raw:** If not otherwise compressible, sent as uncompressed RGBA data.
32//! - **Solid Color:** If the tile contains only one color.
33//! - **Packed Palette:** If the tile contains 2-16 unique colors. Pixels are sent as
34//!   palette indices, which can be run-length encoded.
35//! - **Plain RLE:** If the tile has more than 16 colors but is still compressible with RLE.
36//!
37
38use bytes::{BufMut, BytesMut};
39use flate2::write::ZlibEncoder;
40use flate2::{Compress, Compression, FlushCompress};
41use std::collections::HashMap;
42use std::io::Write;
43
44use crate::{Encoding, PixelFormat};
45
46const TILE_SIZE: usize = 64;
47
48/// Analyzes pixel data to count RLE runs, single pixels, and unique colors.
49/// Returns: (runs, `single_pixels`, `palette_vec`)
50/// CRITICAL: The palette Vec must preserve insertion order (order colors first appear)
51/// as required by RFC 6143 for proper ZRLE palette encoding.
52/// Optimized: uses inline array for small palettes to avoid `HashMap` allocation.
53fn analyze_runs_and_palette(pixels: &[u32]) -> (usize, usize, Vec<u32>) {
54    let mut runs = 0;
55    let mut single_pixels = 0;
56    let mut palette: Vec<u32> = Vec::with_capacity(16); // Most tiles have <= 16 colors
57
58    if pixels.is_empty() {
59        return (0, 0, palette);
60    }
61
62    let mut i = 0;
63    while i < pixels.len() {
64        let color = pixels[i];
65
66        // For small palettes (common case), linear search is faster than HashMap
67        if palette.len() < 256 && !palette.contains(&color) {
68            palette.push(color);
69        }
70
71        let mut run_len = 1;
72        while i + run_len < pixels.len() && pixels[i + run_len] == color {
73            run_len += 1;
74        }
75
76        if run_len == 1 {
77            single_pixels += 1;
78        } else {
79            runs += 1;
80        }
81        i += run_len;
82    }
83    (runs, single_pixels, palette)
84}
85
86/// Encodes a rectangle of pixel data using ZRLE with a persistent compressor.
87/// This maintains compression state across rectangles as required by RFC 6143.
88///
89/// # Errors
90///
91/// Returns an error if zlib compression fails
92#[allow(dead_code)]
93#[allow(clippy::cast_possible_truncation)] // ZRLE protocol requires u8/u16/u32 packing of pixel data
94pub fn encode_zrle_persistent(
95    data: &[u8],
96    width: u16,
97    height: u16,
98    _pixel_format: &PixelFormat,
99    compressor: &mut Compress,
100) -> std::io::Result<Vec<u8>> {
101    let width = width as usize;
102    let height = height as usize;
103    let mut uncompressed_data = BytesMut::new();
104
105    for y in (0..height).step_by(TILE_SIZE) {
106        for x in (0..width).step_by(TILE_SIZE) {
107            let tile_w = (width - x).min(TILE_SIZE);
108            let tile_h = (height - y).min(TILE_SIZE);
109
110            // Extract tile pixel data
111            let tile_data = extract_tile(data, width, x, y, tile_w, tile_h);
112
113            // Analyze and encode the tile
114            encode_tile(&mut uncompressed_data, &tile_data, tile_w, tile_h);
115        }
116    }
117
118    // Compress using persistent compressor with Z_SYNC_FLUSH
119    // RFC 6143: use persistent zlib stream with dictionary for compression continuity
120    let input = &uncompressed_data[..];
121    let mut output_buf = vec![0u8; input.len() * 2 + 1024]; // Generous buffer
122
123    let before_out = compressor.total_out();
124
125    // Single compress call with Z_SYNC_FLUSH - this should handle all input
126    compressor.compress(input, &mut output_buf, FlushCompress::Sync)?;
127
128    let produced = (compressor.total_out() - before_out) as usize;
129    let compressed_output = &output_buf[..produced];
130
131    // Build result with length prefix (big-endian) + compressed data
132    let mut result = BytesMut::with_capacity(4 + compressed_output.len());
133    result.put_u32(compressed_output.len() as u32);
134    result.extend_from_slice(compressed_output);
135
136    #[cfg(feature = "debug-logging")]
137    log::info!(
138        "ZRLE: compressed {}->{}  bytes ({}x{} tiles)",
139        uncompressed_data.len(),
140        compressed_output.len(),
141        width,
142        height
143    );
144
145    Ok(result.to_vec())
146}
147
148/// Encodes a rectangle of pixel data using the ZRLE encoding.
149/// This creates a new compressor for each rectangle (non-RFC compliant, deprecated).
150///
151/// # Errors
152///
153/// Returns an error if zlib compression fails
154#[allow(clippy::cast_possible_truncation)] // ZRLE protocol requires u8/u16/u32 packing of pixel data
155pub fn encode_zrle(
156    data: &[u8],
157    width: u16,
158    height: u16,
159    _pixel_format: &PixelFormat, // Assuming RGBA32
160    compression: u8,
161) -> std::io::Result<Vec<u8>> {
162    let compression_level = match compression {
163        0 => Compression::fast(),
164        1..=3 => Compression::new(u32::from(compression)),
165        4..=6 => Compression::default(),
166        _ => Compression::best(),
167    };
168    let mut zlib_encoder = ZlibEncoder::new(Vec::new(), compression_level);
169    let mut uncompressed_data = BytesMut::new();
170
171    let width = width as usize;
172    let height = height as usize;
173
174    for y in (0..height).step_by(TILE_SIZE) {
175        for x in (0..width).step_by(TILE_SIZE) {
176            let tile_w = (width - x).min(TILE_SIZE);
177            let tile_h = (height - y).min(TILE_SIZE);
178
179            // Extract tile pixel data
180            let tile_data = extract_tile(data, width, x, y, tile_w, tile_h);
181
182            // Analyze and encode the tile
183            encode_tile(&mut uncompressed_data, &tile_data, tile_w, tile_h);
184        }
185    }
186
187    zlib_encoder.write_all(&uncompressed_data)?;
188    let compressed = zlib_encoder.finish()?;
189
190    // ZRLE requires a 4-byte big-endian length prefix before the zlib data
191    let mut result = BytesMut::with_capacity(4 + compressed.len());
192    result.put_u32(compressed.len() as u32); // big-endian length
193    result.extend_from_slice(&compressed);
194
195    Ok(result.to_vec())
196}
197
198/// Encodes a single tile, choosing the best sub-encoding.
199/// Optimized to minimize allocations by working directly with RGBA data where possible.
200#[allow(clippy::cast_possible_truncation)] // ZRLE palette indices and run lengths limited to u8 per RFC 6143
201fn encode_tile(buf: &mut BytesMut, tile_data: &[u8], width: usize, height: usize) {
202    const CPIXEL_SIZE: usize = 3; // CPIXEL is 3 bytes for depth=24
203
204    // Quick check for solid color by scanning RGBA data directly (avoid allocation)
205    if tile_data.len() >= 4 {
206        let first_r = tile_data[0];
207        let first_g = tile_data[1];
208        let first_b = tile_data[2];
209        let mut is_solid = true;
210
211        for chunk in tile_data.chunks_exact(4).skip(1) {
212            if chunk[0] != first_r || chunk[1] != first_g || chunk[2] != first_b {
213                is_solid = false;
214                break;
215            }
216        }
217
218        if is_solid {
219            let color = u32::from(first_r) | (u32::from(first_g) << 8) | (u32::from(first_b) << 16);
220            encode_solid_color_tile(buf, color);
221            return;
222        }
223    }
224
225    // Convert RGBA to RGB24 pixels (still needed for analysis)
226    let pixels = rgba_to_rgb24_pixels(tile_data);
227    let (runs, single_pixels, palette) = analyze_runs_and_palette(&pixels);
228
229    let mut use_rle = false;
230    let mut use_palette = false;
231
232    // Start assuming raw encoding size
233    let mut estimated_bytes = width * height * CPIXEL_SIZE;
234
235    let plain_rle_bytes = (CPIXEL_SIZE + 1) * (runs + single_pixels);
236
237    if plain_rle_bytes < estimated_bytes {
238        use_rle = true;
239        estimated_bytes = plain_rle_bytes;
240    }
241
242    if palette.len() < 128 {
243        let palette_size = palette.len();
244
245        // Palette RLE encoding
246        let palette_rle_bytes = CPIXEL_SIZE * palette_size + 2 * runs + single_pixels;
247
248        if palette_rle_bytes < estimated_bytes {
249            use_rle = true;
250            use_palette = true;
251            estimated_bytes = palette_rle_bytes;
252        }
253
254        // Packed palette encoding (no RLE)
255        if palette_size < 17 {
256            let bits_per_packed_pixel = match palette_size {
257                2 => 1,
258                3..=4 => 2,
259                _ => 4, // 5-16 colors
260            };
261            // Round up: (bits + 7) / 8 to match actual encoding
262            let packed_bytes =
263                CPIXEL_SIZE * palette_size + (width * height * bits_per_packed_pixel).div_ceil(8);
264
265            if packed_bytes < estimated_bytes {
266                use_rle = false;
267                use_palette = true;
268                // No need to update estimated_bytes, this is the last check
269            }
270        }
271    }
272
273    if use_palette {
274        // Palette (Packed Palette or Packed Palette RLE)
275        // Build index lookup from palette (preserves insertion order)
276        let color_to_idx: HashMap<_, _> = palette
277            .iter()
278            .enumerate()
279            .map(|(i, &c)| (c, i as u8))
280            .collect();
281
282        if use_rle {
283            // Packed Palette RLE
284            encode_packed_palette_rle_tile(buf, &pixels, &palette, &color_to_idx);
285        } else {
286            // Packed Palette (no RLE)
287            encode_packed_palette_tile(buf, &pixels, width, height, &palette, &color_to_idx);
288        }
289    } else {
290        // Raw or Plain RLE
291        if use_rle {
292            // Plain RLE - encode directly to buffer (avoid intermediate Vec)
293            buf.put_u8(128);
294            encode_rle_to_buf(buf, &pixels);
295        } else {
296            // Raw
297            encode_raw_tile(buf, tile_data);
298        }
299    }
300}
301
302/// Extracts a tile from the full framebuffer.
303/// Optimized to use a single allocation and bulk copy operations.
304#[allow(clippy::uninit_vec)] // Performance optimization: all bytes written via bulk copy before return
305fn extract_tile(
306    full_frame: &[u8],
307    frame_width: usize,
308    x: usize,
309    y: usize,
310    width: usize,
311    height: usize,
312) -> Vec<u8> {
313    let tile_size = width * height * 4;
314    let mut tile_data = Vec::with_capacity(tile_size);
315
316    // Use unsafe for performance - we know the capacity is correct
317    unsafe {
318        tile_data.set_len(tile_size);
319    }
320
321    let row_bytes = width * 4;
322    for row in 0..height {
323        let src_start = ((y + row) * frame_width + x) * 4;
324        let dst_start = row * row_bytes;
325        tile_data[dst_start..dst_start + row_bytes]
326            .copy_from_slice(&full_frame[src_start..src_start + row_bytes]);
327    }
328    tile_data
329}
330
331/// Converts RGBA to 32-bit RGB pixels (0x00BBGGRR format for VNC).
332fn rgba_to_rgb24_pixels(data: &[u8]) -> Vec<u32> {
333    data.chunks_exact(4)
334        .map(|c| u32::from(c[0]) | (u32::from(c[1]) << 8) | (u32::from(c[2]) << 16))
335        .collect()
336}
337
338/// Writes a CPIXEL (3 bytes for depth=24) in little-endian format.
339/// CPIXEL format: R at byte 0, G at byte 1, B at byte 2
340fn put_cpixel(buf: &mut BytesMut, pixel: u32) {
341    buf.put_u8((pixel & 0xFF) as u8); // R at bits 0-7
342    buf.put_u8(((pixel >> 8) & 0xFF) as u8); // G at bits 8-15
343    buf.put_u8(((pixel >> 16) & 0xFF) as u8); // B at bits 16-23
344}
345
346/// Sub-encoding for a tile with a single color.
347fn encode_solid_color_tile(buf: &mut BytesMut, color: u32) {
348    buf.put_u8(1); // Solid color sub-encoding
349    put_cpixel(buf, color); // Write 3-byte CPIXEL
350}
351
352/// Sub-encoding for raw pixel data.
353fn encode_raw_tile(buf: &mut BytesMut, tile_data: &[u8]) {
354    buf.put_u8(0); // Raw sub-encoding
355                   // Convert RGBA (4 bytes) to CPIXEL (3 bytes) for each pixel
356    for chunk in tile_data.chunks_exact(4) {
357        buf.put_u8(chunk[0]); // R
358        buf.put_u8(chunk[1]); // G
359        buf.put_u8(chunk[2]); // B (skip alpha channel)
360    }
361}
362
363/// Sub-encoding for a tile with a small palette.
364#[allow(clippy::cast_possible_truncation)] // ZRLE palette size limited to 16 colors (u8) per RFC 6143
365fn encode_packed_palette_tile(
366    buf: &mut BytesMut,
367    pixels: &[u32],
368    width: usize,
369    height: usize,
370    palette: &[u32],
371    color_to_idx: &HashMap<u32, u8>,
372) {
373    let palette_size = palette.len();
374    let bits_per_pixel = match palette_size {
375        2 => 1,
376        3..=4 => 2,
377        _ => 4,
378    };
379
380    buf.put_u8(palette_size as u8); // Packed palette sub-encoding
381
382    // Write palette as CPIXEL (3 bytes each) - in insertion order
383    for &color in palette {
384        put_cpixel(buf, color);
385    }
386
387    // Write packed pixel data ROW BY ROW per RFC 6143 ZRLE specification
388    // Critical: Each row must be byte-aligned
389    for row in 0..height {
390        let mut packed_byte = 0;
391        let mut nbits = 0;
392        let row_start = row * width;
393        let row_end = row_start + width;
394
395        for &pixel in &pixels[row_start..row_end] {
396            let idx = color_to_idx[&pixel];
397            // Pack from MSB: byte = (byte << bppp) | index
398            packed_byte = (packed_byte << bits_per_pixel) | idx;
399            nbits += bits_per_pixel;
400
401            if nbits >= 8 {
402                buf.put_u8(packed_byte);
403                packed_byte = 0;
404                nbits = 0;
405            }
406        }
407
408        // Pad remaining bits to MSB at end of row per RFC 6143
409        if nbits > 0 {
410            packed_byte <<= 8 - nbits;
411            buf.put_u8(packed_byte);
412        }
413    }
414}
415
416/// Sub-encoding for a tile with a small palette and RLE.
417#[allow(clippy::cast_possible_truncation)] // ZRLE palette size limited to 16 colors (u8) per RFC 6143
418fn encode_packed_palette_rle_tile(
419    buf: &mut BytesMut,
420    pixels: &[u32],
421    palette: &[u32],
422    color_to_idx: &HashMap<u32, u8>,
423) {
424    let palette_size = palette.len();
425    buf.put_u8(128 | (palette_size as u8)); // Packed palette RLE sub-encoding
426
427    // Write palette as CPIXEL (3 bytes each)
428    for &color in palette {
429        put_cpixel(buf, color);
430    }
431
432    // Write RLE data using palette indices per RFC 6143 specification
433    let mut i = 0;
434    while i < pixels.len() {
435        let color = pixels[i];
436        let index = color_to_idx[&color];
437
438        let mut run_len = 1;
439        while i + run_len < pixels.len() && pixels[i + run_len] == color {
440            run_len += 1;
441        }
442
443        // Short runs (1-2 pixels) are written WITHOUT RLE marker per RFC 6143
444        if run_len <= 2 {
445            // Write index once for length 1, twice for length 2
446            if run_len == 2 {
447                buf.put_u8(index);
448            }
449            buf.put_u8(index);
450        } else {
451            // RLE encoding for runs >= 3 per RFC 6143
452            buf.put_u8(index | 128); // Set bit 7 to indicate RLE follows
453                                     // Encode run length - 1 using variable-length encoding
454            let mut remaining_len = run_len - 1;
455            while remaining_len >= 255 {
456                buf.put_u8(255);
457                remaining_len -= 255;
458            }
459            buf.put_u8(remaining_len as u8);
460        }
461        i += run_len;
462    }
463}
464
465/// Encodes pixel data using run-length encoding directly to buffer (optimized).
466#[allow(clippy::cast_possible_truncation)] // ZRLE run lengths encoded as u8 per RFC 6143
467fn encode_rle_to_buf(buf: &mut BytesMut, pixels: &[u32]) {
468    let mut i = 0;
469    while i < pixels.len() {
470        let color = pixels[i];
471        let mut run_len = 1;
472        while i + run_len < pixels.len() && pixels[i + run_len] == color {
473            run_len += 1;
474        }
475        // Write CPIXEL (3 bytes)
476        put_cpixel(buf, color);
477
478        // Encode run length - 1 per RFC 6143 ZRLE specification
479        // Length encoding: write 255 for each full 255-length chunk, then remainder
480        // NO continuation bits - just plain bytes where 255 means "add 255 to length"
481        let mut len_to_encode = run_len - 1;
482        while len_to_encode >= 255 {
483            buf.put_u8(255);
484            len_to_encode -= 255;
485        }
486        buf.put_u8(len_to_encode as u8);
487
488        i += run_len;
489    }
490}
491
492/// Implements the VNC "ZRLE" (Zlib Run-Length Encoding).
493pub struct ZrleEncoding;
494
495impl Encoding for ZrleEncoding {
496    fn encode(
497        &self,
498        data: &[u8],
499        width: u16,
500        height: u16,
501        _quality: u8,
502        compression: u8,
503    ) -> BytesMut {
504        // ZRLE doesn't use quality, but it does use compression.
505        let pixel_format = PixelFormat::rgba32(); // Assuming RGBA32 for now
506        if let Ok(encoded_data) = encode_zrle(data, width, height, &pixel_format, compression) {
507            BytesMut::from(&encoded_data[..])
508        } else {
509            // Fallback to Raw encoding if ZRLE fails.
510            let mut buf = BytesMut::with_capacity(data.len());
511            for chunk in data.chunks_exact(4) {
512                buf.put_u8(chunk[0]); // R
513                buf.put_u8(chunk[1]); // G
514                buf.put_u8(chunk[2]); // B
515                buf.put_u8(0); // Padding
516            }
517            buf
518        }
519    }
520}