Skip to content

feat(egfx)!: switch WireToSurface1Pdu rectangles to exclusive bounds#1238

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

feat(egfx)!: switch WireToSurface1Pdu rectangles to exclusive bounds#1238
Greg Lamberson (glamberson) wants to merge 1 commit intoDevolutions:masterfrom
lamco-admin:feat/egfx-rect-exclusive

Conversation

@glamberson
Copy link
Copy Markdown
Contributor

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

Summary

Spotted by GlassOnTin during review of #1175 against Haven's downstream EGFX implementation and Windows Server 2025 captures: MS-RDPEGFX 2.2.1.4.1 RDPGFX_RECT16 specifies right and bottom as exclusive (one-past-end), but WireToSurface1Pdu.destination_rectangle was typed as InclusiveRectangle, which interprets the same wire bytes as inclusive bounds. The result was a one-pixel discrepancy in width/height computed via the trait method, which surfaces on tiles where a precise dimension match matters (ClearCodec glyph cache hits, AVC420 macroblock alignment).

The numerical fix landed in #1175 by computing right - left directly at the call sites. This PR is the type-level fix that makes the wire-format and the Rust type agree, so the trait method Rectangle::width() returns the spec-correct value without inline workarounds.

Changes

  • WireToSurface1Pdu.destination_rectangle: ExclusiveRectangle (was InclusiveRectangle)
  • BitmapUpdate.destination_rectangle: ExclusiveRectangle (was InclusiveRectangle) — public API for GraphicsPipelineHandler::on_bitmap_updated consumers
  • compute_dest_rect returns ExclusiveRectangle, converts from Avc420Region's inclusive bounds with +1 on right and bottom
  • send_uncompressed_frame drops the dest_width.saturating_sub(1) / dest_height.saturating_sub(1) workarounds
  • Test fixtures updated from inclusive to exclusive coordinates (e.g., a 4×4 region was right=3, bottom=3, now right=4, bottom=4)

Breaking change

Marked with ! in the conventional commit. The break is in two places:

  1. Direct construction of WireToSurface1Pdu: callers must now use exclusive bounds (right = width, not right = width - 1).
  2. Implementations of GraphicsPipelineHandler::on_bitmap_updated: the BitmapUpdate.destination_rectangle field type changes. The wire format on the network does not change; only the Rust interpretation does.

Pre-1.0, and consistent with the project's stance on breaking unreleased API for correctness (per #1209 discussion). No crates.io consumers are affected today since ironrdp-egfx has not yet published.

Out of scope

The other RDPGFX_RECT16 uses in crates/ironrdp-egfx/src/pdu/cmd.rs (SolidFillPdu.rectangles, SurfaceToSurfacePdu.source_rectangle, SurfaceToCachePdu.source_rectangle, CacheToSurfacePdu.destinations) have the same wire-format mismatch and stay InclusiveRectangle in this PR. Each warrants its own audit and breaking-change announcement; will submit separate PRs for consideration of these additional spec-compliant breaking changes.

The Avc420Region struct stays inclusive (it is documented that way and is consumer-facing). compute_dest_rect converts at the boundary.

