Skip to content

feat(zgfx): add LZ77 compression support - #1097

Merged
Benoît Cortier (CBenoit) merged 2 commits into
Devolutions:masterfrom
glamberson:zgfx-compressor-standards
Feb 13, 2026
Merged

Benoît Cortier (CBenoit) merged 2 commits into
Devolutions:masterfrom
glamberson:zgfx-compressor-standards

Conversation

@glamberson

Copy link
Copy Markdown
Contributor

Adds ZGFX (RDP8) LZ77 compression to complement the existing decompressor, plus a high-level API for EGFX PDU preparation with auto/always/never mode selection.

The compressor uses a hash table mapping 3-byte prefixes to history positions for O(1) match candidate lookup against the 2.5 MB sliding window.

Depends on #1076 (segment wrapping utilities).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds ZGFX (RDP8) compression-side support and a convenience API for preparing EGFX payloads as ZGFX-segmented PDUs, complementing the existing ZGFX decompressor.

Changes:

  • Introduces an LZ77-based ZGFX compressor (Compressor) with a prefix hash table over a 2.5MB history window.
  • Adds ZGFX segment wrapping helpers for uncompressed and “already-compressed” payloads.
  • Adds a high-level compress_and_wrap_egfx API with Never/Auto/Always compression mode selection.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
crates/ironrdp-graphics/src/zgfx/wrapper.rs Adds ZGFX single/multipart segment wrapping helpers and tests.
crates/ironrdp-graphics/src/zgfx/mod.rs Wires new modules and re-exports Compressor, wrapper functions, and the new API.
crates/ironrdp-graphics/src/zgfx/compressor.rs Implements ZGFX LZ77 compression with hash-based match lookup and bitstream encoding.
crates/ironrdp-graphics/src/zgfx/api.rs Adds CompressionMode and compress_and_wrap_egfx convenience API with tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/ironrdp-graphics/src/zgfx/wrapper.rs Outdated
Comment thread crates/ironrdp-graphics/src/zgfx/compressor.rs
Comment thread crates/ironrdp-graphics/src/zgfx/compressor.rs Outdated
Comment thread crates/ironrdp-graphics/src/zgfx/api.rs Outdated
Comment thread crates/ironrdp-graphics/src/zgfx/wrapper.rs
Comment thread crates/ironrdp-graphics/src/zgfx/wrapper.rs Outdated
Comment thread crates/ironrdp-graphics/src/zgfx/wrapper.rs Outdated
@CBenoit

Copy link
Copy Markdown
Member

Thanks! PR now needs to be rebased on top of master

Implements the compression side of the ZGFX codec (MS-RDPEGFX 2.2.1.1.1)
and utilities for wrapping data in ZGFX segment structure.

Compressor uses a hash table mapping 3-byte prefixes to history positions
for O(1) match candidate lookup against the 2.5 MB sliding window.

Segment wrapping handles both single and multipart formats, with support
for compressed and uncompressed payloads. A high-level API provides
auto/always/never compression mode selection.
- Deduplicate HISTORY_SIZE between compressor and decompressor
- Enforce MAX_POSITIONS_PER_PREFIX in boundary indexing path
- Guard compress_and_wrap_egfx against oversized compressed output
  by falling back to uncompressed wrapping
@glamberson

Copy link
Copy Markdown
Contributor Author

