Skip to content

feat(egfx): add client-side EGFX with surface management and AVC420 decode#1103

Open
Greg Lamberson (glamberson) wants to merge 1 commit intoDevolutions:masterfrom
lamco-admin:feat/egfx-client-core
Open

feat(egfx): add client-side EGFX with surface management and AVC420 decode#1103
Greg Lamberson (glamberson) wants to merge 1 commit intoDevolutions:masterfrom
lamco-admin:feat/egfx-client-core

Conversation

@glamberson
Copy link
Contributor

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):

  • Surface lifecycle: CreateSurface, DeleteSurface, MapSurfaceToOutput, ResetGraphics [3.3.1.6]
  • FrameAcknowledge sent after every EndFrame with queue depth tracking [3.3.5.12]
  • WireToSurface1 dispatch by codec_id [3.3.5.2]
  • AVC420 decode via RFX_AVC420_BITMAP_STREAM [2.2.4.4]
  • Capability advertisement on DVC channel start [3.3.5.1]

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).

Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Feb 16, 2026
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.
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Feb 16, 2026
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.
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Feb 17, 2026
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.
@CBenoit
Copy link
Member

Heads-up: this is taking me more time than I initially thought, but I made progress.

@glamberson
Copy link
Contributor Author

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.

Lamco Development Office Staff (lamco-office) pushed a commit to lamco-admin/IronRDP that referenced this pull request Feb 22, 2026
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.
@glamberson
Copy link
Contributor Author

Updated: rebased + capability gating + complete PDU dispatch

Rebased onto current master (resolved conflict from edition 2024 migration).

Capability gating fix: DvcProcessor::start() now filters AVC-capable capability sets when no H264Decoder is provided. Previously, the client would advertise V10.7/V8.1 AVC support regardless, causing the server to send H.264 frames that would be silently dropped. With no decoder, only non-AVC sets (like V8) are advertised.

Complete server→client PDU dispatch: Added 11 dedicated handler trait methods for all remaining server→client PDUs that were falling through to on_unhandled_pdu():

  • Surface operations: on_solid_fill, on_surface_to_surface
  • Cache operations: on_surface_to_cache, on_cache_to_surface, on_evict_cache_entry, on_cache_import_reply
  • Surface mapping: on_map_surface_to_window, on_map_surface_to_scaled_output, on_map_surface_to_scaled_window
  • Progressive codec: on_wire_to_surface2, on_delete_encoding_context

All methods have default no-op implementations — no breaking changes for existing handler implementations. Each includes MS-RDPEGFX spec section references.

…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.
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Mar 11, 2026
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.
@glamberson
Copy link
Contributor Author

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.

@glamberson
Copy link
Contributor Author

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!

@CBenoit
Copy link
Member

Benoît Cortier (CBenoit) commented Mar 12, 2026

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.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 decode module defining H264Decoder, DecodedFrame, and DecoderError for 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.

Comment on lines +651 to +655
// 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,
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
if let Some(ref mut decoder) = self.h264_decoder {
decoder.reset();
}

Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;).

Suggested change
// 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();

Copilot uses AI. Check for mistakes.
Comment on lines +696 to +707
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,
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
Comment on lines +976 to +978
assert_eq!(messages.len(), 1);
// The default capabilities() returns V10.7, V8.1, V8 — without
// a decoder, only V8 should survive the filter.
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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"
);

Copilot uses AI. Check for mistakes.
//! 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.
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
//! no `std` dependency, `Send` only.
//! `Send` only. They are intended for use in `std` environments;
//! `no_std` + `alloc` support is not currently guaranteed.

Copilot uses AI. Check for mistakes.
Comment on lines +714 to +722
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,
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants