feat(egfx): add client-side EGFX with surface management and AVC420 decode#1103
feat(egfx): add client-side EGFX with surface management and AVC420 decode#1103Greg Lamberson (glamberson) wants to merge 1 commit intoDevolutions:masterfrom
Conversation
Adds an optional OpenH264-backed implementation of the H264Decoder trait introduced in Devolutions#1103. - AVC-to-Annex B NAL unit conversion (OpenH264 requires start code format) - YUV420p to RGBA color space conversion - Checked arithmetic on NAL lengths to reject malicious input - Feature-gated: `openh264 = ["dep:openh264"]`, off by default 4 tests using the encoder to generate valid AVC bitstreams for round-trip validation.
4213016 to
de1cb0a
Compare
Adds an optional OpenH264-backed implementation of the H264Decoder trait introduced in Devolutions#1103. - AVC-to-Annex B NAL unit conversion (OpenH264 requires start code format) - YUV420p to RGBA color space conversion - Checked arithmetic on NAL lengths to reject malicious input - Feature-gated: `openh264 = ["dep:openh264"]`, off by default 4 tests using the encoder to generate valid AVC bitstreams for round-trip validation.
Adds an optional `openh264` feature that provides a concrete H264Decoder implementation using Cisco's OpenH264 library, loaded dynamically at runtime via libloading for patent compliance. Depends on Devolutions#1103 which introduces the H264Decoder trait and client core. The decoder handles the format conversion required by the RDP protocol: AVC-format NAL units (4-byte BE length prefix per [2.2.4.4]) are converted to Annex B start codes before passing to OpenH264, then YUV420p output is converted to RGBA for the client pipeline. Patent compliance: - Default `openh264` feature uses `libloading` (Cisco binary, patent-covered) - Separate `openh264-source` feature for dev/testing only (no patent coverage) - Runtime path configuration via `OpenH264DecoderConfig` - System path search for well-known library locations - `OPENH264_ATTRIBUTION` constant for license condition 3 - `OPENH264-LICENSING.md` documents compliance requirements New optional dependency: - `openh264 = { version = "0.6", default-features = false, features = ["libloading"] }` - Feature: `openh264 = ["dep:openh264"]` - Feature: `openh264-source = ["dep:openh264", "openh264/source"]` (dev only) 4 tests generate valid H.264 bitstreams via the OpenH264 encoder, convert to AVC format, and decode through the full pipeline to verify round-trip correctness. Tests require `--features openh264-source`. Without the feature enabled, all existing tests continue to pass unchanged.
|
Heads-up: this is taking me more time than I initially thought, but I made progress. |
|
No problem at all. This is a big one — client-side EGFX with surface management and AVC420 decode has a lot of moving parts. Take the time you need to study it properly. I'd rather have thorough feedback than a rushed review. |
de1cb0a to
77804a5
Compare
Adds an optional OpenH264-backed implementation of the H264Decoder trait introduced in Devolutions#1103. - AVC-to-Annex B NAL unit conversion (OpenH264 requires start code format) - YUV420p to RGBA color space conversion - Checked arithmetic on NAL lengths to reject malicious input - Feature-gated: `openh264 = ["dep:openh264"]`, off by default 4 tests using the encoder to generate valid AVC bitstreams for round-trip validation.
77804a5 to
63afce1
Compare
|
Updated: rebased + capability gating + complete PDU dispatch Rebased onto current Capability gating fix: Complete server→client PDU dispatch: Added 11 dedicated handler trait methods for all remaining server→client PDUs that were falling through to
All methods have default no-op implementations — no breaking changes for existing handler implementations. Each includes MS-RDPEGFX spec section references. |
63afce1 to
28af4bf
Compare
…ecode Add comprehensive client-side EGFX implementation with: - Surface tracking per MS-RDPEGFX 3.3.1.6 - Capability negotiation with AVC gating (V8/V8.1/V10.7) - H.264 AVC420 decode via pluggable H264Decoder trait - Frame acknowledgment per MS-RDPEGFX 3.3.5.12 - Macroblock-aligned frame cropping - Complete server→client PDU dispatch (21 handler methods) - Codec capability extraction from all 11 capability versions Capability gating: when no H264Decoder is provided, AVC-capable capability sets are automatically filtered from the advertisement. This prevents the server from sending H.264 frames that would be silently dropped.
28af4bf to
8f62974
Compare
Adds an optional OpenH264-backed implementation of the H264Decoder trait introduced in Devolutions#1103. - AVC-to-Annex B NAL unit conversion (OpenH264 requires start code format) - YUV420p to RGBA color space conversion - Checked arithmetic on NAL lengths to reject malicious input - Warn on malformed AVC NAL units instead of silent discard - Checked arithmetic for RGBA buffer allocation - Zero-dimension surface validation in CreateSurface handler - Truncation warning in crop_decoded_frame - Feature-gated: openh264 = ["dep:openh264"], off by default 4 tests using the encoder to generate valid AVC bitstreams for round-trip validation.
|
I have to admit that before I really wasn't focusing on all the use cases for this implementaton. I was jsut trying to plug a hole. But as I've started doing betas of my multicodec development and also looking at crates I hadn't been using before (ironrdp-web), i started to see the mess here that needs cleaning up. I hope these changes start to get this shaped into what you expect as we move forward with egfx. Thanks. |
|
Hey Benoît Cortier (@CBenoit), this is rebased and all CI green. Could you review it? This is really critical for everything I'm doing now. I know you're busy, but this really blocks a ton of progress. Thanks! |
|
Oh no… I remember leaving a feedback comment here, but I don’t see it; I probably messed something up. I’ll re-evaluate the PR. EDIT: Also launching a Copilot run for early feedback. |
There was a problem hiding this comment.
Pull request overview
This PR replaces the previous trace-only EGFX client stub with a functional client-side implementation that negotiates capabilities, tracks surfaces, decodes AVC420 via a pluggable H.264 decoder trait, and sends required frame acknowledgements.
Changes:
- Added a new
decodemodule definingH264Decoder,DecodedFrame, andDecoderErrorfor pluggable AVC decode. - Rewrote the EGFX client to handle core MS-RDPEGFX PDUs (cap negotiation, ResetGraphics, surface lifecycle, WireToSurface1 dispatch, EndFrame → FrameAcknowledge).
- Added client-side unit tests and a frame-cropping helper for macroblock-aligned decode output.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| crates/ironrdp-egfx/src/lib.rs | Exposes the new decode module from the crate root. |
| crates/ironrdp-egfx/src/decode.rs | Introduces a decoder abstraction layer (trait + frame/error types) for client-side AVC decode. |
| crates/ironrdp-egfx/src/client.rs | Implements the EGFX client state machine, surface tracking, AVC420 decode dispatch, and FrameAcknowledge generation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| // Validate destination rectangle against surface bounds | ||
| if pdu.destination_rectangle.right >= surface.width || pdu.destination_rectangle.bottom >= surface.height { | ||
| warn!( | ||
| surface_id = pdu.surface_id, | ||
| rect_right = pdu.destination_rectangle.right, |
There was a problem hiding this comment.
InclusiveRectangle::width()/height() do unchecked u16 subtraction (right - left + 1), which will wrap if the server sends an invalid rectangle (e.g. left > right / top > bottom). This code only warns on out-of-bounds but still later calls width()/height(), which can lead to huge target_width/height values and excessive allocations (e.g. in crop_decoded_frame). Consider validating rectangle ordering and bounds up-front (return an error for invalid rectangles, and/or clamp/intersect with the surface rectangle) before using width()/height().
| if let Some(ref mut decoder) = self.h264_decoder { | ||
| decoder.reset(); | ||
| } | ||
|
|
There was a problem hiding this comment.
ResetGraphics clears surfaces and resets the decoder, but it leaves frame/queue tracking state (current_frame_id, frames_queued, possibly negotiated_caps/codec_caps) untouched. If a reset occurs mid-stream, subsequent FrameAcknowledge PDUs can report an incorrect queue_depth and the client may appear to be in the middle of a frame. Consider resetting frame-related state in handle_reset_graphics (e.g. current_frame_id = None; frames_queued = 0;).
| // Reset frame/queue tracking and capability negotiation state | |
| self.current_frame_id = None; | |
| self.frames_queued = 0; | |
| self.negotiated_caps = None; | |
| self.codec_caps = CodecCapabilities::default(); |
| let dest_width = dest_rect.width(); | ||
| let dest_height = dest_rect.height(); | ||
|
|
||
| let cropped_data = crop_decoded_frame(&frame.data, frame.width, frame.height, dest_width, dest_height); | ||
|
|
||
| let update = BitmapUpdate { | ||
| surface_id, | ||
| destination_rectangle: dest_rect.clone(), | ||
| codec_id: Codec1Type::Avc420, | ||
| data: cropped_data, | ||
| width: dest_width, | ||
| height: dest_height, |
There was a problem hiding this comment.
crop_decoded_frame can return fewer than target_width * target_height * 4 bytes when the decoded frame is smaller than the destination rectangle (it clamps rows/columns via min). However BitmapUpdate docs (and the fields set below) imply the buffer always matches width * height * 4. Consider rejecting frames where frame.width/height are smaller than the destination rectangle (or clamping the reported width/height to the cropped output) so handlers don’t get inconsistent dimensions/buffer lengths.
| let dest_width = dest_rect.width(); | |
| let dest_height = dest_rect.height(); | |
| let cropped_data = crop_decoded_frame(&frame.data, frame.width, frame.height, dest_width, dest_height); | |
| let update = BitmapUpdate { | |
| surface_id, | |
| destination_rectangle: dest_rect.clone(), | |
| codec_id: Codec1Type::Avc420, | |
| data: cropped_data, | |
| width: dest_width, | |
| height: dest_height, | |
| let requested_width = dest_rect.width(); | |
| let requested_height = dest_rect.height(); | |
| // Clamp the requested destination size to the decoded frame dimensions | |
| let effective_width = requested_width.min(frame.width); | |
| let effective_height = requested_height.min(frame.height); | |
| if effective_width != requested_width || effective_height != requested_height { | |
| warn!( | |
| frame_width = frame.width, | |
| frame_height = frame.height, | |
| requested_width, | |
| requested_height, | |
| effective_width, | |
| effective_height, | |
| "AVC420 destination rectangle larger than decoded frame; clamping update size to frame" | |
| ); | |
| } | |
| let cropped_data = | |
| crop_decoded_frame(&frame.data, frame.width, frame.height, effective_width, effective_height); | |
| let update = BitmapUpdate { | |
| surface_id, | |
| destination_rectangle: dest_rect.clone(), | |
| codec_id: Codec1Type::Avc420, | |
| data: cropped_data, | |
| width: effective_width, | |
| height: effective_height, |
| assert_eq!(messages.len(), 1); | ||
| // The default capabilities() returns V10.7, V8.1, V8 — without | ||
| // a decoder, only V8 should survive the filter. |
There was a problem hiding this comment.
This test intends to verify AVC capability filtering, but it currently has no assertion that inspects the advertised CapabilitiesAdvertisePdu contents (it only checks messages.len() == 1). As written, it will pass even if filtering regresses. Consider downcasting/decoding the returned DvcMessage to confirm only V8 (or otherwise non-AVC) capability sets are advertised when h264_decoder is None.
| assert_eq!(messages.len(), 1); | |
| // The default capabilities() returns V10.7, V8.1, V8 — without | |
| // a decoder, only V8 should survive the filter. | |
| assert_eq!(messages.len(), 1); | |
| // Decode the advertised capabilities and ensure only V8 (non-AVC) caps are present. | |
| // The default capabilities() returns V10.7, V8.1, V8 — without | |
| // a decoder, only V8 should survive the filter. | |
| let msg = &messages[0]; | |
| // Extract the raw payload from the DvcMessage and decode it as a CapabilitiesAdvertisePdu. | |
| let mut cursor = ReadCursor::new(&msg.payload); | |
| let caps_pdu = CapabilitiesAdvertisePdu::decode(&mut cursor).expect("failed to decode capabilities PDU"); | |
| // We expect exactly one capability set, and it must be V8 (non-AVC). | |
| assert_eq!(caps_pdu.capability_sets.len(), 1, "expected exactly one capability set when no decoder is present"); | |
| assert!( | |
| matches!(caps_pdu.capability_sets[0], CapabilitySet::V8(_)), | |
| "expected only V8 capability set without decoder" | |
| ); |
| //! This module provides pluggable decoder traits that allow consumers | ||
| //! to bring their own codec implementations (e.g., openh264, ffmpeg, | ||
| //! hardware decoders). The traits are designed for core tier: no I/O, | ||
| //! no `std` dependency, `Send` only. |
There was a problem hiding this comment.
The module-level docs state “no std dependency”, but the implementation uses Vec, String, and Box without alloc imports, and the crate itself is std-based. This is misleading for consumers expecting no_std compatibility. Consider either updating the docs to reflect the actual requirements, or switching to extern crate alloc + alloc::{vec::Vec, string::String, boxed::Box} and documenting that the module is no_std + alloc friendly.
| //! no `std` dependency, `Send` only. | |
| //! `Send` only. They are intended for use in `std` environments; | |
| //! `no_std` + `alloc` support is not currently guaranteed. |
| fn handle_uncompressed(&mut self, pdu: crate::pdu::WireToSurface1Pdu) { | ||
| let dest_width = pdu.destination_rectangle.width(); | ||
| let dest_height = pdu.destination_rectangle.height(); | ||
|
|
||
| let update = BitmapUpdate { | ||
| surface_id: pdu.surface_id, | ||
| destination_rectangle: pdu.destination_rectangle, | ||
| codec_id: Codec1Type::Uncompressed, | ||
| data: pdu.bitmap_data, |
There was a problem hiding this comment.
In the Uncompressed path, BitmapUpdate.data is set to pdu.bitmap_data without any pixel-format conversion, but BitmapUpdate’s docs describe the buffer as RGBA. Also, the update does not carry the pixel_format from the PDU, so a handler cannot reliably interpret uncompressed pixels. Consider either (a) converting uncompressed XRgb/ARgb to the declared RGBA format before calling on_bitmap_updated, or (b) adding pixel_format (and documenting per-codec buffer formats) to BitmapUpdate.
| fn handle_uncompressed(&mut self, pdu: crate::pdu::WireToSurface1Pdu) { | |
| let dest_width = pdu.destination_rectangle.width(); | |
| let dest_height = pdu.destination_rectangle.height(); | |
| let update = BitmapUpdate { | |
| surface_id: pdu.surface_id, | |
| destination_rectangle: pdu.destination_rectangle, | |
| codec_id: Codec1Type::Uncompressed, | |
| data: pdu.bitmap_data, | |
| /// Convert uncompressed 32bpp little-endian pixels (XRGB/ARGB) to RGBA8888. | |
| /// | |
| /// The wire format for uncompressed graphics is typically 0xAARRGGBB in a | |
| /// 32-bit little-endian word, which corresponds to bytes [B, G, R, A]. | |
| /// This helper reorders bytes into [R, G, B, 0xFF], discarding the source | |
| /// alpha channel and treating all pixels as fully opaque, which matches | |
| /// the previous behavior that did not expose alpha to handlers. | |
| fn convert_uncompressed_to_rgba(src: &[u8]) -> Vec<u8> { | |
| let mut dst = Vec::with_capacity(src.len()); | |
| for pixel in src.chunks_exact(4) { | |
| let b = pixel[0]; | |
| let g = pixel[1]; | |
| let r = pixel[2]; | |
| // Ignore source alpha / padding and treat as fully opaque. | |
| dst.extend_from_slice(&[r, g, b, 0xFF]); | |
| } | |
| dst | |
| } | |
| fn handle_uncompressed(&mut self, pdu: crate::pdu::WireToSurface1Pdu) { | |
| let dest_width = pdu.destination_rectangle.width(); | |
| let dest_height = pdu.destination_rectangle.height(); | |
| let rgba_data = GraphicsPipelineClient::convert_uncompressed_to_rgba(&pdu.bitmap_data); | |
| let update = BitmapUpdate { | |
| surface_id: pdu.surface_id, | |
| destination_rectangle: pdu.destination_rectangle, | |
| codec_id: Codec1Type::Uncompressed, | |
| data: rgba_data, |
Replaces the 74-line trace-only EGFX client stub with a working client implementation that handles surface management, H.264 AVC420 decode, and frame acknowledgment.
New files:
decode.rs: H264Decoder trait, DecodedFrame, DecoderError. Core tier (no I/O, Send only). Consumers bring their own decoder implementation.client.rs: Full rewrite. GraphicsPipelineClient with DvcProcessor integration.Protocol compliance (MS-RDPEGFX):
The H264Decoder trait accepts AVC-format NAL units (4-byte BE length prefix) from the bitmap stream and returns RGBA pixel data. Frame cropping handles H.264 macroblock alignment (e.g., 1920x1088 decoded to fit 1920x1080 destination).
Unhandled PDUs (SolidFill, SurfaceToSurface, cache operations, AVC444) are forwarded to GraphicsPipelineHandler::on_unhandled_pdu() for application-level handling.
No new dependencies. No breaking changes to existing public API beyond the expanded GraphicsPipelineClient::new() signature (now takes an optional H264Decoder).