Skip to content

feat(graphics,egfx): add progressive RFX decode and EGFX integration#1197

Merged
Benoît Cortier (CBenoit) merged 3 commits into
Devolutions:masterfrom
lamco-admin:feat/progressive-rfx-decode
May 21, 2026
Merged

feat(graphics,egfx): add progressive RFX decode and EGFX integration#1197
Benoît Cortier (CBenoit) merged 3 commits into
Devolutions:masterfrom
lamco-admin:feat/progressive-rfx-decode

Conversation

@glamberson
Copy link
Copy Markdown
Contributor

@glamberson Greg Lamberson (glamberson) commented Apr 1, 2026

Depends on #1196 (merged).
Depended on by #1198 (server-side encode) and #1199 (round-trip tests). Part of the multi-codec EGFX implementation described in #1158 (Section 4).

Summary

Add the Progressive RemoteFX decode pipeline (algorithm, tile state, surface management, EGFX-WireToSurface2 dispatch) and a server-side queue helper that wraps pre-encoded progressive bytes into a WireToSurface2Pdu for transmission. Actual progressive encoding lives in #1198.

ironrdp-graphics: Progressive decode algorithms (first-pass and upgrade-pass coefficient reconstruction via SRL + inverse DWT), tile state tracking for multi-pass refinement, surface-level tile management with automatic quality progression, and a per-axis surface-dimension cap matching the MS-RDPEGFX 2.2.2.14 normative ceiling.

ironrdp-egfx: WireToSurface2 dispatch for progressive codec in GraphicsPipelineClient. Server-side queue helper (send_remotefx_progressive_frame) for pre-encoded progressive payloads, used by #1198's encode side.

Stack ordering

This is step 1 of 3. The full chain (must merge in PR-number order):

Step PR Scope
1 this PR Progressive RFX decode pipeline + EGFX client/server dispatch + surface-dimension cap
2 #1198 Progressive RFX server-side encode + mixed-codec frames
3 #1199 Progressive RFX encode/decode round-trip tests

Branch is rebased onto current master. Each subsequent PR is stacked on its predecessor.

Surface-dimension cap

SurfaceTiles::new rejects per-axis dimensions above MAX_SURFACE_DIM = 32768 and returns ProgressiveDecodeError::SurfaceTooLarge. The cap is the next power of two above the MS-RDPEGFX 2.2.2.14 normative ceiling (32766 px), so every spec-conformant surface is accepted. At the cap, the backing Vec<Option<Box<TileState>>> is bounded to 512 × 512 = 262144 slots = 2 MiB of pointer storage per surface before any tile is populated.

Concurrent-surface bounding (total per-connection budget across N attacker-controllable surfaces) is deferred to a follow-up. Per-surface bounding is necessary but not sufficient against a misbehaving server that creates many surfaces; the spec does not cap concurrent surface count.

Known limitation: RFX_TILE_DIFFERENCE deferred

@GlassOnTin surfaced during review that this PR's decoder parses the flags byte on TileSimple / TileFirst but does not consume the RFX_TILE_DIFFERENCE flag (flags & 0x01). Difference-encoded tiles (per MS-RDPRFX 3.1.8.1.7.1) carry coefficient deltas relative to the previously-decoded tile at the same (quantIdx, x, y) slot. This PR lands the non-difference path only; the difference path is intentionally tracked in #1240 pending real-world captures (Server 2025 emits difference-encoded tiles under common progressive refinement conditions; synthetic test fixtures do not exercise this path).

Changes

4 files, ~1483 lines (progressive.rs is the bulk).

Test plan

cargo xtask check fmt/lints/tests/typos/locks all pass on the current head. Coverage includes the at-cap and over-cap rejection paths.

@GlassOnTin
Copy link
Copy Markdown
Contributor

The dwt_extrapolate.rs band layout matches the asymmetric subband sizes (33+31 / 17+16 / 9+8) that Windows Server 2025 emits on every Progressive region we observed during our downstream EGFX work — captures all had region.flags & RFX_DWT_REDUCE_EXTRAPOLATE = 0x01 set, so this path is the common case for modern Windows. Without extrapolate-mode handling the desktop is blank in cmd / PowerShell windows, so this is the right shape.