Test plan

  • cargo xtask check fmt -v clean
  • cargo xtask check lints -v clean (workspace, all-targets, with helper + __bench features)
  • cargo xtask check tests -v passes (all egfx integration tests, including the ClearCodec / AVC420 / uncompressed paths)
  • Confirm against captured Server 2025 EGFX session (pending GlassOnTin's offered captures)

MS-RDPEGFX 2.2.1.4.1 RDPGFX_RECT16 specifies the right and bottom fields
as exclusive (one-past-end), matching FreeRDP's reference implementation.
The struct previously used InclusiveRectangle, which interprets the same
bytes as inclusive bounds and yields width/height that are one larger
than the wire format intends. The mismatch was visible against modern
Windows servers when ClearCodec tiles arrived: a 64x64 tile encoded as
right=64, left=0 was being decoded as 65 wide.

This commit changes WireToSurface1Pdu.destination_rectangle and
BitmapUpdate.destination_rectangle to ExclusiveRectangle, updates the
server-side construction sites (compute_dest_rect now adds 1 to convert
from Avc420Region's inclusive bounds, send_uncompressed_frame drops the
saturating_sub(1) workaround), and updates test fixtures to use exclusive
coordinates.

This is a breaking change to the ironrdp-egfx public API: handler
implementations of GraphicsPipelineHandler::on_bitmap_updated will see
ExclusiveRectangle instead of InclusiveRectangle, and any direct
construction of WireToSurface1Pdu must use exclusive bounds. The wire
format on the network is unchanged: this commit only fixes how the
parsed bytes are interpreted in Rust types.

Other RDPGFX_RECT16 uses in cmd.rs (SolidFill, SurfaceToSurface,
SurfaceToCache, CacheToSurface) remain InclusiveRectangle for now and
will follow as a separate PR.
GlassOnTin added a commit to GlassHaven/Haven that referenced this pull request Apr 30, 2026
Sister-env-var to EGFX_DUMP_DIR (which writes rendered PPM frames).
This one writes the post-zgfx-decompressed byte slice for every
ServerPdu the EGFX channel decodes, plus the legacy slow-path
BitmapUpdateData payload, into <dir>/pdu_NNNN_<kind>.bin.

Motivated by upstream IronRDP PR Devolutions/IronRDP#1238 — the
maintainer asked for Server 2025 captures to validate the
RDPGFX_RECT16 inclusive→exclusive type flip on WireToSurface1Pdu and
BitmapUpdate. The dump is exactly what the parser saw, so feeding
the bytes back through `<ServerPdu as Decode>::decode` is a
deterministic reproduction the upstream test plan can pin against.

pdu_kind_label is exhaustive (no `_` arm) so a future ironrdp version
adding a new ServerPdu variant fails the build and forces us to
extend the filename mapping.
GlassOnTin added a commit to GlassHaven/Haven that referenced this pull request Apr 30, 2026
Companion to the dumper added in bd2e52d. Reads a captured .bin (or
batches a whole directory) and prints a one-line summary per file:
ServerPdu variant, surface ids, codec ids, payload sizes — plus, for
WireToSurface1, both the inclusive and the exclusive interpretation
of width/height from the destination_rectangle. That dual print makes
attached fixtures self-document what Devolutions/IronRDP#1238 is
fixing: spec-correct bytes have right=W (exclusive), but today's
ironrdp parses them as InclusiveRectangle and the trait method's
width() returns W+1.

Auto-detects mode from filename (slow_path_bitmap_update_*.bin →
BitmapUpdateData, else ServerPdu); --egfx and --slow-path force a
mode. Exit codes: 0 = all decoded, 1 = none decoded, 2 = mixed.
Useful as a CI gate before sending captures upstream.

Build with `cargo build --features host-cli --bin replay-egfx-pdu`.
Codec1Type / Codec2Type matches are exhaustive so a future ironrdp
codec addition fails the build here and forces an update.
@GlassOnTin
Copy link
Copy Markdown
Contributor

Greg Lamberson (@glamberson) — Server 2025 capture confirms the exclusive interpretation, both against common tile sizes and against the codec's own internal dimension headers.

Fixtures attached as binary assets on a small fixture-only release on the Haven side: https://github.com/GlassHaven/Haven/releases/tag/egfx-pr1238-fixtures-2026-04-30. Each file is the post-zgfx-decompressed bytes of a single ServerPdu from a live winserver2025 (KVM) session via Haven's rdp-cli. Feed back through <ServerPdu as Decode>::decode(&mut ReadCursor::new(&bytes)) for a deterministic reproduction; the dumper that produced them is at GlassHaven/Haven@bd2e52d7 and the replay/triage tool is at GlassHaven/Haven@c6cdcc49.

file dest rect inclusive (today's ironrdp) exclusive (this PR)
wts1_64x64_clearcodec_tile.bin (128, 192, 192, 256) 65×65 64×64
wts1_576x128_nscodec_subregion.bin (64, 64, 640, 192) 577×129 576×128
wts1_64x32_taskbar_strip.bin (0, 768, 64, 800) 65×33 64×32
wts1_8x4_micro_tile.bin (374, 521, 382, 525) 9×5 8×4
create_surface_1280x800_context.bin — (CreateSurface for surface 0) surface 0 = 1280×800

The 576×128 file is the strongest standalone reproducer: the same PDU's ClearCodec NSCodec sub-region header parses 576×128 internally — the codec's own metadata says 576×128, and the destination_rectangle's exclusive width agrees with that, while inclusive disagrees by one. So the PDU is internally self-consistent with the spec-correct exclusive interpretation; today's ironrdp parses the same bytes inconsistently.

Across all 31 wire_to_surface1 PDUs from a 30-second session, every rectangle's right - left and bottom - top matched a clean integer tile size (8, 13, 32, 64, 128, 320…) under the exclusive interpretation, and each was wrong by exactly +1 under inclusive. Not a single rectangle agreed with the inclusive reading.

One scope note for the test plan: the dumper records what the parser sees, i.e. post-zgfx bytes. So these fixtures exercise everything from <ServerPdu as Decode>::decode outward, but not the zgfx layer itself — that's separate and unaffected by this PR.

Happy to capture more (different codecs, larger tiles, AVC420) if useful.

@glamberson
Copy link
Copy Markdown
Contributor Author

GlassOnTin Thanks for the find and assistance in confirmation. I'll review further when I get home.

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