diff --git a/crates/ironrdp-egfx/src/client.rs b/crates/ironrdp-egfx/src/client.rs index 4610f7ca0..56d375c7d 100644 --- a/crates/ironrdp-egfx/src/client.rs +++ b/crates/ironrdp-egfx/src/client.rs @@ -1,7 +1,7 @@ //! Client-side EGFX implementation //! //! This module provides client-side support for the Graphics Pipeline Extension -//! ([MS-RDPEGFX]), including H.264 AVC420 decode and surface management. +//! ([MS-RDPEGFX]), including H.264 AVC420 decode, ClearCodec decode, and surface management. //! //! # Protocol Compliance //! @@ -24,7 +24,7 @@ //! | | //! | (For each frame:) | //! |--- StartFrame ----------------------->| -//! |--- WireToSurface1 (H.264) ----------->| -> H264Decoder::decode() +//! |--- WireToSurface1 (codec) ----------->| -> H264/ClearCodec decode //! |--- EndFrame ------------------------->| -> FrameAcknowledge //! | | //! |<---------- FrameAcknowledge ----------| @@ -57,8 +57,9 @@ use std::collections::BTreeMap; use ironrdp_core::{Decode as _, ReadCursor, impl_as_any}; use ironrdp_dvc::{DvcClientProcessor, DvcMessage, DvcProcessor}; +use ironrdp_graphics::clearcodec::ClearCodecDecoder; use ironrdp_graphics::zgfx; -use ironrdp_pdu::geometry::{ExclusiveRectangle, Rectangle as _}; +use ironrdp_pdu::geometry::ExclusiveRectangle; use ironrdp_pdu::{PduResult, decode_cursor, decode_err, pdu_other_err}; use tracing::{debug, trace, warn}; @@ -383,6 +384,7 @@ enum ClientState { pub struct GraphicsPipelineClient { handler: Box, h264_decoder: Option>, + clearcodec_decoder: ClearCodecDecoder, decompressor: zgfx::Decompressor, decompressed_buffer: Vec, @@ -401,10 +403,12 @@ impl GraphicsPipelineClient { /// Create a new `GraphicsPipelineClient` /// /// If `h264_decoder` is `None`, AVC420 frames are logged and skipped. + /// ClearCodec decoding is always available (no external decoder required). pub fn new(handler: Box, h264_decoder: Option>) -> Self { Self { handler, h264_decoder, + clearcodec_decoder: ClearCodecDecoder::new(), decompressor: zgfx::Decompressor::new(), decompressed_buffer: Vec::new(), state: ClientState::WaitingForConfirm, @@ -632,6 +636,9 @@ impl GraphicsPipelineClient { if let Some(ref mut decoder) = self.h264_decoder { decoder.reset(); } + // ClearCodec maintains V-bar and glyph caches that must be rebuilt + // after a graphics reset (the server will re-send all needed state). + self.clearcodec_decoder = ClearCodecDecoder::new(); debug!(width, height, "Graphics reset"); self.handler.on_reset_graphics(width, height); @@ -720,6 +727,9 @@ impl GraphicsPipelineClient { debug!("AVC444 codec not yet implemented, forwarding to handler"); self.handler.on_unhandled_pdu(&GfxPdu::WireToSurface1(pdu)); } + Codec1Type::ClearCodec => { + self.decode_clearcodec(pdu.surface_id, &pdu.destination_rectangle, &pdu.bitmap_data)?; + } Codec1Type::Uncompressed => { self.handle_uncompressed(pdu); } @@ -745,8 +755,10 @@ impl GraphicsPipelineClient { .decode(stream.data) .map_err(|e| pdu_other_err!("H.264 decode", source: e))?; - let dest_width = dest_rect.width(); - let dest_height = dest_rect.height(); + // MS-RDPEGFX 2.2.1.4.1: RDPGFX_RECT16 right/bottom are exclusive (one-past-end), + // so dimensions are right-left / bottom-top despite the parsed type's name. + let dest_width = dest_rect.right - dest_rect.left; + let dest_height = dest_rect.bottom - dest_rect.top; // Decoded frame must be at least as large as the destination rectangle. // Larger is expected (macroblock alignment) and handled by cropping. @@ -777,9 +789,41 @@ impl GraphicsPipelineClient { Ok(()) } + fn decode_clearcodec( + &mut self, + surface_id: u16, + dest_rect: &ExclusiveRectangle, + bitmap_data: &[u8], + ) -> PduResult<()> { + // MS-RDPEGFX 2.2.1.4.1: see decode_avc420 above for wire-format note. + let dest_width = dest_rect.right - dest_rect.left; + let dest_height = dest_rect.bottom - dest_rect.top; + + let bgra = self + .clearcodec_decoder + .decode(bitmap_data, dest_width, dest_height) + .map_err(|e| pdu_other_err!("ClearCodec decode", source: e))?; + + // ClearCodec outputs BGRA; convert to RGBA for the uniform BitmapUpdate format + let rgba = convert_bgra_to_rgba(&bgra); + + let update = BitmapUpdate { + surface_id, + destination_rectangle: dest_rect.clone(), + codec_id: Codec1Type::ClearCodec, + data: rgba, + width: dest_width, + height: dest_height, + }; + + self.handler.on_bitmap_updated(&update); + Ok(()) + } + fn handle_uncompressed(&mut self, pdu: crate::pdu::WireToSurface1Pdu) { - let dest_width = pdu.destination_rectangle.width(); - let dest_height = pdu.destination_rectangle.height(); + // MS-RDPEGFX 2.2.1.4.1: see decode_avc420 above for wire-format note. + let dest_width = pdu.destination_rectangle.right - pdu.destination_rectangle.left; + let dest_height = pdu.destination_rectangle.bottom - pdu.destination_rectangle.top; // Convert wire-format pixels to RGBA. // BitmapUpdate.data is always RGBA8888 regardless of codec -- this is @@ -807,7 +851,9 @@ impl GraphicsPipelineClient { self.handler.on_frame_complete(frame_id); - // Per [3.3.5.12]: client MUST send FrameAcknowledge after EndFrame + // Per [3.3.5.12]: client MUST send FrameAcknowledge after EndFrame. + // We send the actual queue depth (not Unavailable / 0xFFFFFFFF as FreeRDP does); + // the real value gives the server backpressure information for frame pacing. let ack = GfxPdu::FrameAcknowledge(FrameAcknowledgePdu { queue_depth: QueueDepth::from_u32(self.frames_queued), frame_id, @@ -896,6 +942,19 @@ impl DvcClientProcessor for GraphicsPipelineClient {} // Frame Cropping // ============================================================================ +/// Convert BGRA pixel data to RGBA8888 +/// +/// ClearCodec produces BGRA output per [MS-RDPEGFX 2.2.4.1]. Reorder to +/// [R, G, B, A] for the uniform `BitmapUpdate` pixel format. +fn convert_bgra_to_rgba(src: &[u8]) -> Vec { + debug_assert!(src.len() % 4 == 0, "BGRA input length not aligned to 4 bytes"); + let mut dst = Vec::with_capacity(src.len()); + for pixel in src.chunks_exact(4) { + dst.extend_from_slice(&[pixel[2], pixel[1], pixel[0], pixel[3]]); + } + dst +} + /// Convert uncompressed 32bpp little-endian pixels to RGBA8888 /// /// The wire format for uncompressed graphics is 0xAARRGGBB in a 32-bit @@ -1057,6 +1116,24 @@ mod tests { assert_eq!(cropped.len(), 1920 * 1080 * 4); } + #[test] + fn convert_bgra_to_rgba_reorders_channels() { + // BGRA input: [B, G, R, A] per pixel + let bgra = vec![ + 0xFF, 0x00, 0x00, 0xCC, // B=255, G=0, R=0, A=204 (blue) + 0x00, 0xFF, 0x00, 0x80, // B=0, G=255, R=0, A=128 (green) + ]; + let rgba = convert_bgra_to_rgba(&bgra); + // Expected: [R, G, B, A] per pixel + assert_eq!( + rgba, + vec![ + 0x00, 0x00, 0xFF, 0xCC, // R=0, G=0, B=255, A=204 + 0x00, 0xFF, 0x00, 0x80, // R=0, G=255, B=0, A=128 + ] + ); + } + #[test] fn convert_uncompressed_bgrx_to_rgba() { // Wire format: [B, G, R, A] per pixel (0xAARRGGBB little-endian) diff --git a/crates/ironrdp-egfx/src/server.rs b/crates/ironrdp-egfx/src/server.rs index dd1261b8e..c86f5f71a 100644 --- a/crates/ironrdp-egfx/src/server.rs +++ b/crates/ironrdp-egfx/src/server.rs @@ -1502,6 +1502,52 @@ impl GraphicsPipelineServer { Some(frame_id) } + /// Queue a ClearCodec frame for transmission. + /// + /// ClearCodec is a mandatory lossless codec for all EGFX versions. It + /// provides excellent compression for text, UI elements, and icons. + /// + /// `bitmap_data` should be a pre-encoded ClearCodec bitmap stream + /// (as produced by `ironrdp_graphics::clearcodec::ClearCodecEncoder`). + /// + /// Returns `Some(frame_id)` if queued, `None` if backpressure is active + /// or the server is not ready. + pub fn send_clearcodec_frame( + &mut self, + surface_id: u16, + destination_rectangle: ExclusiveRectangle, + bitmap_data: Vec, + timestamp_ms: u32, + ) -> Option { + if !self.is_ready() { + return None; + } + if self.should_backpressure() { + self.qoe.record_backpressure(); + return None; + } + + let surface = self.surfaces.get(surface_id)?; + + let timestamp = Self::make_timestamp(timestamp_ms); + let frame_id = self.frames.begin_frame(timestamp); + + self.output_queue + .push_back(GfxPdu::StartFrame(StartFramePdu { timestamp, frame_id })); + + self.output_queue.push_back(GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id, + codec_id: Codec1Type::ClearCodec, + pixel_format: surface.pixel_format, + destination_rectangle, + bitmap_data, + })); + + self.output_queue.push_back(GfxPdu::EndFrame(EndFramePdu { frame_id })); + + Some(frame_id) + } + // ======================================================================== // Mixed-Codec Frame Support // ======================================================================== diff --git a/crates/ironrdp-graphics/src/clearcodec/mod.rs b/crates/ironrdp-graphics/src/clearcodec/mod.rs index ab6e653c5..c643ee39f 100644 --- a/crates/ironrdp-graphics/src/clearcodec/mod.rs +++ b/crates/ironrdp-graphics/src/clearcodec/mod.rs @@ -142,6 +142,12 @@ impl ClearCodecDecoder { let max_offset = output.len(); let mut offset = 0; for seg in &segments { + // Trim each segment's run_length to the bytes remaining in the + // output buffer. When a segment's declared run would exceed + // the buffer, the excess is intentionally dropped (not a + // parse error) so a single malformed segment cannot CPU-spin + // the decoder. Subsequent segments are also skipped via the + // `break` below once the buffer is full. let pixels_remaining = (max_offset.saturating_sub(offset)) / 4; let effective_run = u32::try_from(pixels_remaining).unwrap_or(u32::MAX).min(seg.run_length); for _ in 0..effective_run { @@ -220,6 +226,12 @@ impl ClearCodecDecoder { .vbar_cache .get_short_vbar(*index) .ok_or_else(|| invalid_field_err!("shortVbarIndex", "short V-bar cache miss on hit"))?; + if usize::from(*y_on) + usize::from(cached_short.pixel_count) > usize::from(band_height) { + return Err(invalid_field_err!( + "shortVBarYOn", + "y_on + pixel_count exceeds band height" + )); + } // Create a modified short vbar with the y_on from this reference let modified = ShortVBar { y_on: *y_on, @@ -291,46 +303,51 @@ impl ClearCodecDecoder { SubcodecId::Rlex => { let rlex = ironrdp_pdu::codecs::clearcodec::decode_rlex(sub.bitmap_data)?; let w = usize::from(sub.width); - let region_pixels = usize::from(sub.width) * usize::from(sub.height); - let palette_len = rlex.palette.len(); + let h = usize::from(sub.height); + let pixel_budget = w * h; let mut px = 0usize; for seg in &rlex.segments { - if usize::from(seg.start_index) >= palette_len { - return Err(invalid_field_err!("rlex", "start_index exceeds palette size")); - } - if usize::from(seg.stop_index) >= palette_len { - return Err(invalid_field_err!("rlex", "stop_index exceeds palette size")); - } - - let color = &rlex.palette[usize::from(seg.start_index)]; - for _ in 0..seg.run_length { - if px >= region_pixels { - return Err(invalid_field_err!("rlex", "run exceeds region pixel count")); + // Run: repeat start_index color for run_length pixels + if let Some(color) = rlex.palette.get(usize::from(seg.start_index)) { + for _ in 0..seg.run_length { + if px >= pixel_budget { + break; + } + let col = px % w; + let row = px / w; + let x = usize::from(sub.x_start) + col; + let y = usize::from(sub.y_start) + row; + let dst_idx = (y * sw + x) * 4; + if dst_idx + 3 < output.len() { + output[dst_idx] = color[0]; // B + output[dst_idx + 1] = color[1]; // G + output[dst_idx + 2] = color[2]; // R + output[dst_idx + 3] = 0xFF; + } + px += 1; } - let x = usize::from(sub.x_start) + px % w; - let y = usize::from(sub.y_start) + px / w; - let dst_idx = (y * sw + x) * 4; - output[dst_idx] = color[0]; - output[dst_idx + 1] = color[1]; - output[dst_idx + 2] = color[2]; - output[dst_idx + 3] = 0xFF; - px += 1; } + // Suite: sequential palette walk from start_index to stop_index for palette_idx in seg.start_index..=seg.stop_index { - if px >= region_pixels { - return Err(invalid_field_err!("rlex", "suite exceeds region pixel count")); + if px >= pixel_budget { + break; + } + if let Some(color) = rlex.palette.get(usize::from(palette_idx)) { + let col = px % w; + let row = px / w; + let x = usize::from(sub.x_start) + col; + let y = usize::from(sub.y_start) + row; + let dst_idx = (y * sw + x) * 4; + if dst_idx + 3 < output.len() { + output[dst_idx] = color[0]; + output[dst_idx + 1] = color[1]; + output[dst_idx + 2] = color[2]; + output[dst_idx + 3] = 0xFF; + } + px += 1; } - let color = &rlex.palette[usize::from(palette_idx)]; - let x = usize::from(sub.x_start) + px % w; - let y = usize::from(sub.y_start) + px / w; - let dst_idx = (y * sw + x) * 4; - output[dst_idx] = color[0]; - output[dst_idx + 1] = color[1]; - output[dst_idx + 2] = color[2]; - output[dst_idx + 3] = 0xFF; - px += 1; } } } @@ -428,6 +445,8 @@ impl ClearCodecEncoder { } // Composite payload: residual only (bands=0, subcodec=0) + // ClearCodec tiles are bounded by EGFX surface limits, so residual + // data for a single tile is always well within u32 range. let residual_len = u32::try_from(residual_data.len()).unwrap_or(u32::MAX); out.extend_from_slice(&residual_len.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); // bandsByteCount diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs index c2a5d7701..26a4e0c50 100644 --- a/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs @@ -83,7 +83,14 @@ pub fn decode_rlex(data: &[u8]) -> DecodeResult { // Each byte is a run length factor for palette[0] decode_single_palette_segments(&mut src, &mut segments)?; } else { - decode_multi_palette_segments(remaining, &mut src, stop_index_bits, suite_depth_bits, &mut segments)?; + decode_multi_palette_segments( + remaining, + &mut src, + stop_index_bits, + suite_depth_bits, + palette_count, + &mut segments, + )?; } Ok(RlexData { palette, segments }) @@ -106,6 +113,7 @@ fn decode_multi_palette_segments( src: &mut ReadCursor<'_>, stop_index_bits: u8, suite_depth_bits: u8, + palette_count: u8, segments: &mut Vec, ) -> DecodeResult<()> { let stop_mask = (1u8 << stop_index_bits) - 1; @@ -116,6 +124,10 @@ fn decode_multi_palette_segments( let stop_index = packed & stop_mask; let suite_depth = (packed >> stop_index_bits) & depth_mask; + if stop_index >= palette_count { + return Err(invalid_field_err!("rlexStopIndex", "stop_index exceeds palette count")); + } + let start_index = stop_index.saturating_sub(suite_depth); let run_length = decode_run_length(src)?; @@ -211,4 +223,27 @@ mod tests { let data = [128]; // palette_count = 128 > 127 assert!(decode_rlex(&data).is_err()); } + + #[test] + fn reject_stop_index_beyond_palette() { + // palette_count=2, stop_index_bits=1, so valid stop_index is 0 or 1. + // Craft a packed byte with stop_index=1 (valid) then one with stop_index + // that exceeds palette_count by using a 4-palette setup where the mask + // allows index 3 but only 2 entries exist. + // + // palette_count=2, stop_index_bits = bit_length(1) = 1, stop_mask = 0x01 + // Maximum stop_index from 1 bit = 1, which equals palette_count-1. Valid. + // So use palette_count=3 instead: stop_index_bits = bit_length(2) = 2, + // stop_mask = 0x03. Maximum stop_index = 3, but palette only has 3 entries + // (indices 0..2). stop_index=3 is invalid. + let mut data = Vec::new(); + data.push(3); // palette_count + data.extend_from_slice(&[0, 0, 0]); // palette[0] + data.extend_from_slice(&[1, 1, 1]); // palette[1] + data.extend_from_slice(&[2, 2, 2]); // palette[2] + // packed byte: stop_index=3 (bits 1:0), suite_depth=0 (bits 7:2) + data.push(0x03); + data.push(1); // run_length + assert!(decode_rlex(&data).is_err()); + } } diff --git a/crates/ironrdp-testsuite-core/tests/egfx/client.rs b/crates/ironrdp-testsuite-core/tests/egfx/client.rs index 44bb079e4..181afd20b 100644 --- a/crates/ironrdp-testsuite-core/tests/egfx/client.rs +++ b/crates/ironrdp-testsuite-core/tests/egfx/client.rs @@ -7,6 +7,7 @@ use ironrdp_egfx::pdu::{ Codec1Type, CreateSurfacePdu, DeleteSurfacePdu, EndFramePdu, GfxPdu, PixelFormat, ResetGraphicsPdu, StartFramePdu, Timestamp, WireToSurface1Pdu, }; +use ironrdp_graphics::clearcodec::ClearCodecEncoder; use ironrdp_graphics::zgfx::wrap_uncompressed; use ironrdp_pdu::geometry::ExclusiveRectangle; @@ -459,6 +460,175 @@ fn client_tolerates_out_of_bounds_rectangle() { ); } +// ============================================================================ +// Tests: ClearCodec Decode +// ============================================================================ + +#[test] +fn client_dispatches_clearcodec_via_process() { + let mut client = setup_active_client_with_surface(None, 1, 4, 4); + + // Encode a valid ClearCodec frame: 4x4 solid red (BGRA: B=0, G=0, R=255, A=255) + let mut cc_enc = ClearCodecEncoder::new(); + let bgra: Vec = (0..16).flat_map(|_| [0x00u8, 0x00, 0xFF, 0xFF]).collect(); + let cc_data = cc_enc.encode(&bgra, 4, 4); + + let pdu = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 1, + codec_id: Codec1Type::ClearCodec, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 0, + top: 0, + right: 4, + bottom: 4, + }, + bitmap_data: cc_data, + }); + client + .process(0, &encode_for_process(&pdu)) + .expect("ClearCodec decode should succeed"); +} + +#[test] +fn client_clearcodec_produces_rgba_output() { + use std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct CapturedBitmap { + data: Option>, + codec: Option, + } + + struct CapturingHandler { + captured: Arc>, + } + + impl GraphicsPipelineHandler for CapturingHandler { + fn on_bitmap_updated(&mut self, update: &BitmapUpdate) { + let mut cap = self.captured.lock().unwrap(); + cap.data = Some(update.data.clone()); + cap.codec = Some(update.codec_id); + } + } + + let captured = Arc::new(Mutex::new(CapturedBitmap::default())); + let handler = CapturingHandler { + captured: Arc::clone(&captured), + }; + let mut client = GraphicsPipelineClient::new(Box::new(handler), None); + + // Activate and create surface + let confirm = GfxPdu::CapabilitiesConfirm(CapabilitiesConfirmPdu::from_typed(&CapabilitySet::V8 { + flags: CapabilitiesV8Flags::empty(), + })); + client.process(0, &encode_for_process(&confirm)).expect("confirm"); + + let create = GfxPdu::CreateSurface(CreateSurfacePdu { + surface_id: 1, + width: 2, + height: 1, + pixel_format: PixelFormat::XRgb, + }); + client.process(0, &encode_for_process(&create)).expect("create surface"); + + // Encode a 2x1 frame: pixel 0 = blue (BGRA: FF,00,00,FF), pixel 1 = green (BGRA: 00,FF,00,FF) + let mut cc_enc = ClearCodecEncoder::new(); + let bgra = vec![0xFF, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF]; + let cc_data = cc_enc.encode(&bgra, 2, 1); + + let pdu = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 1, + codec_id: Codec1Type::ClearCodec, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 0, + top: 0, + right: 2, + bottom: 1, + }, + bitmap_data: cc_data, + }); + client + .process(0, &encode_for_process(&pdu)) + .expect("ClearCodec should succeed"); + + // Verify BGRA-to-RGBA conversion: blue (BGRA FF,00,00,FF) -> RGBA (00,00,FF,FF) + let cap = captured.lock().unwrap(); + assert_eq!(cap.codec, Some(Codec1Type::ClearCodec)); + let bitmap = cap.data.as_ref().expect("handler should have received bitmap"); + assert_eq!(bitmap.len(), 8, "2 pixels * 4 bytes"); + assert_eq!(&bitmap[0..4], &[0x00, 0x00, 0xFF, 0xFF], "pixel 0: blue in RGBA"); + assert_eq!(&bitmap[4..8], &[0x00, 0xFF, 0x00, 0xFF], "pixel 1: green in RGBA"); +} + +#[test] +fn client_clearcodec_survives_reset() { + let mut client = setup_active_client_with_surface(None, 1, 4, 4); + + // Encode and decode a ClearCodec frame + let mut cc_enc = ClearCodecEncoder::new(); + let bgra: Vec = (0..16).flat_map(|_| [0x00u8, 0x00, 0xFF, 0xFF]).collect(); + let cc_data = cc_enc.encode(&bgra, 4, 4); + + let pdu = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 1, + codec_id: Codec1Type::ClearCodec, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 0, + top: 0, + right: 4, + bottom: 4, + }, + bitmap_data: cc_data, + }); + client + .process(0, &encode_for_process(&pdu)) + .expect("pre-reset ClearCodec should succeed"); + + // Reset graphics (clears decoder caches) + let reset = GfxPdu::ResetGraphics(ResetGraphicsPdu { + width: 1920, + height: 1080, + monitors: vec![], + }); + client + .process(0, &encode_for_process(&reset)) + .expect("reset should succeed"); + + // Re-create surface and decode another frame + let create = GfxPdu::CreateSurface(CreateSurfacePdu { + surface_id: 2, + width: 4, + height: 4, + pixel_format: PixelFormat::XRgb, + }); + client + .process(0, &encode_for_process(&create)) + .expect("create surface after reset"); + + // Use fresh encoder (seq starts at 0 again, matching reset decoder state) + let mut cc_enc2 = ClearCodecEncoder::new(); + let cc_data2 = cc_enc2.encode(&bgra, 4, 4); + + let pdu2 = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 2, + codec_id: Codec1Type::ClearCodec, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 0, + top: 0, + right: 4, + bottom: 4, + }, + bitmap_data: cc_data2, + }); + client + .process(0, &encode_for_process(&pdu2)) + .expect("post-reset ClearCodec should succeed with fresh decoder"); +} + // ============================================================================ // Tests: Multiple Frames // ============================================================================