ProgressiveDecoder::decode_bitmap API is cleaner than the one we shipped in Haven (we keep per-context state at the caller; you keep it in a BTreeMap per codec_context_id and expose delete_context for DeleteEncodingContext — better).

One minor observation: WBT_TILE_FIRST tiles with flags & RFX_TILE_DIFFERENCE = 0x01 (coefficient deltas against a previously-decoded same tile) — does decode_first_pass handle that? Our captures didn't include this case so we only handle the non-difference path; flagging in case it bit you too.

Tested against a captured Server 2025 session: the three Progressive PDUs we have on disk decoded clean. Happy to share the captures + a smoke-test bin if it'd help.

@glamberson
Copy link
Copy Markdown
Contributor Author

Thanks for the careful review and especially for confirming the dwt_extrapolate.rs band layout against your Server 2025 captures, that asymmetric (33+31 / 17+16 / 9+8) layout was the part I was least confident about given the spec only documents the symmetric case. Good to hear the captures all had RFX_DWT_REDUCE_EXTRAPOLATE set — that matches what I saw on the synthetic test fixtures and confirms this is the modern-Windows common case.

On the BTreeMap / delete_context framing: the per-context state moved to the decoder when I realized DeleteEncodingContext can arrive interleaved with frames from other contexts, so caller-side state would force every consumer to re-implement the same lookup logic. Glad it lines up with where you would have ended up.