OK, thanks. Done.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@CBenoit
Benoît Cortier (CBenoit) merged commit 4871548 into Devolutions:master Feb 13, 2026
10 checks passed
@glamberson
Greg Lamberson (glamberson) deleted the zgfx-compressor-standards branch March 17, 2026 12:14
Marc-André Moreau (mamoreau-devolutions) pushed a commit that referenced this pull request Aug 29, 2026
…der against attacker-controlled input (#1333)

## Summary

- Implements target 3 of #1316 (egfx fuzz-coverage umbrella):
`egfx_zgfx_decompress` oracle and target.
- Target shape mirrors PR #1285's `bulk_*` pattern: panic plus sanitizer
oracle on `Decompressor::decompress`, fresh decompressor per iteration
so history state does not leak between fuzz inputs.
- Ships alongside a complete input-validation audit of the ZGFX decoder,
following PR #1271's precedent for "target plus the bugs it surfaces in
one PR."

## Hardening

Eight input-validation gaps closed in `ironrdp-graphics` (all class-(c)
per #1314: reachable via attacker-controlled wire inputs). Each one was
surfaced by a rigorous smoke-fuzz iteration:

1. `Bits::try_split_to` (new in `utils.rs`) provides the checked
counterpart to `split_to` that every fix below builds on.
2. `SegmentedDataPdu::from_buffer` multipart segment-size:
`split_at_checked` plus a defensive `Vec::with_capacity` cap mirroring
PR #1271.
3. `decompress_segment` trailing-unused-bits subtraction: `checked_mul`
+ `checked_sub`.
4. `decompress_segment` token loop: checked prefix indexing + checked
`split_to(8)` for the NullLiteral value.
5. `handle_match`: checked distance-bits split, `checked_add` for
`distance_base + value`, plus `distance > HISTORY_SIZE` bound check.
6. `read_unencoded_bytes`: checked splits for the 15-bit length,
pad-to-boundary, and `length * 8` payload.
7. `read_encoded_bytes`: checked splits + `usize::BITS` bound on
`load_be::<usize>()` + `checked_shl` replacing `pow` + `checked_add` for
`base + value`.
8. `FixedCircularBuffer::read_with_offset`: defense-in-depth `offset >
buffer.len()` bound check below the caller-side guard.

Plus memory-budget enforcement, which a match-copy chain makes
necessary: it can expand a few bytes of input into unbounded output, and
an unbounded run reached a 1.5 GB peak.

9. New `MAX_DECOMPRESSED_PER_SEGMENT = 64 MiB` ceiling on a single
segment's decompressed output, enforced per-token in
`decompress_segment` and threaded through the match-copy paths so it is
checked before any allocation. **This is an implementation resource
limit, not a wire requirement**, and the code says so, but MS-RDPEGFX
3.1.9.1.2 ("RDP 8.0 compressor limits") does supply a number: a
compliant compressor MUST NOT produce any single segment past 65,535
uncompressed bytes. This crate's production compressor path honors that:
`wrapper.rs`'s `wrap_compressed` panics above 65,535, and
`compress_and_wrap_egfx`, the only production caller, falls back to
uncompressed rather than risk exceeding it. The
`compress_high_entropy_round_trips_and_bounds_table` test that an
earlier revision of this PR body cited as evidence real traffic exceeds
65,535 does not show that: it calls `Compressor::compress` directly and
feeds the result straight to `decompress_segment`, bypassing the wrapper
every real sender uses. 64 MiB is still used here, not 65,535, because
this ceiling exists to catch non-conforming or hostile input, not to
police conforming input: a decoder that hard-rejects at exactly the
compressor-side limit has no margin for a peer implementation with a
minor, benign spec deviation. Same footing as the compositor's
total-byte budget in #1460.
10. Multipart running-total check against the declared
`uncompressedSize`, surfacing early detection of segments that
collectively exceed the wire-declared bound. Unlike item 9 this one *is*
spec-grounded: 2.2.5.1 defines `uncompressedSize` as the size of
`segmentArray` once reassembled and decompressed, and 3.1.9.1.2.1 states
it MUST equal the total number of decompressed bytes across all
segments.

The sender side is unchanged and already agreed with the spec:
`ZGFX_SEGMENTED_MAXSIZE = 65535` in `wrapper.rs` splits into multipart
above that, which is exactly the encoder-side behaviour 3.1.9.1.2.1
describes.

Seven new `ZgfxError` variants total with matching Display and
`Error::source` impl entries.

## Breaking change

`ZgfxError` is a public exhaustive enum, so the seven added variants
(`InvalidTrailingBitCount`, `SegmentSizeExceedsBuffer`,
`IncompleteBitStream`, `MatchDistanceOutOfRange`,
`LengthTokenSizeTooLarge`, `SegmentDecompressedSizeExceedsLimit`,
`MultipartTotalExceedsDeclared`) break exhaustive matches downstream.
Confirmed by `cargo semver-checks --baseline-rev <merge-base>`: seven
`enum_variant_added` failures on `ironrdp-graphics`. Title carries `!`
accordingly.

## Validation

- `cargo xtask check fmt/lints/tests/typos/locks` all pass.
- `check_egfx_zgfx_decompress` regression-replay passes against both
shipped crash artifacts.
- 46 existing ZGFX unit tests continue to pass against the hardened
code.
- Final 15-minute rigorous fuzz: 2,942,961 iterations in 901 seconds
(~3,266 exec/s sustained), peak RSS 84 MB, zero panics, zero sanitizer
reports, zero OOMs.

Iterative audit progression on a 15-minute libFuzzer + ASan budget per
round:
- Round 1 (no fixes): crash at iter ~30 (trailing-bit underflow)
- Round 2 (after fixes 1-2): crash at iter ~28 (bit-budget)
- Round 3 (after F1-F8 audit): OOM at iter ~9877 (1.5 GB peak)
- Round 4 (after F1-F10 complete): clean

## Notes

- Crash artifacts for the two header-parse bugs added to
`crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_zgfx_decompress/`.
The later-round crashes are not added as separate regression entries
because the libFuzzer corpus accumulated 161 representative inputs and
the unit-tested error paths cover the cases directly.
- The MS-RDPEGFX 3.1.9.1.2 ("RDP 8.0 compressor limits") per-segment
bound is a semantic change: inputs that previously decoded to more than
65,535 bytes per segment now return `Err`. Such inputs were never
spec-conformant. Codebase precedent (PR #1097 et seq.) is to add
`ZgfxError` variants without a `!` marker on the commit; following that
convention.
- Memory-budget design discussion lives on #1120
(`issuecomment-4558356535`) rather than in this PR body so future
readers find the rationale on the canonical fuzz-umbrella thread.

Refs #1316.

## Note on the fuzz numbers above

The iteration counts and the 84 MB peak RSS were measured while the
per-segment cap was 65,535. Raising it to 64 MiB does not change what
the oracle exercises, but those figures describe the earlier revision
rather than what ships here.


## Rebase 2026-08-19

Rebased onto current master (was CONFLICTING after the RDPEUDP/RDPEMT
series landed). Two conflicts, both purely additive insertions colliding
with unrelated new master content at the same point:

- `fuzz/Cargo.toml`: master's 8 new RDPEUDP/RDPEMT `[[bin]]` entries
plus this PR's own `egfx_zgfx_decompress` entry, both kept.
- `crates/ironrdp-fuzzing/src/oracles/mod.rs`: master's 8 new oracle
functions plus this PR's own, both appended. The automatic merge dropped
this PR's own closing brace for `egfx_zgfx_decompress`, attaching the
shared trailing syntax to master's last function instead of both (the
same failure mode hit #1337's rebase the other way around, master's
brace dropped there). Caught before continuing the rebase this time by
diffing the resolved file against both source commits directly; fixed
and re-verified byte-identical to both.

None of the ZGFX hardening code (`utils.rs`, `zgfx/circular_buffer.rs`,
`zgfx/control_messages.rs`, `zgfx/mod.rs`) had any conflict; it applied
unchanged. All checks re-run on the new head: `cargo xtask check
fmt/lints/tests/typos/locks`, a direct `cargo check --all-targets` in
`fuzz/`, and `cargo xtask wasm check`, all green. `fuzz/Cargo.lock`
unchanged.


## Review round 2026-08-20

Four findings addressed, three of them posted as review-body text rather
than inline threads and never actually worked despite the PR reading as
fully resolved. (1) `ZgfxError` marked `#[non_exhaustive]`, matching the
same convention already on `DecodeErrorKind`/`EncodeErrorKind` in
ironrdp-core; this PR adds several new variants and every downstream
exhaustive match would have broken without it. (2) Corrected the doc
comment on `MAX_DECOMPRESSED_PER_SEGMENT`: MS-RDPEGFX 3.1.9.1.2's
compressor-limits list does supply a bound (65,535 uncompressed bytes
per segment for a compliant compressor), and this crate's own compressor
honors it (`wrapper.rs` panics above it, the only production caller
falls back to uncompressed instead of risking it). (3) Closed a real
gap: the multipart aggregate memory budget was only checked against the
sender's own declared `uncompressedSize`, a fully attacker-controlled
32-bit field, so a message built from small segments could drive real
allocation to roughly 4.29 GiB before that check fired. Added
`MAX_DECOMPRESSED_TOTAL` (256 MiB, same footing as
`MAX_COMPOSITOR_BYTES` in ironrdp-egfx's compositor.rs), checked
independently. (4) Masked the trailer byte to its low 3 bits per
MS-RDPEGFX 3.1.9.1.2.4 ("the five high-order bits... are reserved");
reading the whole byte let a sender's nonzero reserved bits get misread
as part of the unused-bit count. Both (3) and (4) ship with regression
tests negative-verified against the code without the fix. All 6 gates
green throughout.



## Rebase 2026-08-20

Rebased onto current master, 9 behind to 0. Two conflicts, both pure
unions with master's own new fuzz-target entries in `fuzz/Cargo.toml`
and `fuzz_regression.rs`, same shape as the earlier rebase round.
`oracles/mod.rs` auto-merged without conflict markers this time;
explicitly re-checked it did not repeat the closing-brace-drop failure
from that earlier round (the function's own closing brace confirmed
present, then a full `cargo check -p ironrdp-fuzzing --all-targets` run
clean). All 6 gates green, fuzz workspace lock stable.
holovchenko pushed a commit to holovchenko/IronRDP that referenced this pull request Sep 25, 2026
…der against attacker-controlled input (Devolutions#1333)

- Implements target 3 of Devolutions#1316 (egfx fuzz-coverage umbrella):
`egfx_zgfx_decompress` oracle and target.
- Target shape mirrors PR Devolutions#1285's `bulk_*` pattern: panic plus sanitizer
oracle on `Decompressor::decompress`, fresh decompressor per iteration
so history state does not leak between fuzz inputs.
- Ships alongside a complete input-validation audit of the ZGFX decoder,
following PR Devolutions#1271's precedent for "target plus the bugs it surfaces in
one PR."

Eight input-validation gaps closed in `ironrdp-graphics` (all class-(c)
per Devolutions#1314: reachable via attacker-controlled wire inputs). Each one was
surfaced by a rigorous smoke-fuzz iteration:

1. `Bits::try_split_to` (new in `utils.rs`) provides the checked
counterpart to `split_to` that every fix below builds on.
2. `SegmentedDataPdu::from_buffer` multipart segment-size:
`split_at_checked` plus a defensive `Vec::with_capacity` cap mirroring
PR Devolutions#1271.
3. `decompress_segment` trailing-unused-bits subtraction: `checked_mul`
+ `checked_sub`.
4. `decompress_segment` token loop: checked prefix indexing + checked
`split_to(8)` for the NullLiteral value.
5. `handle_match`: checked distance-bits split, `checked_add` for
`distance_base + value`, plus `distance > HISTORY_SIZE` bound check.
6. `read_unencoded_bytes`: checked splits for the 15-bit length,
pad-to-boundary, and `length * 8` payload.
7. `read_encoded_bytes`: checked splits + `usize::BITS` bound on
`load_be::<usize>()` + `checked_shl` replacing `pow` + `checked_add` for
`base + value`.
8. `FixedCircularBuffer::read_with_offset`: defense-in-depth `offset >
buffer.len()` bound check below the caller-side guard.

Plus memory-budget enforcement, which a match-copy chain makes
necessary: it can expand a few bytes of input into unbounded output, and
an unbounded run reached a 1.5 GB peak.

9. New `MAX_DECOMPRESSED_PER_SEGMENT = 64 MiB` ceiling on a single
segment's decompressed output, enforced per-token in
`decompress_segment` and threaded through the match-copy paths so it is
checked before any allocation. **This is an implementation resource
limit, not a wire requirement**, and the code says so, but MS-RDPEGFX
3.1.9.1.2 ("RDP 8.0 compressor limits") does supply a number: a
compliant compressor MUST NOT produce any single segment past 65,535
uncompressed bytes. This crate's production compressor path honors that:
`wrapper.rs`'s `wrap_compressed` panics above 65,535, and
`compress_and_wrap_egfx`, the only production caller, falls back to
uncompressed rather than risk exceeding it. The
`compress_high_entropy_round_trips_and_bounds_table` test that an
earlier revision of this PR body cited as evidence real traffic exceeds
65,535 does not show that: it calls `Compressor::compress` directly and
feeds the result straight to `decompress_segment`, bypassing the wrapper
every real sender uses. 64 MiB is still used here, not 65,535, because
this ceiling exists to catch non-conforming or hostile input, not to
police conforming input: a decoder that hard-rejects at exactly the
compressor-side limit has no margin for a peer implementation with a
minor, benign spec deviation. Same footing as the compositor's
total-byte budget in Devolutions#1460.
10. Multipart running-total check against the declared
`uncompressedSize`, surfacing early detection of segments that
collectively exceed the wire-declared bound. Unlike item 9 this one *is*
spec-grounded: 2.2.5.1 defines `uncompressedSize` as the size of
`segmentArray` once reassembled and decompressed, and 3.1.9.1.2.1 states
it MUST equal the total number of decompressed bytes across all
segments.

The sender side is unchanged and already agreed with the spec:
`ZGFX_SEGMENTED_MAXSIZE = 65535` in `wrapper.rs` splits into multipart
above that, which is exactly the encoder-side behaviour 3.1.9.1.2.1
describes.

Seven new `ZgfxError` variants total with matching Display and
`Error::source` impl entries.

`ZgfxError` is a public exhaustive enum, so the seven added variants
(`InvalidTrailingBitCount`, `SegmentSizeExceedsBuffer`,
`IncompleteBitStream`, `MatchDistanceOutOfRange`,
`LengthTokenSizeTooLarge`, `SegmentDecompressedSizeExceedsLimit`,
`MultipartTotalExceedsDeclared`) break exhaustive matches downstream.
Confirmed by `cargo semver-checks --baseline-rev <merge-base>`: seven
`enum_variant_added` failures on `ironrdp-graphics`. Title carries `!`
accordingly.

- `cargo xtask check fmt/lints/tests/typos/locks` all pass.
- `check_egfx_zgfx_decompress` regression-replay passes against both
shipped crash artifacts.
- 46 existing ZGFX unit tests continue to pass against the hardened
code.
- Final 15-minute rigorous fuzz: 2,942,961 iterations in 901 seconds
(~3,266 exec/s sustained), peak RSS 84 MB, zero panics, zero sanitizer
reports, zero OOMs.

Iterative audit progression on a 15-minute libFuzzer + ASan budget per
round:
- Round 1 (no fixes): crash at iter ~30 (trailing-bit underflow)
- Round 2 (after fixes 1-2): crash at iter ~28 (bit-budget)
- Round 3 (after F1-F8 audit): OOM at iter ~9877 (1.5 GB peak)
- Round 4 (after F1-F10 complete): clean

- Crash artifacts for the two header-parse bugs added to
`crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_zgfx_decompress/`.
The later-round crashes are not added as separate regression entries
because the libFuzzer corpus accumulated 161 representative inputs and
the unit-tested error paths cover the cases directly.
- The MS-RDPEGFX 3.1.9.1.2 ("RDP 8.0 compressor limits") per-segment
bound is a semantic change: inputs that previously decoded to more than
65,535 bytes per segment now return `Err`. Such inputs were never
spec-conformant. Codebase precedent (PR Devolutions#1097 et seq.) is to add
`ZgfxError` variants without a `!` marker on the commit; following that
convention.
- Memory-budget design discussion lives on Devolutions#1120
(`issuecomment-4558356535`) rather than in this PR body so future
readers find the rationale on the canonical fuzz-umbrella thread.

Refs Devolutions#1316.

The iteration counts and the 84 MB peak RSS were measured while the
per-segment cap was 65,535. Raising it to 64 MiB does not change what
the oracle exercises, but those figures describe the earlier revision
rather than what ships here.

Rebased onto current master (was CONFLICTING after the RDPEUDP/RDPEMT
series landed). Two conflicts, both purely additive insertions colliding
with unrelated new master content at the same point:

- `fuzz/Cargo.toml`: master's 8 new RDPEUDP/RDPEMT `[[bin]]` entries
plus this PR's own `egfx_zgfx_decompress` entry, both kept.
- `crates/ironrdp-fuzzing/src/oracles/mod.rs`: master's 8 new oracle
functions plus this PR's own, both appended. The automatic merge dropped
this PR's own closing brace for `egfx_zgfx_decompress`, attaching the
shared trailing syntax to master's last function instead of both (the
same failure mode hit Devolutions#1337's rebase the other way around, master's
brace dropped there). Caught before continuing the rebase this time by
diffing the resolved file against both source commits directly; fixed
and re-verified byte-identical to both.

None of the ZGFX hardening code (`utils.rs`, `zgfx/circular_buffer.rs`,
`zgfx/control_messages.rs`, `zgfx/mod.rs`) had any conflict; it applied
unchanged. All checks re-run on the new head: `cargo xtask check
fmt/lints/tests/typos/locks`, a direct `cargo check --all-targets` in
`fuzz/`, and `cargo xtask wasm check`, all green. `fuzz/Cargo.lock`
unchanged.

Four findings addressed, three of them posted as review-body text rather
than inline threads and never actually worked despite the PR reading as
fully resolved. (1) `ZgfxError` marked `#[non_exhaustive]`, matching the
same convention already on `DecodeErrorKind`/`EncodeErrorKind` in
ironrdp-core; this PR adds several new variants and every downstream
exhaustive match would have broken without it. (2) Corrected the doc
comment on `MAX_DECOMPRESSED_PER_SEGMENT`: MS-RDPEGFX 3.1.9.1.2's
compressor-limits list does supply a bound (65,535 uncompressed bytes
per segment for a compliant compressor), and this crate's own compressor
honors it (`wrapper.rs` panics above it, the only production caller
falls back to uncompressed instead of risking it). (3) Closed a real
gap: the multipart aggregate memory budget was only checked against the
sender's own declared `uncompressedSize`, a fully attacker-controlled
32-bit field, so a message built from small segments could drive real
allocation to roughly 4.29 GiB before that check fired. Added
`MAX_DECOMPRESSED_TOTAL` (256 MiB, same footing as
`MAX_COMPOSITOR_BYTES` in ironrdp-egfx's compositor.rs), checked
independently. (4) Masked the trailer byte to its low 3 bits per
MS-RDPEGFX 3.1.9.1.2.4 ("the five high-order bits... are reserved");
reading the whole byte let a sender's nonzero reserved bits get misread
as part of the unused-bit count. Both (3) and (4) ship with regression
tests negative-verified against the code without the fix. All 6 gates
green throughout.

Rebased onto current master, 9 behind to 0. Two conflicts, both pure
unions with master's own new fuzz-target entries in `fuzz/Cargo.toml`
and `fuzz_regression.rs`, same shape as the earlier rebase round.
`oracles/mod.rs` auto-merged without conflict markers this time;
explicitly re-checked it did not repeat the closing-brace-drop failure
from that earlier round (the function's own closing brace confirmed
present, then a full `cargo check -p ironrdp-fuzzing --all-targets` run
clean). All 6 gates green, fuzz workspace lock stable.

(cherry picked from commit 4232026)

Conflict resolution (fork): upstream's context for this commit carried two
sibling oracles the fork does not have — egfx_avc444_decode (from 71e0ae7)
and egfx_surface_state (from 501d1eb) — which produced conflicts in
crates/ironrdp-fuzzing/src/oracles/mod.rs, crates/ironrdp-testsuite-core/tests/fuzz_regression.rs
and fuzz/Cargo.toml. Kept the fork's existing oracle/test/bin entries as-is
and added only what 4232026 itself introduces: the egfx_zgfx_decompress
oracle function and its doc-comment update to egfx_round_trip, the
check_egfx_zgfx_decompress regression test, the egfx_zgfx_decompress
[[bin]] entry, its fuzz target file, and its three regression test-data
files. Dropped the egfx_avc444_decode oracle, test, and [[bin]] entry that
appeared only as merge context from the sibling commit the fork lacks. All
ZGFX hardening code in ironrdp-graphics (utils.rs, zgfx/circular_buffer.rs,
zgfx/control_messages.rs, zgfx/mod.rs) applied cleanly with no conflicts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Marc-André Moreau (mamoreau-devolutions) pushed a commit that referenced this pull request Oct 9, 2026
…2001)

The ZGFX compressor keeps its history in a Vec and, once the history
reaches its 2.5 MB limit, add_to_history drains the front of the Vec and
walks every entry of the match table to rebase and prune positions.
add_to_history runs once per literal byte and once per match, so after
the history fills, every emitted token costs a copy of the whole window
plus a pass over the table. With ClearCodec output from a 1280x800
desktop fed through one Compressor, the first three frames took 10 to 51
ms each, and the next three took 0.38, 4.9 and 6.8 seconds. #1344
bounded the hash table, which is a different limit, and this shows up on
any long session that uses CompressionMode::Auto or Always. The
compressor came from my own #1097.

This keeps the history as a fixed ring of HISTORY_SIZE bytes addressed
by absolute stream position, the same layout the decompressor already
uses with FixedCircularBuffer. Appending is a copy into the ring, the
match table stores absolute positions, and a candidate is skipped when
it has fallen out of the window or is further back than
MAX_MATCH_DISTANCE, so nothing has to be rebased. The encoded output
format is unchanged, and so is the rule in MS-RDPEGFX 3.1.9.1.2 that
every output byte, including those of segments sent uncompressed, is
recorded in the history.

With the same input the output is the same size on every frame, and the
frames after the history fills now take 9 to 15 ms each.

A new test compresses more than twice HISTORY_SIZE through one
Compressor, in segments that repeat earlier content, and decompresses
every segment with one Decompressor, which covers matches that reach
across the wrap point. A second test checks the ring directly, including
a push larger than the ring. The xtask fmt, lints, tests, typos and
locks checks pass.

#2003 builds on this: it sends H.264 surface commands uncompressed and
records their bytes in the ring.
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