feat(egfx): add client-side EGFX with surface management and AVC420 decode#1103
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 ordering before computing dimensions - Reset frame tracking state on ResetGraphics (preserving cap state) - Reject undersized decoded frames in AVC420 path - Assert cap set contents in capability advertisement tests - Clarify std requirement in decode module docs - Convert BGRX wire format to RGBA in uncompressed bitmap path
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.
|
OK I think this is ready for review now. Thank you. |
|
Well, there's no reason to sit on moving these tests which can be moved... I moved the public-API tests to the testsuite (egfx/client.rs). They go through process() with ZGFX-wrapped payloads now instead of calling handle_pdu() directly. I kept a few unit tests inline for things that need private access: State transitions, frame tracking after reset, crop_decoded_frame, convert_uncompressed_to_rgba. Same approach as fast_path.rs and display.rs. I also added some new coverage while I was at it as I'm focused on close, restart, and error conditions anyway:
Now it's 5 inline + 16 testsuite = 21 total tests, up from 17. All xtask checks green. |
Move public-API tests to ironrdp-testsuite-core/tests/egfx/client.rs, exercising the DVC process() path with ZGFX-wrapped payloads. Keep private-access unit tests inline (state transitions, frame tracking, crop/convert helpers). New coverage: surface lifecycle, error paths (unknown surface, invalid rectangle, out-of-bounds tolerance), multi-frame tracking, close. 5 inline + 16 testsuite = 21 total (was 17 inline-only).
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.
Benoît Cortier (CBenoit)
left a comment
There was a problem hiding this comment.
Thank you! All my main concerns are gone now.
I still left a few extra comments below, but I think this can be follow up PRs. I’ll merge this PR now because I made you wait for too long already, and I think it’s already very solid.
LGTM!
| fn handle_create_surface(&mut self, surface_id: u16, width: u16, height: u16, pixel_format: PixelFormat) { | ||
| let surface = Surface { | ||
| id: surface_id, | ||
| width, | ||
| height, | ||
| pixel_format, | ||
| is_mapped: false, | ||
| output_origin_x: 0, | ||
| output_origin_y: 0, | ||
| }; | ||
|
|
||
| debug!(surface_id, width, height, ?pixel_format, "Surface created"); | ||
| self.handler.on_surface_created(&surface); | ||
| self.surfaces.insert(surface_id, surface); | ||
| } |
There was a problem hiding this comment.
question: FreeRDP defensively handles CreateSurface on an already-used surface ID by attempting a delete first before recreating the surface. Here we overwrite the map entry directly.
create surface PDU sometimes happens for surface ID that are already in use and have not
been removed yet. Ensure that there is no surface with the new ID by trying to remove it
manually.
I’m sure you tested and the current code interoperates correctly, but what do you think? Should we match FreeRDP?
| // Per spec, ResetGraphics implicitly destroys all surfaces | ||
| self.surfaces.clear(); |
There was a problem hiding this comment.
Can you quote the part of the spec about this? Is it MS-RDPEGFX §3.3.5.14 (Processing an RDPGFX_RESET_GRAPHICS_PDU message)?
The structure and fields of the RDPGFX_RESET_GRAPHICS_PDU message are specified in section 2.2.2.14. The header field MUST be processed as specified in section 3.1.5.1. Once the RDPGFX_RESET_GRAPHICS_PDU message has been successfully decoded, the client MUST resize the Graphics Output Buffer (section 3.3.1.7) ADM element.
I don’t understand where it says all surfaces should be destroyed. Can you double check that?
Incidentally, FreeRDP in the rdpgfx_recv_reset_graphics_pdu function fires the ResetGraphics callback and does nothing to the surface table: https://github.com/Devolutions/FreeRDP/blob/1a40f8fdb778a5b8d92c3c6d59290cc94e44d081/channels/rdpgfx/client/rdpgfx_main.c#L510-L604
| //! H.264 data arrives inside [RFX_AVC420_BITMAP_STREAM][1] payloads | ||
| //! within `RDPGFX_WIRE_TO_SURFACE_PDU_1` messages. The NAL units | ||
| //! are in AVC format (4-byte big-endian length prefix per NAL unit), | ||
| //! not Annex B (start code prefix). |
There was a problem hiding this comment.
This documentation seems to be backwards?
As per MS-RDPEGFX §2.2.4.4 (RFX_AVC420_BITMAP_STREAM):
The RFX_AVC420_BITMAP_STREAM structure encapsulates regions of a graphics frame compressed using the MPEG-4 AVC/H.264 codec in YUV420p mode (as specified in [ITU-H.264-201201]) and conforming to the byte stream format specified in [ITU-H.264-201201] Annex B.
The H.264 data uses Annex B start codes (0x00 0x00 0x00 0x01), not AVCC length prefixes. Any downstream decoder implementation built on this comment will be wrong. The test data in client_dispatches_avc420_via_process uses Annex B bytes ([0x00, 0x00, 0x00, 0x01, 0x67]), which seems actually correct.
Can you double check?
| fn decode_avc420(&mut self, surface_id: u16, dest_rect: &InclusiveRectangle, bitmap_data: &[u8]) -> PduResult<()> { | ||
| let mut cursor = ReadCursor::new(bitmap_data); | ||
| let stream = Avc420BitmapStream::decode(&mut cursor).map_err(|e| decode_err!(e))?; | ||
|
|
||
| let Some(ref mut decoder) = self.h264_decoder else { | ||
| debug!("No H.264 decoder configured, skipping AVC420 frame"); | ||
| return Ok(()); | ||
| }; | ||
|
|
||
| let frame = decoder | ||
| .decode(stream.data) | ||
| .map_err(|e| pdu_other_err!("H.264 decode", source: e))?; |
There was a problem hiding this comment.
I don’t see rectangles array handling (carried by Avc420BitmapStream).
For the common single-region case this is probably harmless, but for a multi-region frame (the server sends multiple region rectangles and quant values covering non-contiguous areas of the decoded frame), the current code applies the wrong single destination rect and ignores the actual region metadata.
Cross checking with FreeRDP: FreeRDP passes the full H264_METABLOCK (with all region rects and quant quality values) to the codec layer.
This won't be observable until a server sends multi-region AVC420 frames, but it's worth at least a // TODO: multi-region AVC420 support comment.
Can be a follow up PR if you prefer.
b6200c7
into
Devolutions:master
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.
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).