On RFX_TILE_DIFFERENCE: confirmed gap. The PDU layer parses the flags byte on TileSimple and TileFirst, but the graphics decoder unconditionally sets is_difference = false in decode_first and never reads the flag for coefficient-delta accumulation. Per MS-RDPRFX §3.1.8.1.7.1 a TILE_FIRST with flags & 0x01 = 1 should add the new RLGR-decoded coefficients to the previous decoded coefficients at the same (quantIdx, x, y) slot rather than replace them, and we do not do that. This will be a follow-up PR rather than expanding scope here, since it needs the per-tile previous-coefficient retention plus its own test fixtures (and ideally a captured difference-encoded frame, which is exactly the case your captures didn't include). Yes please on the captures + smoke-test bin offer if it's not too much trouble — would help close that follow-up faster.

Add progressive RemoteFX decode pipeline and wire it into the EGFX
client and server:

ironrdp-graphics: Progressive decode algorithms (first-pass and
upgrade-pass coefficient reconstruction via SRL + inverse DWT),
tile state tracking for multi-pass refinement, surface-level tile
management with automatic quality progression.

ironrdp-egfx: WireToSurface2 dispatch for progressive codec in
GraphicsPipelineClient. Progressive surface state stored on the
server for multi-pass frame scheduling.
The merged progressive primitives PR (Devolutions#1196) added validation that
a ProgressiveRegion must contain at least one rectangle. The test
used an empty rects vec which now fails decoding.
@glamberson
Copy link
Copy Markdown
Contributor Author

Full pre-review pass on the Progressive RFX stack. Three force-pushes, ordering tables added to all three bodies, fuzz infrastructure built on a separate branch for follow-up.

Rebased onto current master (20 days behind → fresh)

Step PR Old head New head
1 #1197 (this PR) `a1089234` `1f223787`
2 #1198 `a22a842d` `840bcd35`
3 #1199 `5b648153` `4ff51ddc`

Each step independently reviewable; intended merge order is PR-number order per the "Stack ordering" table now consistent across all three bodies. Author identity normalized to `Greg Lamberson greg@lamco.io` on every commit (matches the rest of my upstream contributions).

Rebase fix surfaced on #1198

Master moved during the 20-day window and exposed a missing `InclusiveRectangle` import in #1198's `MixedTilePayload::ClearCodec` variant. The variant declared `destination: InclusiveRectangle` but pushed into `WireToSurface1Pdu.destination_rectangle` which is `ExclusiveRectangle` per MS-RDPEGFX 2.2.1.4.1. Corrected the enum variant to use `ExclusiveRectangle` directly; `#1198` body has the post-rebase note.

RFX_TILE_DIFFERENCE deferred per original scoping (#1240)

Restated explicitly in #1197's body. The decoder parses the `flags` byte but does not consume `flags & 0x01` (delta-mode tile per MS-RDPRFX 3.1.8.1.7.1). Per #1240's "Scope" section, this is intentionally tracked as a follow-up pending Server 2025 captures from GlassOnTin that exercise the difference path. Synthetic test fixtures do not cover it.

Fuzz infrastructure (separate branch)

Two fuzz oracles built on `lamco-admin/IronRDP:precheck-fuzz-progressive` (rooted on the rebased #1199 head). To be filed as a small follow-up PR after the stack lands:

  • `progressive_rfx_decode`: feeds arbitrary bytes through `ProgressiveDecoder::decode_bitmap`. 588K iterations / 60s smoke + 5-min extended run, no panics, no real OOMs.
  • `progressive_rfx_multi_pass`: stateful oracle exercising state propagation across N decode_bitmap calls on a single decoder instance (the first workspace implementation of the multi-frame stateful oracle shape; ClearCodec will follow). 118K iterations / 60s smoke, no panics.

A round-trip oracle (encode → decode pixel match) is filed as a skeleton; full implementation needs a stable in-process helper that wraps `encode_first_pass` output as TILE_SIMPLE PDU bytes for re-feeding through `decode_bitmap`. Tracked as a follow-up.

A differential vs FreeRDP 3.15.0 `progressive_decompress` was scoped but deferred for this PR pass: `progressive_decompress` requires multi-surface `PROGRESSIVE_CONTEXT` lifecycle + `REGION16` interaction, which is a larger harness extension than the ClearCodec differential. Will be filed under #1120 once the harness is ready.

Verification post-rebase

Per-PR, on each new head:

  • `cargo xtask check fmt -v` clean
  • `cargo xtask check lints -v` clean (workspace, all-targets)
  • `cargo xtask check tests -v` passes
  • `cargo xtask check typos -v` clean

Address precheck findings on the Progressive RFX decode pipeline.

Cap per-axis surface dimensions at MAX_SURFACE_DIM = 32768 (rounded up
from the MS-RDPEGFX 2.2.2.14 normative ceiling of 32766 px) and return
ProgressiveDecodeError::SurfaceTooLarge for inputs above the cap. The
cap rejects nothing the spec considers conformant; it bounds the
backing Vec<Option<Box<TileState>>> at 512 * 512 = 262144 slots
= 2 MiB of pointer storage per surface before any tile is populated.

Reject streams missing the SYNC + CONTEXT prefix instead of silently
defaulting use_reduce_extrapolate = false, which would decode malformed
input under the wrong band layout. Per MS-RDPEGFX 2.2.4.2 every
Progressive stream MUST begin with SYNC + CONTEXT.

Move the redundant `extern crate alloc` declaration to its conventional
top-of-file location and import BTreeMap and Entry directly.

Document the RawBitReader past-end-of-stream zero-extension behavior at
the type level (matches the FreeRDP reference implementation's
tolerance for short truncation in this upgrade path).

Use saturating_add on TileState::pass to prevent theoretical wraparound
across upgrade-pass overflow.

Revert four incidental import-reorder changes (ironrdp-dvc/{client,lib}
and ironrdp-server/{lib,server}.rs) that drifted in from a local
nightly rustfmt run; both states pass cargo xtask check fmt because the
workspace's imports_granularity and group_imports settings are
nightly-only and the pinned stable rustfmt skips them with warnings.
These four files are outside this PR's stated graphics + egfx scope.

Adds one test (surface_tiles_rejects_over_cap_dimensions) covering the
new at-cap and over-cap behavior on each axis.
@glamberson
Copy link
Copy Markdown
Contributor Author

Per improved internal prechecks I'm running on my PRs, took the following tightening actions on this stack (#1197 / #1198 / #1199):

  • Cap SurfaceTiles per-axis dimensions at MAX_SURFACE_DIM = 32768 (rounded up from the MS-RDPEGFX 2.2.2.14 normative ceiling of 32766 px). Returns ProgressiveDecodeError::SurfaceTooLarge; accepts every spec-conformant surface. Concurrent-surface budget deferred to a follow-up.
  • ProgressiveDecoder::decode_bitmap now rejects streams missing the SYNC + CONTEXT prefix with MissingBlock("CONTEXT") instead of silently defaulting use_reduce_extrapolate = false and decoding under the wrong band layout. Per MS-RDPEGFX 2.2.4.2 every Progressive stream MUST begin with SYNC + CONTEXT.
  • Document RawBitReader's past-end-of-stream zero-extension behavior at the type level (matches FreeRDP's tolerance for short truncation on this upgrade path). TileState::pass uses saturating_add to bound the theoretical wraparound.
  • Revert four incidental import-reorder changes in ironrdp-dvc/{client,lib}.rs and ironrdp-server/{lib,server}.rs that drifted in from a local nightly-rustfmt run; both states pass cargo xtask check fmt because the workspace's imports_granularity and group_imports settings are nightly-only and the pinned stable rustfmt skips them with warnings. These four files are outside this PR's stated graphics + egfx scope.
  • Body rewritten for accuracy on file count, the encode-vs-server-send distinction, and the new cap rationale (spec citation included).

Stack heads after the precheck-driven amend + clean rebase chain: #1197 44e92ec0, #1198 fe1de9ab, #1199 5ec0ac1a. All five cargo xtask check gates green on all three heads. The 37 progressive tests pass on #1199's head including the new surface_tiles_rejects_over_cap_dimensions rejection-path test.

Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request May 20, 2026
…encode invariants

Address precheck findings on the Progressive RFX encode pipeline.

Mark `MixedTilePayload` as `#[non_exhaustive]` so future EGFX codec
additions (Avc444, hardware-accelerated paths, future Microsoft codecs)
land without a SemVer break for downstream consumers that pattern-match
on the enum. Matches the workspace convention used for analogous codec
enums in `ironrdp-egfx/src/pdu/cmd.rs`.

Document the monotonic-refinement contract on `encode_upgrade_pass` per
MS-RDPRFX 3.1.8.1.7.2: the `saturating_sub` on `raw_mag` is deliberate
because upgrade passes only add magnitude bits; the decoder accumulates
with DAS-determined sign, so signed deltas have no place in the wire
format. Future maintainers reading the code should not "fix" this.

Document the i16 truncation on the zero-DAS SRL delta: SRL stream values
are i16 by wire-format definition, so the `clamp_i16` is the wire
boundary rather than a precision compromise.

Document the `count <= 32` invariant on `RawBitWriter::write_bits` at
the type level and add a `debug_assert` to catch misuse. Mirrors the
counterpart documentation added on `RawBitReader` in the Devolutions#1197 amend.
Copy link
Copy Markdown
Member

@CBenoit Benoît Cortier (CBenoit) left a comment

Choose a reason for hiding this comment

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

I’ll be honest, I don’t have the bandwidth to review this properly now, but I think we left the PR sit for long enough now, and you performed a self review yourself, so we may as well merge now and fix issues later if necessary! It will be easier for you to move forward that way.

@CBenoit Benoît Cortier (CBenoit) merged commit a142799 into Devolutions:master May 21, 2026
21 checks passed
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request May 21, 2026
…encode invariants

Address precheck findings on the Progressive RFX encode pipeline.

Mark `MixedTilePayload` as `#[non_exhaustive]` so future EGFX codec
additions (Avc444, hardware-accelerated paths, future Microsoft codecs)
land without a SemVer break for downstream consumers that pattern-match
on the enum. Matches the workspace convention used for analogous codec
enums in `ironrdp-egfx/src/pdu/cmd.rs`.

Document the monotonic-refinement contract on `encode_upgrade_pass` per
MS-RDPRFX 3.1.8.1.7.2: the `saturating_sub` on `raw_mag` is deliberate
because upgrade passes only add magnitude bits; the decoder accumulates
with DAS-determined sign, so signed deltas have no place in the wire
format. Future maintainers reading the code should not "fix" this.

Document the i16 truncation on the zero-DAS SRL delta: SRL stream values
are i16 by wire-format definition, so the `clamp_i16` is the wire
boundary rather than a precision compromise.

Document the `count <= 32` invariant on `RawBitWriter::write_bits` at
the type level and add a `debug_assert` to catch misuse. Mirrors the
counterpart documentation added on `RawBitReader` in the Devolutions#1197 amend.
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