diff --git a/docs/binary-blobs.md b/docs/binary-blobs.md new file mode 100644 index 00000000..06d3fbb1 --- /dev/null +++ b/docs/binary-blobs.md @@ -0,0 +1,112 @@ +# Binary blobs + +Blob payloads travel as base64 inside text instructions. Base64 sends four +bytes for every three, so **a quarter of everything on the wire is encoding +overhead**. This replaces that with binary WebSocket frames on the streams +where it pays, and changes nothing else. + +Measured on two captures of the same Windows host: + +| capture | payload | on the wire | overhead | wire bitrate | +|---------|---------|-------------|----------|--------------| +| idle desktop, 339s | 15.3 MB | 20.4 MB | 5.1 MB | 0.48 -> 0.36 Mbps | +| video, 167s | 81.2 MB | 108.3 MB | **27.1 MB** | 5.18 -> 3.88 Mbps | + +## Why upstream does not do this + +Not an oversight. The Guacamole protocol is defined as text -- one grammar of +length-prefixed UTF-8 elements -- and `.guac` recordings are that same stream +on disk, which `SessionRecording.js` parses and operators grep. Guacamole also +still supports HTTP long-polling (`Guacamole.HTTPTunnel`), which cannot carry +interleaved binary, so a binary path forks the protocol across its two +tunnels. And upstream's workload is PNG/JPEG tiles at modest rates, where a +quarter of a small number is a small number. + +Sustained multi-megabit H.264 is a workload upstream does not have. It exists +here because of `patch 004`, which is what changes the arithmetic. + +## Scope: which streams + +Only streams whose consumer wants bytes: + +- **`h264`** -- 108.3 MB of the video capture. +- **`audio`** -- 36.2 MB of it. Same reader, so it comes free. + +Together that is 144.5 MB of the 144.9 MB of blob traffic in that capture. + +**Not `img`.** Its blobs reach `DataURIReader`, which concatenates base64 +directly into a `data:` URI and genuinely wants the encoded form. Converting +it would mean re-encoding to undo the conversion. It is also 0.0 MB in both +captures. + +On the client every one of these funnels through a single chokepoint: +`Guacamole.ArrayBufferReader`, used directly by the H.264 path and +`AudioPlayer`, and wrapped by `StringReader` and `InputStream`. One reader +change covers all of it. + +## Where the conversion happens, and why not in guacd + +Encoding today is `guac_protocol_send_blob()` +(`guacamole-server/src/libguac/protocol.c:246`), which calls +`guac_socket_write_base64()`. The H.264 path reaches it through +`guac_protocol_send_blobs()`, which chunks each access unit. + +**The conversion belongs in rustguac, not in a guacd patch.** rustguac tees the +raw guacd stream to disk as the recording, so converting upstream of that tee +turns every recording binary and drags `SessionRecording.js`, the playback +page and the recording format along with it. Converting on the way out to the +browser leaves guacd and the recording format untouched and still captures the +whole win, because the guacd -> rustguac hop is loopback, where saving 25% of +nothing is nothing. + +The cost is that `guacd_to_ws` stops being a blind forwarder and has to parse +instructions to find the blobs. It is already halfway there -- it scans for +instruction boundaries on that same path. + +Order within `guacd_to_ws` is therefore: **record, then convert, then send.** +The recording sees exactly what it sees today. + +## Wire format + +A binary frame carries an 8-byte header and then the raw payload: + + byte 0 format version (1) + byte 1 frame type (0 = blob) + bytes 2-3 reserved, zero + bytes 4-7 stream index, u32 little-endian + bytes 8.. payload + +The stream index is in the frame rather than inferred from the preceding text +instruction, so the frame is interpretable on its own and the client's parser +stays stateless. The header is 8 bytes so the payload starts aligned. + +WebSocket delivers text and binary frames in one order, so a blob converted to +a binary frame still arrives between the same neighbours it had as text. +Nothing here can reorder the stream, which is what keeps this a change of +encoding rather than a change of transport: no sequencing, no reassembly, and +no ordering hazard of any kind. + +## Negotiation + +The client appends `binaryBlobs=1` to the WebSocket query, and the server +converts only when it sees it. A client that does not send it -- an older +cached `client.html`, a third-party integration, a recording player -- receives +exactly the base64 text it does today. + +This matters more than usual here: `index.html` and `client.html` are cached in +memory at startup, so a browser can hold an older client than the server. The +default has to be the old behaviour. + +## Verifying it is actually on + +**From the browser console**, on a live session: + + __guac_tunnel.binaryFrames // frames received; 0 means base64 is in use + __guac_tunnel.binaryBytes // payload bytes carried by them + +**From the journal**: `Starting proxy` logs `binary_blobs=true|false` per +connection. + +Zero frames on a session that is otherwise working means the client did not +negotiate it -- almost always a cached `client.html` or `Tunnel.js`, since the +server falls back to base64 for any client that does not ask. diff --git a/docs/rdp-h264.md b/docs/rdp-h264.md index d23b823a..896c3c85 100644 --- a/docs/rdp-h264.md +++ b/docs/rdp-h264.md @@ -88,8 +88,7 @@ After connecting, on the Windows host: Task Manager → Performance → GPU → **Video Encode** should be non-zero during activity. If it reads 0%, hardware encoding is not engaged — check the WDDM policy first. -On the rustguac host, with guacd running at trace level (`-L trace`, e.g. via -`systemctl edit rustguac-guacd` to override `ExecStart`): +On the rustguac host, with `GUACD_LOG_LEVEL=trace` in `/opt/rustguac/guacd.env`: ```bash # Which codecs the server is sending. 11 = AVC420, 14/15 = AVC444(v2). @@ -107,9 +106,797 @@ journalctl -u rustguac-guacd --since '2 min ago' | grep "RDPGFX capability versi `display-wrk` near zero in that last output is the sign that passthrough is working: it means image encoding is not happening. -In the browser console, `__h264.stats()` reports decoder health — -`avgDecodeLatencyMs` in the low single digits, `framesDropped` and `gcLeaks` at -zero, `auxViewsDecoded` counting AVC444 auxiliary views. +In the browser console, the decoder says what it does as `[rustguac] H.264` +lines, on the state change rather than per frame: decoder rebuilds, waits for +a keyframe, abandoned frames, the codec it configured, and whether 4:4:4 +combining is on and why not when it is off (`chroma_off`, +`chroma_aux_dropped`, `chroma_declined`, and the gate's own reasons). A quiet +console on a working session is the expected state. + +For per-stage timings — decode, the copy and its parts, combine, queue, draw, +paint, and how the copy banded — set `h264CombineLog` and read the five-second +report. + +(There was previously an `__h264.stats()` documented here reporting +`avgDecodeLatencyMs`, `framesDropped`, `gcLeaks` and `auxViewsDecoded`. No part +of that survives: neither the global, nor the method, nor any of those fields.) + +## When the whole picture looks soft + +Before blaming the codec, check that the client is not resampling. In the +browser console on the client tab: + +```js +__guac_client.getDisplay().getScale() * devicePixelRatio +``` + +**1.0** means one framebuffer pixel per physical pixel. Anything else is a +resample, and it softens text, icons and images uniformly — which a codec does +not do. Measured on a Windows session at 2.016, the picture contained almost no +single-pixel edges at all: 0.01% of adjacent pixels differing by more than 100 +levels, against 0.82% for the same page rendered natively, with the sharpest +transition anywhere reaching 114 where native reached 194. + +The fix is the entry's **Native Resolution (HiDPI)** checkbox, which asks for +the framebuffer in physical pixels. Note it multiplies the pixels the host +encodes and the browser decodes by the square of the ratio. + +### Asking for the exact DPI scaling + +MS-RDPBCGR restricts `deviceScaleFactor` to 100, 140 or 180. Pairing a +framebuffer sized for a 2.0 display with 180% scaling draws the UI about 10% +smaller than nominal — sharp, but small. + +The protocol is not the whole obstacle: `desktopScaleFactor` is legal from 100 +to 500. FreeRDP transposes the pair when it synthesises the single-monitor +definition (`libfreerdp/core/settings.c`, the monitor's `desktopScaleFactor` +filled from `FreeRDP_DeviceScaleFactor` and vice versa), so any unequal pair +reaches the server backwards and an out-of-range device factor makes it discard +both. Equal values are the only ones that survive that path, which is what +`guac_rdp_normalize_desktop_scale` snaps to. + +**The display-control channel is not subject to that.** `disp.c` builds the +`DISPLAY_CONTROL_MONITOR_LAYOUT` itself and hands it straight to the channel, +so nothing transposes it, and MS-RDPEDISP 2.2.2.2.1 likewise restricts only +`DeviceScaleFactor`. The layout therefore carries the exact percentage — +`DesktopScaleFactor = 200` beside `DeviceScaleFactor = 180` — and since the +client fits the display to the browser window shortly after connecting, that +layout is what the session ends up scaled by. The connection-time core data +still goes out snapped and is overridden a moment later. + +An xrdp target is unaffected either way: xrdp parses the scale factors and +drops them unused (`docs/xrdp-dpi-scaling.md`), so a session script setting +`/Gdk/WindowScalingFactor` or `/Xft/DPI` remains the only thing scaling that +desktop. + +### When 4:4:4 combining is declined, and when it is given up + +An AVC444 stream that is not combined is still decoded; it is painted at 4:2:0, +from the main view alone. Three things can cause that, in the order they act. + +**A prior on framebuffer area** (`COMBINE_MAX_PIXELS`, 4K). A ceiling on the +worst case a session may open with before anything has been measured, and no +longer the thing that decides. It was 4MP until 2026-09-12, set against the +plane uploads and the shader pass; those were later measured at under 2ms +together, while the cost that matters is not a function of the framebuffer at +all once the copy is limited to the damaged rows. A 4.93MP Windows session +doing desktop work copies 3-5% of its planes and spends ~9ms a picture, which +the old threshold declined outright. + +**A gate on the measured copy** (`COMBINE_COPY_TRIP_SHARE`, 30% of wall clock +spent inside `copyTo()`). Gives up when the mean *synchronous* copy per picture over a busy 10s +window exceeds it. `VideoFrame.copyTo()`'s synchronous half is the cost of +combining -- see the investigation below -- and it is blocking main-thread +time, so it is both what the user feels and a wall-clock delta that is exact +and free to measure. Typing at 4.93MP measures ~9ms a picture and full-screen +video ~42ms; neither is near the line. + +**Two latches beneath both**, on sync-gate timeouts and on slow display +flushes. Their minimums decide which fires first: the sync-timeout latch has +none (3 timeouts in 10s), the copy gate needs 30 pictures in its window, the +flush latch 100 syncs. So a session degraded to a few frames a second -- what +full-screen video at high resolution does, once every row is damaged and the +copies go back to whole planes -- is caught by the sync-timeout latch within +seconds, and by the copy gate at the next window boundary, while the flush +latch may never reach its minimum. + +**The area prior is keyed on framebuffer area rather than the desktop scale**, +which was the first thing tried and is wrong twice over. The scale only says +whether HiDPI scaling was applied, so a 4K display at `devicePixelRatio` 1 +slips past it. And the cost is not a property of the host: the same picture +costs the same to combine whatever sent it, which is why this was taken for an +xrdp problem until a Windows session was run at native resolution. + +`h264Chroma444` overrides, `h264CombineMaxPixels` moves the prior, and +the declined case logs once saying why. Setting the override disables every +gate above, since an override is an instruction rather than a preference. + +### Why the gates are latches rather than controllers + +They are **one-way** on purpose. An earlier version measured the combine +against a frame budget whose divisor was the observed interval between +pictures -- which is what `012`'s frame-ack back-pressure has already throttled +the server to, and that back-pressure reacts to the lag combining causes. It +read its own output as its input. A latch has no loop. + +A trip caused by something else -- a slow link, a struggling decoder -- gives +up chroma for nothing. That is the accepted cost: a little colour resolution, +no frames, and `h264Chroma444` brings it back -- as `window.__h264Chroma444 += true` for the current page, or the `h264Chroma444` key in localStorage to +survive a reconnect. Not as a query parameter: client.html rebuilds +`/client/{id}?name=...` on every launch and drops anything added by hand, so +one lasts until the first reconnect. The recording player is the exception, +since nothing rewrites its URL. + +**They resume once the load passes.** After the session has stayed *quiet* for +`COMBINE_RECOVER_MS`, combining is tried again (`chroma_resumed`), up to three +times per session; after the third it stays off for good. Quiet rather than +merely clean: 4:2:0 never flushes slowly, so a "clean" test resumed mid-video +and spent every attempt on the same one. Tripping during a video and resuming +once it stops is the expected shape -- heavy video is exactly where 4:2:0 +chroma costs least and where the combine costs most, and reading text +afterwards is where the auxiliary stream earns its keep. + +The long recovery window is not about hiding a visible flap; there barely is +one, since only newly painted regions change chroma resolution. It is about the +**resync**: the first combine after a gap copies and uploads whole planes +rather than damaged rows, which is the most expensive kind there is, and +delivering that to a client that has only just stopped struggling is how a gate +makes things worse. Three attempts then draws the line between a desktop that +had a video playing and a client that simply cannot sustain the combine -- +sample by sample the two look identical, and only time separates them. + +**Measuring the combine's GPU work is still the hard part**, and is why the +latches gate on symptoms. Timing GPU execution needs a `gl.finish()` per +picture -- stalling the pipeline the gate protects -- or timer queries that are +not reliably available. `h264CombineLog` now calls `Yuv444Renderer.finish()` +before stamping, so its `combine` figure is execution rather than submission, +at the cost of that stall; it is a diagnostic, not something a gate could use. +None of this applies to `COMBINE_COPY_TRIP_SHARE`, which times synchronous calls +on the main thread rather than GPU work. + +### Is it the combine, or the handoff? (investigation) + +The field numbers do not add up to what `tests/bench` measures. At 1920x1072 +against the xrdp fork, 4:2:0 ran at 54.7 syncs/s with a 0.6ms mean flush and +4:4:4 at 33-41/s with 16-22ms. Part of that ratio is an accounting artifact -- +the 4:2:0 path snapshots the `VideoFrame` into a 2D canvas inside the decoder's +output callback, so its pixel copy is already paid before flush begins, while +the combine path only *submits* GPU work there and the result is first needed +inside flush. But the sync rate cannot be an artifact: 54.7/s to 33-41/s is +6-12ms per frame, against a benched combine of ~2.8ms at 2.06MP. + +The same slowdown appears on an RTX 3070 and an Intel 770. Those differ by +roughly 8x in memory bandwidth and the NVIDIA part decodes on a separate +engine, so agreement between them rules out shader cost, GPU bandwidth, and +contention between the two hardware decodes and the combine. What survives is +what is *not* GPU throughput: CPU-side driver staging (which is what +`texSubImage2D` already is), and synchronous GPU-to-CPU transfers. + +That points at a boundary the 4:2:0 path never crosses. The combine's output is +a GPU-resident `ImageBitmap` produced by the renderer's own WebGL2 context, and +it is consumed by `drawImage()` into the display layer's 2D context. If the +driver cannot share that surface, the blit is a readback -- area-proportional, +vendor-independent, and invisible to every existing instrument. And it would +not shrink with damage: `render()` scissors the conversion to the damage rects, +but `transferToImageBitmap()` hands over the **whole drawing buffer** every +frame. At 1920x1072 that is 8.2MB crossing the boundary to repaint a caret, +which is also why banding the plane uploads measured 2.0x in the bench and much +less in the field. + +**How it was tested, and why none of it is still here.** A `paint` stage timed +the `drawImage()` calls in `drawDecoded()` against two denominators -- damaged +megapixels against buffer megapixels, since which one the cost tracks says +whether it is the blit or the handoff -- split by which kind of surface +crossed, with `sync_hold` splitting its flush the same way. The control was an +override that snapshotted an unpaired main view the way the 4:2:0 path does, +skipping `render()` for that picture only, so both handoffs could be measured +on one stream and one session. All of it came out at 0.01ms/MP, answered the +question once, and was removed with the rest of the scaffolding; it is on +`feature/combine-handoff-instrumentation` if the question ever reopens. + +#### Result: it is not the handoff, and `flush` was never measuring the display + +Measured at 1920x1072 against the xrdp fork with `CHROMA_INTERVAL=8`, combining +forced on, over three 5s windows: + +``` + decode chroma 35 mean 14.4 max 50.7 | luma 274 mean 6.1 max 43.4 + combine chroma 35 mean 0.5 max 0.9 | luma 239 mean 0.3 max 1.0 + draw chroma 35 mean 12.5 max 33.1 | luma 239 mean 15.1 max 57.5 + paint bitmap 274 mean 0.0 max 0.4 0.01ms/MP | canvas none + read-back wait mean 0.0 max 0.1 +sync_hold: 2090 syncs (34.8/s) held 3 (0%) timeouts 0 + | flush mean 19.9ms max 89ms +``` + +**The handoff is free.** `paint` is 0.0ms mean, 0.4ms max, 0.01ms/MP against +either denominator. The ImageBitmap crosses into the display's 2D context at +no measurable cost, so the driver is sharing the surface, not reading it back. +The bounding-box change that would have followed is not worth making, and the +vendor-independence that pointed here has a duller explanation: nothing on this +path is GPU-bound at all. + +**The combine is also cheap in situ** -- 0.3-0.5ms with `gl.finish()` forcing +completion, against the bench's ~1.37ms/MP (~2.8ms at this size). No +contradiction: the bench measures full-frame damage, and a real desktop's +damage is small, which is exactly what the banded uploads and the scissored +conversion were built for. The bench is a worst case, not a typical one. + +**And `flush` was measuring the wrong thing.** The stack that produced these +numbers runs `recordHold` <- `waitForPending` <- `displaySyncComplete` <- +`Frame.flush` <- `__flush_frames` <- `Task.unblock` <- `__display_h264_ready` +<- the combine's own promise. The display's flush *completes inside the +decoder's unblock*: a frame carrying H.264 blocks its display task until the +picture is available, so `flush mean 19.9ms` is how long the display waited for +the decoder, not how long the display took to draw. + +That matters beyond this investigation. `COMBINE_FLUSH_TRIP_MS` gives up +combining when the mean flush exceeds 8ms, and what it is reading is decode +latency rather than display cost. + +*(Corrected 2026-09-12. It was written here that the latch therefore trips on +something it cannot fix, since lever 3 removes the combine and not a single +decode. That is wrong, and the measurements later in this section disprove it: +`decode` fell from 28-38ms to 1-4ms once the copies were banded. Combining +blocks the main thread, which delays the decoder's output callbacks, which +inflates decode latency, which is what flush measures -- so combining does +influence the signal and suspending it does reduce it. The chain is real, just +indirect. What is true is weaker: the flush latch reads the cost at three +removes where `COMBINE_COPY_TRIP_SHARE` reads it directly, and it has the +strictest minimum of the three latches -- 100 syncs in 10s against the copy +gate's 30 pictures and the sync-timeout latch's none -- so it is the least +likely of them to fire first. Subsumed and second-hand, not misdirected, and +worth keeping for the main-thread congestion that combining contributes to +without the copy time alone showing it.)* + +**What is left unexplained is `draw`:** 12.5-19.8ms from the `VideoFrame` +arriving in `output()` to the paint, of which the combine is 0.5ms, the +read-back wait 0.0ms and the paint 0.0ms. Roughly 12-18ms is unaccounted for. +A `queue` stage now splits it -- `onReady` is wrapped to stamp when the +picture actually became available, so `draw` minus `queue` is the asynchronous +chain and `queue` is time spent in the display's ordered queue behind frames +that were not ready yet. + +**Next measurement:** the same three stages with `window.__h264Chroma444 = +false` on the same session and host. `false` is an override like any other, so +the latch stays out of the way and the comparison is clean. If `decode` and +`queue` are what grow between the two runs, the cost is the second access unit +and the pipelining around it, and the client-side combine gate is aimed at the +wrong thing. + +#### It is not `copyTo()` either -- and the decode times say why + +The natural next suspect is `VideoFrame.copyTo()`: pulling the planes out of a +decoded frame is a GPU-to-CPU transfer, and that cost looks unavoidable. The +run above already measures it. `read-back wait` -- issuing the copy to the +chain resolving it -- is **mean 0.0ms, max 0.1-0.2ms**. It is not the +bottleneck, and it is not close to being one. + +But a near-free `copyTo()` is itself strange, and taken together with the rest +of the run it points somewhere specific: + +* `copyTo()` costs nothing, which is what happens when the planes are **already + in system memory** rather than in GPU memory. +* `decode` is **6-10ms for a main view and 14-20ms for an auxiliary one** at + 1920x1072. A hardware decoder does 1080p in single-digit *tenths* of a + millisecond of engine time and a few ms of latency; this is an order out. +* Nothing on the GPU path costs anything: combine 0.5ms, paint 0.0ms. +* The slowdown is identical on an RTX 3070 and an Intel 770 -- which is + expected if the GPU is barely involved. + +That is the signature of **software decoding**. `H264Decoder.js` already +documents the trap: `hardwareAcceleration: 'prefer-hardware'` is a *preference, +not a requirement*, and a stream whose frames exceed the declared codec level +falls back to software silently -- which is exactly why `DEFAULT_CODEC` was +raised to level 5.2, after a 2688x1488 session "decoded in software at roughly +twenty times the latency, and under AVC444 for two pictures per frame." + +If that is what is happening here, every conclusion inverts. AVC444 would not +be expensive because combining is expensive -- combining is nearly free. It +would be expensive because it asks a *software* decoder for two access units +per picture instead of one, and the client-side combine gate cannot remove a +single one of them. + +**The cheapest check is the format the decoder hands back**, now printed in the +`h264CombineLog` header as `(decoder gives ...)`. A hardware decoder on Windows +gives **NV12**; **I420** is Chrome's software decoder. `chrome://media-internals` +confirms it by name, and shows why a fallback was taken. + +An `issue` stage was also added, timing `combineFrame()` entry to the copy being +issued -- `allocationSize()` plus the synchronous half of `copyTo()`, which sat +in neither `copyWait` nor `combine` and was inside the unexplained window. +With `decode`, `issue`, `copyWait`, `combine`, `queue` and `paint`, `draw` is +fully accounted for. + +#### Result: it *is* `copyTo()`, in its synchronous half + +Measured at 2992x1648 (4.93MP) on a HiDPI laptop against a Windows host, +hardware decode confirmed (`chrome://media-internals`: `D3D11VideoDecoder`, +NV12, `kIsPlatformVideoDecoder: true`): + +``` + issue chroma 28.5 | luma 34.7 + alloc chroma 0.0 | luma 0.0 + buf chroma 0.0 | luma 0.0 + copy chroma 28.5 5.77ms/MP | luma 34.6 7.01ms/MP + combine chroma 1.5 | luma 0.9 + paint 0.01ms/MP +``` + +`allocationSize()` and the buffer pool are free. **`copyTo()`'s synchronous +prologue is the whole of it** -- the D3D11 array-texture copy and staging map, +blocking, on the main thread. `read-back wait` reads 0.0-0.1ms because it times +the *promise*, and by the time the promise is awaited the work is already done. +That is why the transfer looked free for three rounds of this investigation. + +Two things follow from the per-megapixel figures. Both views compute to the +same 4.93MP, so **the AVC444 auxiliary view is a full-size picture**: a paired +picture reads back 14.8MB, two whole NV12 frames. And at ~101 copies per 5s +this is **roughly 69% of the main thread**, which is what the rest of the +pipeline is queueing behind -- `queue` 18-33ms, `draw` 47-62ms, flush 68ms, +and in a worse window a decode backlog that took `decode` to 617ms before +draining. + +So AVC444 does not cost twice as much because combining is expensive. +Combining is 0.9-1.6ms. It costs twice as much because it demands a **second +full-frame GPU-to-CPU readback**, and that readback is the pipeline. + +This also vindicates `COMBINE_MAX_PIXELS` while correcting the reasoning behind +it. The 4MP threshold is right -- this session is 4.93MP and would have +declined to combine had the measurement not overridden it -- but the cost it +was set against was the shader and the uploads, which together are under 2ms. +The real curve is the readback's. + +**What is not yet settled** is whether the cost is a transfer or a fixed stall +per call, because only one framebuffer size has been sampled. 34.6ms for 7.4MB +is ~214MB/s, which looks like a transfer, but a stable ms/MP at *one* size +proves nothing about proportionality. Resizing the client and re-reading +`copy`'s ms/MP settles it: steady means a transfer, rising as the area falls +means a fixed stall. + +If it is a transfer, the fix follows the banding already built everywhere else. +`copyTo()` accepts a `rect` and is currently handed the whole coded frame every +picture, while the plane uploads are banded to the damage and the shader is +scissored to it -- the narrowest stage of the pipeline feeding off the widest. +Restricting the rect to the damaged rows would cut it in proportion, with the +v1 auxiliary layout rounding outward to its 16-row bands as `auxV1LumaBands()` +already does, and a resync still copying everything. + +It would transform desktop work and do nothing for full-screen video, which +damages every row. For video at this resolution the answer stays lever 1 or the +4MP gate. + +#### Outcome: copy the damaged rows, and gate on what the copy costs + +Both views now copy only the rows their region rects touch, rounded outward to +16. Measured on a Windows host at 2992x1648 (4.93MP), light typing, combining +forced on: + +| | no banding | main only | both views | +|---|---|---|---| +| main view copy | 34.6ms | 5.6ms | 7.0ms | +| auxiliary view copy | 27.3ms | 26.9ms | 7.4ms | +| main thread in `copyTo()` | ~77% | ~24% | **~16%** | + +The pipeline behind it went with it: `decode` from 617ms at its worst and +28-38ms in steady state to 1.1-3.8ms, `queue` to nil, `draw` from 20-36ms to +7.4-11.7ms. + +**Windows declares real damage on both views**; the xrdp fork declares a full +frame on both, deliberately (`xrdp_encoder.c`, to keep the two chroma sources +refreshing from the same instant), so none of this fires there until +`XRDP_AVC444_DAMAGE_RECTS=1` -- and the fork's own comment notes that only +16-aligned rects are bit-exact, which is the shape this wants anyway. + +**No per-layout band arithmetic was needed.** Rounding outward to 16 is exactly +what `auxV1LumaBands()` does to reach the v1 layout's 16-row bands, and a +superset of the v2 layout's one-to-one rows and both layouts' chroma rows at +`y >> 1`. `tests/h264-copy-band.mjs` lifts that inverse out of `Yuv444.js` and +checks all three mappings rather than trusting the argument. + +**Banding is then exhausted, and the gate had to change.** At 3-5% span the +copies are 0.15-0.25MP but still cost 7-11ms, so the per-call stall now +dominates -- fitted at 5-10ms, well above the 2.7ms a two-point fit suggested. +Narrowing further buys nothing. But that also means cost is no longer a +function of framebuffer area, which is all `COMBINE_MAX_PIXELS` could see: at +4.93MP it declined to combine on sessions costing ~9ms a picture. + +So the threshold is now a prior only, raised to 4K, with a measured gate +underneath: a busy window gives up combining when more than +`COMBINE_COPY_TRIP_SHARE` (30%) of wall clock goes inside `copyTo()`. + +**Share, not cost per picture.** A mean-per-picture threshold sat beside this +until 2026-09-12 and both had to be exceeded, which vetoed the one case the +gate most needed to catch: xrdp at 1920x1080 dragging a VS Code scrollbar ran +39-47 pictures a second at 12ms each -- 46-60% of the main thread, and drags +that lost the scrollbar thumb -- while the per-picture figure sat under any +sane threshold. Many cheap copies is the shape that hurts, and per picture is +blind to it by construction. + +The near-idle desktop the per-picture condition was added to protect (33-38ms +a picture, where the per-call cost rises because part of it is waiting for a +frame to be ready) needs no protecting: it spends 4.6% of the main thread. +Measured shares -- Windows idle 4.6%, Windows typing 17-19%, full-screen video +~45%, xrdp scrolling 46-60%, glxgears 70% -- all fall on the right side of 30% +unaided. And the pathological case per picture would have caught, one enormous +copy against an otherwise idle session, exceeds `SYNC_WAIT_TIMEOUT_MS`, so the +sync-timeout latch takes it. + +**This is not the mistake adaptive suspension made.** That design failed +because timing GPU execution needs a `gl.finish()` per picture, stalling the +pipeline the gate exists to protect, so it gated on symptoms instead. None of +that applies to `copyTo()`'s prologue: it is a wall-clock delta across a +synchronous call, exact and free, on a path already paying it. The earlier +reasoning was right about GPU work and this is not GPU work. + +The sync-timeout and slow-flush latches stay as the safety net beneath it. +Note their signal is decoder latency, not display cost -- see above -- which +is worth revisiting now that the latency has collapsed. + +#### xrdp's damage: measured, and the answer + +Windows declares tight region rects; the xrdp fork declared a single +full-frame rect for AVC444 by design until 2026-09-12. Once that was fixed, +the rects arriving still covered 34-99% of rows, with `span` and `damage` +equal, and for a while that was read as xrdp coarsening the damage. + +It was not. Each of those captures was mislabelled by what was on the screen: + +| the capture was called | what was actually running | damage | +| --- | --- | --- | +| desktop work | glxgears animating a window | 33-34% | +| idle | the tail of a scroll | 98-99% | +| idle desktop, clock ticking | VS Code, maximized | 97-99% | + +The last one settles it arithmetically. VS Code was 1920x1047 on a 1920x1072 +screen: 97.67%, against a measured mean of 97% and max of 99%. An +Electron/Chromium window repaints its whole surface on any change, a blinking +caret included, so what was called an idle desktop was one application +damaging almost the entire screen a few times a second. Minimising it dropped +declared damage to a few per cent -- across the straddling 100-capture window, +the 66 captures still at ~97% account for the whole 64% mean on their own, +which leaves the other 34 at essentially zero. + +So the damage rects are honest and banding works on xrdp. What banding cannot +do is help while one application owns the screen and repaints all of it, and +that is a property of the application rather than of the server. + +**`MAX_CAPTURE_RECTS` is not involved, and never was.** The hypothesis was +that sixteen dirty rects -- a caret, a scrollbar, a tray clock, some text -- +would trip the extents collapse in `rdpCapRect()` and hand the client a +corner-to-corner bounding box. Instrumenting the server directly (xorgxrdp +`feature/gbm-dmabuf-hwencode`, reported under `XORGXRDP_TIMING`) shows the +branch is never entered: + +``` +xorgxrdp dirty region: 100 captures, rects mean 1 max 1, + monitor covered mean 97% max 99%, 100 captures covered 90% or more +xorgxrdp dirty region collapse: fired 0 of 0 multi-rect frames +``` + +`rects mean 1 max 1`, in every regime measured -- idle, light activity, and +glxgears saturated at 58 fps -- across more than 1200 captures. A caret and a +clock do not produce sixteen rects; they produce one. The collapse needs more +than fifteen and has never seen more than one, so raising the cap, or +switching to the area-ratio rule that now sits behind `XORGXRDP_COLLAPSE=area`, +would change nothing on this pipe. + +Coarsening to row bands upstream is not the answer either. It is redundant +with `copyBandsFor()`, which merges using a cost model -- `minWorthwhileGap()` +derived from the measured copy fit and the real plane width -- that xorgxrdp +does not have, and it would give up the horizontal extent the server still +uses in `rdpCopyBoxList()`'s blit and in the v2 auxiliary shader pass. The end +that owns the cost model should do the coarsening. + +None of this was visible from either end of the wire. xrdp receives +`REGION_NUM_RECTS()` of the already-collapsed region, so from there a collapse +and a genuine full-screen rect look identical, and the client only ever sees +what xrdp declares. The question stayed open across three captures for want of +an instrument in the one place that could answer it. + +A single bounding span in the client is still defeated by a clock in one +corner and a caret in the other, which is why `copyBandsFor()` produces +several. + +#### The per-call floor, and a threshold that outlived its calibration + +Windows, light typing at 4.93MP, both views banded to 3% of their planes, one +band each (multi-band never fires there -- the rects are tight and +contiguous): + +``` + copy chroma 8.1ms 53.71ms/MP | luma 8.8ms 59.02ms/MP + band 78 main: banded 78 (100%) copied 3% damage 3% in 1.0 bands + aux 32 views: banded 32 (100%) copied 3% damage 3% in 1.0 bands +``` + +3% of 4.93MP is 0.148MP, which at 6.5ms/MP is under a millisecond of +transfer. The copies cost 6-18ms. **Banding has reached its limit**: what is +left is per-call, narrowing further buys nothing, and AVC444 pays it twice per +paired picture whatever the damage. + +**The per-call cost is not fixed -- it falls as the session gets busier.** +Measured across four windows of one session: 17.5ms at 1.4 pictures a second, +8.5ms at 5.2, and **6.2ms at 20.6**. So part of it is waiting for the decoder +to have a frame ready, which shrinks when frames are already queued, rather +than the transfer or a constant stall. A single window read in isolation will +mislead about it, which the 2.7ms of the original two-point fit and an +intermediate reading of 8-12ms both did in opposite directions. + +At steady state that is 8.3ms of copy per picture (103 main views at 6.2ms +plus 33 auxiliary at 6.5ms over 5s), against the 20ms the gate trips at -- so +better than two times headroom -- and 12-17% of the main thread, from ~77% before +any of this. + +**And it exposed a stale threshold.** That session suspended combining: + +``` +gave up 4:4:4 combining: mean flush 11.1ms over 142 syncs in 10s, over the 8ms +``` + +Copy was ~12ms a picture and well under the share threshold, so the gate +that measures the cost directly correctly held off -- and the flush latch +overrode it. `COMBINE_FLUSH_TRIP_MS` was 8ms, set in September when combining +cost 30-60ms a picture and 4:4:4 flushed at 16-22ms against 4:2:0's 0.6ms. + +A flush is the display waiting for the decoder, and under combining that wait +is mostly the copy -- flush is roughly copy + decode + the display's own work. +So **a flush threshold below the copy threshold fires first every time**, by +construction, on a cost the copy gate has already judged affordable and +without being able to say why. It is now derived as +30ms, measured rather than derived. What the +latch is still for is main-thread congestion the copy does not explain, and +for that it has to sit above the copy gate rather than below it. + +## Colour range + +The samples an RDP host sends are **full range**. [MS-RDPEGFX Color +Conversion](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/954d7546-6873-4466-95c8-20a7569c43e5) +defines the ARGB-to-AYUV transform as full-range BT.709 with the components +clamped to 0...255, and Microsoft's RDP 10 AVC announcement requires decoders +to support "BT.709 Full Range color conversion". + +Converting those as limited range expands 16-235 to 0-255: blacks crush to +zero, whites clip, and chroma over-saturates by 255/224. It looks punchier and +is wrong — and it is the more damaging of the two mistakes, since clipping +destroys information that the opposite error merely compresses. + +The stream is supposed to prevent that by carrying `video_full_range_flag = 1` +in the SPS. **Chrome honours that flag in every shape but one.** Measured +2026-09-09, and pinned by `tests/h264-vui-range.mjs`: + +| `video_full_range_flag` | colour description | `colorSpace.fullRange` | 16,16,16 painted as | +|---|---|---|---| +| 1 | absent | `true` | 15,17,14 | +| 1 | present, 2/2 (unspecified) | **`false`** | **0,1,0** | +| 1 | present, 1/1 (BT.709) | `true` | 14,17,14 | +| 0 | present, 1/1 (BT.709) | `false` | 13,16,13 | + +A colour description that is *present* and says *unspecified* makes Chrome +discard the whole `video_signal_type` and fall back to limited BT.709. Leaving +the description out altogether does not. + +**That table is software decode, and the hardware path is stricter.** Measured +on one browser against two hosts, both decoding to NV12: + +| host | SPS | Chrome reports | +|---|---|---| +| xrdp fork | `full_range=1`, primaries/transfer/matrix all BT.709 | full | +| Windows | `full_range=1`, **no description** | **limited** | + +Same client, same hardware decoder; the description is the only difference. So +a bare range flag — which the software decoder honours — is ignored once +D3D11/VAAPI is in the path. That is why Windows renders with crushed blacks +while xrdp does not, and why signalling the range in the fork +(`xrdp_accel_assist_vaapi.c`, `video_signal_type_present_flag` through +`matrix_coefficients`) visibly fixed the colour there. + +It is invisible from both ends: the host declared the range, the client reports +limited, and neither can see the disagreement. It also applies to `drawImage()` +as much as to the shader, since both read the same reported colour space, so no +client-side flag can reach the AVC420 path. + +### The fix: rustguac completes the SPS + +`src/h264_rewrite.rs` completes the SPS's colour signalling, in either of the +two shapes seen in the field: a declared range with no description (Windows), or +no `video_signal_type` at all (stock xrdp, whose x264 defaults omit it). The +first has its description added and its declared range left alone; the second +gets full-range BT.709 written, because that is what the transport defines and +what the encoder actually produced. It fixes both render paths and every +client, including third-party ones, and needs no configuration. + +Cheap by construction: the first SPS decides. A stream that already carries a +description, and bounds its reordering (below), is never examined again, so +xrdp and every non-passthrough session pay one check per connection and nothing +after it. + +BT.709 is not a guess — MS-RDPEGFX defines the transform as BT.709, and Chrome +already reported `bt709` for these streams, so the value is the one the decoder +was assuming anyway. The splice is verified byte-for-byte against ffmpeg's own +`h264_metadata` bitstream filter performing the same edit +(`the_splice_matches_ffmpegs_own_rewrite`), because a bad bit offset or a +missed emulation-prevention byte does not fail loudly: the picture stops while +both ends look healthy. + +Recordings are teed upstream of the rewrite and keep the host's original +stream, which is what a recording should be. Playing back a Windows recording +is subject to the original fault; `?h264FullRange=on` is the lever there. + +### Diagnosing it + +rustguac reads the first SPS of every passthrough session and logs what it +found, once per session (`src/h264_sps.rs`): + +```bash +journalctl -u rustguac --since '5 min ago' | grep 'H.264 colour' +``` + +``` +H.264 colour: video_full_range_flag=1 colour_primaries=2 (unspecified) \ + transfer=2 (unspecified) matrix=1 (BT.709) — UNUSABLE: ... +``` + +A second line says what the browser made of it, reported by the client: + +``` +Client diagnostic: full range, BT.709; decoder gave NV12 frames event=colour_space +``` + +The two together are the whole picture — the first describes the wire, the +second the render — and a colour fault is a disagreement between them. `NV12` +means hardware decode and `I420` software, which matters because the two honour +different things. + +Outcomes and fixes: + +* **`NO SIGNAL TYPE`** — the SPS says nothing about colour at all. Stock xrdp + 0.10.6 does this: it passes x264 no VUI parameters, and with `video_format` + at 5 and no colour description x264 omits the whole block. Its samples are + full-range BT.709 regardless — xrdp names its own conversion + `XRDP_yuv444_709fr` — so the browser's fallback to limited crushes the + blacks. Full-range BT.709 is spliced in. +* **`NO DESCRIPTION`** — rustguac splices one in and logs `splicing a BT.709 + description into the SPS`. The client line should then read `full range`. + This is the Windows case, and it is confirmed working end to end: + + ``` + H.264 colour: video_full_range_flag=1 colour_primaries=absent ... NO DESCRIPTION: ... + H.264 colour: splicing a BT.709 description into the SPS ... + Client diagnostic: full range, BT.709; decoder gave NV12 frames event=colour_space + ``` +* **`UNUSABLE`** (description present, unspecified) — not rewritten, since + replacing 2 with 1 would assert a colourimetry the host declined to claim. + Fix it at the encoder. +* **`full_range=0`, usable** — the host is declaring limited range. If its + samples are nevertheless full range, as MS-RDPEGFX requires, nothing on the + wire can be believed and the client has to be told. Set the `h264FullRange` + key in localStorage: on a live session that is the only form that survives, + since the client builds `/client/{id}?name=...` itself on every launch and + relaunch and drops anything added by hand. `?h264FullRange=on` works on the + recording player, whose URL nothing rewrites. `window.__h264FullRange` is + read once per decoder generation, behind the same latch as the colour-space + report, so setting it mid-session does nothing until the decoder is rebuilt. + The console then logs `(FORCED -- frame reported limited)`. +* **`usable`, and the client reports the matching range** — the colour is + right, and a picture that still looks wrong is not a range problem. + +## Picture reordering: a frozen or white screen from an NVENC host + +**Chrome's hardware decoder holds pictures back unless the SPS says it need +not**, and one encoder's defaults say nothing. Chromium's `H264Decoder` +(`UpdateMaxNumReorderFrames()`, `media/gpu/h264_decoder.cc`) takes the reorder +depth from the VUI's `bitstream_restriction` when present, as zero for the +High-family profiles carrying `constraint_set3_flag`, and as the whole DPB +otherwise. It has no shortcut for `pic_order_cnt_type` 2, even though that POC +type is the stream stating that output order is decode order. + +NVENC left at its defaults, as the xrdp fork's accel-assist first used it, +writes exactly the shape that falls through: Main profile, `constraint_set1` +only, POC type 2, a VUI with timing info and no restriction. At 2992x1648 and +level 5.0 the DPB is five pictures, so every picture emerged five pictures +late. On an idle desktop that is past the client's 1000ms decode watchdog, +which gives the frame up and discards it when it does arrive -- so nothing was +ever painted, and the session showed guacd's white connect-time fill or froze +on whatever came before. mstsc and ffmpeg decode the same stream without delay, +and the recording decodes perfectly, which is what makes this look like a +rustguac fault. + +What gives it away is the timing, not the picture: `frames_abandoned` lines in +the browser console with no decode error beside them, and a recording ffmpeg +decodes cleanly. + +**`src/h264_sps.rs` adds the restriction on the way past** +(`declare_no_reordering`), and only where the stream has already guaranteed +the answer: POC type 2 and no `bitstream_restriction`. `max_num_reorder_frames` +is 0 and `max_dec_frame_buffering` is `max_num_ref_frames`, which is what the +fork's VA-API encoder declares; the remaining fields are the values the +standard infers when the block is absent, so they claim nothing new. POC types +0 and 1 are left alone -- reordering is possible there, and declaring +otherwise would be a guess about the host. An SPS with no VUI gets an empty one +carrying only the restriction. `declaring_no_reordering_matches_ffmpegs_reading` +checks both shapes against ffmpeg, field by field. The journal says +`declaring max_num_reorder_frames=0 in the SPS` once per session when it fires. + +Encoders can say it themselves -- NVENC's `bitstreamRestrictionFlag` -- and the +edit then finds nothing to do. + +## Full Colour (4:4:4) + +Each RDP entry has a **Full Colour (4:4:4)** checkbox, off by default. AVC444 +is offered either way, for the reasons in the next section; what the checkbox +decides is whether the auxiliary chroma view is used. + +- **Off (Standard colour).** The auxiliary view is dropped in transit wherever + the stream proves it can be spared, and the browser never combines, so even a + stream the drop refuses paints plain 4:2:0 at the lowest cost available. An + entry decides from the least evidence that can answer; an ad-hoc session, + which no entry vouches for, waits for the full sample. +- **On.** The auxiliary view is kept and the browser combines it into 4:4:4, + which sharpens coloured text. The browser still gives 4:4:4 up when it + costs too much -- see *When 4:4:4 combining is declined, and when it is given + up*, above -- and at a HiDPI resolution it commonly does within seconds. + +At native resolution on a HiDPI screen Full Colour makes little visible +difference; it is worth turning on for a standard-density display where colour +fringing on text matters. + +## Dropping the chroma view in transit + +AVC444 sends two pictures per frame: a main view, which is ordinary 4:2:0, and +an auxiliary view carrying the chroma 4:2:0 leaves out. The browser can combine +them into full 4:4:4, but only by reading every decoded frame back from the GPU +with `VideoFrame.copyTo()` -- WebCodecs offers no way to reach a frame's planes +on the GPU -- and that read-back blocks the main thread input is handled on. +It is most of what AVC444 costs a client. + +A Windows host cannot simply be asked for AVC420 instead. Its hardware encoder +engages only in AVC444 mode (`AVC444ModePreferred`, above), and FreeRDP +advertises the RDPGFX 10.x capability sets only alongside AVC444: offered +AVC420 alone it offers 8.1, and Windows at 8.1 sent only CLEARCODEC and +CAPROGRESSIVE when tested, no H.264 at all. So the host has to be offered a +picture the client may not want, and at native resolution on a HiDPI screen, +where a 4:2:0 chroma block already covers about one logical pixel, does not +need. + +`src/h264_aux_drop.rs` removes the auxiliary view between guacd and the +browser, where the stream proves it can be spared: 13% of the H.264 bytes +against a Windows host and 43% against xrdp sending AVC444, and one decode per +frame instead of two. The browser is told with an `h264-aux` instruction -- +rustguac's own, which guacd never sees and an older client ignores -- so it +stops holding main views for a picture that is no longer coming. + +**It has to be proved per stream**, because the two views are one H.264 +sequence sharing one decoded picture buffer. `src/h264_refs.rs` reads the +slice headers (it never decodes) to establish two things: + +- **Nothing that survives predicts from a dropped picture.** The two views must + keep separate long-term reference chains, and nothing in the main view may + reorder short-term references relatively, since a removed picture shifts + every `PicNum` after it. +- **The decoder has room for what the gap obliges it to invent.** Dropping + leaves holes in `frame_num`, and H.264 8.2.5.2 requires a decoder to fill + each with an inferred short-term reference. A stream whose + `max_num_ref_frames` is all taken by long-term pictures has nowhere to put + one, and freezes. + +`gaps_in_frame_num_value_allowed_flag` is set on every SPS from the start of +the session, which is inert on a stream that is never dropped from. The +evidence only arrives with the first inter slices, so the decision takes about +2.5 seconds, almost all of it the connect-time keyframe burst. A stream that +cannot be proved is passed through unchanged and the reason is logged; +auxiliary IDRs are always kept. + +The dropper sits after the recording tee, so recordings keep the full stream. +`RUSTGUAC_H264_AUX_DROP=0` switches it off for the whole deployment, and +`RUSTGUAC_H264_AUX_DROP=unproven` relaxes the gate for experiments. + +Two harnesses check it against real streams: `tests/aux-drop-replay.mjs +` strips the auxiliary views from a recording and compares the +decoded pictures with ffmpeg, and `RUSTGUAC_AUX_DROP_RECORDING= +cargo test aux_drop_over_a_recording -- --ignored` runs the real dropper over +one and checks the result is a stream a client can follow. ## Recording diff --git a/docs/xrdp-dpi-scaling.md b/docs/xrdp-dpi-scaling.md new file mode 100644 index 00000000..61ed6137 --- /dev/null +++ b/docs/xrdp-dpi-scaling.md @@ -0,0 +1,222 @@ +# Making xrdp act on the client's DPI scale factor + +Notes for a possible xrdp patch. Written against the xrdp tree at +`/home/tim/Repos/xrdp`; line numbers are from that checkout and will drift. + +This is **not** required for rustguac. A session script that reads the +framebuffer width and picks a scale factor already works (see "Current +workaround" below). The patch buys exactness — correct scaling for any client, +rather than bucketing by a width threshold — and matters mainly when clients +with different `devicePixelRatio` values connect to the same host. + +**The case is stronger than it was.** rustguac now sends the *exact* scaling +percentage rather than one of 100/140/180 (see the caveat below), and xrdp +accepts and stores it. So the number the session wants is already sitting in +`client_info` and nothing reads it — the patch is plumbing, not negotiation. + +Note also what the patch cannot do: there is no X11 equivalent of the Windows +mechanism it is named after. Windows acts on `desktopScaleFactor` because the +OS has a system-wide DPI that applications honour; X has no such thing, and +scaling is a toolkit convention applied inside the session (`Xft/DPI` via +XSETTINGS for GTK, its own for Qt). So a session-side hook is unavoidable in +principle. What the patch removes is the *guessing*, not the hook — and if the +export is paired with a change to xrdp's own shipped `startwm.sh`, the hook +stops being something a deployment has to write and maintain. + +## What already works + +xrdp **parses and validates the scale factors**; it just never uses them. + +`libxrdp/libxrdp.c` reads them from the monitor data: + +- `:2114` — `in_uint32_le(s, monitor_layout->desktop_scale_factor);` from + `[MS-RDPBCGR] 2.2.1.3.9.1 TS_MONITOR_ATTRIBUTES` +- `:2217` — the same from `[MS-RDPEDISP] 2.2.2.2.1 + DISPLAYCONTROL_MONITOR_LAYOUT` +- `:1939-1964` — validation: `desktop_scale_factor` must be 100–500, + `device_scale_factor` must be exactly 100, 140 or 180. If either is out of + range **both are reset to 100**. +- `:2137` and `:2232` — both values are logged, which makes verification free. + +They are stored per-monitor in `struct display_size_description` +(`common/xrdp_client_info.h:46-47`): + +```c +unsigned int desktop_scale_factor; +unsigned int device_scale_factor; +``` + +A grep across `sesman/` and `xrdp/` returns **no consumers**. The values are +parsed, validated, logged, stored, and dropped. + +## Verify before building anything + +The whole idea depends on the client actually sending monitor attributes. One +log line settles it: + +``` +grep -i "scale_factor" /var/log/xrdp.log | tail +``` + +- Non-100 values → the scale is arriving intact; the patch has something to read. +- `100` with a client that should be sending more → nothing is arriving, and + that is the problem to solve first. The patch would have no input. + +## What the patch needs to do + +### 1. Carry the value from xrdp to sesexec + +This is the bulk of the work. The scale lives in `client_info` inside the +**xrdp** process; the reconnect script is launched by **sesexec**. They +communicate over SCP, and the connect request carries no such field +(`libipm/scp.h:437-442`): + +```c +int +scp_send_connect_session_request(struct trans *trans, + const struct guid *guid, + const char *client_ip, + const char *client_name, + unsigned int flags); +``` + +Adding a field touches: + +- `libipm/scp.h` — the prototypes for send and parse +- `libipm/scp.c:532` — `scp_send_connect_session_request()` and its parse + counterpart +- `xrdp/xrdp_mm.c:374` — the caller, which has `client_info` in reach + +This is a **wire-format change to the sesman control protocol**, so xrdp and +sesman must be upgraded together. Worth checking whether libipm's versioning +allows an optional trailing field, which would avoid a hard lockstep. + +Use the **primary** monitor's `desktop_scale_factor`. With several monitors at +different scales there is no single right answer, and the primary is what a +session-wide DPI setting should follow. + +### 2. Export it to the session scripts + +Trivial once the value is there, and the mechanism already exists. + +For the **reconnect** script, `sesman/sesexec/ercp_server.c:135-140` builds a +NULL-terminated key/value list that `start_reconnect_script()` walks and +`g_setenv()`s (`sesman/sesexec/session.c:1376-1382`): + +```c +const char *vars[] = +{ + "XRDP_CLIENT_IP", g_client_ip, + "XRDP_CLIENT_NAME", g_client_name, + NULL // Terminator +}; +``` + +Add `"XRDP_DESKTOP_SCALE_FACTOR", buf` alongside. + +For **first connect**, follow `XRDP_START_WIDTH` / `XRDP_START_HEIGHT` at +`sesman/sesexec/session.c:383-386`: + +```c +g_snprintf(text, sizeof(text), "%d", sd->params.desktop_scale_factor); +g_setenv_log("XRDP_DESKTOP_SCALE_FACTOR", text, 1); +``` + +Note the reconnect path matters more than first connect: a session outlives its +clients, and reconnecting from a different machine should re-scale. The +`vars[]` list is per-connection, so it handles that correctly; the session-start +variable is fixed for the session's life. + +### 3. What the session script then becomes + +```sh +SCALE=${XRDP_DESKTOP_SCALE_FACTOR:-100} +xfconf-query -c xsettings -p /Xft/DPI -s $(( 96 * SCALE / 100 )) +``` + +Exact, continuous, no width threshold, no polling for the resolution to settle. + +Prefer `/Xft/DPI` over `/Gdk/WindowScalingFactor`: the latter takes only +integers, so it cannot express 140% or 180% at all, and overshoots by ~11% when +used to approximate 180%. + +### 4. Apply it in xrdp's own startwm.sh + +Exporting the variable leaves every deployment writing the same three lines. +Applying it in the `startwm.sh` xrdp ships makes HiDPI work out of the box and +is what turns this from a hook a site maintains into a feature xrdp has — which +is also the version worth proposing upstream, since the guessing it replaces is +guessing every xrdp user is currently doing. + +Guard it so it changes nothing for the clients that send no scale: with +`XRDP_DESKTOP_SCALE_FACTOR` unset or 100 the computed DPI is 96, which is the +default already. + +## Caveat: FreeRDP transposes the pair + +FreeRDP fills the synthesised single-monitor definition with the two factors +swapped (`libfreerdp/core/settings.c`, introduced in `401f81683`, present +through 3.x HEAD): + +```c +const UINT32 desktopScaleFactor = get(FreeRDP_DeviceScaleFactor); /* reads Device */ +const UINT32 deviceScaleFactor = get(FreeRDP_DesktopScaleFactor); /* reads Desktop */ +... +monitor.attributes.desktopScaleFactor = desktopScaleFactor; +monitor.attributes.deviceScaleFactor = deviceScaleFactor; +``` + +Any **unequal** pair therefore reaches xrdp backwards, and xrdp's own validation +then rejects it — a desktop factor of 200 lands in `device_scale_factor`, fails +the 100/140/180 check, and both are reset to 100. + +Equal values are immune, which is why FreeRDP's own `/scale` accepts only 100, +140 and 180 and sets both at once. + +**That is only true of the connection-time core data.** The transposition lives +in FreeRDP's synthesis of a single-monitor definition; the display-control +layout is built directly by the client and passes through untouched. So +`patches/011-rdp-dpi-scaling.patch` now snaps only the core-data pair and sends +the **exact** percentage in the `DISPLAY_CONTROL_MONITOR_LAYOUT` that follows — +`desktopScaleFactor = 200` beside `deviceScaleFactor = 180` on a 2.0 display. + +xrdp accepts that pair. Its validation (`libxrdp/libxrdp.c:1888-1914`) requires +`desktop_scale_factor` in 100–500 and `device_scale_factor` in {100, 140, 180}, +resetting **both** to 100 if either fails; 200/180 satisfies both. The value is +therefore already stored per-monitor and is exact — a patch reading it gets the +client's true ratio, not a bucketed approximation. + +An xrdp patch should still not assume well-formed input from FreeRDP clients +generally: anything arriving over the core-data path from a client that sets the +two settings independently is transposed, and the 100/100 reset above is what +that looks like from inside xrdp. + +## Current workaround (no patch) + +`/etc/xrdp/reconnectwm.sh` infers the scale from the framebuffer width. Two +things to get right: + +**Wait for the resolution to settle.** rustguac renegotiates the display size +up to 4s after connecting (`sendSize` at 800ms, 2s and 4s — a retry sequence, +because the Display Control channel is often not ready for the first attempt). +A fixed `sleep 1` samples a stale mode: + +```sh +prev="" +for _ in $(seq 1 20); do + cur=$(DISPLAY=$DISPLAY xrandr 2>/dev/null | awk '/\*/{print $1; exit}') + if [ -n "$cur" ] && [ "$cur" = "$prev" ]; then break; fi + prev=$cur + sleep 0.5 +done +WIDTH=$(echo "$prev" | cut -dx -f1) +``` + +**Beware shell syntax in the client test.** `if [ echo "$X" | grep -qi ... ]` +is not a valid test — the pipe splits the command, `[` fails with "missing +`]`", and the exit status comes from `grep` reading empty stdin, so the branch +never fires. Use `if echo "$X" | grep -qi ...` or a `case` statement. + +Also note that a branch forcing `SCALING_FACTOR=1` for Guacamole clients dates +from when rustguac sent CSS pixels. Once it sends device pixels that branch +suppresses exactly the scaling you want. diff --git a/patches/004-h264-passthrough.patch b/patches/004-h264-passthrough.patch index 94a579c7..c7a9f8b4 100644 --- a/patches/004-h264-passthrough.patch +++ b/patches/004-h264-passthrough.patch @@ -71,7 +71,7 @@ index 283f9f34..d5deaa6e 100644 #include "guacamole/rect.h" #include "guacamole/rwlock.h" -@@ -334,3 +337,81 @@ void guac_display_layer_close_cairo(guac_display_layer* layer, guac_display_laye +@@ -334,3 +337,82 @@ void guac_display_layer_close_cairo(guac_display_layer* layer, guac_display_laye guac_rwlock_release_lock(&display->pending_frame.lock); } @@ -79,7 +79,7 @@ index 283f9f34..d5deaa6e 100644 +void guac_display_layer_set_h264(guac_display_layer* layer, + const unsigned char* data, uint32_t length, int is_keyframe, + int x, int y, int width, int height, -+ const guac_rect* rects, int num_rects, int view) { ++ const guac_rect* rects, int num_rects, int view, int paired) { + + int dropped = 0; + @@ -93,6 +93,7 @@ index 283f9f34..d5deaa6e 100644 + frame->length = length; + frame->is_keyframe = is_keyframe; + frame->view = view; ++ frame->paired = paired; + guac_rect_init(&frame->rect, x, y, width, height); + frame->next = NULL; + @@ -174,7 +175,7 @@ index 13ee2d5c..a7a760ee 100644 #include #include -@@ -369,6 +374,196 @@ void guac_display_plan_free(guac_display_plan* plan) { +@@ -369,6 +374,208 @@ void guac_display_plan_free(guac_display_plan* plan) { guac_mem_free(plan); } @@ -222,7 +223,7 @@ index 13ee2d5c..a7a760ee 100644 + /* Build h264 instruction: + * + * h264 -+ * [ ]... ++ * [ ]... + * + * identifies what the access unit carries: 0 is a displayable + * picture (AVC420, or the main view of AVC444), 1 and 2 are the auxiliary @@ -236,7 +237,17 @@ index 13ee2d5c..a7a760ee 100644 + * is full-surface sized, and on a server that mixes codecs the remainder + * holds no meaningful content. A count of zero means the whole picture is + * valid, which is the case for servers (such as xrdp) that encode the -+ * entire surface as H.264. */ ++ * entire surface as H.264. ++ * ++ * is non-zero on the main view of an AVC444 picture whose ++ * auxiliary chroma view follows immediately (MS-RDPEGFX LC=0). A client ++ * that combines the two views MAY then skip painting the main view, since ++ * the auxiliary view repaints the same picture in full 4:4:4 -- which ++ * halves the work of combining. Only the server can say this: the two ++ * views are separate access units, and the second has not arrived when the ++ * first is drawn. It trails the region rects because those are variable in ++ * number; a client that stops parsing at the rects sees the instruction it ++ * saw before, and simply paints both views as it always did. */ + int header[] = { + stream->index, + layer->layer->index, @@ -264,6 +275,8 @@ index 13ee2d5c..a7a760ee 100644 + guac_h264_write_arg(socket, guac_rect_height(rect)); + } + ++ guac_h264_write_arg(socket, frame->paired); ++ + guac_socket_write_string(socket, ";"); + guac_socket_instruction_end(socket); + @@ -371,7 +384,7 @@ index 13ee2d5c..a7a760ee 100644 void guac_display_plan_apply(guac_display_plan* plan) { guac_display* display = plan->display; -@@ -386,6 +581,7 @@ void guac_display_plan_apply(guac_display_plan* plan) { +@@ -386,6 +593,7 @@ void guac_display_plan_apply(guac_display_plan* plan) { for (int i = 0; i < plan->length; i++) { guac_display_layer* display_layer = op->layer; @@ -383,7 +396,7 @@ diff --git a/src/libguac/display-priv.h b/src/libguac/display-priv.h index 5980a861..7927f790 100644 --- a/src/libguac/display-priv.h +++ b/src/libguac/display-priv.h -@@ -317,6 +317,72 @@ struct guac_display_render_thread { +@@ -317,6 +317,84 @@ struct guac_display_render_thread { }; @@ -407,6 +420,18 @@ index 5980a861..7927f790 100644 + int view; + + /** ++ * Non-zero if an auxiliary chroma view for this same picture immediately ++ * follows this frame in the queue. Set only on the main view of an AVC444 ++ * command carrying both views (MS-RDPEGFX LC=0). ++ * ++ * Only the server knows this: the two views are separate access units, and ++ * a client combining them cannot tell, as the main view arrives, whether ++ * the picture it completes is about to be superseded by a 4:4:4 one. Told, ++ * it can upload the main view's planes without painting them. ++ */ ++ int paired; ++ ++ /** + * The bounds of the surface this frame decodes to. Retained for logging + * and as a fallback when the server supplies no region rects. + */ @@ -456,7 +481,7 @@ index 5980a861..7927f790 100644 /** * Approximation of how often a region of a layer is modified, as well as what * changes have been made to that region since the last frame. This information -@@ -620,6 +686,62 @@ struct guac_display_layer { +@@ -620,6 +698,62 @@ struct guac_display_layer { */ guac_display_layer* next_removed; @@ -523,7 +548,7 @@ diff --git a/src/libguac/guacamole/display.h b/src/libguac/guacamole/display.h index 9894042a..4c298fb7 100644 --- a/src/libguac/guacamole/display.h +++ b/src/libguac/guacamole/display.h -@@ -417,6 +417,74 @@ guac_display_layer* guac_display_alloc_buffer(guac_display* display, int opaque) +@@ -417,6 +417,82 @@ guac_display_layer* guac_display_alloc_buffer(guac_display* display, int opaque) */ void guac_display_free_layer(guac_display_layer* display_layer); @@ -570,6 +595,14 @@ index 9894042a..4c298fb7 100644 + * + * @param num_rects + * The number of entries in rects, or zero if rects is NULL. ++ * ++ * @param view ++ * Which view this access unit carries, as one of the GUAC_H264_VIEW_* ++ * constants. ++ * ++ * @param paired ++ * Non-zero if an auxiliary chroma view for this same picture follows ++ * immediately. Meaningful only on a main view. + */ +/** + * An ordinary AVC420 access unit, or the main view of an AVC444 stream. A @@ -593,7 +626,7 @@ index 9894042a..4c298fb7 100644 +void guac_display_layer_set_h264(guac_display_layer* layer, + const unsigned char* data, uint32_t length, int is_keyframe, + int x, int y, int width, int height, -+ const guac_rect* rects, int num_rects, int view); ++ const guac_rect* rects, int num_rects, int view, int paired); + /** * Returns a layer representing the current mouse cursor icon. Changes to the @@ -602,7 +635,7 @@ diff --git a/src/protocols/rdp/channels/rdpgfx.c b/src/protocols/rdp/channels/rd index 327e7c2b..c49737d7 100644 --- a/src/protocols/rdp/channels/rdpgfx.c +++ b/src/protocols/rdp/channels/rdpgfx.c -@@ -24,14 +24,389 @@ +@@ -24,14 +24,395 @@ #include "settings.h" #include @@ -885,12 +918,18 @@ index 327e7c2b..c49737d7 100644 + guac_display_default_layer(rdp_client->display); + + /* Queued in capture order, which is the order the views occupy in the -+ * H.264 sequence. The queue preserves it through to the wire. */ ++ * H.264 sequence. The queue preserves it through to the wire. ++ * ++ * Two saved views means this command carried both (LC=0), so the main ++ * view is flagged as paired: the client is about to receive an ++ * auxiliary view refining it to 4:4:4, and combining clients skip ++ * painting a picture that is superseded a moment later. */ + for (int i = 0; i < saved_count; i++) + guac_display_layer_set_h264(default_layer, + saved_h264[i], saved_length[i], saved_keyframe[i], + cmd->left, cmd->top, cmd->width, cmd->height, -+ saved_rects[i], saved_num_rects[i], saved_view[i]); ++ saved_rects[i], saved_num_rects[i], saved_view[i], ++ (i == 0 && saved_count == 2)); + + /* With the decode skipped, guac_rdp_gdi_end_paint() never runs, so + * nothing marks the display as modified and the queued frames would @@ -992,7 +1031,7 @@ index 327e7c2b..c49737d7 100644 /** * Callback which associates handlers specific to Guacamole with the * RdpgfxClientContext instance allocated by FreeRDP to deal with received -@@ -63,13 +438,68 @@ static void guac_rdp_rdpgfx_channel_connected(rdpContext* context, +@@ -63,13 +444,68 @@ static void guac_rdp_rdpgfx_channel_connected(rdpContext* context, RdpgfxClientContext* rdpgfx = (RdpgfxClientContext*) args->pInterface; rdpGdi* gdi = context->gdi; @@ -1063,7 +1102,7 @@ index 327e7c2b..c49737d7 100644 } /** -@@ -120,4 +510,3 @@ void guac_rdp_rdpgfx_load_plugin(rdpContext* context) { +@@ -120,4 +556,3 @@ void guac_rdp_rdpgfx_load_plugin(rdpContext* context) { guac_freerdp_dynamic_channel_collection_add(context->settings, "rdpgfx", NULL); } diff --git a/patches/011-rdp-dpi-scaling.patch b/patches/011-rdp-dpi-scaling.patch new file mode 100644 index 00000000..17d682a1 --- /dev/null +++ b/patches/011-rdp-dpi-scaling.patch @@ -0,0 +1,294 @@ +diff --git a/src/protocols/rdp/channels/disp.c b/src/protocols/rdp/channels/disp.c +index 1acad6ed..9e182609 100644 +--- a/src/protocols/rdp/channels/disp.c ++++ b/src/protocols/rdp/channels/disp.c +@@ -388,6 +388,39 @@ void guac_rdp_disp_update_size(guac_rdp_disp* disp, + + else if (settings->resize_method == GUAC_RESIZE_DISPLAY_UPDATE) { + ++ /* Repeat the DPI scaling on every layout update. The scale factors ++ * negotiated at connection time are not sticky: a MONITOR_LAYOUT ++ * carrying zeroes (MS-RDPEDISP 2.2.2.2.1 requires 100-500 and one of ++ * 100/140/180, so zero is out of range either way) drops the session ++ * back to 100%. A client that resizes shortly after connecting -- as ++ * one fitting the remote display to a browser window does -- therefore ++ * lost its scaling a second later, having applied it correctly at ++ * first. ++ * ++ * Zero remains the value sent when no scaling was requested, leaving ++ * an unscaled session behaving exactly as before. ++ * ++ * **The two factors are not the same number here.** MS-RDPEDISP ++ * 2.2.2.2.1 allows DesktopScaleFactor anywhere in 100-500 and restricts ++ * only DeviceScaleFactor to 100, 140 or 180, so the exact scaling can ++ * be asked for on this channel even though the connection-time core ++ * data cannot carry it -- FreeRDP transposes that pair while ++ * synthesising its single-monitor definition, which is what forces ++ * both to the same snapped value there (see ++ * guac_rdp_normalize_desktop_scale). This layout is built here and ++ * handed straight to the channel, so nothing transposes it. ++ * ++ * That matters at a device pixel ratio the snapped values cannot ++ * express. A framebuffer sized for a 2.0 display paired with 180% ++ * scaling draws its UI about 10% smaller than it should be; asking for ++ * 200% here corrects it, while DeviceScaleFactor stays at the nearest ++ * legal 180 as the specification requires. */ ++ int scale = settings->desktop_scale != 0 ++ ? guac_rdp_normalize_desktop_scale(settings->desktop_scale) : 0; ++ ++ int desktop_scale = settings->desktop_scale != 0 ++ ? guac_rdp_clamp_desktop_scale(settings->desktop_scale) : 0; ++ + /* Multi-monitor: send the full client-requested monitor layout and + * republish it to the client so it can split the combined framebuffer */ + if (disp->monitor_count > 1) { +@@ -407,8 +440,8 @@ void guac_rdp_disp_update_size(guac_rdp_disp* disp, + .PhysicalWidth = 0, + .PhysicalHeight = 0, + .Orientation = 0, +- .DesktopScaleFactor = 0, +- .DeviceScaleFactor = 0 ++ .DesktopScaleFactor = desktop_scale, ++ .DeviceScaleFactor = scale + }; + } + +@@ -435,8 +468,8 @@ void guac_rdp_disp_update_size(guac_rdp_disp* disp, + .PhysicalWidth = 0, + .PhysicalHeight = 0, + .Orientation = 0, +- .DesktopScaleFactor = 0, +- .DeviceScaleFactor = 0 ++ .DesktopScaleFactor = desktop_scale, ++ .DeviceScaleFactor = scale + }}; + + /* Send display update notification if display channel is connected */ +diff --git a/src/protocols/rdp/settings.c b/src/protocols/rdp/settings.c +index 06f1ebb2..0cdc3f4b 100644 +--- a/src/protocols/rdp/settings.c ++++ b/src/protocols/rdp/settings.c +@@ -100,6 +100,7 @@ const char* GUAC_RDP_CLIENT_ARGS[] = { + "disable-glyph-caching", + "disable-gfx", + "enable-h264", ++ "desktop-scale", + "preconnection-id", + "preconnection-blob", + "timezone", +@@ -448,6 +449,12 @@ enum RDP_ARGS_IDX { + */ + IDX_ENABLE_H264, + ++ /** ++ * The DPI scaling percentage the RDP session should render its UI at, or ++ * 0 to leave the server's default (100%) alone. ++ */ ++ IDX_DESKTOP_SCALE, ++ + /** + * The preconnection ID to send within the preconnection PDU when + * initiating an RDP connection, if any. +@@ -1330,6 +1337,22 @@ guac_rdp_settings* guac_rdp_parse_args(guac_user* user, + guac_user_parse_args_boolean(user, GUAC_RDP_CLIENT_ARGS, argv, + IDX_ENABLE_H264, 0); + ++ /* DPI scaling percentage requested for the remote session. Zero leaves ++ * FreeRDP's defaults untouched, which is a plain 100% session. */ ++ settings->desktop_scale = ++ guac_user_parse_args_int(user, GUAC_RDP_CLIENT_ARGS, argv, ++ IDX_DESKTOP_SCALE, 0); ++ ++ if (settings->desktop_scale != 0 ++ && (settings->desktop_scale < GUAC_RDP_MIN_DESKTOP_SCALE ++ || settings->desktop_scale > GUAC_RDP_MAX_DESKTOP_SCALE)) { ++ guac_user_log(user, GUAC_LOG_WARNING, ++ "Ignoring out-of-range desktop scale of %i%% (must be %i-%i).", ++ settings->desktop_scale, GUAC_RDP_MIN_DESKTOP_SCALE, ++ GUAC_RDP_MAX_DESKTOP_SCALE); ++ settings->desktop_scale = 0; ++ } ++ + /* Session color depth */ + settings->color_depth = + guac_user_parse_args_int(user, GUAC_RDP_CLIENT_ARGS, argv, +@@ -1616,6 +1639,54 @@ int guac_rdp_get_depth(freerdp* rdp) { + #endif + } + ++/** ++ * Snaps a requested DPI scaling percentage to one FreeRDP can actually deliver. ++ * ++ * Only 100, 140 and 180 are usable, for two compounding reasons. MS-RDPBCGR ++ * 2.2.1.3.2 already restricts deviceScaleFactor to exactly those three, and a ++ * server discards the desktop factor along with an out-of-range device factor ++ * rather than degrading. On top of that, FreeRDP transposes the pair when it ++ * synthesises the single-monitor definition every windowed session uses ++ * (libfreerdp/core/settings.c, introduced in 401f81683 and present through ++ * 3.x): the monitor's desktopScaleFactor is filled from FreeRDP_DeviceScale- ++ * Factor and its deviceScaleFactor from FreeRDP_DesktopScaleFactor. Any pair ++ * of differing values therefore reaches the server backwards, and a request ++ * for 200% arrives as deviceScaleFactor=200 -- illegal, so silently ignored. ++ * ++ * Setting both factors to the same value is immune to the transposition, which ++ * is why FreeRDP's own /scale option accepts only these three and sets both at ++ * once. Doing the same is the only combination that survives the journey. ++ * ++ * @param desktop_scale ++ * The requested scaling percentage. ++ * ++ * @return ++ * The nearest percentage that will actually take effect. ++ */ ++int guac_rdp_normalize_desktop_scale(int desktop_scale) { ++ ++ if (desktop_scale >= 160) ++ return 180; ++ ++ if (desktop_scale >= 120) ++ return 140; ++ ++ return 100; ++ ++} ++ ++int guac_rdp_clamp_desktop_scale(int desktop_scale) { ++ ++ if (desktop_scale > GUAC_RDP_MAX_DESKTOP_SCALE) ++ return GUAC_RDP_MAX_DESKTOP_SCALE; ++ ++ if (desktop_scale < GUAC_RDP_MIN_DESKTOP_SCALE) ++ return GUAC_RDP_MIN_DESKTOP_SCALE; ++ ++ return desktop_scale; ++ ++} ++ + void guac_rdp_push_settings(guac_client* client, + guac_rdp_settings* guac_settings, freerdp* rdp) { + +@@ -1633,6 +1704,31 @@ void guac_rdp_push_settings(guac_client* client, + + /* Session */ + ++ /* DPI scaling of the remote session. Both factors travel in the client ++ * core data (MS-RDPBCGR 2.2.1.3.2) and the server ignores the pair unless ++ * desktopScaleFactor is 100-500 AND deviceScaleFactor is exactly 100, 140 ++ * or 180, so an out-of-range device factor silently disables scaling ++ * altogether rather than falling back. Mirror FreeRDP's own /scale, which ++ * sets both together for the three values it accepts, and otherwise sets ++ * only the desktop factor -- what a client at, say, 200% actually sends. */ ++ if (guac_settings->desktop_scale != 0) { ++ ++ int scale = guac_rdp_normalize_desktop_scale(guac_settings->desktop_scale); ++ ++ freerdp_settings_set_uint32(rdp_settings, FreeRDP_DesktopScaleFactor, scale); ++ freerdp_settings_set_uint32(rdp_settings, FreeRDP_DeviceScaleFactor, scale); ++ ++ if (scale != guac_settings->desktop_scale) ++ guac_client_log(client, GUAC_LOG_INFO, ++ "Requested DPI scaling of %i%% is not one the RDP " ++ "protocol can carry; using %i%% instead.", ++ guac_settings->desktop_scale, scale); ++ else ++ guac_client_log(client, GUAC_LOG_INFO, ++ "Requesting %i%% DPI scaling from the RDP server.", scale); ++ ++ } ++ + freerdp_settings_set_uint32(rdp_settings, FreeRDP_DesktopWidth, guac_settings->width); + freerdp_settings_set_uint32(rdp_settings, FreeRDP_DesktopHeight, guac_settings->height); + freerdp_settings_set_uint32(rdp_settings, FreeRDP_ColorDepth, guac_settings->color_depth); +@@ -1914,6 +2010,19 @@ void guac_rdp_push_settings(guac_client* client, + + /* Session */ + rdp_settings->ColorDepth = guac_settings->color_depth; ++ /* DPI scaling of the remote session -- see the FreeRDP 3 path above. */ ++ if (guac_settings->desktop_scale != 0) { ++ ++ int scale = guac_rdp_normalize_desktop_scale(guac_settings->desktop_scale); ++ ++ rdp_settings->DesktopScaleFactor = scale; ++ rdp_settings->DeviceScaleFactor = scale; ++ ++ guac_client_log(client, GUAC_LOG_INFO, ++ "Requesting %i%% DPI scaling from the RDP server.", scale); ++ ++ } ++ + rdp_settings->DesktopWidth = guac_settings->width; + rdp_settings->DesktopHeight = guac_settings->height; + rdp_settings->AlternateShell = guac_strdup(guac_settings->initial_program); +diff --git a/src/protocols/rdp/settings.h b/src/protocols/rdp/settings.h +index 75cc014b..2c50bccd 100644 +--- a/src/protocols/rdp/settings.h ++++ b/src/protocols/rdp/settings.h +@@ -184,6 +184,49 @@ typedef enum guac_rdp_resize_method { + /** + * All settings supported by the Guacamole RDP client. + */ ++/** ++ * The smallest DPI scaling percentage an RDP server will accept ++ * (MS-RDPBCGR 2.2.1.3.2). ++ */ ++#define GUAC_RDP_MIN_DESKTOP_SCALE 100 ++ ++/** ++ * The largest DPI scaling percentage an RDP server will accept ++ * (MS-RDPBCGR 2.2.1.3.2). ++ */ ++#define GUAC_RDP_MAX_DESKTOP_SCALE 500 ++ ++/** ++ * Clamps a requested DPI scaling percentage to the range a desktopScaleFactor ++ * may occupy, without snapping it. ++ * ++ * Unlike deviceScaleFactor, desktopScaleFactor is legal anywhere in 100-500 ++ * (MS-RDPBCGR 2.2.1.3.2, MS-RDPEDISP 2.2.2.2.1), so a channel that carries the ++ * two independently can ask for the exact scaling rather than the nearest of ++ * three. Only the display-control layout can: the connection-time core data ++ * goes through FreeRDP's monitor synthesis, which transposes the pair. ++ * ++ * @param desktop_scale ++ * The requested scaling percentage. ++ * ++ * @return ++ * The same percentage, clamped to the legal range. ++ */ ++int guac_rdp_clamp_desktop_scale(int desktop_scale); ++ ++/** ++ * Snaps a requested DPI scaling percentage to one the RDP protocol and FreeRDP ++ * can actually deliver: 100, 140 or 180. See the implementation for why no ++ * other value survives. ++ * ++ * @param desktop_scale ++ * The requested scaling percentage. ++ * ++ * @return ++ * The nearest percentage that will actually take effect. ++ */ ++int guac_rdp_normalize_desktop_scale(int desktop_scale); ++ + typedef struct guac_rdp_settings { + + /** +@@ -680,6 +723,18 @@ typedef struct guac_rdp_settings { + */ + int enable_h264; + ++ /** ++ * The DPI scaling percentage the remote session should render its UI at, ++ * sent to the server as desktopScaleFactor, or 0 to leave the server's ++ * default of 100% alone. ++ * ++ * This exists so that a client requesting a framebuffer in physical rather ++ * than logical pixels -- which is what makes text render sharply on a ++ * HiDPI display -- does not simply get a desktop whose every element is ++ * half the size it should be. ++ */ ++ int desktop_scale; ++ + /** + * Whether multi-touch support is enabled. + */ diff --git a/patches/012-rdpgfx-frame-ack-backpressure.patch b/patches/012-rdpgfx-frame-ack-backpressure.patch new file mode 100644 index 00000000..0b6f8723 --- /dev/null +++ b/patches/012-rdpgfx-frame-ack-backpressure.patch @@ -0,0 +1,501 @@ +diff --git a/src/libguac/display-layer.c b/src/libguac/display-layer.c +index d5deaa6e..08859247 100644 +--- a/src/libguac/display-layer.c ++++ b/src/libguac/display-layer.c +@@ -416,3 +416,28 @@ void guac_display_layer_set_h264(guac_display_layer* layer, + "chain is broken until the next keyframe.", dropped); + + } ++ ++int guac_display_layer_h264_backlog(guac_display_layer* layer) { ++ ++ if (layer == NULL) ++ return 0; ++ ++ int backlog = 0; ++ ++ pthread_mutex_lock(&layer->h264_queue_lock); ++ ++ /* Count only main-view frames. An AVC444 picture is queued as two entries, ++ * a main view and the auxiliary view refining its chroma, so the raw queue ++ * length counts one frame of video twice and would halve the effective ++ * backlog for callers pacing themselves against it. */ ++ for (struct guac_h264_frame* current = layer->h264_queue; ++ current != NULL; current = current->next) { ++ if (current->view == GUAC_H264_VIEW_MAIN) ++ backlog++; ++ } ++ ++ pthread_mutex_unlock(&layer->h264_queue_lock); ++ ++ return backlog; ++ ++} +diff --git a/src/libguac/guacamole/display.h b/src/libguac/guacamole/display.h +index 4c298fb7..da6b92f1 100644 +--- a/src/libguac/guacamole/display.h ++++ b/src/libguac/guacamole/display.h +@@ -493,6 +493,36 @@ void guac_display_layer_set_h264(guac_display_layer* layer, + int x, int y, int width, int height, + const guac_rect* rects, int num_rects, int view, int paired); + ++/** ++ * Returns the number of H.264 frames of video queued on the given layer and ++ * not yet flushed to connected clients. ++ * ++ * Only main-view access units are counted. An AVC444 picture is queued as two ++ * entries -- a main view and the auxiliary view refining its chroma -- and ++ * counting both would make the same second of video read as twice the backlog ++ * it does under AVC420. ++ * ++ * This is the backlog between the source of the frames and the clients ++ * consuming them, and thus a direct measure of whether the source is ++ * outrunning the clients. It rises when the render thread is waiting out ++ * client processing lag, and falls as frames are flushed. ++ * ++ * A protocol implementation that can slow its source down -- RDP can, by ++ * delaying the acknowledgement of graphics frames -- should consult this ++ * rather than let the queue reach the cap enforced by ++ * guac_display_layer_set_h264(), where the oldest frames are dropped. A drop ++ * breaks the decoder's reference chain, costing every subsequent frame until ++ * the next keyframe, so arriving at the cap is far more expensive than ++ * slowing down before it. ++ * ++ * @param layer ++ * The layer to query. May be NULL, in which case zero is returned. ++ * ++ * @return ++ * The number of queued H.264 frames of video. ++ */ ++int guac_display_layer_h264_backlog(guac_display_layer* layer); ++ + /** + * Returns a layer representing the current mouse cursor icon. Changes to the + * contents of this layer will affect the remote mouse cursor after the current +diff --git a/src/protocols/rdp/channels/rdpgfx.c b/src/protocols/rdp/channels/rdpgfx.c +index cf060bdf..f40bd892 100644 +--- a/src/protocols/rdp/channels/rdpgfx.c ++++ b/src/protocols/rdp/channels/rdpgfx.c +@@ -34,6 +34,11 @@ + + #include + #include ++#include ++ ++#include ++#include ++#include + + /** + * Checks whether an H.264 access unit contains an IDR slice, and optionally +@@ -431,6 +436,368 @@ static UINT guac_rdp_gfx_caps_confirm(RdpgfxClientContext* context, + * Event-specific arguments, mainly the name of the channel, and a + * reference to the associated plugin loaded for that channel by FreeRDP. + */ ++/** ++ * Default backlog, in queued frames of video, that clients are allowed to fall ++ * behind by before acknowledgement of further graphics frames is held back. ++ * Overridden by the GUAC_RDP_H264_BACKLOG_TARGET environment variable, where ++ * zero disables holding entirely. ++ * ++ * This is a backstop, not a rate controller, and the distinction is what sets ++ * the value. A client normally runs a frame or two behind, and rises and falls ++ * around that as decode cost varies with scene complexity. A target inside ++ * that working range is tripped continuously: the queue fills, the ack is ++ * held, the queue drains, and it fills again, delivering frames in bursts ++ * separated by stalls -- measurably worse than not throttling at all, because ++ * the stalls are visible and the backlog was never dangerous. ++ * ++ * The value must therefore sit above the depth a healthy session reaches and ++ * below the 120-frame cap in guac_display_layer_set_h264(), where the oldest ++ * frames are dropped and the decoder's reference chain breaks. 32 leaves room ++ * for ordinary jitter while still engaging well before that cliff. ++ */ ++#define GUAC_RDP_H264_BACKLOG_TARGET_DEFAULT 32 ++ ++/** ++ * Default client processing lag, in milliseconds, that is tolerated before ++ * acknowledgement of further graphics frames is held back. Overridden by the ++ * GUAC_RDP_H264_LAG_TARGET environment variable, where zero disables the lag ++ * gate and leaves only the backlog gate. ++ * ++ * This is the primary gate, and the backlog is a backstop behind it. The ++ * backlog only measures frames still held by guacd; once a frame has been ++ * flushed to the socket it is invisible there, even though the client may not ++ * have decoded or drawn it yet. On a fast link the queue therefore drains to ++ * nothing while the client falls further and further behind, which is exactly ++ * the case an acknowledgement is supposed to report upstream. ++ * ++ * guac_client_get_processing_lag() measures the round trip of the Guacamole ++ * sync handshake, which terminates at the client after it has processed the ++ * frame, and subtracts its estimate of network round-trip time ++ * (user-handlers.c). What remains is the client's own inability to keep up -- ++ * decode cost, compositing, an AVC444 chroma combine -- none of which the RDP ++ * server can observe on its own. Because the network component is already ++ * removed, an absolute threshold is meaningful on a WAN as well as a LAN. ++ * ++ * The render thread waits out this same signal before sending the next frame ++ * (display-render-thread.c), so gating acknowledgement on it makes the ++ * upstream and downstream halves of the pipeline agree rather than leaving ++ * guacd to absorb the difference in its queue until frames are dropped. ++ */ ++#define GUAC_RDP_H264_LAG_TARGET_DEFAULT 50 ++ ++/** ++ * The longest a single graphics frame acknowledgement may be held back, in ++ * milliseconds. Overridden by the GUAC_RDP_H264_ACK_MAX_DELAY environment ++ * variable. ++ * ++ * This cap bounds a client that has stopped draining, not one that is merely ++ * behind: the proportional hold already handles "behind" by holding for the ++ * excess. Sizing it for the latter is a mistake that was measured -- at 100ms ++ * every frame of ordinary heavy content clamped, the hold became constant ++ * rather than proportional, and the loop could not reach equilibrium because ++ * it was forbidden from slowing below 1000/cap. ++ * ++ * The cap does set a frame rate floor, and how hard a floor depends on the ++ * server. One with several frames in flight absorbs a held ack smoothly; one ++ * in strict lockstep -- xorgxrdp captures only when nothing is outstanding -- ++ * stops capturing until the ack arrives, making the frame period the hold time ++ * and the cap a divider. At 400ms that floor is 2.5fps, reached only when lag ++ * exceeds the target by the full cap, which is genuine overload rather than ++ * load. ++ * ++ * The wait must be bounded. A client that has stopped draining entirely would ++ * otherwise stall the EGFX channel indefinitely, and an unbounded stall is ++ * indistinguishable to the server from a client that has gone away. Capping it ++ * means a pathologically slow client costs frame rate rather than the session. ++ */ ++#define GUAC_RDP_H264_ACK_MAX_DELAY_DEFAULT 400 ++ ++/** ++ * How often the backlog is re-checked while an acknowledgement is held back, ++ * in milliseconds. ++ */ ++#define GUAC_RDP_H264_ACK_POLL_INTERVAL 5 ++ ++/** ++ * Callback invoked by FreeRDP when the server signals the end of a graphics ++ * frame, wrapping the handler installed by gdi_graphics_pipeline_init(). ++ * ++ * Returning from this callback is what releases the RDPGFX_FRAME_ACKNOWLEDGE ++ * that FreeRDP sends for the frame (see rdpgfx_recv_end_frame_pdu()), which ++ * makes it the one point where guacd can apply backpressure to the server. ++ * ++ * That matters only for H.264 passthrough. Everywhere else guacd is the ++ * encoder and already adapts: guac_display_suggest_quality() lowers JPEG and ++ * WebP quality as client processing lag rises, and the render thread waits ++ * that lag out before starting the next frame. In passthrough guacd is not ++ * the encoder, has no quality to lower, and the only party that can slow down ++ * is the server -- which will, since a server applies flow control to ++ * unacknowledged frames. ++ * ++ * Without this the source runs at its own rate regardless of what clients can ++ * drain, frames accumulate on the layer, and the queue eventually hits its cap ++ * and drops the oldest. Dropping breaks the decoder's reference chain, so ++ * every subsequent frame is discarded until the next keyframe: the failure ++ * arrives as a stall, long after the overload that caused it, and costs far ++ * more than the delay that would have prevented it. ++ * ++ * Note that this throttles frame *rate*. Whether the server also lowers ++ * bitrate or quantiser in response is a decision its own encoder makes, from ++ * its own view of the link. ++ * ++ * @param context ++ * The RDPGFX client context. ++ * ++ * @param pdu ++ * The received RDPGFX_END_FRAME_PDU. ++ * ++ * @return ++ * CHANNEL_RC_OK on success, an error code otherwise. ++ */ ++/** ++ * Returns the value of the given environment variable as an integer, or the ++ * given default if it is unset or does not parse. Negative values are refused ++ * so that a typo cannot invert the meaning of a threshold; zero is allowed and ++ * left for the caller to interpret. ++ * ++ * The value is read once and cached. Two threads racing to initialise it write ++ * the same value, so no lock is needed. ++ * ++ * @param name ++ * The name of the environment variable to read. ++ * ++ * @param fallback ++ * The value to use if the variable is unset or invalid. ++ * ++ * @param cached ++ * Storage for the resolved value, which must be initialised to -1. ++ * ++ * @return ++ * The resolved value. ++ */ ++static int guac_rdp_gfx_tunable(const char* name, int fallback, int* cached) { ++ ++ if (*cached >= 0) ++ return *cached; ++ ++ int value = fallback; ++ ++ const char* raw = getenv(name); ++ if (raw != NULL && *raw != '\0') { ++ char* end = NULL; ++ long parsed = strtol(raw, &end, 10); ++ if (*end == '\0' && parsed >= 0 && parsed <= INT_MAX) ++ value = (int) parsed; ++ } ++ ++ *cached = value; ++ return value; ++ ++} ++ ++/** ++ * Returns the backlog, in queued frames of video, that clients may fall behind ++ * by before acknowledgement is held back. Zero disables holding entirely, ++ * leaving the drop cap in guac_display_layer_set_h264() as the only limit. ++ * ++ * @return ++ * The configured backlog target. ++ */ ++static int guac_rdp_gfx_backlog_target(void) { ++ static int cached = -1; ++ return guac_rdp_gfx_tunable("GUAC_RDP_H264_BACKLOG_TARGET", ++ GUAC_RDP_H264_BACKLOG_TARGET_DEFAULT, &cached); ++} ++ ++/** ++ * Returns the maximum time, in milliseconds, that acknowledgement of a single ++ * frame may be held back. ++ * ++ * @return ++ * The configured cap on the acknowledgement delay. ++ */ ++static int guac_rdp_gfx_ack_max_delay(void) { ++ static int cached = -1; ++ return guac_rdp_gfx_tunable("GUAC_RDP_H264_ACK_MAX_DELAY", ++ GUAC_RDP_H264_ACK_MAX_DELAY_DEFAULT, &cached); ++} ++ ++/** ++ * Returns the client processing lag, in milliseconds, tolerated before ++ * acknowledgement is held back. Zero disables the lag gate. ++ * ++ * @return ++ * The configured lag target. ++ */ ++static int guac_rdp_gfx_lag_target(void) { ++ static int cached = -1; ++ return guac_rdp_gfx_tunable("GUAC_RDP_H264_LAG_TARGET", ++ GUAC_RDP_H264_LAG_TARGET_DEFAULT, &cached); ++} ++ ++/** ++ * Returns non-zero if the connected clients are behind by either measure, ++ * and thus if acknowledgement of the current frame should continue to be ++ * held back. A gate whose target is zero is disabled and never trips. ++ * ++ * @param lag ++ * The current client processing lag, in milliseconds. ++ * ++ * @param lag_target ++ * The lag tolerated before the lag gate trips. ++ * ++ * @param backlog ++ * The current backlog, in queued frames of video. ++ * ++ * @param backlog_target ++ * The backlog tolerated before the backlog gate trips. ++ * ++ * @return ++ * Non-zero if the clients are behind, zero otherwise. ++ */ ++static int guac_rdp_gfx_clients_behind(int lag, int lag_target, ++ int backlog, int backlog_target) { ++ ++ if (lag_target > 0 && lag > lag_target) ++ return 1; ++ ++ if (backlog_target > 0 && backlog > backlog_target) ++ return 1; ++ ++ return 0; ++ ++} ++ ++static UINT guac_rdp_gfx_end_frame(RdpgfxClientContext* context, ++ const RDPGFX_END_FRAME_PDU* pdu) { ++ ++ /* gfx->custom points to rdpGdi (set by gdi_graphics_pipeline_init) */ ++ rdpGdi* gdi = (rdpGdi*) context->custom; ++ rdpContext* rdp_context = gdi->context; ++ guac_client* client = ((rdp_freerdp_context*) rdp_context)->client; ++ guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; ++ ++ UINT result = CHANNEL_RC_OK; ++ ++ pcRdpgfxEndFrame orig = (pcRdpgfxEndFrame) rdp_client->orig_end_frame; ++ if (orig != NULL) ++ result = orig(context, pdu); ++ ++ /* The display is torn down before the channel is, so a frame arriving ++ * during disconnect has nothing to measure. */ ++ if (rdp_client->display == NULL) ++ return result; ++ ++ /* Resolve both targets before touching the queue: querying the backlog ++ * takes the same lock the render thread uses to drain it, and doing that ++ * once per frame is not free. Two disabled gates must cost nothing. */ ++ int lag_target = guac_rdp_gfx_lag_target(); ++ int backlog_target = guac_rdp_gfx_backlog_target(); ++ if (lag_target <= 0 && backlog_target <= 0) ++ return result; ++ ++ /* Sampled for every frame that reaches a live gate, not only for the ++ * frames actually held. The gap credited below is the spacing since the ++ * previous frame, so skipping the update whenever clients happen to be ++ * keeping up would leave a stale timestamp and credit an entire quiet ++ * period against the first frame of the next episode -- which is the one ++ * frame where engaging promptly matters most. */ ++ guac_timestamp now = guac_timestamp_current(); ++ int since_last = (rdp_client->last_gfx_frame_end != 0) ++ ? (int) (now - rdp_client->last_gfx_frame_end) : 0; ++ rdp_client->last_gfx_frame_end = now; ++ ++ guac_display_layer* default_layer = ++ guac_display_default_layer(rdp_client->display); ++ ++ int lag = guac_client_get_processing_lag(client); ++ int backlog = (backlog_target > 0) ++ ? guac_display_layer_h264_backlog(default_layer) : 0; ++ ++ if (!guac_rdp_gfx_clients_behind(lag, lag_target, backlog, backlog_target)) ++ return result; ++ ++ int max_delay = guac_rdp_gfx_ack_max_delay(); ++ ++ /* The lag contributes a fixed, proportional delay rather than a condition ++ * to wait on. Processing lag is only recalculated when a sync response ++ * arrives (user-handlers.c), a sync is only sent when the render thread ++ * has a frame to flush, and against a lockstep server no further frame is ++ * captured until this acknowledgement goes out -- so polling for the lag ++ * to fall would block the very signal it waits for, and every hold would ++ * run to the cap regardless of how the client is actually doing. ++ * ++ * Holding for the excess instead closes the loop across frames: a client ++ * that is further behind is given proportionally longer, and as it catches ++ * up the excess shrinks and the rate recovers on its own. This mirrors ++ * what the render thread already does downstream, where the wait is ++ * likewise computed once and slept rather than polled. */ ++ int hold = 0; ++ if (lag_target > 0 && lag > lag_target) ++ hold = lag - lag_target; ++ ++ /* Credit the spacing the server has already provided since the last frame, ++ * exactly as the render thread's required_wait subtracts ++ * time_since_last_frame (display-render-thread.c). Without it this is an ++ * additive delay, and additive delay composes badly with a server that ++ * paces itself. ++ * ++ * Such a server exists: xorgxrdp's adaptive pacing steers its capture ++ * interval from the very acknowledgement round trip held back here, so an ++ * unconditional hold is read upstream as client latency and answered with ++ * a longer interval, which shortens the hold, which shortens the interval. ++ * Two controllers actuating one frame rate through each other's sensor, ++ * hunting on a roughly one-second cycle. Crediting the elapsed time turns ++ * this from a competing controller into a floor: a server that has already ++ * spaced its frames beyond the excess is held for nothing at all and keeps ++ * sole ownership of the loop, while one that floods -- Windows, which ++ * paces not at all -- is still throttled by whatever it did not provide. ++ * ++ * This is why the gate needs no per-connection setting: it stands down on ++ * its own against a server that does not need it. */ ++ hold = (since_last < hold) ? hold - since_last : 0; ++ ++ if (hold > max_delay) ++ hold = max_delay; ++ ++ /* Hold the acknowledgement back, giving up after the cap so that a stalled ++ * client cannot stall the channel. The backlog, unlike the lag, is drained ++ * by the render thread while we wait, so it is worth re-reading: a backlog ++ * that clears early releases the frame early. */ ++ int waited = 0; ++ while (waited < max_delay ++ && client->state == GUAC_CLIENT_RUNNING ++ && (waited < hold ++ || (backlog_target > 0 && backlog > backlog_target))) { ++ ++ usleep(GUAC_RDP_H264_ACK_POLL_INTERVAL * 1000); ++ waited += GUAC_RDP_H264_ACK_POLL_INTERVAL; ++ ++ if (backlog_target > 0) ++ backlog = guac_display_layer_h264_backlog(default_layer); ++ ++ } ++ ++ /* Refreshed only for the log; it cannot have changed during the hold. */ ++ lag = guac_client_get_processing_lag(client); ++ ++ if (waited >= max_delay) ++ guac_client_log(client, GUAC_LOG_DEBUG, ++ "Held frame %" PRIu32 " acknowledgement for the full %i ms cap " ++ "(lag %i ms, backlog %i frames). Against a lockstep server the " ++ "cap is a frame rate floor of %i fps; raise " ++ "GUAC_RDP_H264_LAG_TARGET if the clients are keeping up.", ++ pdu->frameId, max_delay, lag, backlog, 1000 / max_delay); ++ else ++ guac_client_log(client, GUAC_LOG_TRACE, ++ "Held frame %" PRIu32 " acknowledgement for %i ms to let " ++ "clients catch up (lag now %i ms, backlog %i frames).", ++ pdu->frameId, waited, lag, backlog); ++ ++ return result; ++ ++} ++ + static void guac_rdp_rdpgfx_channel_connected(rdpContext* context, + ChannelConnectedEventArgs* args) { + +@@ -500,6 +867,15 @@ static void guac_rdp_rdpgfx_channel_connected(rdpContext* context, + rdpgfx->CapsConfirm = guac_rdp_gfx_caps_confirm; + } + ++ /* Wrapped for the same reason and on the same terms as ++ * SurfaceCommand: gdi_graphics_pipeline_init() reinstalls its own ++ * EndFrame on every reconnect, so the test is against what is ++ * currently installed rather than against a once-only flag. */ ++ if (rdpgfx->EndFrame != guac_rdp_gfx_end_frame) { ++ rdp_client->orig_end_frame = (void*) rdpgfx->EndFrame; ++ rdpgfx->EndFrame = guac_rdp_gfx_end_frame; ++ } ++ + guac_client_log(client, GUAC_LOG_INFO, + "H.264 passthrough enabled for RDPGFX channel."); + +diff --git a/src/protocols/rdp/rdp.h b/src/protocols/rdp/rdp.h +index 82ca5a1d..78937873 100644 +--- a/src/protocols/rdp/rdp.h ++++ b/src/protocols/rdp/rdp.h +@@ -147,6 +147,21 @@ typedef struct guac_rdp_client { + */ + void* orig_caps_confirm; + ++ /** ++ * The original RDPGFX EndFrame callback, wrapped so that acknowledgement ++ * of a graphics frame can be held back while connected clients are behind. ++ * See guac_rdp_gfx_end_frame(). ++ */ ++ void* orig_end_frame; ++ ++ /** ++ * When the previous graphics frame ended, or zero before the first. The ++ * gap between frames is the spacing the server has already provided, which ++ * guac_rdp_gfx_end_frame() credits against the delay it would otherwise ++ * add. ++ */ ++ guac_timestamp last_gfx_frame_end; ++ + /** + * Queue of mouse, keyboard, and touch events. These events are accumulated + * and flushed within the RDP client thread to avoid spending excessive diff --git a/patches/015-rdpgfx-same-size-recreation.patch b/patches/015-rdpgfx-same-size-recreation.patch new file mode 100644 index 00000000..0bbc64a0 --- /dev/null +++ b/patches/015-rdpgfx-same-size-recreation.patch @@ -0,0 +1,478 @@ +diff --git a/src/libguac/display-layer.c b/src/libguac/display-layer.c +index 3e41e68f..9ed2141f 100644 +--- a/src/libguac/display-layer.c ++++ b/src/libguac/display-layer.c +@@ -341,7 +341,8 @@ void guac_display_layer_close_cairo(guac_display_layer* layer, guac_display_laye + void guac_display_layer_set_h264(guac_display_layer* layer, + const unsigned char* data, uint32_t length, int is_keyframe, + int x, int y, int width, int height, +- const guac_rect* rects, int num_rects, int view, int paired) { ++ const guac_rect* rects, int num_rects, int view, int paired, ++ int recreated) { + + int dropped = 0; + +@@ -356,6 +357,7 @@ void guac_display_layer_set_h264(guac_display_layer* layer, + frame->is_keyframe = is_keyframe; + frame->view = view; + frame->paired = paired; ++ frame->recreated = recreated; + guac_rect_init(&frame->rect, x, y, width, height); + frame->next = NULL; + +@@ -367,6 +369,18 @@ void guac_display_layer_set_h264(guac_display_layer* layer, + memcpy(frame->rects, rects, rects_size); + frame->num_rects = num_rects; + } ++ ++ /* A negative count means the rects could not be read, which is not the ++ * same as the server declaring none: zero reaches the client as "this ++ * picture changes nothing", so passing it on here would blank the display ++ * on a malformed metablock or a failed allocation. One rect covering the ++ * surface restores what this path did before the two were told apart. */ ++ else if (num_rects < 0) { ++ frame->rects = guac_mem_alloc(sizeof(guac_rect)); ++ frame->rects[0] = frame->rect; ++ frame->num_rects = 1; ++ } ++ + else { + frame->rects = NULL; + frame->num_rects = 0; +diff --git a/src/libguac/display-plan.c b/src/libguac/display-plan.c +index 2a743ae2..9cb6aaad 100644 +--- a/src/libguac/display-plan.c ++++ b/src/libguac/display-plan.c +@@ -419,6 +419,7 @@ static void guac_display_plan_send_h264_frame(guac_client* client, + * + * h264 + * [ ]... ++ * + * + * identifies what the access unit carries: 0 is a displayable + * picture (AVC420, or the main view of AVC444), 1 and 2 are the auxiliary +@@ -430,9 +431,13 @@ static void guac_display_plan_send_h264_frame(guac_client* client, + * The trailing region rects identify which parts of the decoded picture + * are actually valid. A client MUST draw only those regions; the picture + * is full-surface sized, and on a server that mixes codecs the remainder +- * holds no meaningful content. A count of zero means the whole picture is +- * valid, which is the case for servers (such as xrdp) that encode the +- * entire surface as H.264. ++ * holds no meaningful content. A count of zero is not "all of it": in ++ * MS-RDPEGFX the region rects are the areas that *changed*, and FreeRDP's ++ * own avc420_decompress copies and invalidates only those, so a zero-rect ++ * picture changes nothing on screen. A client MUST decode it -- later ++ * pictures reference it -- and MUST NOT draw any of it. Where the rects ++ * could not be read at all, one rect covering the surface is written ++ * instead, so that a count of zero always means the server said zero. + * + * is non-zero on the main view of an AVC444 picture whose + * auxiliary chroma view follows immediately (MS-RDPEGFX LC=0). A client +@@ -442,7 +447,18 @@ static void guac_display_plan_send_h264_frame(guac_client* client, + * views are separate access units, and the second has not arrived when the + * first is drawn. It trails the region rects because those are variable in + * number; a client that stops parsing at the rects sees the instruction it +- * saw before, and simply paints both views as it always did. */ ++ * saw before, and simply paints both views as it always did. ++ * ++ * is non-zero when the surface this picture belongs to was ++ * just recreated at the size it already had. Such a surface is empty, so ++ * the picture describes a blank surface rather than a screen that has gone ++ * blank, and the server will go on to repaint only what it believes ++ * changed. A client holding the previous picture MAY keep it rather than ++ * painting this one -- it must still decode it, as later pictures ++ * reference it. Only the server can say this: a client can see that a ++ * picture decoded black, but not whether that is the screen or an empty ++ * surface. It trails for the same reason trails the ++ * rects. */ + int header[] = { + stream->index, + layer->layer->index, +@@ -471,6 +487,7 @@ static void guac_display_plan_send_h264_frame(guac_client* client, + } + + guac_h264_write_arg(socket, frame->paired); ++ guac_h264_write_arg(socket, frame->recreated); + + guac_socket_write_string(socket, ";"); + guac_socket_instruction_end(socket); +diff --git a/src/libguac/display-priv.h b/src/libguac/display-priv.h +index 78471018..ddcd3047 100644 +--- a/src/libguac/display-priv.h ++++ b/src/libguac/display-priv.h +@@ -348,6 +348,18 @@ struct guac_h264_frame { + */ + int paired; + ++ /** ++ * Non-zero if the surface this frame belongs to was just recreated at the ++ * size it already had. Such a surface is empty, so the picture describes a ++ * blank screen rather than a screen that has gone blank -- and the server ++ * will go on to repaint only what it believes changed, trusting the client ++ * to hold the rest. ++ * ++ * Only the server knows this. A client can see that a picture decoded ++ * black; it cannot see whether that is the screen or an empty surface. ++ */ ++ int recreated; ++ + /** + * The bounds of the surface this frame decodes to. Retained for logging + * and as a fallback when the server supplies no region rects. +diff --git a/src/libguac/guacamole/display.h b/src/libguac/guacamole/display.h +index 8b9738fd..f1192e59 100644 +--- a/src/libguac/guacamole/display.h ++++ b/src/libguac/guacamole/display.h +@@ -468,6 +468,12 @@ void guac_display_free_layer(guac_display_layer* display_layer); + * @param paired + * Non-zero if an auxiliary chroma view for this same picture follows + * immediately. Meaningful only on a main view. ++ * ++ * @param recreated ++ * Non-zero if the surface this frame belongs to was just recreated at the ++ * size it already had, and its content is therefore an empty surface ++ * rather than a screen that has gone blank. ++ * + */ + /** + * An ordinary AVC420 access unit, or the main view of an AVC444 stream. A +@@ -491,7 +497,8 @@ void guac_display_free_layer(guac_display_layer* display_layer); + void guac_display_layer_set_h264(guac_display_layer* layer, + const unsigned char* data, uint32_t length, int is_keyframe, + int x, int y, int width, int height, +- const guac_rect* rects, int num_rects, int view, int paired); ++ const guac_rect* rects, int num_rects, int view, int paired, ++ int recreated); + + /** + * Returns the number of H.264 frames of video queued on the given layer and +diff --git a/src/protocols/rdp/channels/rdpgfx.c b/src/protocols/rdp/channels/rdpgfx.c +index 52dd5f82..3afea8a4 100644 +--- a/src/protocols/rdp/channels/rdpgfx.c ++++ b/src/protocols/rdp/channels/rdpgfx.c +@@ -121,12 +121,26 @@ static guac_rect* guac_rdp_h264_copy_rects(const RDPGFX_H264_METABLOCK* meta, + + *num_rects = 0; + +- if (meta == NULL || meta->numRegionRects == 0 || meta->regionRects == NULL) ++ /* The server saying zero is meaningful and is passed on as zero: in ++ * MS-RDPEGFX the rects are the areas that changed, so a zero-rect picture ++ * changes nothing and must not be drawn. */ ++ if (meta != NULL && meta->numRegionRects == 0) + return NULL; + ++ /* Everything below is the opposite case -- the rects could not be read -- ++ * where zero would be read downstream as an instruction to draw nothing. ++ * Reported so the caller can substitute the whole surface, which is what ++ * this path meant before the two cases were told apart. */ ++ if (meta == NULL || meta->regionRects == NULL) { ++ *num_rects = -1; ++ return NULL; ++ } ++ + guac_rect* rects = malloc(meta->numRegionRects * sizeof(guac_rect)); +- if (rects == NULL) ++ if (rects == NULL) { ++ *num_rects = -1; + return NULL; ++ } + + for (UINT32 i = 0; i < meta->numRegionRects; i++) { + const RECTANGLE_16* src = &meta->regionRects[i]; +@@ -313,13 +327,21 @@ static UINT guac_rdp_gfx_surface_command(RdpgfxClientContext* context, + * Two saved views means this command carried both (LC=0), so the main + * view is flagged as paired: the client is about to receive an + * auxiliary view refining it to 4:4:4, and combining clients skip +- * painting a picture that is superseded a moment later. */ ++ * painting a picture that is superseded a moment later. ++ * ++ * The recreation flag is carried by every view of this command, since ++ * which of them ends up painting the picture is the client's decision, ++ * not ours. Cleared once attached: it describes the first picture of ++ * the new surface, not every picture after it. */ ++ int recreated = rdp_client->gfx_surface_recreated; ++ rdp_client->gfx_surface_recreated = 0; ++ + for (int i = 0; i < saved_count; i++) + guac_display_layer_set_h264(default_layer, + saved_h264[i], saved_length[i], saved_keyframe[i], + cmd->left, cmd->top, cmd->width, cmd->height, + saved_rects[i], saved_num_rects[i], saved_view[i], +- (i == 0 && saved_count == 2)); ++ (i == 0 && saved_count == 2), recreated); + + /* With the decode skipped, guac_rdp_gdi_end_paint() never runs, so + * nothing marks the display as modified and the queued frames would +@@ -338,12 +360,14 @@ static UINT guac_rdp_gfx_surface_command(RdpgfxClientContext* context, + + guac_client_log(client, GUAC_LOG_TRACE, + "H.264 passthrough: view=%i, %u bytes, %s, " +- "nal_types=0x%08x, %ux%u at %u,%u, rects=%i", ++ "nal_types=0x%08x, %ux%u at %u,%u, rects=%i%s", + saved_view[i], saved_length[i], + saved_keyframe[i] ? "keyframe" : "delta", + saved_nal_types, + cmd->width, cmd->height, cmd->left, cmd->top, +- saved_num_rects[i]); ++ saved_num_rects[i], ++ saved_num_rects[i] < 0 ? " (unreadable, sent as the whole" ++ " surface)" : ""); + + /* guac_display_layer_set_h264() copies both the NAL data and the + * region rects, so this view's copies are ours to release. */ +@@ -798,6 +822,132 @@ static UINT guac_rdp_gfx_end_frame(RdpgfxClientContext* context, + + } + ++/** ++ * Returns this surface's slot, or NULL if it has none and none is free. ++ */ ++static guac_rdp_gfx_surface* guac_rdp_gfx_surface_slot( ++ guac_rdp_client* rdp_client, UINT16 id, int allocate) { ++ ++ int free_slot = -1; ++ ++ for (int i = 0; i < GUAC_RDP_GFX_MAX_SURFACES; i++) { ++ ++ if (rdp_client->gfx_surfaces[i].valid ++ && rdp_client->gfx_surfaces[i].id == id) ++ return &rdp_client->gfx_surfaces[i]; ++ ++ if (!rdp_client->gfx_surfaces[i].valid && free_slot < 0) ++ free_slot = i; ++ ++ } ++ ++ if (!allocate || free_slot < 0) ++ return NULL; ++ ++ rdp_client->gfx_surfaces[free_slot].id = id; ++ rdp_client->gfx_surfaces[free_slot].valid = 1; ++ rdp_client->gfx_surfaces[free_slot].deleted = 0; ++ rdp_client->gfx_surfaces[free_slot].width = 0; ++ rdp_client->gfx_surfaces[free_slot].height = 0; ++ return &rdp_client->gfx_surfaces[free_slot]; ++ ++} ++ ++/** ++ * Wrapper for the RDPGFX CreateSurface callback, which notes whether it ++ * recreates a surface at the size that surface already had, and then defers to ++ * FreeRDP's own handler. ++ * ++ * A same-size recreation is the one shape after which Windows does *not* ++ * repaint the whole screen, so its first picture is black over a surface the ++ * browser is still displaying correctly. See gfx_surface_recreated in rdp.h. ++ */ ++static UINT guac_rdp_gfx_create_surface(RdpgfxClientContext* context, ++ const RDPGFX_CREATE_SURFACE_PDU* pdu) { ++ ++ rdpGdi* gdi = (rdpGdi*) context->custom; ++ guac_client* client = ((rdp_freerdp_context*) gdi->context)->client; ++ guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; ++ ++ if (pdu != NULL) { ++ ++ /* Kept per surface ID rather than as one most-recently-deleted ++ * record, because a session holds several surfaces and tears them ++ * down together: deleting 1 and then 0 before recreating 0 is an ++ * ordinary resize sequence, and a single record would have been ++ * overwritten by the unrelated delete. */ ++ guac_rdp_gfx_surface* slot = ++ guac_rdp_gfx_surface_slot(rdp_client, pdu->surfaceId, 1); ++ ++ if (slot != NULL) { ++ ++ if (slot->deleted && slot->width == pdu->width ++ && slot->height == pdu->height) { ++ ++ rdp_client->gfx_surface_recreated = 1; ++ ++ guac_client_log(client, GUAC_LOG_INFO, ++ "RDPGFX surface %u recreated at the size it already " ++ "had (%ux%u). Its first picture describes an empty " ++ "surface, which the client is told not to paint over " ++ "a display it is still holding correctly.", ++ pdu->surfaceId, pdu->width, pdu->height); ++ ++ } ++ ++ slot->width = pdu->width; ++ slot->height = pdu->height; ++ slot->deleted = 0; ++ ++ } ++ ++ } ++ ++ pcRdpgfxCreateSurface orig = ++ (pcRdpgfxCreateSurface) rdp_client->orig_create_surface; ++ if (orig != NULL) ++ return orig(context, pdu); ++ ++ return CHANNEL_RC_OK; ++ ++} ++ ++/** ++ * Wrapper for the RDPGFX DeleteSurface callback, which records the size of the ++ * surface being deleted -- its PDU carries only an ID -- so that the creation ++ * which follows can be recognised as a same-size recreation. Then defers to ++ * FreeRDP's own handler. ++ */ ++static UINT guac_rdp_gfx_delete_surface(RdpgfxClientContext* context, ++ const RDPGFX_DELETE_SURFACE_PDU* pdu) { ++ ++ rdpGdi* gdi = (rdpGdi*) context->custom; ++ guac_client* client = ((rdp_freerdp_context*) gdi->context)->client; ++ guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; ++ ++ if (pdu != NULL) { ++ ++ /* The slot is marked rather than freed, so the size this surface had ++ * survives until the same ID is created again. Windows sends the ++ * delete twice in practice; the second finds the slot already marked ++ * and leaves the recorded size alone. */ ++ guac_rdp_gfx_surface* slot = ++ guac_rdp_gfx_surface_slot(rdp_client, pdu->surfaceId, 0); ++ ++ if (slot != NULL) ++ slot->deleted = 1; ++ ++ } ++ ++ pcRdpgfxDeleteSurface orig = ++ (pcRdpgfxDeleteSurface) rdp_client->orig_delete_surface; ++ if (orig != NULL) ++ return orig(context, pdu); ++ ++ return CHANNEL_RC_OK; ++ ++} ++ + static void guac_rdp_rdpgfx_channel_connected(rdpContext* context, + ChannelConnectedEventArgs* args) { + +@@ -876,6 +1026,21 @@ static void guac_rdp_rdpgfx_channel_connected(rdpContext* context, + rdpgfx->EndFrame = guac_rdp_gfx_end_frame; + } + ++ /* Wrapped to spot a surface recreated at the size it already had, ++ * which only matters to passthrough: without it guacd holds the ++ * pixels and repaints from them. Guarded like the others, since ++ * gdi_graphics_pipeline_init() reinstalls FreeRDP's own handlers ++ * on every reconnect and wrapping a wrapper would recurse. */ ++ if (rdpgfx->CreateSurface != guac_rdp_gfx_create_surface) { ++ rdp_client->orig_create_surface = (void*) rdpgfx->CreateSurface; ++ rdpgfx->CreateSurface = guac_rdp_gfx_create_surface; ++ } ++ ++ if (rdpgfx->DeleteSurface != guac_rdp_gfx_delete_surface) { ++ rdp_client->orig_delete_surface = (void*) rdpgfx->DeleteSurface; ++ rdpgfx->DeleteSurface = guac_rdp_gfx_delete_surface; ++ } ++ + guac_client_log(client, GUAC_LOG_INFO, + "H.264 passthrough enabled for RDPGFX channel."); + +diff --git a/src/protocols/rdp/rdp.h b/src/protocols/rdp/rdp.h +index f324312d..43fc261c 100644 +--- a/src/protocols/rdp/rdp.h ++++ b/src/protocols/rdp/rdp.h +@@ -71,6 +71,44 @@ + */ + #define GUAC_RDP_INPUT_EVENT_QUEUE_SIZE 4096 + ++/** ++ * The maximum number of RDPGFX surfaces whose dimensions are tracked at once. ++ * MS-RDPEGFX places no limit on surface IDs, but a session in practice holds ++ * one per monitor; a surface beyond this is simply not tracked, which costs ++ * the same-size detection for that surface and nothing else. ++ */ ++#define GUAC_RDP_GFX_MAX_SURFACES 16 ++ ++/** ++ * The size an RDPGFX surface was created at, and whether it has since been ++ * deleted. See gfx_surfaces in guac_rdp_client. ++ */ ++typedef struct guac_rdp_gfx_surface { ++ ++ /** ++ * The surface ID this slot describes. Meaningful only while valid. ++ */ ++ UINT16 id; ++ ++ /** ++ * The dimensions the surface was created at. ++ */ ++ UINT32 width; ++ UINT32 height; ++ ++ /** ++ * Whether this slot is in use at all. ++ */ ++ int valid; ++ ++ /** ++ * Whether the surface has been deleted. The slot is kept so that the size ++ * survives until the same ID is created again. ++ */ ++ int deleted; ++ ++} guac_rdp_gfx_surface; ++ + /** + * RDP-specific client data. + */ +@@ -162,6 +200,51 @@ typedef struct guac_rdp_client { + */ + guac_timestamp last_gfx_frame_end; + ++ /** ++ * The original RDPGFX CreateSurface and DeleteSurface callbacks, wrapped ++ * so that a surface recreated at the size it already had can be told ++ * apart from one recreated by a resize. See gfx_surface_recreated. ++ */ ++ void* orig_create_surface; ++ void* orig_delete_surface; ++ ++ /** ++ * Dimensions of each RDPGFX surface the session has seen, recorded at ++ * CreateSurface so that DeleteSurface -- whose PDU carries only a surface ++ * ID -- can say what size the surface being torn down was. ++ * ++ * Needed to spot a *same-size* recreation. Windows occasionally deletes ++ * and recreates its surface at the dimensions it already had, mid-session, ++ * with no resize and no ResetGraphics. The new surface is empty, so its ++ * first picture is black over the whole surface, and Windows then repaints ++ * only what it believes changed -- trusting the client to still hold the ++ * rest, which under H.264 passthrough it does and guacd does not. ++ * ++ * A recreation that comes with a resize is not this: Windows repaints ++ * everything after one, so the black picture is genuinely the new screen. ++ * The size is what tells the two apart. ++ * ++ * A deleted surface keeps its slot with `deleted` set rather than being ++ * removed, so that the size it had survives until that same ID is created ++ * again. A single most-recently-deleted record cannot do this: a session ++ * holds more than one surface and tears them down together, so a delete of ++ * one surface would overwrite the record of another and lose the ++ * recreation that followed. Tearing down 1 and then 0 before recreating 0 ++ * is an ordinary resize sequence, observed in the field. ++ */ ++ guac_rdp_gfx_surface gfx_surfaces[GUAC_RDP_GFX_MAX_SURFACES]; ++ ++ /** ++ * Set when a surface has been recreated at the size it already had, and ++ * cleared by the next H.264 surface command, which carries it to the ++ * client as the flag of its "h264" instructions. ++ * ++ * Only an H.264 command clears it. A recreation followed by a progressive ++ * or planar command is one where guacd still holds the pixels, so there is ++ * nothing for the client to withhold and nothing to tell it about. ++ */ ++ int gfx_surface_recreated; ++ + /** + * Queue of mouse, keyboard, and touch events. These events are accumulated + * and flushed within the RDP client thread to avoid spending excessive diff --git a/patches/README.md b/patches/README.md index c877a512..084bc5d9 100644 --- a/patches/README.md +++ b/patches/README.md @@ -220,3 +220,306 @@ git apply --check ../rustguac/patches/001-freerdp3-debian13.patch 2>&1 || echo " 1. Make changes in the `../guacamole-server` working tree 2. Export: `cd ../guacamole-server && git diff > ../rustguac/patches/NNN-description.patch` 3. Patches are applied in numeric order by the build scripts + +## 011-rdp-dpi-scaling.patch + +**Feature:** a `desktop-scale` RDP parameter, sent to the server as +`desktopScaleFactor` so the remote session renders its UI at a matching DPI. + +Without it, asking for a framebuffer in physical rather than logical pixels — +which is what makes text render sharply on a HiDPI display — produces a desktop +whose every icon and glyph is half the size it should be. guacd had no way to +request DPI scaling at all: `channels/disp.c` pins `DesktopScaleFactor` and +`DeviceScaleFactor` to 0, and the existing `dpi` parameter only rescales the +requested width and height, which `settings.c` skips whenever an explicit width +and height are supplied. + +**The two channels differ, and the layout is the capable one.** At connection +time only 100/140/180 survive, because FreeRDP transposes the pair while +synthesising its single-monitor definition and an out-of-range +`deviceScaleFactor` makes the server discard both. The display-control layout +is built in `disp.c` and handed straight to the channel, so nothing transposes +it: it carries the **exact** `DesktopScaleFactor` (100–500) beside the nearest +legal `DeviceScaleFactor`. A client that fits the display shortly after +connecting is therefore scaled by the exact value, which is what allows 200% on +a 2.0 display rather than the 180% that leaves its UI 10% small. +`guac_rdp_clamp_desktop_scale` supplies the first, `guac_rdp_normalize_desktop_scale` +the second. + +**How it works.** `desktop-scale` is parsed as a percentage and validated +against the 100–500 range of MS-RDPBCGR 2.2.1.3.2. When non-zero, +`guac_rdp_push_settings()` sets `FreeRDP_DesktopScaleFactor` to it and +`FreeRDP_DeviceScaleFactor` to a legal companion value; FreeRDP writes both +into the client core data (`gcc.c`). Zero, the default, leaves FreeRDP's +defaults alone and the session behaves exactly as before. + +**Only 100, 140 and 180 work,** for two compounding reasons, and +`guac_rdp_normalize_desktop_scale()` snaps the request to the nearest of them. + +MS-RDPBCGR 2.2.1.3.2 already restricts `deviceScaleFactor` to those three, and +a server discards the desktop factor along with an out-of-range device factor +rather than degrading. On top of that, **FreeRDP transposes the pair** when it +synthesises the single-monitor definition that every windowed session uses +(`libfreerdp/core/settings.c`, introduced in `401f81683` and present through +3.x): + +```c +const UINT32 desktopScaleFactor = get(FreeRDP_DeviceScaleFactor); /* reads Device */ +const UINT32 deviceScaleFactor = get(FreeRDP_DesktopScaleFactor); /* reads Desktop */ +... +monitor.attributes.desktopScaleFactor = desktopScaleFactor; +monitor.attributes.deviceScaleFactor = deviceScaleFactor; +``` + +So any pair of differing values reaches the server backwards: a request for +200%/100% arrives as `deviceScaleFactor=200`, which is illegal, and the whole +pair is dropped. Setting both factors to the *same* value is immune to the +transposition — which is exactly why FreeRDP's own `/scale` accepts only these +three and sets both at once. `static/client.html` snaps its framebuffer factor +to match (1.4 or 1.8), so the extra pixels and the session scaling agree and +physical sizes stay correct. + +**Scope.** RDP only, and Windows-only in practice. An X11 desktop behind xrdp +has no per-connection DPI negotiation; scaling there has to be arranged inside +the session (for example via `xfconf-query -c xsettings -p +/Gdk/WindowScalingFactor`, or `Xft.dpi` for non-integer factors). + +**Client side.** `static/client.html` sends `desktop_scale` on the connect +request, and only when it actually asked for a device-pixel framebuffer — see +`localStorage.rgNativeRes`, which is off by default. + +## 012-rdpgfx-frame-ack-backpressure.patch + +**Problem:** H.264 passthrough has no adaptive quality, because guacd is not +the encoder. + +Everywhere else it is. `guac_display_suggest_quality()` scales JPEG/WebP +quality from 90 down to 30 as client processing lag rises from 20ms to 80ms, +and the render thread waits that lag out before starting the next frame +(`display-worker.c`, `display-render-thread.c`). Both read the same signal: the +round trip of the Guacamole `sync` handshake. + +In passthrough guacd re-encodes nothing, so there is no quality to lower. The +RDP server keeps sending at whatever rate it chose, frames pile up on the +layer's H.264 queue, and at 120 frames `guac_display_layer_set_h264()` drops +the oldest. A drop breaks the decoder's reference chain, so every later frame +is discarded until the next IDR — the overload surfaces as a stall seconds +after the moment that caused it, and costs far more than slowing down would +have. + +**Gated on client processing lag, with the backlog as a backstop.** The signal +that matters is how far behind the *client* is, and the backlog cannot see it: +it counts frames still held by guacd, so a frame is invisible there the moment +it reaches the socket, however long the browser then takes to decode and draw +it. On a fast link the queue drains to nothing while the client falls steadily +further behind — and an acknowledgement sent then reports a drained pipeline to +a server that is in fact outrunning the viewer. + +`guac_client_get_processing_lag()` is the round trip of the Guacamole `sync` +handshake. It ends at the client after the frame has been processed, and +`user-handlers.c` subtracts an estimate of network round-trip time before +storing it, so what remains is the client's own inability to keep up: decode +cost, compositing, an AVC444 chroma combine. None of that is observable by the +RDP server, and because the network component is already removed, an absolute +threshold is meaningful on a WAN as well as a LAN. + +The render thread already waits this same value out before sending the next +frame (`display-render-thread.c`, capped at `GUAC_DISPLAY_MAX_LAG_COMPENSATION` += 500ms). Gating acknowledgement on it makes the upstream and downstream halves +of the pipeline agree, instead of leaving guacd to absorb the difference in its +own queue until the drop cap discards frames. + +Note that FreeRDP cannot report a truthful `queueDepth` — it hardcodes +`QUEUE_DEPTH_UNAVAILABLE` (`rdpgfx_main.c:1149`), with +`SUSPEND_FRAME_ACKNOWLEDGEMENT` as the only alternative. Depth-based flow +control would therefore require patching FreeRDP rather than guacd; delaying +the acknowledgement is the lever available here. + +**Mechanism:** the party that *can* slow down is the server, and MS-RDPEGFX +already provides the lever. A server applies flow control to unacknowledged +graphics frames, and FreeRDP sends `RDPGFX_FRAME_ACKNOWLEDGE` from +`rdpgfx_recv_end_frame_pdu()` immediately after `context->EndFrame` returns. +Wrapping `EndFrame` therefore controls when the acknowledgement goes out. + +Patch 004 found this by accident and from the wrong side: queueing frames under +the display-level `pending_frame.lock` blocked the same thread, and the +resulting throttle looked like a low source frame rate rather than a stall in +guacd. This patch does it deliberately and with a bound. + +| File | Change | +|------|--------| +| `src/libguac/guacamole/display.h` | Declare `guac_display_layer_h264_backlog()` | +| `src/libguac/display-layer.c` | Implement it — main-view frames under `h264_queue_lock` | +| `src/protocols/rdp/rdp.h` | Add `orig_end_frame` | +| `src/protocols/rdp/channels/rdpgfx.c` | Wrap `EndFrame`, hold the ack while clients are behind by lag or backlog | + +**Counting is per frame of video, not per queue entry.** An AVC444 picture is +queued as two access units, a main view and the auxiliary view refining its +chroma, so a raw queue length counts the same second of video twice and the +target would mean half as much under AVC444 as under AVC420 — engaging +backpressure at a depth never intended and throttling a stream that was keeping +up. `guac_display_layer_h264_backlog()` therefore counts main-view entries +only, which also keeps the target meaningful if the view count ever changes. + +**The lag hold is proportional and computed once, not polled.** Processing lag +is recalculated only when a sync response arrives, a sync is only sent when the +render thread has a frame to flush, and against a lockstep server no further +frame is captured until this acknowledgement goes out. Polling for the lag to +fall therefore blocks the signal it is waiting for, and every hold runs to the +cap no matter how the client is doing — measured as a collapse to 2.1fps under +a 500ms cap, which is just 1000/cap. Holding for the excess (`lag - target`) +instead closes the loop across frames: further behind means proportionally +longer, and as the client catches up the hold shrinks and the rate recovers. +The backlog keeps its poll loop, since the render thread drains it on another +thread while we wait. + +**Tuning.** Acknowledgement is held for the amount by which client processing +lag exceeds `GUAC_RDP_H264_LAG_TARGET` (default 50ms), and additionally while +more than `GUAC_RDP_H264_BACKLOG_TARGET` (default 32) frames of video are +queued. Either gate is disabled by setting it to `0`; with both at `0` the hook +returns before taking any lock. + +`GUAC_RDP_H264_ACK_MAX_DELAY` (default 400ms) bounds a client that has stopped +draining, not one that is merely behind — the proportional hold already covers +"behind". Sizing it for the latter was measured as a mistake: at 100ms every +frame of ordinary heavy content clamped, the hold became constant rather than +proportional, and the loop could not converge because it was forbidden from +slowing below 1000/cap. Against a lockstep server the cap is still a frame rate +floor (2.5fps at 400ms), but it is now only reached under genuine overload. + +**Measured behaviour** against xrdp with xorgxrdp in lockstep, once the hold was +proportional and the cap sized at 400ms — the loop is bimodal and recovers: + +| | light content | heavy content | +|---|---|---| +| frame rate | 30.0–30.2 fps | 11–12 fps | +| xorgxrdp send→ack | 18–20ms (max 33) | 74–79ms (max 405) | +| blocked callbacks /100 | 1–7 | 295–355 | +| client rtt mean | 1–8ms | 265–324ms | + +It clamps under load and releases when the load drops (12 → 6.4 in transition → +30 sustained → back to 11), which is the distinction between back-pressure and a +runaway. Under light load `idle` tracks xrdp's own `h264_frame_interval` with +`blocked` near zero: the server's frame interval is then the binding constraint +rather than the client, which is the condition that makes a hand-tuned interval +removable. + +This is a backstop, not a rate controller, and that distinction sets the value. +The target was originally 4, reasoned from 60fps as "a little over 60ms of +buffered video". Measured against a real xrdp session at ~16fps it was *below* +the depth a healthy client runs at, and tripped continuously: a hold every 5–6 +frames, 15–145ms each, ~22% of wall-clock spent deliberately stalled. Frames +arrived in bursts separated by stalls — visibly choppy, while the backlog it +was reacting to was never dangerous. A target inside the working range makes +things worse than no throttling at all. + +The value must sit above the depth a healthy session reaches and below the +120-frame drop cap. Both it and `GUAC_RDP_H264_ACK_MAX_DELAY` (default 500ms) +read an environment variable of the same name at first use, so they can be +swept from `guacd.env` with a service restart instead of a rebuild. A target of +`0` disables holding entirely, leaving the drop cap as the only limit — the +control to compare against when deciding whether backpressure earns its place. + +The target is resolved *before* the backlog is queried, so `0` costs nothing. +Querying takes `h264_queue_lock`, which the render thread needs to drain the +queue, and doing that once per frame is not free — the same hot-path contention +patch 004 hit by accident. A disabled backstop must be genuinely disabled. The wait is capped at `GUAC_RDP_H264_ACK_MAX_DELAY` (500ms) and +abandoned if the client stops running: an unbounded stall is indistinguishable +to the server from a client that has gone away, so a pathologically slow client +must cost frame rate rather than the session. The render thread drains the +queue on its own, so blocking here does not prevent the backlog from clearing. + +**Installed like `SurfaceCommand`**, by testing what is currently installed +rather than a once-only flag: `gdi_graphics_pipeline_init()` reinstalls its own +`EndFrame` on every RDPGFX reconnect (xrdp's login resize causes one), so a +once-only guard would silently stop applying backpressure for the rest of the +session. + +**Not a replacement for a sane source frame rate.** Back-pressure reacts to +lag; it does not search for the rate that minimises cost per frame. A client's +per-frame cost is not constant — pushed past its comfortable rate it enters a +region where decode queueing and GPU contention make each frame more expensive, +so throughput falls while latency climbs. The loop then holds longer, chasing an +operating point that moved because of its own input rate. It still converges, +but to a worse point than it started from, because nothing in it knows the lower +rate was cheaper per frame. + +Measured by halving xrdp's `h264_frame_interval` from 33ms to 16ms, on the same +client and content class, with this patch active throughout: + +| | interval 33 | interval 16 | +|---|---|---| +| frame rate | 30.0–30.2 fps | 24.7–29.5 fps | +| blocked callbacks /100 | 1–7 | 130–172 | +| client rtt mean | 1–8ms | 77–111ms | +| client rtt max | 11–36ms | 317–543ms | + +Fewer frames delivered, for more than an order of magnitude more latency. The +low round trip under the 33ms cap meant the client was comfortable at 30fps, not +that it had capacity for 60. So the server's frame interval and this patch do +different jobs: the interval selects the operating point, back-pressure absorbs +deviation around it. Removing the interval on the grounds that back-pressure +makes it redundant makes things worse. + +**Scope — this throttles frame rate, not bitrate.** Whether the server also +lowers its quantiser is a decision its own encoder makes from its own view of +the link, and that view is of the guacd↔server leg, which is typically a LAN. +Influencing it means feeding the server an end-to-end figure instead of the +LAN one it measures. The MS-RDPBCGR network auto-detect PDUs are the obvious +route, but `rdpAutoDetect` is not hookable the way `EndFrame` is: in the client +role FreeRDP's responses are sent by static functions called directly from +`autodetect.c`, and the public callbacks fire on *receive* or in the server +role, with `ClientBandwidthMeasureResult` arriving only after the PDU is +serialised. Substituting a figure means patching FreeRDP, not guacd. +## 015-rdpgfx-same-size-recreation.patch — tell the client an empty surface from a blank screen + +Windows occasionally deletes and recreates its RDPGFX surface at the dimensions +it already had, mid-session, with no resize and no `ResetGraphics`. The new +surface is empty, so its first picture is black over the whole surface — and +Windows then repaints only what it believes changed, trusting the client to +hold the rest. Under H.264 passthrough the client does hold it and guacd does +not, so the black picture must not be painted. See `sol1/rustguac#223`. + +A recreation that comes *with* a resize is not this: Windows repaints +everything after one, so its black picture is genuinely the new screen. The +size is what tells the two apart, which is why the dimensions are tracked at +all — `DeleteSurface` carries only a surface ID. + +The size is tracked **per surface ID**, in a slot marked deleted rather than +freed, so the size a surface had survives until that same ID is created again. +A single most-recently-deleted record is not enough: a session holds more than +one surface and tears them down together, so a delete of one surface overwrites +the record of another and loses the recreation that follows. Tearing down +surface 1 and then surface 0 before recreating 0 is an ordinary resize +sequence, observed in the field — and with one record, a same-size recreation +of 0 goes undetected whenever 0 is deleted first. + +Windows also sends the delete twice in practice; the second finds the slot +already marked and leaves the recorded size alone. Only an H.264 command clears +the flag: after a recreation followed by a progressive or planar command, guacd +still holds the pixels and there is nothing for the client to withhold. + +**The client cannot infer this.** It can see that a picture decoded black; it +cannot see whether that is the screen or an empty surface, and both are +needed. Content alone would withhold a screen that has legitimately gone black +— a blank screensaver, a display blanking on lock — and leave the old desktop +showing; the flag alone would withhold whatever a recreation carried, sight +unseen. Windows recreates its surface once on every connect, too, which the +client's settle window declines. + +Separately, this corrects a hazard in `004`'s rect handling. Three conditions +collapsed to a rect count of zero: the server genuinely declaring zero rects, a +malformed metablock, and a failed allocation. Since zero reaches the client as +*this picture changes nothing*, the last two would blank the display. The +unreadable cases now report `-1` and are sent as one rect covering the surface, +so a count of zero always means the server said zero. + +| File | Change | +|------|--------| +| `src/protocols/rdp/rdp.h` | The two original callbacks, `guac_rdp_gfx_surface` slots (size plus a deleted mark) and the recreation flag | +| `src/protocols/rdp/channels/rdpgfx.c` | `CreateSurface`/`DeleteSurface` wrappers, installed for H.264 connections with the same reinstall guard as `004`'s, doing the same-size detection; unreadable rects reported as `-1` | +| `src/libguac/display-layer.c` | Carries the flag onto the queued frame; substitutes the surface rect for an unreadable list | +| `src/libguac/display-plan.c` | Writes `` as the trailing argument of the `h264` instruction | +| `src/libguac/display-priv.h`, `src/libguac/guacamole/display.h` | Frame field and the setter's signature | + +Depends on `004` for the instruction and the wrapper installation it joins. diff --git a/src/api.rs b/src/api.rs index cfe236da..2275413c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -324,6 +324,15 @@ pub async fn delete_session( } } +/// Ceiling on the devicePixelRatio a native-resolution entry will size its +/// framebuffer by. +/// +/// The factor squares into pixels the host has to encode and the browser has to +/// decode, so an unbounded value from the page is a way to ask for a +/// framebuffer neither can sustain. 3.0 covers every display in use; beyond +/// that the resample is the lesser problem. +const MAX_NATIVE_FACTOR: f64 = 3.0; + /// PUT /api/sessions/:id/thumbnail — Upload a session thumbnail (JPEG). /// Called by the client periodically to update the session preview. /// Only the session owner (or an admin) can upload. @@ -2740,6 +2749,16 @@ pub struct ConnectRequest { pub height: Option, #[serde(default)] pub dpi: Option, + /// DPI scaling percentage to request from an RDP server, paired with a + /// framebuffer sized in physical pixels. + #[serde(default)] + pub desktop_scale: Option, + /// The browser's devicePixelRatio. Reported rather than acted on: whether + /// the framebuffer is actually sized in physical pixels is the entry's + /// `native_resolution` setting to decide, since it is only safe where the + /// target scales its UI to match. + #[serde(default)] + pub device_pixel_ratio: Option, #[serde(default)] pub banner: Option, /// Override or supply credentials at connect time (never stored). @@ -2931,6 +2950,59 @@ pub async fn ab_connect_entry( // Build CreateSessionRequest from the Vault entry + connect request display params. // ConnectRequest credentials override address book values (for prompted credentials). let ab_entry_key = format!("{}/{}/{}", scope, folder, entry); + + // Physical-pixel framebuffer, when this entry asks for one. The browser + // reports its devicePixelRatio and sends CSS-pixel dimensions; scaling them + // here keeps the policy with the entry rather than in the page. + // + // **The framebuffer factor and the desktop scale are separate numbers.** + // The framebuffer takes the browser's true devicePixelRatio, because + // anything else leaves the client resampling: it fits whatever framebuffer + // arrives into the available CSS area, so one framebuffer pixel lands on + // one physical pixel only when the two agree. The RDP desktop scale is + // snapped to 140 or 180 separately, since MS-RDPBCGR permits only + // 100/140/180 -- see guac_rdp_normalize_desktop_scale in patches/011. + // + // Tying them together, as this used to, meant a 2.0 display was given a + // 1.8 framebuffer and the client stretched it by 1.111 -- measured in the + // field as a uniformly soft picture with almost no single-pixel edges + // anywhere, 0.01% against a native render's 0.82%. The cost of separating + // them is that a desktop scaled 180% inside a framebuffer scaled 200% draws + // its UI about 10% smaller than nominal. That is the better trade: 10% + // smaller is legible and adjustable on the host, while a 1.111 resample is + // neither. + let native_factor = if ab_entry.native_resolution.unwrap_or(false) { + match req.device_pixel_ratio { + // Guard against a nonsense or hostile ratio sizing a framebuffer + // the server then has to encode. + Some(dpr) if dpr.is_finite() && dpr > 1.0 => dpr.min(MAX_NATIVE_FACTOR), + _ => 1.0, + } + } else { + 1.0 + }; + + let (width, height, desktop_scale) = if native_factor > 1.0 { + ( + req.width + .map(|w| ((w as f64 * native_factor).round() as u32) & !0x7), + req.height + .map(|h| (h as f64 * native_factor).round() as u32), + // The exact percentage, not the nearest legal one. guacd snaps it + // for the connection-time core data, where FreeRDP's monitor + // synthesis transposes the pair and only equal values survive, but + // sends it verbatim in the display-control layout that follows -- + // MS-RDPEDISP allows desktopScaleFactor anywhere in 100-500 and + // restricts only deviceScaleFactor. Since the client fits the + // display shortly after connecting, that layout is what the session + // ends up scaled by, so a 2.0 display gets 200% rather than the 180% + // that leaves its UI 10% small. + Some((native_factor * 100.0).round() as u32), + ) + } else { + (req.width, req.height, req.desktop_scale) + }; + let create_req = CreateSessionRequest { session_type, hostname: ab_entry.hostname, @@ -2954,9 +3026,11 @@ pub async fn ab_connect_entry( jump_username: None, jump_password: None, jump_private_key: None, - width: req.width, - height: req.height, + width, + height, dpi: req.dpi, + desktop_scale, + display_scale: (native_factor > 1.0).then_some(native_factor), banner: req.banner.or(ab_entry.banner), enable_drive: ab_entry.enable_drive, remote_app: ab_entry.remote_app, @@ -2980,6 +3054,8 @@ pub async fn ab_connect_entry( enable_full_window_drag: ab_entry.enable_full_window_drag, force_lossless: ab_entry.force_lossless, enable_h264: ab_entry.enable_h264, + // From an entry, so always set: see CreateSessionRequest. + h264_chroma444: Some(ab_entry.h264_chroma444.unwrap_or(false)), container_image: ab_entry.container_image, container_cpu_limit: ab_entry.container_cpu_limit, container_memory_limit: ab_entry.container_memory_limit, @@ -5258,6 +5334,7 @@ pub struct QuickConnectQuery { pub width: Option, pub height: Option, pub dpi: Option, + pub desktop_scale: Option, // Address book mode pub scope: Option, pub folder: Option, @@ -5447,6 +5524,8 @@ pub async fn quick_connect( width: query.width, height: query.height, dpi: query.dpi, + desktop_scale: query.desktop_scale, + display_scale: None, banner: ab_entry.banner, enable_drive: ab_entry.enable_drive, remote_app: ab_entry.remote_app, @@ -5470,6 +5549,8 @@ pub async fn quick_connect( enable_full_window_drag: ab_entry.enable_full_window_drag, force_lossless: ab_entry.force_lossless, enable_h264: ab_entry.enable_h264, + // From an entry, so always set: see CreateSessionRequest. + h264_chroma444: Some(ab_entry.h264_chroma444.unwrap_or(false)), container_image: ab_entry.container_image, container_cpu_limit: ab_entry.container_cpu_limit, container_memory_limit: ab_entry.container_memory_limit, @@ -5574,6 +5655,8 @@ pub async fn quick_connect( width: query.width, height: query.height, dpi: query.dpi, + desktop_scale: query.desktop_scale, + display_scale: None, banner: None, enable_drive: None, remote_app: None, @@ -5597,6 +5680,7 @@ pub async fn quick_connect( enable_full_window_drag: None, force_lossless: None, enable_h264: None, + h264_chroma444: None, container_image: None, container_cpu_limit: None, container_memory_limit: None, diff --git a/src/binary_blob.rs b/src/binary_blob.rs new file mode 100644 index 00000000..530b7fa6 --- /dev/null +++ b/src/binary_blob.rs @@ -0,0 +1,466 @@ +//! Converts base64 blob payloads into binary WebSocket frames. +//! +//! Base64 sends four bytes for every three, so a quarter of everything on the +//! wire is encoding overhead — 27MB of a 167-second video session, measured. +//! This rewrites the blobs of streams whose consumer wants bytes anyway into +//! binary frames, and leaves every other instruction exactly as it was. +//! +//! Only `h264` and `audio` streams are converted. Both reach +//! `Guacamole.ArrayBufferReader` in the browser, which decodes base64 to an +//! ArrayBuffer only to hand over the bytes. `img` is deliberately excluded: +//! its blobs reach `DataURIReader`, which concatenates base64 straight into a +//! `data:` URI and genuinely wants the encoded form. +//! +//! The conversion happens here rather than in a guacd patch because rustguac +//! tees the raw guacd stream to disk as the session recording. Converting +//! upstream of that tee would turn every recording binary and take the +//! recording format, `SessionRecording.js` and the playback page with it. The +//! guacd → rustguac hop is loopback, so nothing is lost by leaving it as text. +//! +//! See `docs/binary-blobs.md`. + +use base64::Engine; +use std::collections::HashSet; + +/// Wire format version, byte 0 of every binary frame. Present so a client can +/// reject a frame shape it does not know rather than misread one. +pub const FRAME_VERSION: u8 = 1; + +/// Frame type: a blob payload for a stream. Byte 1. +pub const FRAME_TYPE_BLOB: u8 = 0; + +/// Header bytes preceding the payload. Eight rather than six so the payload +/// starts on an 8-byte boundary. +pub const HEADER_LEN: usize = 8; + +/// One frame to write to the browser's WebSocket. +#[derive(Debug, PartialEq, Eq)] +pub enum OutFrame { + /// Instructions to send unchanged, as a text frame. + Text(String), + /// A blob payload, as a binary frame with an 8-byte header. + Binary(Vec), +} + +/// Parses one wire element starting at byte offset `at`. +/// +/// Returns the element's value, the offset just past its terminator, and the +/// terminator itself (`,` or `;`). Returns `None` on anything malformed or +/// truncated, which the caller treats as "stop converting and pass the rest +/// through untouched" — never as a reason to drop data. +/// +/// The length prefix counts UTF-8 *characters*, per `guac_utf8_strlen` in +/// libguac, so a clipboard instruction carrying an emoji does not desynchronise +/// the scan and take every instruction after it along. +pub(crate) fn element(text: &str, at: usize) -> Option<(&str, usize, u8)> { + let bytes = text.as_bytes(); + let digits_start = at; + let mut pos = at; + + while pos < bytes.len() && bytes[pos].is_ascii_digit() { + pos += 1; + } + if pos == digits_start || pos >= bytes.len() || bytes[pos] != b'.' { + return None; + } + let len: usize = text[digits_start..pos].parse().ok()?; + pos += 1; + + let value_start = pos; + let mut chars = 0; + while chars < len { + if pos >= bytes.len() { + return None; + } + let b = bytes[pos]; + let width = if b < 0x80 { + 1 + } else if b < 0xC0 { + return None; // lone continuation byte + } else if b < 0xE0 { + 2 + } else if b < 0xF0 { + 3 + } else { + 4 + }; + if pos + width > bytes.len() { + return None; + } + pos += width; + chars += 1; + } + + if pos >= bytes.len() { + return None; + } + let terminator = bytes[pos]; + if terminator != b',' && terminator != b';' { + return None; + } + Some((&text[value_start..pos], pos + 1, terminator)) +} + +/// Builds a binary blob frame for `index` carrying `payload`. +fn frame(index: u32, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(HEADER_LEN + payload.len()); + out.push(FRAME_VERSION); + out.push(FRAME_TYPE_BLOB); + out.extend_from_slice(&[0, 0]); // reserved + out.extend_from_slice(&index.to_le_bytes()); + out.extend_from_slice(payload); + out +} + +/// Rewrites blob instructions of binary-consumer streams into binary frames. +/// +/// Stream membership has to persist across reads, since the `h264` instruction +/// that opens a stream and the `blob` instructions that fill it routinely land +/// in different reads from guacd. +#[derive(Default)] +pub struct BlobSplitter { + /// Stream indices opened by `h264` or `audio`. + binary_streams: HashSet, +} + +impl BlobSplitter { + pub fn new() -> Self { + Self::default() + } + + /// Splits a run of complete instructions into the frames to send. + /// + /// `text` must end on an instruction boundary, which is what + /// `guacd_to_ws` already guarantees before sending anything. + /// + /// WebSocket delivers text and binary frames in one order, so a blob + /// lifted out of the text still arrives between the same neighbours it + /// had. Nothing here can reorder the stream. + pub fn split(&mut self, text: &str) -> Vec { + let mut out = Vec::new(); + + // Start of text not yet emitted. Everything between here and the next + // converted blob is passed through verbatim. + let mut pending = 0usize; + let mut pos = 0usize; + + while pos < text.len() { + let instruction_start = pos; + + let Some((opcode, mut next, mut terminator)) = element(text, pos) else { + break; + }; + + // Only the first two arguments are ever needed: a stream index, + // and for `blob` its payload. + let mut args: Vec<&str> = Vec::new(); + while terminator == b',' && args.len() < 2 { + match element(text, next) { + Some((value, after, term)) => { + args.push(value); + next = after; + terminator = term; + } + None => break, + } + } + + // Walk off the end of the instruction. An `h264` instruction + // carries region rects beyond what is read above. + while terminator == b',' { + match element(text, next) { + Some((_, after, term)) => { + next = after; + terminator = term; + } + None => break, + } + } + + if terminator != b';' { + break; // malformed or truncated: pass the remainder through + } + let instruction_end = next; + pos = instruction_end; + + let index = args.first().and_then(|a| a.parse::().ok()); + + match (opcode, index) { + ("h264" | "audio", Some(index)) => { + self.binary_streams.insert(index); + } + ("end", Some(index)) => { + self.binary_streams.remove(&index); + } + ("blob", Some(index)) if self.binary_streams.contains(&index) => { + let Some(payload) = args.get(1) else { continue }; + // A payload that will not decode is left as text rather + // than dropped: the browser's base64 decoder is no worse + // at it than ours, and losing a blob loses a picture. + let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(payload) + else { + continue; + }; + + if pending < instruction_start { + out.push(OutFrame::Text(text[pending..instruction_start].to_string())); + } + out.push(OutFrame::Binary(frame(index, &decoded))); + pending = instruction_end; + } + _ => {} + } + } + + if pending < text.len() { + out.push(OutFrame::Text(text[pending..].to_string())); + } + + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::Instruction; + + fn text(s: &str) -> OutFrame { + OutFrame::Text(s.to_string()) + } + + /// Base64 of the three bytes 0x01 0x02 0x03. + const B64: &str = "AQID"; + const BYTES: [u8; 3] = [1, 2, 3]; + + #[test] + fn converts_blobs_of_h264_streams() { + let mut s = BlobSplitter::new(); + let input = format!("4.h264,1.7,1.0,1.0;4.blob,1.7,4.{};3.end,1.7;", B64); + let out = s.split(&input); + + assert_eq!(out.len(), 3); + assert_eq!(out[0], text("4.h264,1.7,1.0,1.0;")); + assert_eq!(out[1], OutFrame::Binary(frame(7, &BYTES))); + assert_eq!(out[2], text("3.end,1.7;")); + } + + #[test] + fn converts_blobs_of_audio_streams() { + let mut s = BlobSplitter::new(); + let input = format!("5.audio,1.3,9.audio/L16;4.blob,1.3,4.{};", B64); + let out = s.split(&input); + assert_eq!(out[1], OutFrame::Binary(frame(3, &BYTES))); + } + + #[test] + fn leaves_img_blobs_as_base64() { + // DataURIReader concatenates base64 straight into a data: URI, so + // converting an img blob would only have to be undone. + let mut s = BlobSplitter::new(); + let input = format!( + "3.img,1.4,1.1,1.0,9.image/png,1.0,1.0;4.blob,1.4,4.{};", + B64 + ); + let out = s.split(&input); + assert_eq!(out.len(), 1); + assert_eq!(out[0], text(&input)); + } + + #[test] + fn stream_membership_survives_across_reads() { + // The h264 instruction and its blobs routinely arrive in different + // reads from guacd, so the splitter has to remember the stream. + let mut s = BlobSplitter::new(); + assert_eq!( + s.split("4.h264,1.9,1.0,1.0;"), + vec![text("4.h264,1.9,1.0,1.0;")] + ); + let out = s.split(&format!("4.blob,1.9,4.{};", B64)); + assert_eq!(out, vec![OutFrame::Binary(frame(9, &BYTES))]); + } + + #[test] + fn end_releases_the_stream_index() { + // Indices are reused. A blob on a recycled index must not be + // converted just because the previous owner was an h264 stream. + let mut s = BlobSplitter::new(); + s.split("4.h264,1.2,1.0,1.0;3.end,1.2;"); + let input = format!( + "3.img,1.2,1.1,1.0,9.image/png,1.0,1.0;4.blob,1.2,4.{};", + B64 + ); + let out = s.split(&input); + assert_eq!(out, vec![text(&input)]); + } + + #[test] + fn passes_untouched_streams_through_whole() { + let mut s = BlobSplitter::new(); + let input = "4.sync,3.100;5.mouse,3.640,3.480;"; + assert_eq!(s.split(input), vec![text(input)]); + } + + #[test] + fn multibyte_elements_do_not_desynchronise_the_scan() { + // The length prefix counts codepoints, not bytes, per guac_utf8_strlen + // in libguac -- so an emoji four bytes wide has a length of 1 and an + // e-acute two bytes wide has a length of 1. Counting bytes here would + // run off the end of the element and take every instruction after the + // clipboard with it. + let mut s = BlobSplitter::new(); + let input = format!( + "4.h264,1.1,1.0,1.0;9.clipboard,1.0,1.\u{1F600};9.clipboard,1.0,1.\u{e9};\ + 4.blob,1.1,4.{};", + B64 + ); + let out = s.split(&input); + assert_eq!(out.len(), 2); + assert_eq!( + out[0], + text("4.h264,1.1,1.0,1.0;9.clipboard,1.0,1.\u{1F600};9.clipboard,1.0,1.\u{e9};") + ); + assert_eq!(out[1], OutFrame::Binary(frame(1, &BYTES))); + } + + #[test] + fn embedded_semicolon_in_a_payload_is_not_a_boundary() { + let mut s = BlobSplitter::new(); + let input = "4.h264,1.1,1.0,1.0;9.clipboard,1.0,3.a;b;4.sync,3.100;"; + assert_eq!(s.split(input), vec![text(input)]); + } + + #[test] + fn undecodable_payload_is_left_as_text_rather_than_dropped() { + let mut s = BlobSplitter::new(); + let input = "4.h264,1.5,1.0,1.0;4.blob,1.5,3.!!!;"; + assert_eq!(s.split(input), vec![text(input)]); + } + + #[test] + fn malformed_tail_is_passed_through_not_swallowed() { + let mut s = BlobSplitter::new(); + let input = format!("4.h264,1.1,1.0,1.0;4.blob,1.1,4.{};garbage", B64); + let out = s.split(&input); + assert_eq!(out[1], OutFrame::Binary(frame(1, &BYTES))); + assert_eq!(out[2], text("garbage")); + } + + #[test] + fn several_blobs_in_one_read_each_become_a_frame() { + let mut s = BlobSplitter::new(); + let input = format!( + "4.h264,1.1,1.0,1.0;4.blob,1.1,4.{b};4.blob,1.1,4.{b};3.end,1.1;", + b = B64 + ); + let out = s.split(&input); + assert_eq!(out.len(), 4); + assert_eq!(out[1], OutFrame::Binary(frame(1, &BYTES))); + assert_eq!(out[2], OutFrame::Binary(frame(1, &BYTES))); + assert_eq!(out[3], text("3.end,1.1;")); + } + + /// Round-trips a real recording through the splitter and reconstructs the + /// original stream from the frames, asserting it comes back byte for byte. + /// + /// The unit tests above check hand-written instructions; this checks the + /// thing that actually goes over the wire, including whatever guacd emits + /// that nobody thought to write a case for. Ignored by default because it + /// needs a recording: + /// + /// RUSTGUAC_TEST_RECORDING=/path/to.guac \ + /// cargo test -- --ignored round_trips_a_real_recording --nocapture + #[test] + #[ignore = "needs RUSTGUAC_TEST_RECORDING"] + fn round_trips_a_real_recording() { + let path = std::env::var("RUSTGUAC_TEST_RECORDING") + .expect("set RUSTGUAC_TEST_RECORDING to a .guac file"); + let original = std::fs::read_to_string(&path).expect("readable recording"); + + let mut splitter = BlobSplitter::new(); + let mut rebuilt = String::with_capacity(original.len()); + let mut text_bytes = 0usize; + let mut binary_bytes = 0usize; + let mut frames = 0usize; + + // Fed in 64KiB reads split at instruction boundaries, exactly as + // guacd_to_ws does, so the cross-read stream tracking is exercised + // rather than assumed. + let bytes = original.as_bytes(); + let mut at = 0usize; + while at < bytes.len() { + let want = (at + 65536).min(bytes.len()); + let end = match crate::protocol::last_instruction_boundary(&bytes[at..want]) { + Some(e) => at + e, + None => match crate::protocol::last_instruction_boundary(&bytes[at..]) { + Some(e) => at + e, + None => break, + }, + }; + let chunk = &original[at..end]; + at = end; + + for out in splitter.split(chunk) { + match out { + OutFrame::Text(t) => { + text_bytes += t.len(); + rebuilt.push_str(&t); + } + OutFrame::Binary(b) => { + frames += 1; + binary_bytes += b.len(); + let index = u32::from_le_bytes(b[4..8].try_into().unwrap()); + let payload = + base64::engine::general_purpose::STANDARD.encode(&b[HEADER_LEN..]); + rebuilt.push_str( + &Instruction::new("blob", vec![index.to_string(), payload]).encode(), + ); + } + } + } + } + + assert_eq!( + rebuilt.len(), + original[..at].len(), + "reconstructed length differs" + ); + assert!( + rebuilt == original[..at], + "reconstructed stream differs from original" + ); + + let before = original[..at].len(); + let after = text_bytes + binary_bytes; + println!( + " {} frames converted; {:.1} MB -> {:.1} MB on the wire ({:.1}% saved)", + frames, + before as f64 / 1e6, + after as f64 / 1e6, + 100.0 * (before - after) as f64 / before as f64 + ); + } + + #[test] + fn saving_matches_what_base64_would_have_cost() { + // The reported saving has to be the real one, since it is what tells + // an operator the feature is working. base64 emits four characters per + // three bytes, padded. + for len in [0usize, 1, 2, 3, 4, 100, 4096] { + let encoded = base64::engine::general_purpose::STANDARD + .encode(vec![0u8; len]) + .len(); + assert_eq!(len.div_ceil(3) * 4, encoded, "len {}", len); + } + } + + #[test] + fn frame_header_is_the_documented_shape() { + let f = frame(0x01020304, &[0xAA, 0xBB]); + assert_eq!(f[0], FRAME_VERSION); + assert_eq!(f[1], FRAME_TYPE_BLOB); + assert_eq!(&f[2..4], &[0, 0]); + assert_eq!(&f[4..8], &0x01020304u32.to_le_bytes()); + assert_eq!(&f[HEADER_LEN..], &[0xAA, 0xBB]); + } +} diff --git a/src/guacd.rs b/src/guacd.rs index eb2fe44b..3d5cccc0 100644 --- a/src/guacd.rs +++ b/src/guacd.rs @@ -194,6 +194,14 @@ pub struct RdpParams { pub force_lossless: bool, /// Enable H.264 passthrough. Raw H.264 NAL units sent to browser WebCodecs decoder. pub enable_h264: bool, + /// DPI scaling percentage the remote session should render its UI at, sent + /// to the server as desktopScaleFactor. `None` leaves the server at its + /// default of 100%. + /// + /// Only meaningful alongside a framebuffer requested in physical rather + /// than logical pixels: without it, the extra pixels shrink every element + /// of the desktop instead of sharpening it. + pub desktop_scale: Option, /// Number of secondary monitors to allow (beyond the primary). guacd /// advertises this to the client as `secondary-monitors` and drives RDP /// multi-monitor via the Display Control channel. 0 = single monitor. @@ -400,6 +408,7 @@ pub async fn connect_and_handshake( "disable-gfx" => if p.enable_gfx { "false" } else { "true" }.into(), "force-lossless" => if p.force_lossless { "true" } else { "false" }.into(), "enable-h264" => if p.enable_h264 { "true" } else { "false" }.into(), + "desktop-scale" => p.desktop_scale.map_or(String::new(), |s| s.to_string()), "remote-app" => p.remote_app.clone().unwrap_or_default(), "remote-app-dir" => p.remote_app_dir.clone().unwrap_or_default(), "remote-app-args" => p.remote_app_args.clone().unwrap_or_default(), diff --git a/src/h264_aux_drop.rs b/src/h264_aux_drop.rs new file mode 100644 index 00000000..6efc414c --- /dev/null +++ b/src/h264_aux_drop.rs @@ -0,0 +1,1228 @@ +//! Drops an AVC444 stream's auxiliary chroma view on the way to the browser, +//! where the stream proves it can be dropped. +//! +//! The combine gate in `H264Decoder.js` gives up 4:4:4 by discarding the +//! auxiliary view *after* it has crossed the link and been decoded, so it +//! removes main-thread cost and nothing else. This removes the view from the +//! wire instead: the same 4:2:0 picture for less bandwidth and one decode per +//! frame rather than two. It cannot be had at the source, because a Windows +//! host has to be offered AVC444 to send H.264 at all. +//! +//! # The gate +//! +//! The two views share one decoded picture buffer, so dropping is safe only if +//! two things hold. `crate::h264_refs` reads both from the slice headers. +//! +//! **Nothing surviving may predict from a dropped picture.** Windows names its +//! references explicitly with disjoint long-term indices, and the xrdp fork +//! does the same with dual long-term references. +//! +//! **The decoder must have room for the pictures a gap makes it invent.** +//! Dropping a picture leaves a hole in `frame_num`, and 8.2.5.2 obliges a +//! decoder to fill each hole with an inferred *non-existing* frame held as a +//! **short-term** reference. The sliding window can only evict short-term +//! pictures, so a stream whose `max_num_ref_frames` is entirely consumed by +//! long-term references has nowhere to put one, and the decoder fails outright +//! rather than degrading. Asking whether anything *refers* to a dropped picture +//! says nothing about this: an xrdp stream with perfectly separate chains, +//! long-term 0 for main and 1 for the auxiliary view and `max_num_ref_frames` +//! 2, froze within a fifth of a second of the first drop. Windows has 3, two +//! long-term and one to spare. +//! +//! # What it does, in order +//! +//! 1. **Permits gaps immediately.** Every SPS gets +//! `gaps_in_frame_num_value_allowed_flag` set from the first instruction of +//! the session, before anything has been decided, because an SPS only rides +//! a keyframe and a host can go minutes without one. Setting it on a stream +//! that is never dropped from is inert. +//! 2. **Waits** while the probe accumulates -- three auxiliary inter slices +//! and ten main ones, or one of each for a connection entry at Standard +//! colour. Inter slices rather than pictures: an IDR names no reference and +//! marks nothing, so excluding them is already the guard against deciding +//! from the connect-time keyframe burst, and a count of pictures would only +//! add delay on a quiet desktop that sends few. +//! 3. **Drops**, as soon as the stream proves itself, the `h264`, `blob` and +//! `end` instructions of every non-IDR auxiliary view, and clears the +//! trailing `` flag on main views so the client paints them instead +//! of holding them for a view that is no longer coming. +//! 4. **Keeps watching.** A verdict reached from the first few auxiliary views +//! is a verdict about the first few auxiliary views, so one that turns +//! unsafe later stops the drop. +//! +//! From the first SPS to the decision takes about 2.5s on both hosts, almost +//! all of it the keyframe burst. A session that never gets past step 2 says +//! what it was short of in its disconnect summary, since the gate is otherwise +//! silent until it decides and a stream that never qualifies would look +//! identical to one that is merely slow. +//! +//! # What it never drops +//! +//! **Auxiliary IDRs.** An IDR with `long_term_reference_flag` set marks every +//! other reference unused and claims `LongTermFrameIdx` 0 (8.2.5.1), so a main +//! slice naming long-term 0 straight after one is naming that auxiliary +//! picture. guacd flags a keyframe on `nal_type` 5, so every auxiliary IDR +//! passes through and the main slice gets the identical picture either way. +//! They are a few pictures in a hundred. +//! +//! Dropping is safe for guacd too: it calls `guac_client_free_stream` as soon +//! as it has written the blobs, so nothing waits on an acknowledgement for a +//! stream swallowed here. +//! +//! # Where it sits +//! +//! In `guacd_to_ws`, after the recording tee -- so recordings keep the full +//! 4:4:4 stream and `SessionRecording.js` is unaffected -- and before the +//! colour rewrite and the binary blob splitter. +//! +//! `RUSTGUAC_H264_AUX_DROP=0` turns it off. `=unproven` extends it to streams +//! the slice headers cannot prove (`Safety::Unproven`: an auxiliary picture +//! reachable in a main slice's reference list). That is for experiments only: +//! on the one such stream measured, a replay showed 12 of 141 main access +//! units undecodable once the views were removed. + +use std::borrow::Cow; +use std::collections::HashSet; + +use crate::h264_refs::{NalProbe, Safety}; + +/// Stream indices whose remaining instructions are being swallowed. Bounded so +/// a stream that never ends cannot grow it without limit. +const MAX_DROPPED_STREAMS: usize = 256; + +/// How far the probe's own decision is trusted before the first drop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + /// Accumulating. Everything passes through untouched -- except that every + /// SPS is already being given permission to skip frame_num values, so that + /// permission is older than any gap could be. + Deciding, + /// Dropping. + Dropping, + /// Proved unsafe, or switched off. Never looks again. + Off, +} + +/// Removes the auxiliary view from a stream that has proved it can spare it. +pub struct AuxDropper { + probe: NalProbe, + /// Whether to drop on streams the headers leave `Unproven`. Off by + /// default, because the one host with that shape corrupted; never extends + /// to `Unsafe`. + unproven: bool, + /// Set when the connection entry asked for this, which decides from the + /// least evidence that can answer rather than from a corroborated sample. + /// The gate still applies, and so does the running re-check. + eager: bool, + state: State, + dropped_streams: HashSet, + /// Auxiliary pictures dropped, and the payload bytes they carried. + dropped_pictures: u64, + dropped_bytes: u64, + /// Auxiliary IDRs kept, which is the part that is deliberately not done. + kept_idrs: u64, + /// Main views whose `` flag was cleared. + unpaired: u64, + /// Times the in-flight set was cleared wholesale for overflow. + /// + /// Instrumented rather than assumed. Entries are added when an auxiliary + /// view is dropped and removed at its `end`, so the set should hold one or + /// two at a time; if an `end` were ever missed they would accumulate, the + /// set would be cleared, and the blobs of anything mid-drop would leak + /// through as orphans -- an `h264` with no data and no end, which is + /// exactly the shape that hangs a client. Nothing has been seen to cause + /// it, which is why it is counted rather than worked around. + orphan_clears: u64, + /// Whether this session was never in the running: the deployment kill + /// switch, or an entry set to "never drop". + /// + /// Kept apart from `State::Off`, which a stream also reaches by being + /// examined and refused. Both never drop; only one of them has anything + /// to report. A summary saying the gate saw no auxiliary view "in 0 + /// pictures" describes a gate that was waiting, and this one was not. + switched_off: bool, + /// The session the summary belongs to, when there is one to name. + /// + /// Held here because the summary is written from `Drop` rather than by the + /// caller. See the `Drop` impl. + session: Option, +} + +impl AuxDropper { + /// A dropper for one session. + /// + /// `setting` comes from the session's colour choice (see + /// `Session::aux_drop_setting`): `None` waits for the corroborated sample, + /// `Some(true)` decides from the least evidence that can answer, and + /// `Some(false)` -- full colour, which needs the view -- never drops. The + /// environment variable still overrides everything, as a kill switch that + /// needs no entry edited. + pub fn for_session(setting: Option) -> Self { + let mut dropper = Self::new(); + + match setting { + // Decide from the least evidence that can answer the question, + // rather than from the corroborated sample. The gate still + // applies: what an entry asserts is that this target is worth + // dropping on, not that the stream should go unexamined. + // + // It cannot mean "drop from the first picture", however the + // setting is worded. The evidence lives in inter slices, and a + // host opens with a burst of keyframes that name no reference and + // mark nothing -- 14 of them on the xrdp fork, about two seconds. + // Dropping through that would be dropping blind, and a wrong guess + // is not recoverable: the decoder errors, discards its queue and + // waits for a keyframe an idle desktop never sends. + Some(true) if dropper.state != State::Off => dropper.eager = true, + Some(false) => { + dropper.state = State::Off; + dropper.switched_off = true; + } + _ => {} + } + + dropper + } + + fn new() -> Self { + let setting = std::env::var("RUSTGUAC_H264_AUX_DROP").unwrap_or_default(); + let setting = setting.trim().to_ascii_lowercase(); + let enabled = !matches!(setting.as_str(), "0" | "off" | "false" | "no"); + // Only what the slice headers can prove, by default: the one unproven + // stream measured corrupted when its auxiliary views were dropped (see + // the module documentation). `unproven` puts it back for experiments. + let unproven = matches!(setting.as_str(), "unproven" | "force"); + + Self { + probe: NalProbe::new(), + unproven, + state: if enabled { State::Deciding } else { State::Off }, + dropped_streams: HashSet::new(), + dropped_pictures: 0, + dropped_bytes: 0, + kept_idrs: 0, + unpaired: 0, + orphan_clears: 0, + eager: false, + switched_off: !enabled, + session: None, + } + } + + /// Names the session its summary belongs to. Without it the summary is + /// still written, just without the field to correlate it by. + pub fn reporting_as(mut self, session: uuid::Uuid) -> Self { + self.session = Some(session); + self + } + + /// Reads one chunk of the guacd → browser stream, returning what should be + /// sent in its place and any lines to log. + /// + /// The chunk always ends on an instruction boundary (see `guacd_to_ws`), + /// so every instruction start in it is a real one, and an instruction is + /// never split across two calls. + pub fn process<'a>(&mut self, text: &'a str) -> (Cow<'a, str>, Vec) { + self.probe.observe(text); + let mut lines = Vec::new(); + + if self.state == State::Off { + return (Cow::Borrowed(text), lines); + } + + // A verdict reached from the first few auxiliary views is a verdict + // about the first few auxiliary views. The probe goes on parsing the + // whole session -- it observes above, upstream of the filtering, so it + // always sees the unmodified stream -- and a later slice that breaks + // the assumption stops the drop rather than being missed because the + // decision was already taken. + if self.state == State::Dropping && self.probe.safety(self.eager) == Safety::Unsafe { + self.state = State::Off; + lines.push(format!( + "auxiliary view dropping STOPPED: this stream stopped meeting \ + the conditions it met earlier — {}", + self.probe.verdict() + )); + return (Cow::Borrowed(text), lines); + } + + if self.state == State::Deciding { + let safety = self.probe.safety(self.eager); + let unproven = safety == Safety::Unproven; + + match safety { + Safety::Undecided => return (Cow::Borrowed(text), lines), + Safety::Unproven if !self.unproven => { + self.state = State::Off; + lines.push(format!( + "auxiliary view will NOT be dropped on this stream: the \ + slice headers cannot prove it, and the one stream of \ + this shape measured corrupted when it was dropped \ + (RUSTGUAC_H264_AUX_DROP=unproven to try again) — {}", + self.probe.verdict() + )); + return (Cow::Borrowed(text), lines); + } + Safety::Safe | Safety::Unproven => { + self.state = State::Dropping; + lines.push(format!( + "auxiliary view {} on this stream — {}", + if unproven { + "is being dropped, UNPROVEN — the headers cannot \ + rule out a reference to it, so watch for drift \ + between keyframes" + } else { + "is being dropped" + }, + self.probe.verdict() + )); + } + Safety::Unsafe => { + self.state = State::Off; + lines.push(format!( + "auxiliary view will NOT be dropped on this stream — {}", + self.probe.verdict() + )); + return (Cow::Borrowed(text), lines); + } + } + } + + (self.filter(text), lines) + } + + /// Rewrites one chunk, borrowing it unchanged when nothing needed doing -- + /// which is most chunks even while dropping, since only some carry an + /// auxiliary view. + fn filter<'a>(&mut self, text: &'a str) -> Cow<'a, str> { + let mut out: Option = None; + // Start of text not yet copied into `out`. + let mut pending = 0usize; + let mut pos = 0usize; + + while pos < text.len() { + let start = pos; + + // Only the three opcodes this touches are parsed past their first + // element; everything else is stepped over without collecting + // anything, which is most of the stream. + let Some((opcode, mut next, mut terminator)) = crate::binary_blob::element(text, pos) + else { + // Malformed or truncated. Copy the remainder verbatim and + // stop: a blob that cannot be parsed is passed through, never + // dropped, because losing one loses a picture. + break; + }; + + let interesting = matches!(opcode, "h264" | "blob" | "end"); + let mut args: Vec<&str> = Vec::new(); + + while terminator == b',' { + let Some((value, after, term)) = crate::binary_blob::element(text, next) else { + // Truncated mid-instruction. Everything from here is + // copied verbatim by the tail below. + next = text.len(); + break; + }; + if interesting { + args.push(value); + } + next = after; + terminator = term; + } + + pos = next; + + let action = if interesting { + self.classify(opcode, &args) + } else { + Action::Keep + }; + + if matches!(action, Action::Keep) { + continue; + } + + // First edit in this chunk: copy everything before it. + let buf = out.get_or_insert_with(|| String::with_capacity(text.len())); + buf.push_str(&text[pending..start]); + if let Action::Replace(ref s) = action { + buf.push_str(s); + } + pending = pos; + } + + match out { + Some(mut buf) => { + buf.push_str(&text[pending..]); + Cow::Owned(buf) + } + None => Cow::Borrowed(text), + } + } + + /// What to do with one instruction, given its opcode and arguments. + fn classify(&mut self, opcode: &str, args: &[&str]) -> Action { + match opcode { + "h264" => self.classify_h264(args), + + "blob" => { + let Some(index) = args.first().and_then(|v| v.parse::().ok()) else { + return Action::Keep; + }; + if !self.dropped_streams.contains(&index) { + return Action::Keep; + } + // Counted from the base64 length rather than by decoding: the + // saving is what is not sent, and that is this. + if let Some(payload) = args.get(1) { + self.dropped_bytes += (payload.len() / 4 * 3) as u64; + } + Action::Drop + } + + "end" => { + let Some(index) = args.first().and_then(|v| v.parse::().ok()) else { + return Action::Keep; + }; + if self.dropped_streams.remove(&index) { + Action::Drop + } else { + Action::Keep + } + } + + _ => Action::Keep, + } + } + + /// `h264,,,,,,,,,, + /// [ ]...,` + fn classify_h264(&mut self, args: &[&str]) -> Action { + // Indices, counted from the first element after the opcode: + // 0 stream, 1 layer, 2 keyframe, 3 x, 4 y, 5 width, 6 height, + // 7 view, 8 numrects, then 4 per rect, then paired. + if args.len() < 9 { + return Action::Keep; + } + + let Ok(index) = args[0].parse::() else { + return Action::Keep; + }; + let keyframe = args[2] == "1"; + let view: u8 = args[7].parse().unwrap_or(0); + + if view != 0 { + if keyframe { + // Deliberately kept; see the module documentation. + self.kept_idrs += 1; + return Action::Keep; + } + if self.state != State::Dropping { + return Action::Keep; + } + if self.dropped_streams.len() >= MAX_DROPPED_STREAMS { + self.dropped_streams.clear(); + self.orphan_clears += 1; + } + self.dropped_streams.insert(index); + self.dropped_pictures += 1; + return Action::Drop; + } + + if self.state != State::Dropping { + return Action::Keep; + } + + // `` promises an auxiliary view that is no longer coming, and + // a client that believes it holds the main view's paint waiting for + // one. It trails the rects, which vary in number. + let Ok(num_rects) = args[8].parse::() else { + return Action::Keep; + }; + let paired_at = 9 + num_rects * 4; + if args.get(paired_at).copied() != Some("1") { + return Action::Keep; + } + + let mut rewritten = args.to_vec(); + rewritten[paired_at] = "0"; + self.unpaired += 1; + Action::Replace(encode_instruction("h264", &rewritten)) + } + + /// What the session saved, for the disconnect log. + pub fn summary(&self) -> Option { + if self.switched_off { + // Nothing was asked of this stream, so there is nothing to say + // about what it did not do. + return None; + } + if self.dropped_pictures == 0 { + // Nothing was dropped. If it never decided, say what it was short + // of: the gate is silent until it makes up its mind, so otherwise + // a session that never decides leaves nothing to read at all. + return self + .probe + .undecided_reason(self.eager) + .map(|reason| format!("auxiliary view was never dropped: {}", reason)); + } + Some(format!( + "dropped {} auxiliary pictures ({} KiB), kept {} auxiliary \ + keyframes, unpaired {} main views, {} in flight{}", + self.dropped_pictures, + self.dropped_bytes / 1024, + self.kept_idrs, + self.unpaired, + self.dropped_streams.len(), + if self.orphan_clears > 0 { + format!( + ", OVERFLOWED {} times — streams were left in flight and \ + their blobs may have leaked through", + self.orphan_clears + ) + } else { + String::new() + } + )) + } + + /// Whether an SPS crossing now should be given permission to skip + /// `frame_num` values. + /// + /// True from the first instruction of the session, not from the moment the + /// stream proves itself. The flag has to be older than the first gap, and + /// an SPS only rides a keyframe: Windows sends three in its connect-time + /// burst and then can go minutes without one, so waiting to set it meant + /// waiting for a keyframe that might never come, with the auxiliary view + /// still on the wire the whole time. + /// + /// Setting it on a stream that never gets dropped from costs nothing. The + /// flag only permits a decoder to infer pictures for `frame_num` values it + /// never saw (8.2.5.2); with no gaps in the stream there is nothing to + /// infer and nothing behaves differently. + pub fn wants_frame_num_gaps(&self) -> bool { + self.state != State::Off + } + + /// Whether the auxiliary view is being removed from the wire right now. + /// + /// Read once per chunk so the browser can be told, because the browser + /// cannot work it out. It switches 4:4:4 combining on at the first + /// auxiliary view it sees and off at nothing in particular, and auxiliary + /// IDRs are deliberately kept -- so one of those arms the combine, and + /// every main view after it then pays a plane read-back, six texture + /// uploads and a shader pass to produce the ordinary 4:2:0 picture a + /// `drawImage` would have produced for nothing, waiting for a view that + /// will never come. Inferring it from a quiet stretch instead would be + /// guessing: a Windows desktop sends chroma in about one picture in eight + /// and the xrdp fork's `CHROMA_INTERVAL` sends it rarer still, so a + /// silence long enough to be evidence is also long enough to have cost + /// the session, and a wrong guess costs a whole-plane resync each way. + pub fn dropping(&self) -> bool { + self.state == State::Dropping + } +} + +/// Writes the summary on the way out, however the session ended. +/// +/// Written here rather than by the caller after `guacd_to_ws`'s read loop, +/// which is reached only when guacd closes first. A session normally ends the +/// other way round -- the browser goes, `run_proxy`'s select! resolves on the +/// browser-side task and drops the guacd-side future where it stands -- and +/// the line would then be the instrument that is silently absent: a stream the +/// gate never decides about says nothing at all until this reports what it was +/// short of, and the overflow warning for leaked in-flight streams would never +/// be in a position to fire. +/// +/// Drop runs on every one of those paths, including cancellation. +impl Drop for AuxDropper { + fn drop(&mut self) { + let Some(summary) = self.summary() else { + return; + }; + match self.session { + Some(session) => tracing::info!(session_id = %session, "H.264: {}", summary), + None => tracing::info!("H.264: {}", summary), + } + } +} + +enum Action { + Keep, + Drop, + Replace(String), +} + +fn encode_instruction(opcode: &str, args: &[&str]) -> String { + let mut out = String::with_capacity(16 + args.iter().map(|a| a.len() + 6).sum::()); + out.push_str(&format!("{}.{}", opcode.len(), opcode)); + for arg in args { + out.push_str(&format!(",{}.{}", arg.len(), arg)); + } + out.push(';'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One `h264` instruction, built as guac_h264_write_arg() builds it. + fn h264(index: u32, keyframe: bool, view: u8, rects: usize, paired: bool) -> String { + let mut args = vec![ + index.to_string(), + "0".into(), + u8::from(keyframe).to_string(), + "0".into(), + "0".into(), + "1920".into(), + "1080".into(), + view.to_string(), + rects.to_string(), + ]; + for r in 0..rects { + args.extend([r.to_string(), "0".into(), "16".into(), "16".into()]); + } + args.push(u8::from(paired).to_string()); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + encode_instruction("h264", &refs) + } + + fn blob(index: u32, payload: &str) -> String { + encode_instruction("blob", &[&index.to_string(), payload]) + } + + fn end(index: u32) -> String { + encode_instruction("end", &[&index.to_string()]) + } + + /// A dropper already past its gate, so the filtering can be tested without + /// feeding it a session's worth of real bitstream. + fn dropping() -> AuxDropper { + let mut d = AuxDropper::new(); + d.state = State::Dropping; + d + } + + /// The entry setting decides from less evidence; it does not decide + /// differently, and it does not switch the gate off. + #[test] + fn an_entry_setting_is_eager_not_unchecked() { + let d = AuxDropper::for_session(Some(true)); + assert!(d.eager); + assert_eq!(d.state, State::Deciding, "still has to prove the stream"); + } + + /// An entry that says no is never examined. + #[test] + fn an_entry_can_refuse_outright() { + let mut d = AuxDropper::for_session(Some(false)); + assert_eq!(d.state, State::Off); + assert!(!d.wants_frame_num_gaps()); + + let text = h264(1, false, 2, 0, false); + let (out, _) = d.process(&text); + assert_eq!(out, text.as_str()); + } + + /// Unset waits for the corroborated sample rather than the minimum. + #[test] + fn an_unset_entry_waits_for_corroboration() { + let d = AuxDropper::for_session(None); + assert_eq!(d.state, State::Deciding); + assert!(!d.eager); + } + + #[test] + fn an_auxiliary_picture_goes_with_its_blobs_and_its_end() { + let mut d = dropping(); + let text = format!( + "{}{}{}{}{}{}", + h264(7, false, 0, 0, false), + blob(7, "bWFpbg=="), + end(7), + h264(8, false, 2, 0, false), + blob(8, "YXV4"), + end(8) + ); + + let (out, _) = d.process(&text); + assert!(out.contains("4.h264,1.7"), "{}", out); + assert!(out.contains("bWFpbg=="), "{}", out); + assert!(!out.contains("4.h264,1.8"), "{}", out); + assert!(!out.contains("YXV4"), "{}", out); + assert_eq!(out.matches("3.end").count(), 1, "{}", out); + assert_eq!(d.dropped_pictures, 1); + } + + /// A main view promising a pair that will not arrive would be held unpainted + /// forever, so the flag has to be cleared -- past the rects, which vary. + #[test] + fn the_paired_flag_is_cleared_past_the_rects() { + for rects in [0, 1, 9] { + let mut d = dropping(); + let text = h264(3, false, 0, rects, true); + let (out, _) = d.process(&text); + + assert_ne!(out, text.as_str(), "{} rects: not rewritten", rects); + assert_eq!(d.unpaired, 1, "{} rects", rects); + + // The rects themselves must survive the rebuild untouched. + let expected = h264(3, false, 0, rects, false); + assert_eq!(out, expected.as_str(), "{} rects", rects); + } + } + + /// An auxiliary keyframe is a buffer reset the surviving stream is written + /// against, and the interaction is unresolved. It stays. + #[test] + fn an_auxiliary_keyframe_is_kept() { + let mut d = dropping(); + let text = format!( + "{}{}{}", + h264(9, true, 2, 0, false), + blob(9, "aQ=="), + end(9) + ); + + let (out, _) = d.process(&text); + assert_eq!(out, text.as_str()); + assert_eq!(d.kept_idrs, 1); + assert_eq!(d.dropped_pictures, 0); + } + + /// Nothing is touched before the stream has proved itself. + #[test] + fn nothing_is_dropped_while_deciding() { + let mut d = AuxDropper::new(); + let text = format!( + "{}{}{}", + h264(4, false, 2, 0, false), + blob(4, "eA=="), + end(4) + ); + + let (out, _) = d.process(&text); + assert!(matches!(out, Cow::Borrowed(_)), "should not even copy"); + assert_eq!(out, text.as_str()); + } + + /// Permission to skip frame_num values is asked for from the first + /// instruction, not from the moment the stream proves itself. + /// + /// An SPS only rides a keyframe, and Windows sends three at connect and + /// then can go minutes without one — so a flag set at the decision would + /// wait for a keyframe that might never come, with the auxiliary view on + /// the wire throughout. Setting it on a stream that never gets dropped + /// from is inert: it permits inferring pictures for frame_num values never + /// seen, and there are none. + #[test] + fn gaps_are_permitted_before_anything_is_decided() { + let d = AuxDropper::new(); + assert_eq!(d.state, State::Deciding); + assert!(d.wants_frame_num_gaps(), "from the very first chunk"); + } + + /// And a stream that proves itself unsafe stops asking, since it will + /// never open a gap. + #[test] + fn a_stream_that_will_not_be_dropped_stops_asking_for_gaps() { + let mut d = AuxDropper::new(); + d.state = State::Off; + assert!(!d.wants_frame_num_gaps()); + } + + /// Dropping starts as soon as the gate clears, with no keyframe in + /// between. + #[test] + fn dropping_needs_no_keyframe_to_begin() { + let mut d = dropping(); + let aux = h264(4, false, 2, 0, false); + let (out, _) = d.process(&aux); + + assert_eq!(out, "", "{}", out); + assert_eq!(d.dropped_pictures, 1); + } + + /// Instructions this knows nothing about pass through byte for byte, and a + /// chunk needing no edit is never copied. + #[test] + fn unrelated_instructions_are_untouched() { + let mut d = dropping(); + let text = format!( + "4.sync,13.1700000000000;{}5.blob2,3.abc;", + h264(5, false, 0, 2, false) + ); + + let (out, _) = d.process(&text); + assert!(matches!(out, Cow::Borrowed(_)), "no edit, no copy"); + assert_eq!(out, text.as_str()); + } + + /// An unproven stream is left alone by default: the one stream of that + /// shape measured corrupted when its auxiliary views were dropped. + #[test] + fn unproven_streams_are_left_alone_by_default() { + assert!(!AuxDropper::new().unproven); + } + + /// A stream that stops meeting the conditions stops being dropped from. + /// + /// The decision is taken from the first few auxiliary views, so it has to + /// be revisitable: a short-term reordering appearing later is exactly the + /// counter-example the early verdict could not have seen. + #[test] + fn a_stream_that_changes_its_mind_stops_the_drop() { + let mut d = dropping(); + let (_, lines) = d.process(&h264(1, false, 2, 0, false)); + assert!(lines.is_empty(), "nothing wrong yet"); + assert_eq!(d.state, State::Dropping); + assert_eq!(d.dropped_pictures, 1); + + // The probe is real, so rather than fabricate a bitstream that turns + // unsafe, the state is driven directly: what is under test is that a + // verdict of Unsafe while dropping stops it, not how one is reached. + d.state = State::Off; + let text = format!("{}{}", h264(2, false, 2, 0, false), blob(2, "eA==")); + let (out, _) = d.process(&text); + assert_eq!(out, text.as_str(), "passes through once stopped"); + assert_eq!(d.dropped_pictures, 1, "and drops nothing more"); + } + + /// A non-ASCII instruction must not shift the instruction boundaries. + /// + /// Guacamole element lengths count UTF-16 code units, not bytes. The first + /// version of this filter parsed them as bytes, which is identical for + /// ASCII and wrong the moment a clipboard, a name or an error string + /// carries anything else -- and because this rewrites the wire rather than + /// only reading it, a mis-sliced instruction desynchronises the client's + /// parser for the rest of the session. The stream has no resynchronisation + /// point, so that is a hang rather than a glitch. + #[test] + fn non_ascii_instructions_do_not_shift_the_boundaries() { + for text in [ + "9.clipboard,1.1,10.text/plain;", + // Three characters, six bytes: a byte-length reader stops short. + "4.name,3.héé;", + // Outside the BMP. guacd counts codepoints when it writes the + // length (guac_utf8_strlen), so this is 1 and not the 2 that + // Guacamole's Java and JavaScript ends would write from + // String.length(). The parser follows the producer, since that is + // what is being read here; do not "fix" this to 2. + "4.name,1.😀;", + "5.error,5.wonky,4.0512;", + ] { + let mut d = dropping(); + let aux = h264(9, false, 2, 0, false); + let combined = format!("{}{}{}", text, aux, text); + + let (out, _) = d.process(&combined); + assert_eq!( + out, + format!("{}{}", text, text).as_str(), + "boundaries shifted around {:?}", + text + ); + assert_eq!(d.dropped_pictures, 1, "for {:?}", text); + } + } + + /// And one that needs no edit is passed through untouched rather than + /// re-encoded, so a chunk of clipboard traffic costs nothing. + #[test] + fn non_ascii_with_nothing_to_drop_is_not_copied() { + let mut d = dropping(); + let text = "9.clipboard,1.1,10.text/plain;4.blob,1.1,8.w6nDqcOp;3.end,1.1;"; + + let (out, _) = d.process(text); + assert!(matches!(out, Cow::Borrowed(_)), "no edit, no copy"); + assert_eq!(out, text); + } + + /// A blob whose `h264` was never seen belongs to some other stream and is + /// not ours to touch. + #[test] + fn blobs_of_unknown_streams_pass_through() { + let mut d = dropping(); + let text = format!("{}{}", blob(77, "YQ=="), end(77)); + + let (out, _) = d.process(&text); + assert_eq!(out, text.as_str()); + assert_eq!(d.orphan_clears, 0); + } + + /// Runs the real dropper over a real recording and checks that what comes + /// out is a stream a client could follow. + /// + /// Ignored by default because it needs a capture: + /// + /// ```text + /// RUSTGUAC_AUX_DROP_RECORDING=/tmp/xrdp.guac \ + /// cargo test aux_drop_over_a_recording -- --ignored --nocapture + /// ``` + /// + /// `tests/aux-drop-replay.mjs` proves the *bitstream* survives the drop; + /// it decodes pictures and cannot see the instruction framing at all. This + /// is the other half, and the half that hangs a client: an `h264` whose + /// blobs never arrive leaves the browser holding an open stream forever, + /// and a `blob` for a stream that was never announced is dropped on the + /// floor. Neither shows up as corruption -- the display simply stops. + /// + /// Chunked at several sizes because `guacd_to_ws` hands over whatever one + /// read produced, cut at the last instruction boundary, so the filter must + /// not care where the cuts fall. + #[test] + #[ignore] + fn aux_drop_over_a_recording() { + let Ok(path) = std::env::var("RUSTGUAC_AUX_DROP_RECORDING") else { + panic!("set RUSTGUAC_AUX_DROP_RECORDING to a .guac recording"); + }; + let recording = std::fs::read_to_string(&path).expect("readable recording"); + + for chunk_target in [4096usize, 65536, usize::MAX] { + let mut dropper = AuxDropper::for_session(Some(true)); + let mut output = String::new(); + + // Cut on instruction boundaries, as guacd_to_ws does. + let mut pos = 0usize; + while pos < recording.len() { + let mut end = pos; + while end < recording.len() { + let Some((_, next, term)) = crate::binary_blob::element(&recording, end) else { + end = recording.len(); + break; + }; + end = next; + if term == b';' && end - pos >= chunk_target.min(recording.len()) { + break; + } + } + let (out, _) = dropper.process(&recording[pos..end]); + output.push_str(&out); + assert!(end > pos, "chunker made no progress at {}", pos); + pos = end; + } + + // Only H.264 streams are checked: a recording carries clipboard, + // image and audio streams too, whose blobs this never announces + // and must not touch. Indices are recycled across all of them, so + // membership is tracked as the walk goes rather than by index + // alone. + let mut open: HashSet = HashSet::new(); + let mut blobs: std::collections::HashMap = Default::default(); + let mut empty = 0u32; + let mut reopened = 0u32; + let mut instructions = 0u32; + let mut h264s = 0u32; + + let mut at = 0usize; + while at < output.len() { + let Some((opcode, mut next, mut term)) = crate::binary_blob::element(&output, at) + else { + panic!( + "chunk {}: output stops parsing at byte {} of {} — the \ + client's parser would stop here too", + chunk_target, + at, + output.len() + ); + }; + let mut args: Vec<&str> = Vec::new(); + while term == b',' { + let Some((v, a, t)) = crate::binary_blob::element(&output, next) else { + panic!("chunk {}: truncated instruction at {}", chunk_target, at); + }; + args.push(v); + next = a; + term = t; + } + instructions += 1; + assert!(next > at, "verification made no progress at {}", at); + at = next; + + let index = args.first().and_then(|v| v.parse::().ok()); + match (opcode, index) { + ("h264", Some(i)) => { + h264s += 1; + if !open.insert(i) { + reopened += 1; + } + blobs.insert(i, 0); + } + ("blob", Some(i)) if open.contains(&i) => { + *blobs.entry(i).or_default() += 1; + } + ("end", Some(i)) + if open.remove(&i) && blobs.get(&i).copied().unwrap_or(0) == 0 => + { + empty += 1; + } + _ => {} + } + } + + println!( + "chunk {:>10}: {} instructions out, {} h264, {} dropped \ + pictures, {} unpaired, {} in flight", + chunk_target, + instructions, + h264s, + dropper.dropped_pictures, + dropper.unpaired, + dropper.dropped_streams.len() + ); + + assert_eq!( + empty, 0, + "chunk {}: an h264 stream was announced and carried no blobs — \ + the client opens a stream that never receives data", + chunk_target + ); + assert_eq!( + reopened, 0, + "chunk {}: an h264 index was announced twice without closing", + chunk_target + ); + assert_eq!( + open.len(), + 0, + "chunk {}: {} h264 streams left open — the client waits forever", + chunk_target, + open.len() + ); + } + } + + /// A host that only ever sends AVC420 is passed through untouched, and + /// not merely left undropped. + /// + /// There is no auxiliary view to drop, so the gate can never leave + /// `Undecided` and the filter is never entered -- the chunk is borrowed, + /// not rebuilt. Worth pinning because dropping is the default, so this path + /// carries every AVC420 host -- stock xrdp among them. + #[test] + fn an_avc420_only_stream_is_passed_through_untouched() { + for entry in [None, Some(true)] { + let mut d = AuxDropper::for_session(entry); + + // Far more pictures than any threshold asks for. + let mut text = String::new(); + for i in 0..120u32 { + text.push_str(&h264(i, i % 30 == 0, 0, i as usize % 3, false)); + text.push_str(&blob(i, "bWFpbg==")); + text.push_str(&end(i)); + } + + let (out, lines) = d.process(&text); + assert!( + matches!(out, Cow::Borrowed(_)), + "{:?}: an AVC420 stream should not even be copied", + entry + ); + assert_eq!(out, text.as_str(), "{:?}", entry); + assert_eq!(d.dropped_pictures, 0, "{:?}", entry); + assert_eq!(d.state, State::Deciding, "{:?}: never decides", entry); + assert!(lines.is_empty(), "{:?}: and says nothing", entry); + } + } + + /// Even an entry that refuses outright leaves it alone, which is the other + /// AVC420 case: `avc444` off on the entry, so the server sends one view. + #[test] + fn an_avc420_only_stream_is_untouched_when_refused() { + let mut d = AuxDropper::for_session(Some(false)); + let text = format!( + "{}{}{}", + h264(1, true, 0, 2, false), + blob(1, "YQ=="), + end(1) + ); + + let (out, _) = d.process(&text); + assert_eq!(out, text.as_str()); + assert!(!d.wants_frame_num_gaps(), "and no SPS is edited either"); + } + + /// A session that never decides says what it was short of. + /// + /// The gate is otherwise silent until it makes up its mind, so "it seems + /// slow to start dropping" had nothing to read against it. + #[test] + fn a_session_that_never_decides_says_why() { + let mut d = AuxDropper::for_session(None); + + // Main views only: the commonest reason, and the one an AVC420 host + // hits for the whole session. + let mut text = String::new(); + for i in 0..12u32 { + text.push_str(&h264(i, i == 0, 0, 0, false)); + text.push_str(&blob(i, "bWFpbg==")); + text.push_str(&end(i)); + } + d.process(&text); + + let summary = d.summary().expect("a reason, since nothing was dropped"); + assert!( + summary.contains("never dropped") && summary.contains("no auxiliary view"), + "{}", + summary + ); + } + + /// And a session that dropped reports the drop, not a reason. + #[test] + fn a_session_that_dropped_reports_the_drop() { + let mut d = dropping(); + d.process(&h264(1, false, 2, 0, false)); + + let summary = d.summary().expect("a summary"); + assert!(summary.starts_with("dropped 1 auxiliary"), "{}", summary); + } + + /// And it is written on the way out, however the session ended. + /// + /// Written from `Drop` because the tail of `guacd_to_ws`'s read loop is + /// reached only when guacd closes first; a browser closing -- the normal + /// case -- drops that future where it stands. The two tests above cover + /// what the summary *says*; this is the one that covers its being said at + /// all, which is the half they cannot see. + #[test] + fn the_summary_is_written_when_the_dropper_is_dropped() { + use std::io::Write; + use std::sync::{Arc, Mutex}; + + #[derive(Clone)] + struct Buffer(Arc>>); + + impl Write for Buffer { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(data); + Ok(data.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Buffer { + type Writer = Self; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + let buffer = Buffer(Arc::new(Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_writer(buffer.clone()) + .with_ansi(false) + .finish(); + + let session = uuid::Uuid::new_v4(); + + tracing::subscriber::with_default(subscriber, || { + let mut d = dropping().reporting_as(session); + d.process(&h264(1, false, 2, 0, false)); + // No explicit summary() call: only the drop below may write it. + }); + + let written = String::from_utf8(buffer.0.lock().unwrap().clone()).unwrap(); + assert!( + written.contains("dropped 1 auxiliary"), + "the summary was not written on drop: {:?}", + written + ); + assert!( + written.contains(&session.to_string()), + "the summary lost its session id: {:?}", + written + ); + } + + /// A session the drop was switched off for says nothing on the way out. + /// + /// State::Off is reached two ways -- switched off, or examined and refused + /// -- and only the second has anything to report. Reporting both writes + /// "no auxiliary view has arrived in 0 pictures" for a gate that was never + /// waiting, on every never-drop session. + #[test] + fn a_dropper_that_was_switched_off_writes_nothing() { + use std::io::Write; + use std::sync::{Arc, Mutex}; + + let sink = Arc::new(Mutex::new(Vec::new())); + struct W(Arc>>); + impl Write for W { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(data); + Ok(data.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let made = sink.clone(); + let subscriber = tracing_subscriber::fmt() + .with_writer(move || W(made.clone())) + .with_ansi(false) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + // Switched off: no verdict, nothing dropped, no reason to give. + let mut d = AuxDropper::for_session(Some(false)); + d.process(&h264(1, false, 2, 0, false)); + }); + + let written = String::from_utf8(sink.lock().unwrap().clone()).unwrap(); + assert!( + written.is_empty(), + "wrote a summary it had nothing for: {:?}", + written + ); + } + + /// A session that carried no H.264 at all has nothing to report: no + /// stream was waited on, so "never dropped" would describe nothing. + #[test] + fn a_session_without_h264_has_no_summary() { + let mut d = AuxDropper::for_session(None); + d.process("4.sync,4.1000;4.size,1.0,4.1920,4.1080;"); + assert_eq!(d.summary(), None); + } + + /// The env var is the kill switch, and off means never looking. + #[test] + fn the_kill_switch_disables_it() { + let mut d = AuxDropper::new(); + d.state = State::Off; + + let text = format!("{}{}", h264(6, false, 2, 0, false), blob(6, "eA==")); + let (out, _) = d.process(&text); + assert_eq!(out, text.as_str()); + assert_eq!(d.dropped_pictures, 0); + } + + /// Several instructions in one chunk, with the edit in the middle: the + /// prefix before the first edit has to be copied exactly once. + #[test] + fn an_edit_mid_chunk_keeps_both_sides() { + let mut d = dropping(); + let text = format!( + "{}{}{}{}{}", + h264(1, false, 0, 0, false), + blob(1, "YQ=="), + h264(2, false, 2, 0, false), + blob(2, "Yg=="), + h264(3, false, 0, 0, false), + ); + + let (out, _) = d.process(&text); + let expected = format!( + "{}{}{}", + h264(1, false, 0, 0, false), + blob(1, "YQ=="), + h264(3, false, 0, 0, false), + ); + assert_eq!(out, expected.as_str()); + } +} diff --git a/src/h264_refs.rs b/src/h264_refs.rs new file mode 100644 index 00000000..922c66ea --- /dev/null +++ b/src/h264_refs.rs @@ -0,0 +1,1888 @@ +//! Reads the reference structure out of an AVC444 stream's slice headers, to +//! settle whether the auxiliary chroma view can be dropped on the way to the +//! browser. `crate::h264_aux_drop` does the dropping. +//! +//! The two views are **one H.264 sequence sharing one decoded picture +//! buffer**, not two streams multiplexed together: FreeRDP's +//! `avc444_decompress` feeds both through one `H264_CONTEXT`, and the browser +//! decodes both through one `VideoDecoder`. So a view can be dropped only if +//! nothing that survives depends on it. Everything needed to decide that is in +//! the slice headers, and nothing here decodes a picture -- it reads the first +//! ~2KB of each access unit, which is enough to reach the first slice header. +//! +//! # What is read, and why +//! +//! - **`nal_ref_idc` and `frame_num`, per view.** Whether auxiliary pictures +//! are stored as references at all, and whether dropping them leaves holes +//! in `frame_num`. +//! - **`ref_pic_list_modification` in main slices.** +//! `modification_of_pic_nums_idc` 2 names an absolute `long_term_pic_num`, +//! which survives a drop. 0 and 1 step *relatively* through short-term +//! `PicNum`s, which shift when a picture is removed, so any of those in a +//! main slice refuses the stream. +//! - **`memory_management_control_operation`.** Which long-term indices each +//! view *claims*. A dropped picture that had marked itself with an index main +//! reads would take that index's contents with it, so it is the claims that +//! are tested, never what an auxiliary view merely reads. +//! - **`num_ref_idx_l0_active_minus1`.** With no reordering, a P slice's +//! default list puts short-term pictures (by descending `PicNum`) before +//! long-term ones, so long-term auxiliary pictures are beyond main's reach +//! only while main activates a single entry. With more, an auxiliary picture +//! sits at index 1, and whether a macroblock uses it is below the slice +//! header: `Unproven`. +//! - **`max_num_ref_frames`** against the long-term pictures held. Dropping +//! leaves `frame_num` holes the decoder must fill with inferred short-term +//! references (8.2.5.2), and it needs a free slot to do so. +//! +//! # The hosts measured +//! +//! Windows keeps the views on disjoint, explicitly named long-term chains -- +//! main 0, auxiliary 1, every inter picture marking itself with `mmco` 6 -- +//! with three reference frames, and is droppable. The xrdp fork does the same +//! with dual long-term references; without them its main slices took the +//! default list with two entries active, the auxiliary picture was reachable +//! at index 1, and a replay of such a recording showed 12 of 141 main access +//! units undecodable once the views were removed. Neither host leaves an +//! auxiliary picture where a main slice's first reference lands: both have to +//! keep the views from predicting through each other, and H.264 has few tools +//! for it. +//! +//! LC=2 commands -- an auxiliary view with no main view beside it -- occur on +//! Windows, which is why unpaired auxiliary views are counted. +//! +//! The auxiliary view is **13% of the H.264 payload** against Windows and +//! **43% against the xrdp fork**, which sends chroma far more often; the +//! proportion moves with the workload as well as the host. +//! +//! **Auxiliary views send IDRs too.** An IDR with `long_term_reference_flag` +//! set marks every other reference unused and claims `LongTermFrameIdx` 0 +//! (8.2.5.1), so straight after an auxiliary IDR a main slice naming long-term +//! 0 is naming that chroma picture. That is settled by never dropping an IDR: +//! guacd flags a keyframe on `nal_type` 5, every auxiliary IDR passes through, +//! and the main slice gets the identical picture either way. +//! + +use std::collections::BTreeMap; +use std::collections::HashMap; + +use crate::h264_sps::{next_start_code, read_sps_prefix, unescape, BitReader}; + +/// Access units between summaries. The first is early enough to read while a +/// How much of an access unit is buffered before its slice header is read. +/// The header sits within a few dozen bytes of the first VCL NAL, behind at +/// most an access unit delimiter, an SEI, an SPS and a PPS on a keyframe. +const AU_PREFIX_BYTES: usize = 2048; + +/// Enough of an access unit to reach its first slice header, past the +/// parameter sets a keyframe carries. +const AU_ENOUGH_BYTES: usize = 768; + +/// Cap on part-assembled access units held at once, so a stream that never +/// ends cannot grow the map without limit. +const MAX_PENDING_STREAMS: usize = 256; + +/// Main inter slices to see before deciding: enough to know main's habit of +/// naming its reference rather than one slice's. +/// +/// Inter slices rather than access units, because an IDR names no reference +/// and marks nothing -- so this is already the stronger guard against +/// deciding from the connect-time keyframe burst, and a floor on pictures +/// would add only delay. A count of pictures is a bad proxy for elapsed +/// evidence in any case: a quiet Windows desktop sends few of them and sends +/// chroma in about one picture in eight, so a floor that costs a moment on a +/// busy session costs tens of seconds on an idle one. +const MIN_MAIN_INTER_TO_DECIDE: u64 = 10; + +/// Auxiliary inter slices to see before deciding. +/// +/// The binding condition, and deliberately small. Every auxiliary slice +/// carries the same evidence -- which long-term index it names, and whether +/// that is one main also names -- so a handful settles the shape. On the +/// Windows capture this is reached around the fortieth access unit, against +/// the hundred and fiftieth under the old count. +const MIN_AUX_INTER_TO_DECIDE: u64 = 3; + +/// The fields of a sequence parameter set a slice header cannot be read +/// without. +#[derive(Clone, Copy)] +struct Sps { + log2_max_frame_num: u32, + pic_order_cnt_type: u32, + log2_max_poc_lsb: u32, + delta_pic_order_always_zero: bool, + frame_mbs_only: bool, + separate_colour_plane: bool, + chroma_array_type: u32, + gaps_allowed: bool, + max_num_ref_frames: u32, +} + +/// The fields of a picture parameter set a slice header cannot be read without. +#[derive(Clone, Copy)] +struct Pps { + sps_id: u32, + bottom_field_pic_order_in_frame_present: bool, + num_ref_idx_l0_default_active_minus1: u32, + num_ref_idx_l1_default_active_minus1: u32, + weighted_pred: bool, + weighted_bipred_idc: u32, + redundant_pic_cnt_present: bool, +} + +/// What one access unit's first slice says about the reference structure. +struct Slice { + nal_ref_idc: u8, + idr: bool, + slice_type: u32, + frame_num: u32, + /// `(modification_of_pic_nums_idc, its argument)`, in order, for list 0. + /// Empty when the slice took the default ordering. + list_mods: Vec<(u32, u32)>, + /// `(memory_management_control_operation, its principal argument)`, in + /// order. For op 6 — mark the current picture long-term — the argument is + /// the `long_term_frame_idx` assigned, which is what a later slice names + /// with a `modification_of_pic_nums_idc` of 2. + mmco: Vec<(u32, u32)>, + /// `num_ref_idx_l0_active_minus1 + 1`, the number of list-0 entries a + /// macroblock in this slice may index. + /// + /// It decides whether the default reference list ordering is safe. A P + /// slice's default list is the short-term pictures by descending PicNum + /// followed by the long-term ones ascending, so an auxiliary picture that + /// marked itself long-term sits *after* every short-term picture — out of + /// reach entirely when only one entry is active, and addressable from + /// index 1 upwards when more are. + num_ref_idx_l0_active: u32, +} + +impl Slice { + /// The slice type without the "every slice in this picture is this type" + /// offset of 5. + fn base_type(&self) -> u32 { + self.slice_type % 5 + } + + fn type_name(&self) -> &'static str { + match self.base_type() { + 0 => "P", + 1 => "B", + 2 => "I", + 3 => "SP", + 4 => "SI", + _ => "?", + } + } +} + +/// An access unit being assembled from the blobs of one stream. +struct Pending { + view: u8, + /// Payload bytes seen for this access unit, across all of its blobs. + bytes: usize, + buf: Vec, + /// Set once the slice header has been read, so the remaining blobs of a + /// large picture are ignored rather than buffered. + done: bool, +} + +/// Counts for one view. +#[derive(Default)] +struct ViewStats { + total: u64, + reference: u64, + idr: u64, + bytes: u64, + slice_types: BTreeMap<&'static str, u64>, +} + +/// The probe's accumulated answer. +#[derive(Default)] +struct Stats { + aus: u64, + /// Indexed by view: 0 main, 1 auxiliary v1, 2 auxiliary v2. + views: [ViewStats; 3], + /// `frame_num` deltas between consecutive main views, modulo the wrap. + /// A stream whose auxiliary views are non-reference shows 1 here; one + /// whose auxiliary views are reference pictures shows 2. + main_deltas: BTreeMap, + /// `frame_num` of an auxiliary view minus that of the main view before it. + aux_offsets: BTreeMap, + main_with_list_mod: u64, + aux_with_list_mod: u64, + /// Reference list modifications and marking operations, kept per view: + /// what makes the two views separable is that each names its own + /// long-term picture and never the other's. + list_mods_by_view: [BTreeMap<(u32, u32), u64>; 3], + mmco_by_view: [BTreeMap<(u32, u32), u64>; 3], + /// Active list-0 entries per view, over the inter slices that have one. + num_ref_idx_by_view: [BTreeMap; 3], + /// Inter slices per view that marked themselves a long-term reference. + long_term_marked: [u64; 3], + /// Main slices naming a long-term picture while the most recent IDR was an + /// auxiliary view. + /// + /// Auxiliary views arriving with no paired main view ahead of them — an + /// MS-RDPEGFX LC=2 command, whose only bitstream is chroma. + /// + list_mods: BTreeMap<(u32, u32), u64>, + mmco: BTreeMap, + parse_failures: u64, + gaps_allowed: Option, + max_num_ref_frames: Option, + pic_order_cnt_type: Option, +} + +/// Reads the reference structure of a passthrough stream, once enabled. +pub struct NalProbe { + pending: HashMap, + sps: HashMap, + pps: HashMap, + stats: Stats, + last_main_frame_num: Option, +} + +impl NalProbe { + /// A probe for one stream. It reports nothing on its own; the one thing it + /// says out loud is `verdict`, which `crate::h264_aux_drop` logs once when + /// it decides. + pub fn new() -> Self { + Self { + pending: HashMap::new(), + sps: HashMap::new(), + pps: HashMap::new(), + stats: Stats::default(), + last_main_frame_num: None, + } + } + + /// Whether this stream's auxiliary views can be dropped, as far as has + /// been seen. + /// + /// `eager` decides how much corroboration is required before answering + /// anything but `Undecided`. It does not relax the verdict itself: an + /// eager assessment asks the same questions of less evidence, and a stream + /// that fails any of them is refused either way. + pub fn safety(&self, eager: bool) -> Safety { + self.stats.safety(eager) + } + + /// The prose behind `safety`, for logging the decision once. + pub fn verdict(&self) -> String { + self.stats.verdict() + } + + /// What the gate is still waiting for, or `None` if it has what it needs. + /// + /// "It seems slow to start dropping" is not a number, and the gate is + /// otherwise silent until it decides -- so a session that never decides + /// leaves nothing at all to read. This says which condition is short, with + /// the counts, so the answer is a measurement rather than a theory about + /// thresholds. + /// + /// `None` too for a session that never carried an H.264 picture: there is + /// no stream to be undecided about, and "no auxiliary view in 0 pictures" + /// on every non-H.264 session is a line that says nothing. + pub fn undecided_reason(&self, eager: bool) -> Option { + let saw_h264 = self.stats.aus + self.stats.parse_failures > 0; + if !saw_h264 || self.stats.safety(eager) != Safety::Undecided { + return None; + } + Some(self.stats.why_undecided(eager)) + } + + /// Reads one chunk of the guacd → browser stream. The chunk always ends on + /// an instruction boundary (see `guacd_to_ws`), so every instruction start + /// in it is a real one. + pub fn observe(&mut self, text: &str) { + for instr in crate::instruction::instruction_starts(text) { + if let Some(rest) = instr.strip_prefix("4.h264,") { + self.open(rest); + } else if let Some(rest) = instr.strip_prefix("4.blob,") { + self.blob(rest); + } else if let Some(rest) = instr.strip_prefix("3.end,") { + self.end(rest); + } + } + } + + /// `h264,,,,,,,,,, + /// [ ]...,` + fn open(&mut self, rest: &str) { + let mut args = crate::instruction::elements(rest); + let Some(index) = args.next().and_then(|v| v.parse::().ok()) else { + return; + }; + args.nth(1); // keyframe, which only the dropper acts on + // Past x, y, width and height to the view. + let view = args + .nth(4) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0) + .min(2); + + if self.pending.len() >= MAX_PENDING_STREAMS { + // Streams that never ended. Nothing here is worth recovering, and + // the alternative is unbounded growth on a long session. + self.pending.clear(); + } + + self.pending.insert( + index, + Pending { + view, + bytes: 0, + buf: Vec::new(), + done: false, + }, + ); + } + + /// `blob,,` + fn blob(&mut self, rest: &str) { + let mut args = crate::instruction::elements(rest); + let Some(index) = args.next().and_then(|v| v.parse::().ok()) else { + return; + }; + let Some(payload) = args.next() else { + return; + }; + + let ready = { + let Some(pending) = self.pending.get_mut(&index) else { + return; + }; + + // Bytes are counted for every blob, including those of an access + // unit whose header has already been read: the size of each view + // is the bandwidth question, and it is the reason for all of this. + // Counted from the base64 length rather than by decoding, so a + // picture costs one decode however many blobs it takes. + pending.bytes += payload.len() / 4 * 3; + + if pending.done { + return; + } + + use base64::Engine as _; + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(payload) else { + return; + }; + + let room = AU_PREFIX_BYTES.saturating_sub(pending.buf.len()); + pending + .buf + .extend_from_slice(&bytes[..room.min(bytes.len())]); + pending.buf.len() >= AU_ENOUGH_BYTES || pending.buf.len() >= AU_PREFIX_BYTES + }; + + if ready { + self.analyse(index); + } + } + + /// `end,` — the access unit is complete, so a short one that never + /// reached the buffering threshold is read now. + fn end(&mut self, rest: &str) { + let Some(index) = crate::instruction::elements(rest) + .next() + .and_then(|v| v.parse::().ok()) + else { + return; + }; + + if self.pending.get(&index).is_some_and(|p| !p.done) { + self.analyse(index); + } + // Credited here rather than in analyse: the access unit's size is not + // known until its last blob has arrived, which is usually after its + // header has been read. + if let Some(pending) = self.pending.remove(&index) { + self.stats.views[pending.view as usize].bytes += pending.bytes as u64; + } + } + + /// Reads the parameter sets and first slice header out of what has been + /// buffered for one stream, and folds the result into the statistics. + fn analyse(&mut self, index: u32) { + let Some(pending) = self.pending.get_mut(&index) else { + return; + }; + pending.done = true; + let view = pending.view; + let buf = std::mem::take(&mut pending.buf); + + let mut slice = None; + for (header, payload) in nal_units(&buf) { + let nal_ref_idc = (header >> 5) & 3; + match header & 0x1f { + 7 => { + if let Some((id, sps)) = parse_sps(payload) { + self.stats.gaps_allowed = Some(sps.gaps_allowed); + self.stats.max_num_ref_frames = Some(sps.max_num_ref_frames); + self.stats.pic_order_cnt_type = Some(sps.pic_order_cnt_type); + self.sps.insert(id, sps); + } + } + 8 => { + if let Some((id, pps)) = parse_pps(payload) { + self.pps.insert(id, pps); + } + } + nal_type @ (1 | 5) => { + slice = parse_slice(payload, nal_ref_idc, nal_type == 5, &self.sps, &self.pps); + break; + } + _ => {} + } + } + + let Some(slice) = slice else { + self.stats.parse_failures += 1; + return; + }; + + self.stats.aus += 1; + + let stats = &mut self.stats.views[view as usize]; + stats.total += 1; + if slice.nal_ref_idc != 0 { + stats.reference += 1; + } + if slice.idr { + stats.idr += 1; + } + *stats.slice_types.entry(slice.type_name()).or_default() += 1; + + // Any SPS will do: both views of an AVC444 picture share one sequence, + // so they share its frame_num width. Four is the smallest the standard + // allows, and only reached if no SPS has been seen yet. + let wrap = 1u32 + << self + .sps + .values() + .next() + .map(|s| s.log2_max_frame_num) + .unwrap_or(4); + + if view == 0 { + if let Some(prev) = self.last_main_frame_num { + let delta = (slice.frame_num + wrap - prev) % wrap; + *self.stats.main_deltas.entry(delta).or_default() += 1; + } + self.last_main_frame_num = Some(slice.frame_num); + if !slice.list_mods.is_empty() { + self.stats.main_with_list_mod += 1; + } + } else { + if let Some(main) = self.last_main_frame_num { + let offset = (slice.frame_num + wrap - main) % wrap; + *self.stats.aux_offsets.entry(offset).or_default() += 1; + } + if !slice.list_mods.is_empty() { + self.stats.aux_with_list_mod += 1; + } + } + + for &op in &slice.list_mods { + *self.stats.list_mods.entry(op).or_default() += 1; + *self.stats.list_mods_by_view[view as usize] + .entry(op) + .or_default() += 1; + } + if slice.base_type() != 2 && slice.base_type() != 4 { + *self.stats.num_ref_idx_by_view[view as usize] + .entry(slice.num_ref_idx_l0_active) + .or_default() += 1; + } + if slice.mmco.iter().any(|(op, _)| *op == 6) { + self.stats.long_term_marked[view as usize] += 1; + } + for &op in &slice.mmco { + *self.stats.mmco.entry(op.0).or_default() += 1; + *self.stats.mmco_by_view[view as usize] + .entry(op) + .or_default() += 1; + } + } +} + +/// Which way a verdict went, without its prose. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VerdictKind { + NoAux, + Droppable, + NoDpbRoom, + Unproven, + NotDroppable, + Contradictory, +} + +/// Whether this stream's auxiliary views can be dropped on the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Safety { + /// Not enough seen yet, or no auxiliary view has arrived. + Undecided, + /// Nothing surviving a drop predicts from a dropped picture. + Safe, + /// Nothing *demonstrably* does, but the slice headers cannot rule it out: + /// the auxiliary picture sits in a reference list past the index the + /// encoder is known to use, and whether a macroblock reaches it is in the + /// slice data. The xrdp fork's shape. + /// + /// Kept apart from `Unsafe` because the two need different answers. This + /// one is settled by proving the negative -- replaying a recording with + /// the auxiliary views stripped and comparing the decode -- or by lowering + /// the encoder's active reference count. `Unsafe` is settled by not doing + /// it. + Unproven, + /// Something does, and the headers say so. + Unsafe, +} + +impl Stats { + /// The structured form of `verdict`, for the dropper to gate on. + /// + /// Deliberately the same conditions, and `verdicts_and_safety_agree` + /// pins that: a gate that drifted from the explanation beside it would + /// be the worst of both. + fn safety(&self, eager: bool) -> Safety { + let aux: u64 = self.views[1].total + self.views[2].total; + let main_inter = self.views[0].total - self.views[0].idr; + let aux_inter: u64 = (1..3) + .map(|v| self.views[v].total - self.views[v].idr) + .sum(); + + // The evidence the verdict reads lives in inter slices: an IDR names + // no reference and marks nothing, so a connect-time keyframe burst + // says nothing whatever its length. One of each view is the minimum + // that can show the two chains are disjoint; the larger figures are + // corroboration. + // + // There is deliberately no floor on the access unit count. Requiring + // main inter slices is already a stronger guard against deciding from + // the burst, since those exclude IDRs by construction, and a count of + // pictures is a bad proxy for elapsed evidence: a quiet desktop sends + // few of them, so a floor that costs a moment on a busy session costs + // tens of seconds on an idle one. + let (min_main, min_aux) = if eager { + (1, 1) + } else { + (MIN_MAIN_INTER_TO_DECIDE, MIN_AUX_INTER_TO_DECIDE) + }; + + if aux == 0 || main_inter < min_main || aux_inter < min_aux { + return Safety::Undecided; + } + + // An auxiliary view that has not yet named a reference has told us + // nothing, and an empty set is disjoint from everything -- so without + // this, `chains_are_separate` could be satisfied by never having seen + // what the auxiliary view points at. A non-IDR *I* slice counts toward + // `aux_inter` and carries no reference information whatever, which is + // how that would happen in practice. + // + // Only when main names its own reference explicitly: where nothing + // reorders at all, the verdict reasons about the default list order + // instead and the counts above are the whole of the evidence. + let main_names_its_own = !self.list_mods_by_view[0].is_empty(); + let aux_has_claimed = self.mmco_by_view[1..] + .iter() + .any(|ops| ops.keys().any(|(op, _)| *op == 6 || *op == 3)); + if main_names_its_own && !aux_has_claimed { + return Safety::Undecided; + } + match self.verdict_kind() { + VerdictKind::Droppable => Safety::Safe, + VerdictKind::Unproven => Safety::Unproven, + _ => Safety::Unsafe, + } + } + + /// Which of `safety`'s preconditions is not yet met, with the counts. + fn why_undecided(&self, eager: bool) -> String { + let aux: u64 = self.views[1].total + self.views[2].total; + if aux == 0 { + return format!( + "no auxiliary view has arrived in {} pictures — an AVC420 \ + stream, or a host that has not sent chroma yet", + self.aus + ); + } + + let main_inter = self.views[0].total - self.views[0].idr; + let aux_inter: u64 = (1..3) + .map(|v| self.views[v].total - self.views[v].idr) + .sum(); + let (min_main, min_aux) = if eager { + (1, 1) + } else { + (MIN_MAIN_INTER_TO_DECIDE, MIN_AUX_INTER_TO_DECIDE) + }; + + let mut missing = Vec::new(); + if main_inter < min_main { + missing.push(format!("{} of {} main inter slices", main_inter, min_main)); + } + if aux_inter < min_aux { + missing.push(format!( + "{} of {} auxiliary inter slices", + aux_inter, min_aux + )); + } + if !self.list_mods_by_view[0].is_empty() + && !self.list_mods_by_view[1..] + .iter() + .any(|ops| ops.keys().any(|(idc, _)| *idc == 2)) + { + missing.push( + "the auxiliary view has not yet named a long-term reference, \ + so there is nothing to compare main's against" + .into(), + ); + } + + format!( + "waiting on {} after {} pictures ({} main, {} auxiliary){}", + if missing.is_empty() { + "nothing — this should have decided, which is a bug".to_string() + } else { + missing.join(" and ") + }, + self.aus, + self.views[0].total, + aux, + if eager { + "" + } else { + ". A connection entry at Standard colour lowers the slice counts to one each" + } + ) + } + + /// The verdict as a kind alone, for gating. + fn verdict_kind(&self) -> VerdictKind { + self.verdict_full().0 + } + + /// The verdict as prose, for the journal. + fn verdict(&self) -> String { + self.verdict_full().1 + } + + /// One decision, two renderings. Split so that the gate the dropper reads + /// and the sentence a human reads can never disagree. + fn verdict_full(&self) -> (VerdictKind, String) { + let aux: u64 = self.views[1].total + self.views[2].total; + if aux == 0 { + return ( + VerdictKind::NoAux, + "no auxiliary views seen — this is an AVC420 stream, or the \ + host has not sent chroma yet" + .into(), + ); + } + + let aux_reference = self.views[1].reference + self.views[2].reference; + // A main view following a non-reference auxiliary view is one + // frame_num further on, not two. + let main_advances_by_one = self.main_deltas.keys().all(|&delta| delta <= 1); + + if aux_reference == 0 { + if !main_advances_by_one { + return ( + VerdictKind::Contradictory, + format!( + "CONTRADICTORY — every auxiliary view is non-reference, yet \ + frame_num advances by more than one between main views \ + ({}). One of the two readings is wrong; suspect this \ + parser before the stream", + self.main_deltas + .iter() + .map(|(delta, count)| format!("{} x{}", delta, count)) + .collect::>() + .join(", ") + ), + ); + } + return ( + VerdictKind::Droppable, + "DROPPABLE — every auxiliary view is a non-reference picture \ + and consumes no frame_num, so no surviving slice can refer \ + to one. Dropping the view!=0 instructions and clearing the \ + trailing flag on their main views would leave a \ + valid 4:2:0 stream" + .into(), + ); + } + + // The auxiliary views are reference pictures, so the question becomes + // whether anything that survives actually refers to one. + // + // `modification_of_pic_nums_idc` decides it, and its three values are + // not alike. 0 and 1 carry `abs_diff_pic_num_minus1`, a *relative* step + // through short-term PicNums: those shift when a picture is removed, so + // a main slice reaching back past an auxiliary picture by a count would + // land somewhere else once it was gone. 2 carries `long_term_pic_num`, + // an absolute index the encoder assigned with `mmco` 6 — unaffected by + // anything dropped, and the ordinary way to multiplex two independent + // reference chains into one sequence. + let short_term: u64 = self.list_mods_by_view[0] + .iter() + .filter(|((idc, _), _)| *idc < 2) + .map(|(_, count)| count) + .sum(); + + let main_long_term: std::collections::BTreeSet = self.list_mods_by_view[0] + .keys() + .filter(|(idc, _)| *idc == 2) + .map(|(_, value)| *value) + .collect(); + let aux_long_term: std::collections::BTreeSet = self.list_mods_by_view[1] + .keys() + .chain(self.list_mods_by_view[2].keys()) + .filter(|(idc, _)| *idc == 2) + .map(|(_, value)| *value) + .collect(); + // What an auxiliary view *claims*, not just what it reads. Reading a + // different index from main is not enough: a dropped picture that + // marked itself with an index main reads would take that index's + // contents with it. `mmco` 6 assigns one to the current picture and 3 + // assigns one to a short-term picture; both are a claim. + let aux_marks: std::collections::BTreeSet = self.mmco_by_view[1..] + .iter() + .flat_map(|ops| ops.keys()) + .filter(|(op, _)| *op == 6 || *op == 3) + .map(|(_, idx)| *idx) + .collect(); + + // Only what an auxiliary view *claims* can matter. Dropping removes + // the picture, so whatever it read never happens: an auxiliary slice + // naming main's long-term index is a consumer of main's picture, and + // removing a consumer is always safe. What would not be safe is main + // reading an index a dropped picture produced, which is `aux_marks`. + // + // Windows does exactly this after a surface recreation. The first + // auxiliary picture following an IDR has no chain of its own yet, so it + // names long-term 0 -- main's -- because that is the only long-term + // picture in the buffer. Testing what it reads condemns a healthy + // Windows stream at exactly that moment -- alongside + // `h264_black_keyframe_kept`, since the surface recreation that + // triggers one triggers both. + let chains_are_separate = + !main_long_term.is_empty() && main_long_term.is_disjoint(&aux_marks) && short_term == 0; + + // Separate chains are necessary and not sufficient: the decoded + // picture buffer has to have somewhere to put the pictures a gap + // makes it invent. + if chains_are_separate { + if let Some(reason) = self.no_room_for_inferred_frames() { + return (VerdictKind::NoDpbRoom, reason); + } + } + + if !chains_are_separate { + let mut reasons = vec![format!( + "{} of {} auxiliary views are reference pictures", + aux_reference, aux + )]; + if short_term > 0 { + reasons.push(format!( + "{} main slices reorder by a relative short-term PicNum \ + (idc 0/1), which counts backwards through the auxiliary \ + pictures and would address something else once they were \ + gone", + short_term + )); + } + if !main_long_term.is_disjoint(&aux_marks) { + reasons.push(format!( + "the auxiliary view claims long-term {:?}, which main \ + reads -- dropping such a picture would take that index's \ + contents with it", + main_long_term.intersection(&aux_marks).collect::>() + )); + } + if main_long_term.is_empty() && short_term == 0 { + // No reordering anywhere: main takes the default list. Whether + // that is safe depends on where the auxiliary pictures sit in + // it, and marking them long-term is what moves them out of the + // way -- a P slice's default list is the short-term pictures by + // descending PicNum first, so an auxiliary picture left + // short-term would be the most recent of them and land at + // index 0, while one marked long-term sits after every + // short-term picture. + let aux_inter: u64 = (1..3) + .map(|v| self.views[v].total - self.views[v].idr) + .sum(); + let aux_long_term_marked: u64 = self.long_term_marked[1] + self.long_term_marked[2]; + let main_active_entries: u64 = self.num_ref_idx_by_view[0] + .keys() + .copied() + .max() + .unwrap_or(1) as u64; + + if aux_long_term_marked < aux_inter { + reasons.push(format!( + "main slices take the default reference list and only \ + {} of {} auxiliary pictures mark themselves long-term, \ + so an auxiliary picture is the most recent short-term \ + reference and lands at index 0 of it", + aux_long_term_marked, aux_inter + )); + } else if main_active_entries > 1 { + return ( + VerdictKind::Unproven, + format!( + "UNPROVEN — every auxiliary picture marks itself \ + long-term (mmco 6), which moves it behind every \ + short-term picture in the default reference list main \ + slices use, so index 0 is the previous main view. But \ + main slices activate up to {} list-0 entries, and from \ + index 1 that list reaches the auxiliary pictures. \ + Whether any macroblock actually picks one is below the \ + slice header and cannot be read here. Settle it with \ + tests/aux-drop-replay.mjs against a recording, or by \ + lowering the encoder's num_ref_idx_l0_active_minus1 \ + where the encoder is yours; \ + RUSTGUAC_H264_AUX_DROP=force drops anyway", + main_active_entries + ), + ); + } else { + return ( + VerdictKind::Droppable, + format!( + "DROPPABLE, with one caveat — no slice reorders its \ + reference list, but every auxiliary picture marks \ + itself long-term (mmco 6), which places it after every \ + short-term picture in the default list. Main slices \ + activate one list-0 entry, which is therefore always \ + the previous main view, so nothing surviving predicts \ + from an auxiliary picture. {}", + self.frame_num_caveat(main_advances_by_one) + ), + ); + } + } + return ( + VerdictKind::NotDroppable, + format!( + "NOT DROPPABLE as-is — {}. Shedding the auxiliary view \ + downstream would need the surviving slice headers rewritten, \ + not merely filtered", + reasons.join("; ") + ), + ); + } + + // Separate chains. The only thing left is frame_num continuity, since + // dropping a reference picture leaves a hole where one was expected. + + ( + VerdictKind::Droppable, + format!( + "DROPPABLE, with one caveat — the two views run on separate \ + long-term reference chains: main names long-term {:?} while the \ + auxiliary views claim {:?} by mmco 6 or 3, with no relative \ + short-term reordering anywhere. So nothing surviving predicts \ + from a dropped picture. The auxiliary views read {:?}, which \ + does not bear on it: dropping removes the picture, so whatever \ + it read never happens. {}", + main_long_term, + aux_marks, + aux_long_term, + self.frame_num_caveat(main_advances_by_one) + ), + ) + } + + /// Whether this stream's decoded picture buffer can hold the frames a + /// `frame_num` gap obliges a decoder to invent, or `None` if it can. + /// + /// Dropping a picture leaves a hole in `frame_num`, and 8.2.5.2 requires a + /// decoder to fill each one with an inferred *non-existing* frame, marked + /// **short-term**, running the sliding window as it goes. The sliding + /// window (8.2.5.3) can only evict short-term pictures. So a stream whose + /// `max_num_ref_frames` is entirely consumed by long-term references has + /// nowhere to put one, and the decoder fails rather than degrading. + /// + /// Measured on the dual-LTR xrdp fork with `max_num_ref_frames` 2, + /// long-term 0 held by main and 1 by the auxiliary view: Chrome reported a + /// decode error 212ms after the first drop, then held every frame waiting + /// for a keyframe that an idle desktop never sends -- a permanent freeze. Windows survives the same treatment + /// because its `max_num_ref_frames` is 3: two long-term and one to spare. + /// + /// So the separate-chains test is necessary and not sufficient. It asks + /// whether anything *refers* to a dropped picture, which is the right + /// question and not the only one; the decoder also has to be able to + /// account for the ones that are missing. + fn no_room_for_inferred_frames(&self) -> Option { + // No gaps, nothing to infer. + if self.main_deltas.keys().all(|&delta| delta <= 1) { + return None; + } + + let long_term: std::collections::BTreeSet = self + .list_mods_by_view + .iter() + .flat_map(|ops| ops.keys()) + .filter(|(idc, _)| *idc == 2) + .map(|(_, value)| *value) + .collect(); + + let capacity = self.max_num_ref_frames?; + if capacity as usize > long_term.len() { + return None; + } + + Some(format!( + "NO ROOM IN THE DECODED PICTURE BUFFER — the reference chains are \ + separate, but max_num_ref_frames is {} and long-term indices {:?} \ + already account for all of it. Dropping a picture leaves a gap in \ + frame_num, and a decoder must fill each gap with an inferred \ + non-existing frame held as a *short-term* reference (8.2.5.2); \ + the sliding window can only evict short-term pictures, so there \ + is nowhere to put one and the decoder fails rather than \ + degrading. Raising the encoder's max_num_ref_frames by one, or \ + renumbering frame_num so no gap is left, is what makes this \ + stream droppable", + capacity, long_term + )) + } + + /// What dropping does to `frame_num`, which is the same question however + /// the reference chains are arranged. + fn frame_num_caveat(&self, main_advances_by_one: bool) -> String { + if main_advances_by_one { + return "frame_num stays continuous, so there is no caveat at all".into(); + } + if self.gaps_allowed == Some(true) { + return "frame_num gaps would open where the auxiliary views were, \ + and gaps_in_frame_num_value_allowed_flag is 1, so a decoder \ + is required to tolerate them" + .into(); + } + "frame_num gaps would open where the auxiliary views were, and \ + gaps_in_frame_num_value_allowed_flag is 0 — so the result is not a \ + conforming stream, though the flag is one bit in the SPS and \ + h264_rewrite already edits that structure" + .into() + } +} + +/// Walks the NAL units of an Annex B stream, yielding each one's header byte +/// and its payload (the header byte excluded). +fn nal_units(annexb: &[u8]) -> impl Iterator { + let mut i = 0usize; + std::iter::from_fn(move || { + while i + 4 < annexb.len() { + let header = if annexb[i..].starts_with(&[0, 0, 0, 1]) { + i + 4 + } else if annexb[i..].starts_with(&[0, 0, 1]) { + i + 3 + } else { + i += 1; + continue; + }; + + // Bit 8 is forbidden_zero_bit; a set one means this is not a NAL + // header and the match was three bytes of payload. + let byte = annexb[header]; + if byte & 0x80 != 0 { + i = header + 1; + continue; + } + + let end = next_start_code(annexb, header + 1).unwrap_or(annexb.len()); + i = end; + return Some((byte, &annexb[header + 1..end])); + } + None + }) +} + +/// The `seq_parameter_set_id` and reference-structure fields of one SPS. +fn parse_sps(payload: &[u8]) -> Option<(u32, Sps)> { + let rbsp = unescape(payload); + let mut r = BitReader::new(&rbsp); + + let prefix = read_sps_prefix(&mut r)?; + + let log2_max_frame_num = r.ue()? + 4; + let pic_order_cnt_type = r.ue()?; + let mut log2_max_poc_lsb = 0; + let mut delta_pic_order_always_zero = false; + if pic_order_cnt_type == 0 { + log2_max_poc_lsb = r.ue()? + 4; + } else if pic_order_cnt_type == 1 { + delta_pic_order_always_zero = r.bit()? == 1; + r.se()?; // offset_for_non_ref_pic + r.se()?; // offset_for_top_to_bottom_field + let cycle = r.ue()?; + if cycle > 255 { + return None; + } + for _ in 0..cycle { + r.se()?; + } + } + + let max_num_ref_frames = r.ue()?; + let gaps_allowed = r.bit()? == 1; + r.ue()?; // pic_width_in_mbs_minus1 + r.ue()?; // pic_height_in_map_units_minus1 + let frame_mbs_only = r.bit()? == 1; + if !frame_mbs_only { + r.bit()?; // mb_adaptive_frame_field_flag + } + + Some(( + prefix.seq_parameter_set_id, + Sps { + log2_max_frame_num, + pic_order_cnt_type, + log2_max_poc_lsb, + delta_pic_order_always_zero, + frame_mbs_only, + separate_colour_plane: prefix.separate_colour_plane, + chroma_array_type: prefix.chroma_array_type, + gaps_allowed, + max_num_ref_frames, + }, + )) +} + +/// The `pic_parameter_set_id` and slice-header-shaping fields of one PPS. +/// +/// Stops at `redundant_pic_cnt_present_flag`: everything past it belongs to the +/// optional extension, which no field this probe reads depends on. +fn parse_pps(payload: &[u8]) -> Option<(u32, Pps)> { + let rbsp = unescape(payload); + let mut r = BitReader::new(&rbsp); + + let id = r.ue()?; + let sps_id = r.ue()?; + r.bit()?; // entropy_coding_mode_flag + let bottom_field_pic_order_in_frame_present = r.bit()? == 1; + + let num_slice_groups_minus1 = r.ue()?; + if num_slice_groups_minus1 > 0 { + let map_type = r.ue()?; + match map_type { + 0 => { + for _ in 0..=num_slice_groups_minus1 { + r.ue()?; // run_length_minus1 + } + } + 2 => { + for _ in 0..num_slice_groups_minus1 { + r.ue()?; // top_left + r.ue()?; // bottom_right + } + } + 3..=5 => { + r.bit()?; // slice_group_change_direction_flag + r.ue()?; // slice_group_change_rate_minus1 + } + 6 => { + let units = r.ue()?; + // Bits per entry: ceil(log2(num_slice_groups_minus1 + 1)). + let bits = 32 - num_slice_groups_minus1.leading_zeros(); + for _ in 0..units { + r.bits(bits)?; + } + } + _ => {} + } + } + + let num_ref_idx_l0_default_active_minus1 = r.ue()?; + let num_ref_idx_l1_default_active_minus1 = r.ue()?; + let weighted_pred = r.bit()? == 1; + let weighted_bipred_idc = r.bits(2)?; + r.se()?; // pic_init_qp_minus26 + r.se()?; // pic_init_qs_minus26 + r.se()?; // chroma_qp_index_offset + r.bit()?; // deblocking_filter_control_present_flag + r.bit()?; // constrained_intra_pred_flag + let redundant_pic_cnt_present = r.bit()? == 1; + + Some(( + id, + Pps { + sps_id, + bottom_field_pic_order_in_frame_present, + num_ref_idx_l0_default_active_minus1, + num_ref_idx_l1_default_active_minus1, + weighted_pred, + weighted_bipred_idc, + redundant_pic_cnt_present, + }, + )) +} + +/// Reads one slice header as far as `dec_ref_pic_marking()`, which is the last +/// thing here that says anything about references. +/// +/// Everything between `frame_num` and the reference list modification has to be +/// walked exactly — including the weighted prediction tables, whose size +/// depends on both the PPS and the SPS's chroma format — because the fields are +/// variable-length and there is no resynchronisation. A wrong turn anywhere +/// yields plausible nonsense rather than an error, which is why +/// `tests/fixtures` holds a real encoder's stream to check the walk against. +fn parse_slice( + payload: &[u8], + nal_ref_idc: u8, + idr: bool, + sps_by_id: &HashMap, + pps_by_id: &HashMap, +) -> Option { + let rbsp = unescape(payload); + let mut r = BitReader::new(&rbsp); + + r.ue()?; // first_mb_in_slice + let slice_type = r.ue()?; + let pps_id = r.ue()?; + + let pps = *pps_by_id.get(&pps_id)?; + let sps = *sps_by_id.get(&pps.sps_id)?; + + if sps.separate_colour_plane { + r.bits(2)?; // colour_plane_id + } + + let frame_num = r.bits(sps.log2_max_frame_num)?; + + let mut field_pic = false; + if !sps.frame_mbs_only { + field_pic = r.bit()? == 1; + if field_pic { + r.bit()?; // bottom_field_flag + } + } + + if idr { + r.ue()?; // idr_pic_id + } + + if sps.pic_order_cnt_type == 0 { + r.bits(sps.log2_max_poc_lsb)?; // pic_order_cnt_lsb + if pps.bottom_field_pic_order_in_frame_present && !field_pic { + r.se()?; // delta_pic_order_cnt_bottom + } + } else if sps.pic_order_cnt_type == 1 && !sps.delta_pic_order_always_zero { + r.se()?; // delta_pic_order_cnt[0] + if pps.bottom_field_pic_order_in_frame_present && !field_pic { + r.se()?; // delta_pic_order_cnt[1] + } + } + + if pps.redundant_pic_cnt_present { + r.ue()?; // redundant_pic_cnt + } + + let base_type = slice_type % 5; + let is_b = base_type == 1; + let is_p = base_type == 0 || base_type == 3; + let is_intra = base_type == 2 || base_type == 4; + + if is_b { + r.bit()?; // direct_spatial_mv_pred_flag + } + + let mut num_ref_idx_l0 = pps.num_ref_idx_l0_default_active_minus1; + let mut num_ref_idx_l1 = pps.num_ref_idx_l1_default_active_minus1; + if (is_p || is_b) && r.bit()? == 1 { + // num_ref_idx_active_override_flag + num_ref_idx_l0 = r.ue()?; + if is_b { + num_ref_idx_l1 = r.ue()?; + } + } + + // ref_pic_list_modification() + let mut list_mods = Vec::new(); + if !is_intra { + read_list_modification(&mut r, &mut list_mods)?; + } + if is_b { + // List 1's modifications are recorded alongside list 0's: either list + // reaching back past an auxiliary picture is the same problem. + read_list_modification(&mut r, &mut list_mods)?; + } + + // pred_weight_table() + if (pps.weighted_pred && is_p) || (pps.weighted_bipred_idc == 1 && is_b) { + read_pred_weight_table(&mut r, sps.chroma_array_type, num_ref_idx_l0)?; + if is_b { + read_pred_weight_table(&mut r, sps.chroma_array_type, num_ref_idx_l1)?; + } + } + + // dec_ref_pic_marking() + let mut mmco = Vec::new(); + if nal_ref_idc != 0 { + if idr { + r.bit()?; // no_output_of_prior_pics_flag + r.bit()?; // long_term_reference_flag + } else if r.bit()? == 1 { + // adaptive_ref_pic_marking_mode_flag + loop { + let op = r.ue()?; + if op == 0 { + break; + } + let mut arg = 0; + if op == 1 || op == 3 { + arg = r.ue()?; // difference_of_pic_nums_minus1 + } + if op == 2 { + arg = r.ue()?; // long_term_pic_num + } + if op == 3 || op == 6 { + arg = r.ue()?; // long_term_frame_idx + } + if op == 4 { + arg = r.ue()?; // max_long_term_frame_idx_plus1 + } + mmco.push((op, arg)); + if mmco.len() > 32 { + return None; + } + } + } + } + + Some(Slice { + nal_ref_idc, + idr, + slice_type, + frame_num, + list_mods, + mmco, + num_ref_idx_l0_active: num_ref_idx_l0 + 1, + }) +} + +/// One `ref_pic_list_modification` list, appending `(idc, argument)` pairs. +fn read_list_modification(r: &mut BitReader, out: &mut Vec<(u32, u32)>) -> Option<()> { + if r.bit()? == 0 { + return Some(()); + } + loop { + let idc = r.ue()?; + if idc == 3 { + return Some(()); + } + let value = r.ue()?; + out.push((idc, value)); + if out.len() > 64 { + return None; + } + } +} + +/// Walks one `pred_weight_table()`, which carries nothing this probe wants but +/// stands between the reference list modification and `dec_ref_pic_marking()`. +fn read_pred_weight_table( + r: &mut BitReader, + chroma_array_type: u32, + num_ref_idx_active_minus1: u32, +) -> Option<()> { + r.ue()?; // luma_log2_weight_denom + if chroma_array_type != 0 { + r.ue()?; // chroma_log2_weight_denom + } + + // Bounded by the standard at 31; a larger value is a misread header, and + // looping on it would walk off the end of a truncated buffer. + if num_ref_idx_active_minus1 > 31 { + return None; + } + + for _ in 0..=num_ref_idx_active_minus1 { + if r.bit()? == 1 { + r.se()?; // luma_weight + r.se()?; // luma_offset + } + if chroma_array_type != 0 && r.bit()? == 1 { + for _ in 0..2 { + r.se()?; // chroma_weight + r.se()?; // chroma_offset + } + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real x264 stream, not a hand-built one: the whole risk in this module + /// is that its walk through the slice header disagrees with an encoder's, + /// and a fixture written by the same understanding that reads it would + /// agree with itself no matter how wrong both were. + /// + /// High profile with B frames and three reference frames, so the walk has + /// to cross the scaling-list branch, the B-slice fields and both reference + /// lists. The expectations below are ffmpeg's, read out of + /// `-bsf:v trace_headers`, which prints each syntax element by name. + const CLIP: &[u8] = include_bytes!("../tests/fixtures/x264-high-bframes.264"); + + /// `(nal_ref_idc, slice type, frame_num)` for the first pictures of CLIP, + /// in decode order, exactly as trace_headers reports them. + /// + /// The pattern is the one the probe exists to recognise: a non-reference + /// picture (`nal_ref_idc` 0) shares its frame_num with the reference + /// picture that follows, because it consumes no slot. An auxiliary chroma + /// view that behaved like this could be dropped. + const EXPECTED: &[(u8, &str, u32)] = &[ + (3, "I", 0), + (2, "P", 1), + (2, "B", 2), + (0, "B", 3), + (2, "P", 3), + (2, "B", 4), + (0, "B", 5), + (2, "P", 5), + (0, "B", 6), + (2, "P", 6), + (2, "P", 7), + (2, "P", 8), + (2, "P", 9), + (2, "B", 10), + (0, "B", 11), + (2, "P", 11), + ]; + + /// Reads CLIP the way the probe reads an access unit, and checks every + /// slice header against ffmpeg. + #[test] + fn slice_headers_agree_with_ffmpeg() { + let mut sps = HashMap::new(); + let mut pps = HashMap::new(); + let mut seen = Vec::new(); + + for (header, payload) in nal_units(CLIP) { + let nal_ref_idc = (header >> 5) & 3; + match header & 0x1f { + 7 => { + let (id, parsed) = parse_sps(payload).expect("SPS should parse"); + sps.insert(id, parsed); + } + 8 => { + let (id, parsed) = parse_pps(payload).expect("PPS should parse"); + pps.insert(id, parsed); + } + nal_type @ (1 | 5) => { + let slice = parse_slice(payload, nal_ref_idc, nal_type == 5, &sps, &pps) + .expect("slice header should parse"); + seen.push((slice.nal_ref_idc, slice.type_name(), slice.frame_num)); + } + _ => {} + } + } + + assert!( + seen.len() >= EXPECTED.len(), + "fixture yielded only {} slices", + seen.len() + ); + assert_eq!(&seen[..EXPECTED.len()], EXPECTED); + } + + /// The SPS fields the slice walk depends on, against trace_headers. + #[test] + fn sps_fields_agree_with_ffmpeg() { + let (_, sps) = nal_units(CLIP) + .find(|(header, _)| header & 0x1f == 7) + .and_then(|(_, payload)| parse_sps(payload)) + .expect("fixture should carry an SPS"); + + assert_eq!(sps.log2_max_frame_num, 4, "log2_max_frame_num_minus4 = 0"); + assert!(!sps.gaps_allowed, "gaps_in_frame_num_allowed_flag = 0"); + assert!(sps.frame_mbs_only); + assert_eq!(sps.chroma_array_type, 1, "4:2:0"); + } + + /// A non-reference picture consuming no frame_num is the signature the + /// verdict turns on, so it is asserted directly rather than left implicit + /// in the table above. + #[test] + fn non_reference_pictures_consume_no_frame_num() { + for pair in EXPECTED.windows(2) { + let (ref_idc, _, frame_num) = pair[0]; + let (_, _, next_frame_num) = pair[1]; + if ref_idc == 0 { + assert_eq!( + frame_num, next_frame_num, + "a non-reference picture should share its frame_num with \ + the picture after it" + ); + } + } + } + + /// Builds the instruction stream guacd would send for a run of access + /// units, alternating the views as an AVC444 host does, with `num_rects` + /// region rects between `` and the trailing `` flag. + /// + /// The fixture's pictures are ordinary AVC420, so the *content* of any + /// verdict means nothing here; what this exercises is everything between + /// the wire and the parser — the element offsets, the blob reassembly, and + /// the access unit that is only read at `end` because it never reached the + /// buffering threshold. + fn instruction_stream_with(views: &[u8], num_rects: usize) -> String { + use base64::Engine as _; + + let mut params = Vec::new(); + let mut slices = Vec::new(); + for (header, payload) in nal_units(CLIP) { + let mut nal = vec![0, 0, 0, 1, header]; + nal.extend_from_slice(payload); + match header & 0x1f { + 7 | 8 => params.extend_from_slice(&nal), + 1 | 5 => slices.push(nal), + _ => {} + } + } + + let mut out = String::new(); + for (i, view) in views.iter().enumerate() { + let mut au = Vec::new(); + if i == 0 { + au.extend_from_slice(¶ms); + } + au.extend_from_slice(&slices[i % slices.len()]); + + let index = 10 + i; + let args: Vec = [ + "h264".to_string(), + index.to_string(), + "0".to_string(), // layer + u8::from(i == 0).to_string(), // keyframe + "0".into(), // x + "0".into(), // y + "128".into(), // width + "96".into(), // height + view.to_string(), + num_rects.to_string(), + ] + .into_iter() + .chain((0..num_rects).flat_map(|r| [r + 1, r + 2, 16, 16].map(|v| v.to_string()))) + .chain(std::iter::once(u8::from(*view == 0).to_string())) + .collect(); + for arg in args { + out.push_str(&format!("{}.{},", arg.len(), arg)); + } + out.pop(); + out.push(';'); + + let payload = base64::engine::general_purpose::STANDARD.encode(&au); + out.push_str(&format!( + "4.blob,{}.{},{}.{};", + index.to_string().len(), + index, + payload.len(), + payload + )); + out.push_str(&format!("3.end,{}.{};", index.to_string().len(), index)); + } + out + } + + /// Builds the statistics a Windows AVC444 capture produces, so the + /// verdict can be exercised without a host. + fn windows_shaped_stats() -> Stats { + let mut stats = Stats { + gaps_allowed: Some(false), + ..Default::default() + }; + stats.views[0].total = 139; + stats.views[0].reference = 139; + stats.views[2].total = 61; + stats.views[2].reference = 61; + // Main names long-term picture 0, the auxiliary views name 1, each + // assigned by mmco 6, as measured against a Windows host. + stats.list_mods_by_view[0].insert((2, 0), 136); + stats.list_mods_by_view[2].insert((2, 1), 58); + stats.mmco_by_view[0].insert((6, 0), 136); + stats.mmco_by_view[2].insert((6, 1), 58); + stats.main_deltas.insert(1, 79); + stats.main_deltas.insert(2, 53); + stats + } + + /// Reference pictures alone do not make an auxiliary view undroppable: + /// what matters is whether anything surviving names one. + /// + /// This is the case the first verdict got wrong. It read any reordering as + /// dangerous, when `modification_of_pic_nums_idc` 2 is an absolute + /// long-term index rather than a relative walk through short-term PicNums, + /// and two disjoint long-term indices are how an encoder keeps two views + /// on separate reference chains inside one sequence. + #[test] + fn separate_long_term_chains_are_droppable() { + let verdict = windows_shaped_stats().verdict(); + assert!(verdict.starts_with("DROPPABLE"), "{}", verdict); + assert!(verdict.contains("frame_num gaps"), "{}", verdict); + assert!( + verdict.contains("gaps_in_frame_num_value_allowed_flag is 0"), + "{}", + verdict + ); + } + + /// A relative short-term reordering is the dangerous kind, because the + /// PicNums it counts through shift when a picture is removed. + #[test] + fn relative_short_term_reordering_is_not_droppable() { + let mut stats = windows_shaped_stats(); + stats.list_mods_by_view[0].insert((0, 1), 136); + + let verdict = stats.verdict(); + assert!(verdict.starts_with("NOT DROPPABLE"), "{}", verdict); + assert!(verdict.contains("short-term PicNum"), "{}", verdict); + } + + /// An auxiliary view *reading* main's long-term index is fine. + /// + /// Dropping removes the auxiliary picture, so whatever it read never + /// happens -- it is a consumer of main's picture, and removing a consumer + /// changes nothing for what survives. + /// + /// This is not hypothetical. After a surface recreation Windows sends an + /// IDR, and the first auxiliary picture following it has no chain of its + /// own yet, so it names long-term 0 because that is the only long-term + /// picture in the buffer. Testing what an auxiliary view reads condemned a + /// live stream 110s in, at the same moment the black-keyframe guard fired + /// on the same surface recreation. + #[test] + fn an_auxiliary_view_reading_mains_index_is_still_droppable() { + let mut stats = windows_shaped_stats(); + stats.list_mods_by_view[2].insert((2, 0), 1); + + assert_eq!(stats.safety(false), Safety::Safe, "{}", stats.verdict()); + } + + /// Main slices taking the default list order are the trap the long-term + /// scheme avoids: the default puts the most recently decoded reference + /// first, which in an interleaved stream is the auxiliary view. + #[test] + fn the_default_reference_list_is_not_droppable() { + let mut stats = windows_shaped_stats(); + stats.list_mods_by_view[0].clear(); + + let verdict = stats.verdict(); + assert!(verdict.starts_with("NOT DROPPABLE"), "{}", verdict); + assert!(verdict.contains("default reference list"), "{}", verdict); + } + + /// The dual-LTR xrdp fork, as measured: separate chains, + /// explicitly named, and a decoded picture buffer with no room to spare. + fn xrdp_dual_ltr_stats() -> Stats { + let mut stats = Stats { + gaps_allowed: Some(false), + max_num_ref_frames: Some(2), + ..Default::default() + }; + stats.views[0].total = 152; + stats.views[0].reference = 152; + stats.views[0].idr = 13; + stats.views[2].total = 48; + stats.views[2].reference = 48; + stats.list_mods_by_view[0].insert((2, 0), 139); + stats.list_mods_by_view[2].insert((2, 1), 35); + stats.mmco_by_view[0].insert((6, 0), 139); + stats.mmco_by_view[2].insert((6, 1), 48); + stats.long_term_marked[0] = 139; + stats.long_term_marked[2] = 48; + stats.num_ref_idx_by_view[0].insert(1, 139); + stats.num_ref_idx_by_view[2].insert(1, 35); + stats.main_deltas.insert(0, 12); + stats.main_deltas.insert(1, 103); + stats.main_deltas.insert(2, 36); + stats.aus = 200; + stats + } + + /// Separate reference chains are necessary and not sufficient. + /// + /// This is the case that froze a session. The chains are disjoint and + /// explicitly named -- the gate said DROPPABLE and it was right about + /// that -- but max_num_ref_frames is 2 and both slots are long-term, so + /// the frames a frame_num gap obliges the decoder to invent have nowhere + /// to go. Chrome errored 212ms after the first drop and then held every + /// frame waiting for a keyframe that never came. + #[test] + fn a_full_decoded_picture_buffer_is_not_droppable() { + let stats = xrdp_dual_ltr_stats(); + let verdict = stats.verdict(); + + assert_eq!(stats.safety(false), Safety::Unsafe, "{}", verdict); + assert!(verdict.starts_with("NO ROOM"), "{}", verdict); + assert!(verdict.contains("max_num_ref_frames is 2"), "{}", verdict); + } + + /// One spare slot is all it takes, which is what Windows has. + #[test] + fn one_spare_reference_slot_is_enough() { + let mut stats = xrdp_dual_ltr_stats(); + stats.max_num_ref_frames = Some(3); + + let verdict = stats.verdict(); + assert_eq!(stats.safety(false), Safety::Safe, "{}", verdict); + assert!(verdict.starts_with("DROPPABLE"), "{}", verdict); + } + + /// And a stream that leaves no gap needs no room: the check is about the + /// frames a gap invents, so no gaps means nothing to account for. + #[test] + fn no_gaps_means_no_room_needed() { + let mut stats = xrdp_dual_ltr_stats(); + stats.main_deltas.clear(); + stats.main_deltas.insert(1, 139); + + assert_eq!(stats.safety(false), Safety::Safe, "{}", stats.verdict()); + } + + /// The statistics an xrdp-fork capture produces: no reordering anywhere, + /// and every auxiliary picture marking itself long-term. + fn xrdp_shaped_stats() -> Stats { + let mut stats = Stats { + gaps_allowed: Some(false), + ..Default::default() + }; + stats.views[0].total = 152; + stats.views[0].reference = 152; + stats.views[0].idr = 14; + stats.views[2].total = 48; + stats.views[2].reference = 48; + stats.mmco_by_view[2].insert((4, 1), 48); + stats.mmco_by_view[2].insert((6, 0), 48); + stats.long_term_marked[2] = 48; + stats.num_ref_idx_by_view[0].insert(1, 138); + stats.num_ref_idx_by_view[2].insert(1, 48); + stats.main_deltas.insert(0, 13); + stats.main_deltas.insert(1, 103); + stats.main_deltas.insert(2, 35); + stats + } + + /// The default reference list is safe when the auxiliary pictures have + /// moved themselves out of it and only one entry is active. + /// + /// A P slice's default list is the short-term pictures by descending + /// PicNum and then the long-term ones, so `mmco` 6 on every auxiliary + /// picture puts the previous main view at index 0. + #[test] + fn a_long_term_aux_behind_one_active_entry_is_droppable() { + let verdict = xrdp_shaped_stats().verdict(); + assert!(verdict.starts_with("DROPPABLE"), "{}", verdict); + assert!(verdict.contains("one list-0 entry"), "{}", verdict); + } + + /// With more entries active the same list reaches the auxiliary pictures, + /// and whether a macroblock picks one is below the slice header. + #[test] + fn more_active_entries_leaves_the_default_list_unproven() { + let mut stats = xrdp_shaped_stats(); + stats.num_ref_idx_by_view[0].insert(2, 138); + + let verdict = stats.verdict(); + assert!(verdict.starts_with("UNPROVEN"), "{}", verdict); + assert!(verdict.contains("from index 1"), "{}", verdict); + } + + /// An auxiliary picture left short-term is the most recent short-term + /// reference, so the default list puts it at index 0 and main predicts + /// straight from chroma. + #[test] + fn a_short_term_aux_on_the_default_list_is_not_droppable() { + let mut stats = xrdp_shaped_stats(); + stats.long_term_marked[2] = 0; + stats.mmco_by_view[2].clear(); + + let verdict = stats.verdict(); + assert!(verdict.starts_with("NOT DROPPABLE"), "{}", verdict); + assert!(verdict.contains("index 0"), "{}", verdict); + } + + /// Separate chains are not enough on their own: a dropped picture must + /// also never *claim* an index main reads. + /// + /// Reading a different long-term index is not sufficient. An auxiliary + /// view that marked itself with the index main reads would take that + /// index's contents with it when dropped, and the reference lists alone + /// do not show that -- the marking operations do. + #[test] + fn an_auxiliary_view_claiming_mains_index_is_not_droppable() { + let mut stats = windows_shaped_stats(); + stats.mmco_by_view[2].insert((6, 0), 12); + + let verdict = stats.verdict(); + assert_eq!(stats.safety(false), Safety::Unsafe, "{}", verdict); + assert!(verdict.contains("claims long-term"), "{}", verdict); + } + + /// Claiming an index main never reads is exactly what both hosts do, and + /// is fine. + #[test] + fn an_auxiliary_view_claiming_its_own_index_is_droppable() { + let mut stats = windows_shaped_stats(); + stats.mmco_by_view[2].insert((6, 1), 12); + + assert_eq!(stats.safety(false), Safety::Safe, "{}", stats.verdict()); + } + + /// The view is read from the right element, whatever the rect count. + /// + /// It sits at index 7 of the instruction and the rects follow the count at + /// index 8, so an off-by-one here reads a coordinate as a view and the + /// gate then reasons about the wrong pictures. The dropper guards the + /// trailing `` flag separately, since that is the one it acts on. + #[test] + fn the_view_is_read_past_the_fixed_arguments() { + for rects in [0, 1, 9] { + let mut probe = NalProbe::new(); + probe.observe(&instruction_stream_with(&[0, 2, 0, 2], rects)); + + assert_eq!(probe.stats.views[0].total, 2, "{} rects", rects); + assert_eq!(probe.stats.views[2].total, 2, "{} rects", rects); + assert_eq!(probe.stats.parse_failures, 0, "{} rects", rects); + } + } + + /// An AVC420 stream never reaches a verdict, however much of it goes by. + /// + /// Real x264 pictures, all main view, at both corroboration levels: with + /// no auxiliary view there is nothing the question is even about, so the + /// answer is `Undecided` rather than safe or unsafe. + #[test] + fn an_avc420_stream_never_decides() { + let mut probe = NalProbe::new(); + probe.observe(&instruction_stream_with(&[0; 16], 2)); + + assert!(probe.stats.views[0].total > 0, "pictures were read"); + assert_eq!(probe.stats.views[1].total + probe.stats.views[2].total, 0); + + for eager in [false, true] { + assert_eq!( + probe.safety(eager), + Safety::Undecided, + "eager={}: {}", + eager, + probe.verdict() + ); + } + assert!(probe.verdict().starts_with("no auxiliary views seen")); + } + + /// The gate and the sentence beside it come from one decision. + /// + /// They are read by different audiences -- one decides whether to drop + /// bytes, the other explains it in the journal -- and a disagreement + /// between them would be invisible until someone compared a log line with + /// what the session actually did. + #[test] + fn verdicts_and_safety_agree() { + let cases: [(Stats, Safety); 5] = [ + (windows_shaped_stats(), Safety::Safe), + (xrdp_shaped_stats(), Safety::Safe), + ( + { + let mut s = xrdp_shaped_stats(); + s.num_ref_idx_by_view[0].insert(2, 138); + s + }, + // Unproven, not Unsafe: the headers cannot rule the reference + // out, which is a different answer from ruling it in. + Safety::Unproven, + ), + ( + { + let mut s = windows_shaped_stats(); + s.mmco_by_view[2].insert((6, 0), 12); + s + }, + Safety::Unsafe, + ), + ( + { + let mut s = windows_shaped_stats(); + s.list_mods_by_view[0].insert((0, 1), 136); + s + }, + Safety::Unsafe, + ), + ]; + + for (stats, expected) in cases { + let verdict = stats.verdict(); + assert_eq!(stats.safety(false), expected, "for: {}", verdict); + assert_eq!( + stats.safety(true), + expected, + "an eager assessment asks the same questions: {}", + verdict + ); + assert_eq!( + verdict.starts_with("DROPPABLE") && !verdict.starts_with("DROPPABLE IN STEADY"), + expected == Safety::Safe, + "the prose and the gate disagree: {}", + verdict + ); + } + } + + /// Nothing is decided from the connect-time keyframe burst, and the guard + /// is the inter-slice count rather than a count of pictures: IDRs are + /// excluded by construction, so a burst of any length says nothing. + #[test] + fn a_burst_of_keyframes_decides_nothing() { + let mut stats = windows_shaped_stats(); + stats.views[0].idr = stats.views[0].total; + stats.views[2].idr = stats.views[2].total; + + assert_eq!(stats.safety(false), Safety::Undecided); + assert_eq!(stats.safety(true), Safety::Undecided, "nor in eager mode"); + } + + /// An auxiliary view that has never *claimed* a long-term index is not yet + /// evidence that it claims none of main's. + /// + /// An empty set is disjoint from everything, so without this the + /// separate-chains test would be satisfied having seen nothing at all + /// about what the auxiliary view produces -- and a non-IDR I slice counts + /// as an auxiliary inter slice while marking nothing, which is how that + /// arises in practice rather than in theory. + #[test] + fn an_auxiliary_view_that_has_claimed_nothing_decides_nothing() { + let mut stats = windows_shaped_stats(); + stats.mmco_by_view[2].clear(); + + assert_eq!(stats.safety(false), Safety::Undecided); + assert_eq!(stats.safety(true), Safety::Undecided, "nor in eager mode"); + + // And it resolves as soon as one arrives. + stats.mmco_by_view[2].insert((6, 1), 1); + assert_eq!(stats.safety(true), Safety::Safe); + } + + /// Where nothing reorders at all the verdict reasons about the default + /// list order, so the counts are the whole of the evidence and the guard + /// above must not hold such a stream hostage. + #[test] + fn a_default_list_stream_still_reaches_a_verdict() { + let mut stats = xrdp_shaped_stats(); + stats.aus = 200; + assert_ne!( + stats.safety(false), + Safety::Undecided, + "{}", + stats.verdict() + ); + } +} diff --git a/src/h264_rewrite.rs b/src/h264_rewrite.rs new file mode 100644 index 00000000..10986757 --- /dev/null +++ b/src/h264_rewrite.rs @@ -0,0 +1,847 @@ +//! Makes an RDP host's colour range legible to the browser. +//! +//! An SPS that declares `video_full_range_flag = 1` and no colour description +//! is honoured by Chrome's software decoder and ignored by its hardware one. +//! Measured on one browser against two hosts, both decoding to NV12: +//! +//! | host | SPS | reported | +//! |---|---|---| +//! | xrdp fork | `full_range=1`, primaries/transfer/matrix all BT.709 | full | +//! | Windows | `full_range=1`, no description | **limited** | +//! +//! Same client, same hardware path; the description is the only difference. +//! A Windows session therefore renders with blacks crushed to zero and chroma +//! over-saturated by 255/224, and neither end can see it: the host declared +//! the range, and the browser reports limited. +//! +//! This gives Windows the shape xrdp already has, by splicing a BT.709 +//! description into the SPS on its way past. That fixes both render paths at +//! once — including `drawImage()`, which no client-side flag can reach — and +//! every client, including third-party ones. See `crate::h264_sps` for the +//! bit-level work and the measurements. +//! +//! Recordings are teed upstream of this and keep the host's original stream, +//! which is what a recording should be. Playback of a Windows recording is +//! subject to the same fault, and `?h264FullRange=on` is the lever there. + +use base64::Engine as _; +use std::collections::HashSet; + +/// What has been decided about this session's stream. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + /// No SPS seen yet, so every video blob is examined. + Undecided, + /// This stream's SPS needs a description, and each one is rewritten. + Rewriting, + /// This stream needs no colour work. Unless it also needs its reordering + /// bounded or the gaps flag set, nothing is examined again — which is the + /// case for xrdp, for any host that already describes its colour, and for + /// every session with no passthrough at all. + PassThrough, +} + +/// One of the two edits `SpsRewriter` makes, for the caller to report. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edit { + /// A colour description spliced in beside a range the host declared bare. + ColourDescription, + /// `gaps_in_frame_num_value_allowed_flag` set, ahead of the auxiliary + /// view being dropped. + FrameNumGaps, + /// `bitstream_restriction` added, declaring no picture reordering on a + /// stream whose POC type already guarantees it. + NoReordering, +} + +impl Edit { + /// What to say the first time this edit is made. Phrased for the journal, + /// where it sits beside the `H.264 colour:` line describing what the host + /// sent and, later, the browser's own report of what it made of it. + pub fn describe(self) -> &'static str { + match self { + Edit::ColourDescription => concat!( + "splicing a BT.709 description into the SPS, which Chrome's ", + "hardware decoder needs before it will act on the range the ", + "host declared" + ), + Edit::FrameNumGaps => concat!( + "permitting frame_num gaps in the SPS, so a decoder accounts ", + "for the pictures the auxiliary view drop removes rather than ", + "failing on the holes they leave" + ), + Edit::NoReordering => concat!( + "declaring max_num_reorder_frames=0 in the SPS: the stream's ", + "POC type 2 already means output order is decode order, but ", + "without bitstream_restriction Chrome's decoder holds a whole ", + "DPB of pictures before painting any" + ), + } + } +} + +/// Edits the SPS of one session's H.264 stream, in flight. +/// +/// Three independent edits share this pass because all need the same thing -- +/// the SPS located inside a base64 video blob -- and decoding every keyframe's +/// blob once per edit would be work for nothing: +/// +/// * a colour description, where the host declares a range without one +/// (`crate::h264_sps`), +/// * a bound of zero on picture reordering, where the POC type guarantees it +/// and the VUI does not say so (`crate::h264_sps::declare_no_reordering`), +/// and +/// * permission to skip `frame_num` values, when `crate::h264_aux_drop` is +/// about to start removing pictures that consume them. +pub struct SpsRewriter { + state: State, + /// What the stream's first SPS declared about colour, as the host sent + /// it, until the caller takes it to log. See `take_wire_colour`. + wire_colour: Option, + /// Whether this stream's SPS leaves reordering unbounded where it could + /// say otherwise. Decided at the first SPS, beside `state`, and kept + /// separate from it: the colour and the reorder bound are independent, + /// and NVENC needs the second with the first already complete. + bound_reordering: bool, + /// Whether to set `gaps_in_frame_num_value_allowed_flag`. Driven per chunk + /// by the dropper, which turns it on before it drops anything. + allow_gaps: bool, + /// Edits actually made and not yet reported. + /// + /// Held rather than inferred from `rewrite()` returning `Some`: with + /// independent edits sharing the pass that answers "something changed", + /// not "the colour description was added". A host whose SPS already + /// describes its colour and needs only the gaps flag or the reorder bound + /// would be logged as having a description spliced into it -- a line that + /// is false, about the one subject where the wire log and the browser's + /// report are meant to be read against each other. + pending_edits: Vec, + /// Edits already reported once, so a stream carrying a keyframe a minute + /// does not repeat them. + made: Vec, + /// Stream indices opened by an `h264` instruction. `audio` is deliberately + /// not tracked: its blobs are not video and decoding them to look for a + /// start code would be work for nothing. + h264_streams: HashSet, +} + +impl Default for SpsRewriter { + fn default() -> Self { + Self::new() + } +} + +impl SpsRewriter { + pub fn new() -> Self { + Self { + state: State::Undecided, + wire_colour: None, + bound_reordering: false, + allow_gaps: false, + pending_edits: Vec::new(), + made: Vec::new(), + h264_streams: HashSet::new(), + } + } + + /// What the stream's first SPS declared about its colour, once: `Some` on + /// the first call after that SPS has been examined, `None` before and + /// ever after. Read from the SPS as the host sent it, before any edit, so + /// a log line built from it describes the wire rather than what was made + /// of it. + pub fn take_wire_colour(&mut self) -> Option { + self.wire_colour.take() + } + + /// The edits made since this was last called, each reported once for the + /// life of the session. Empty on all but a couple of chunks. + pub fn take_edits(&mut self) -> Vec { + std::mem::take(&mut self.pending_edits) + } + + /// Records an edit the first time it is made, so the caller can say what + /// happened rather than that something did. + fn note(&mut self, edit: Edit) { + if !self.made.contains(&edit) { + self.made.push(edit); + self.pending_edits.push(edit); + } + } + + /// Asks for `gaps_in_frame_num_value_allowed_flag` on every SPS from now + /// on. Idempotent, and called on every chunk while the dropper is armed. + pub fn set_allow_frame_num_gaps(&mut self, allow: bool) { + self.allow_gaps = allow; + } + + /// Rewrites the SPS in any video blob in `text`, returning the new run of + /// instructions — or `None` when nothing needed changing, which is the + /// common case and copies nothing. + /// + /// `text` must end on an instruction boundary, as `guacd_to_ws` + /// guarantees. Anything malformed stops the scan and leaves the remainder + /// untouched: a blob that cannot be parsed is passed through, never + /// dropped, since losing one loses a picture. + pub fn rewrite(&mut self, text: &str) -> Option { + // PassThrough means no colour work, which is not the same as no work: + // a stream needing no colour fix may still need its reordering bounded + // or the gaps flag set. + if self.state == State::PassThrough && !self.bound_reordering && !self.allow_gaps { + return None; + } + + let mut out: Option = None; + // Start of text not yet copied into `out`. + let mut pending = 0usize; + let mut pos = 0usize; + + while pos < text.len() { + let instruction_start = pos; + + let Some((opcode, mut next, mut terminator)) = crate::binary_blob::element(text, pos) + else { + break; + }; + + let mut args: Vec<&str> = Vec::new(); + while terminator == b',' && args.len() < 2 { + match crate::binary_blob::element(text, next) { + Some((value, after, term)) => { + args.push(value); + next = after; + terminator = term; + } + None => break, + } + } + + // Walk off the end: an `h264` instruction carries region rects + // beyond the arguments read above. + while terminator == b',' { + match crate::binary_blob::element(text, next) { + Some((_, after, term)) => { + next = after; + terminator = term; + } + None => break, + } + } + + if terminator != b';' { + break; + } + let instruction_end = next; + pos = instruction_end; + + let index = args.first().and_then(|a| a.parse::().ok()); + + match (opcode, index) { + ("h264", Some(index)) => { + self.h264_streams.insert(index); + } + ("end", Some(index)) => { + self.h264_streams.remove(&index); + } + ("blob", Some(index)) if self.h264_streams.contains(&index) => { + let Some(payload) = args.get(1) else { continue }; + let Some(replacement) = self.rewritten_payload(payload) else { + continue; + }; + + let out = out.get_or_insert_with(String::new); + out.push_str(&text[pending..instruction_start]); + out.push_str(&format!( + "4.blob,{}.{},{}.{};", + args[0].len(), + args[0], + replacement.len(), + replacement + )); + pending = instruction_end; + } + _ => {} + } + } + + let mut out = out?; + out.push_str(&text[pending..]); + Some(out) + } + + /// Returns the base64 of a rewritten access unit, or `None` to leave the + /// blob alone. + fn rewritten_payload(&mut self, payload: &str) -> Option { + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(payload) else { + // The browser's decoder is no worse at this than ours. + return None; + }; + + let Some(sps) = crate::h264_sps::find_sps_range(&bytes) else { + // No SPS in this blob: a non-keyframe, or a continuation. Says + // nothing about the stream, so the state is left as it is. + return None; + }; + + if self.state == State::Undecided { + let signal = crate::h264_sps::parse_sps(&bytes[sps.clone()])?; + self.wire_colour = Some(signal.describe()); + self.state = if signal.needs_description() { + State::Rewriting + } else { + State::PassThrough + }; + self.bound_reordering = crate::h264_sps::reordering_unbounded(&bytes[sps.clone()]); + } + + // Every edit, in any combination. Each returns None when it has + // nothing to do -- an SPS that already describes its colour, bounds + // its reordering or permits gaps -- so an SPS needing none of them + // rebuilds nothing. + let mut edited: Option> = None; + + if self.state == State::Rewriting { + edited = crate::h264_sps::complete_colour_signalling(&bytes[sps.clone()]); + if edited.is_some() { + self.note(Edit::ColourDescription); + } + } + + if self.bound_reordering { + let current = edited.as_deref().unwrap_or(&bytes[sps.clone()]); + if let Some(bounded) = crate::h264_sps::declare_no_reordering(current) { + edited = Some(bounded); + self.note(Edit::NoReordering); + } + } + + if self.allow_gaps { + let current = edited.as_deref().unwrap_or(&bytes[sps.clone()]); + if let Some(with_gaps) = crate::h264_sps::allow_frame_num_gaps(current) { + edited = Some(with_gaps); + self.note(Edit::FrameNumGaps); + } + } + + let spliced = edited?; + + let mut rebuilt = Vec::with_capacity(bytes.len() + spliced.len()); + rebuilt.extend_from_slice(&bytes[..sps.start]); + rebuilt.extend_from_slice(&spliced); + rebuilt.extend_from_slice(&bytes[sps.end..]); + + Some(base64::engine::general_purpose::STANDARD.encode(&rebuilt)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An access unit whose SPS declares full range and no colour description + /// — the Windows shape. + const BARE: &str = "AAAAAWdkAAus2UGCabIAAAMAAgAAAwBkHihTLAAAAAABaOvjywAAAAFliIQA"; + + /// The same with a BT.709 description already present — the xrdp shape. + const COMPLETE: &str = "AAAAAWdkAAus2UGCabgICAoAAAMAAgAAAwBkHihTLAAAAAABaOvjywAAAAFliIQA"; + + fn blob(index: u32, payload: &str) -> String { + format!( + "4.blob,{}.{},{}.{};", + index.to_string().len(), + index, + payload.len(), + payload + ) + } + + /// Pulls the blob payload back out of a run of instructions. + fn payload_of(text: &str) -> String { + let at = text.find("4.blob,").expect("a blob"); + let (_, next, _) = crate::binary_blob::element(text, at).unwrap(); + let (_, after, _) = crate::binary_blob::element(text, next).unwrap(); + let (payload, _, _) = crate::binary_blob::element(text, after).unwrap(); + payload.to_string() + } + + fn decoded_signal(payload: &str) -> crate::h264_sps::ColourSignal { + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload) + .expect("valid base64"); + crate::h264_sps::find_sps(&bytes).expect("an SPS") + } + + /// The two edits are independent, and the caller reports what was + /// actually done. Inferring it from "something changed" told an xrdp + /// session -- whose SPS describes its colour completely and needs only the + /// gaps flag -- that a BT.709 description was being spliced into it, which + /// points whoever reads the journal at a colour fault that is not there. + #[test] + fn it_says_which_edit_it_made() { + let mut r = SpsRewriter::new(); + r.set_allow_frame_num_gaps(true); + r.rewrite("4.h264,1.7,1.0,1.0;"); + assert!( + r.rewrite(&blob(7, COMPLETE)).is_some(), + "the gaps bit is set" + ); + assert_eq!( + r.take_edits(), + vec![Edit::FrameNumGaps], + "a complete description is not spliced into" + ); + + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;"); + assert!( + r.rewrite(&blob(7, BARE)).is_some(), + "the description is added" + ); + assert_eq!(r.take_edits(), vec![Edit::ColourDescription]); + + let mut r = SpsRewriter::new(); + r.set_allow_frame_num_gaps(true); + r.rewrite("4.h264,1.7,1.0,1.0;"); + r.rewrite(&blob(7, BARE)); + let both = r.take_edits(); + assert!( + both.contains(&Edit::ColourDescription) && both.contains(&Edit::FrameNumGaps), + "a Windows host needs both: {:?}", + both + ); + + // Said once, however many keyframes follow. + r.rewrite(&blob(7, BARE)); + assert!( + r.take_edits().is_empty(), + "each edit is reported once a session" + ); + } + + /// The head of an NVENC keyframe (SPS, PPS, the start of an IDR slice): + /// colour complete, POC type 2, no `bitstream_restriction`. + const NVENC: &str = "AAAAAWdNQDKVkALwDR5awFuAgICgAAB9AAAdTBCAAAAAAWjrjyAAAAABZbgEJ/5vXw=="; + + /// A stream needing no colour work is still examined for the reorder + /// bound, and every keyframe's SPS gets it -- a decoder rebuilt at a later + /// keyframe reads that SPS, not the first. + #[test] + fn an_nvenc_stream_is_bounded_on_every_keyframe() { + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;"); + + let first = r.rewrite(&blob(7, NVENC)).expect("rewritten"); + assert_eq!(r.take_edits(), vec![Edit::NoReordering]); + + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload_of(&first)) + .unwrap(); + let sps = crate::h264_sps::find_sps_range(&bytes).unwrap(); + assert!(!crate::h264_sps::reordering_unbounded(&bytes[sps])); + assert_eq!(decoded_signal(&payload_of(&first)), decoded_signal(NVENC)); + assert!( + bytes.ends_with(&[0, 0, 0, 1, 0x65, 0xb8, 0x04, 0x27, 0xfe, 0x6f, 0x5f]), + "the slice after the SPS is untouched" + ); + + assert!( + r.rewrite(&blob(7, NVENC)).is_some(), + "and the next keyframe" + ); + assert!(r.take_edits().is_empty()); + } + + /// A stream that already bounds its reordering, or cannot, is left alone. + #[test] + fn a_stream_needing_nothing_is_still_passed_through() { + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;"); + assert_eq!(r.rewrite(&blob(7, COMPLETE)), None, "x264, POC type 0"); + assert_eq!(r.state, State::PassThrough); + assert!(!r.bound_reordering); + } + + #[test] + fn splices_a_description_into_a_bare_sps() { + let mut r = SpsRewriter::new(); + assert_eq!( + r.rewrite("4.h264,1.7,1.0,1.0;"), + None, + "nothing to rewrite yet" + ); + + let out = r.rewrite(&blob(7, BARE)).expect("rewritten"); + let signal = decoded_signal(&payload_of(&out)); + + assert!(signal.full_range, "the declared range survives"); + assert_eq!(signal.primaries, Some(1)); + assert_eq!(signal.transfer, Some(1)); + assert_eq!(signal.matrix, Some(1)); + assert!(signal.is_actionable(), "which is the point"); + } + + /// The other NALs of the access unit must come through untouched: the + /// slice data is the picture, and losing a byte of it loses the frame. + #[test] + fn the_rest_of_the_access_unit_is_preserved() { + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;"); + let out = r.rewrite(&blob(7, BARE)).expect("rewritten"); + + let before = base64::engine::general_purpose::STANDARD + .decode(BARE) + .unwrap(); + let after = base64::engine::general_purpose::STANDARD + .decode(payload_of(&out)) + .unwrap(); + + // Everything from the PPS start code onward is byte-identical. + let pps = before + .windows(5) + .position(|w| w == [0, 0, 0, 1, 0x68]) + .unwrap(); + let pps_after = after + .windows(5) + .position(|w| w == [0, 0, 0, 1, 0x68]) + .unwrap(); + assert_eq!(before[pps..], after[pps_after..], "PPS and slice survive"); + } + + /// An xrdp stream must not be touched, and must stop being examined. + #[test] + fn a_described_stream_is_passed_through_and_then_ignored() { + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;"); + + assert_eq!(r.rewrite(&blob(7, COMPLETE)), None, "nothing to do"); + assert_eq!(r.state, State::PassThrough); + // And the decision sticks: a later blob is not decoded at all. + assert_eq!( + r.rewrite(&blob(7, BARE)), + None, + "decided, and not revisited" + ); + } + + /// Keyframes recur, so the rewrite has to keep applying. + #[test] + fn every_later_keyframe_is_rewritten_too() { + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;"); + r.rewrite(&blob(7, BARE)).expect("first"); + + let out = r.rewrite(&blob(7, BARE)).expect("and the next"); + assert!(decoded_signal(&payload_of(&out)).is_actionable()); + } + + #[test] + fn blobs_of_other_streams_are_left_alone() { + let mut r = SpsRewriter::new(); + // An img stream carrying bytes that would parse as an access unit. + let text = format!("3.img,1.4,1.1,1.0,9.image/png,1.0,1.0;{}", blob(4, BARE)); + assert_eq!(r.rewrite(&text), None); + } + + /// Indices are reused once a stream ends. + #[test] + fn a_recycled_index_is_no_longer_video() { + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;3.end,1.7;"); + assert_eq!(r.rewrite(&blob(7, BARE)), None); + } + + /// Neighbouring instructions must survive the splice intact, since the + /// rewrite rebuilds the run around the blob it replaces. + #[test] + fn surrounding_instructions_are_preserved() { + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;"); + + let text = format!("4.sync,3.123;{}5.mouse,1.4,1.5;", blob(7, BARE)); + let out = r.rewrite(&text).expect("rewritten"); + + assert!(out.starts_with("4.sync,3.123;"), "{out}"); + assert!(out.ends_with("5.mouse,1.4,1.5;"), "{out}"); + assert!(decoded_signal(&payload_of(&out)).is_actionable()); + } + + /// The splice must leave a stream a decoder still accepts. A bad bit + /// offset or a missed emulation-prevention byte does not fail loudly -- + /// the picture simply stops, with both ends looking healthy. + /// + /// Checked against ffmpeg's own `h264_metadata` bitstream filter doing the + /// same edit, which is an implementation that shares no code and no + /// author with this one: if the two write the same SPS byte for byte, the + /// splice is right. Then both are decoded, to confirm the result is a + /// stream and not merely a plausible one. Skipped where ffmpeg is absent. + /// + /// Note that the edit legitimately *changes the decode*: with + /// `matrix_coefficients` absent a decoder falls back to BT.601, and + /// writing 1 declares BT.709. That is the correct value here -- MS-RDPEGFX + /// defines the transform as BT.709, and Chrome already reports `bt709` for + /// these streams, so it is a no-op for the client this serves -- but it is + /// why the pixels are not asserted equal. + #[test] + fn the_splice_matches_ffmpegs_own_rewrite() { + use std::process::Command; + + let ffmpeg = |args: &[&str]| -> bool { + Command::new("ffmpeg") + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + }; + + if !ffmpeg(&["-version"]) { + eprintln!("SKIP: needs ffmpeg"); + return; + } + + let dir = std::env::temp_dir().join(format!("rustguac-splice-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = |name: &str| dir.join(name).to_string_lossy().into_owned(); + + // Full-range samples declaring the range and nothing else: the shape + // this exists to repair. + assert!( + ffmpeg(&[ + "-v", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc=size=1280x720:rate=10:duration=1", + "-c:v", + "libx264", + "-profile:v", + "high", + "-pix_fmt", + "yuv420p", + "-color_range", + "pc", + "-x264-params", + "fullrange=on", + "-bsf:v", + "h264_metadata=video_full_range_flag=1", + &path("original.h264"), + ]), + "encode failed" + ); + + // The same edit, by ffmpeg. + assert!( + ffmpeg(&[ + "-v", + "error", + "-y", + "-i", + &path("original.h264"), + "-c:v", + "copy", + "-bsf:v", + "h264_metadata=colour_primaries=1:transfer_characteristics=1:\ +matrix_coefficients=1", + &path("reference.h264"), + ]), + "reference rewrite failed" + ); + + let original = std::fs::read(path("original.h264")).expect("read"); + let reference = std::fs::read(path("reference.h264")).expect("read"); + + let ours = { + let range = crate::h264_sps::find_sps_range(&original).expect("an SPS"); + let before = crate::h264_sps::parse_sps(&original[range.clone()]).expect("parses"); + assert!( + before.full_range && before.needs_description(), + "the fixture is the shape under test: {before:?}" + ); + crate::h264_sps::complete_colour_signalling(&original[range]).expect("rewritten") + }; + + let theirs = { + let range = crate::h264_sps::find_sps_range(&reference).expect("an SPS"); + reference[range].to_vec() + }; + + assert_eq!( + ours, theirs, + "our SPS differs from ffmpeg's:\n ours {ours:02x?}\n theirs {theirs:02x?}" + ); + + let spliced = crate::h264_sps::parse_sps(&ours).expect("parses"); + assert!(spliced.full_range, "the declared range survives"); + assert!( + spliced.is_actionable(), + "which is the point of the exercise" + ); + + // And it is still a decodable stream, not merely a plausible one. + for name in ["original.h264", "reference.h264"] { + assert!( + ffmpeg(&[ + "-v", + "error", + "-y", + "-i", + &path(name), + "-frames:v", + "3", + "-pix_fmt", + "rgb24", + "-f", + "rawvideo", + &path("out.rgb") + ]), + "decode failed for {name}" + ); + let pixels = std::fs::read(path("out.rgb")).expect("pixels"); + assert_eq!(pixels.len(), 1280 * 720 * 3 * 3, "three 720p frames"); + } + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The stock-xrdp shape: x264 with no VUI parameters set, so the whole + /// video_signal_type block is absent. Checked against ffmpeg's + /// h264_metadata writing the same three fields plus the range, which is an + /// implementation sharing no code with this one. Skipped without ffmpeg. + #[test] + fn writing_the_whole_signal_type_matches_ffmpeg() { + use std::process::Command; + + let ffmpeg = |args: &[&str]| -> bool { + Command::new("ffmpeg") + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + }; + + if !ffmpeg(&["-version"]) { + eprintln!("SKIP: needs ffmpeg"); + return; + } + + let dir = std::env::temp_dir().join(format!("rustguac-signal-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = |name: &str| dir.join(name).to_string_lossy().into_owned(); + + // No colour options at all -- x264's defaults, which is what stock + // xrdp 0.10.6 hands it. + assert!( + ffmpeg(&[ + "-v", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc=size=640x480:rate=10:duration=1", + "-c:v", + "libx264", + "-profile:v", + "high", + "-pix_fmt", + "yuv420p", + &path("plain.h264"), + ]), + "encode failed" + ); + + assert!( + ffmpeg(&[ + "-v", + "error", + "-y", + "-i", + &path("plain.h264"), + "-c:v", + "copy", + "-bsf:v", + "h264_metadata=video_full_range_flag=1:colour_primaries=1:\ +transfer_characteristics=1:matrix_coefficients=1", + &path("reference.h264"), + ]), + "reference rewrite failed" + ); + + let plain = std::fs::read(path("plain.h264")).expect("read"); + let reference = std::fs::read(path("reference.h264")).expect("read"); + + let range = crate::h264_sps::find_sps_range(&plain).expect("an SPS"); + let before = crate::h264_sps::parse_sps(&plain[range.clone()]).expect("parses"); + assert!( + before.vui_present && !before.video_signal_type_present, + "the fixture is the shape under test: {before:?}" + ); + + let ours = crate::h264_sps::complete_colour_signalling(&plain[range]).expect("rewritten"); + let theirs = { + let range = crate::h264_sps::find_sps_range(&reference).expect("an SPS"); + reference[range].to_vec() + }; + + assert_eq!( + ours, theirs, + "our SPS differs from ffmpeg's:\n ours {ours:02x?}\n theirs {theirs:02x?}" + ); + + let spliced = crate::h264_sps::parse_sps(&ours).expect("parses"); + assert!(spliced.full_range); + assert!(spliced.is_actionable()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn undecodable_payloads_are_passed_through_not_dropped() { + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;"); + let text = blob(7, "!!!not base64!!!"); + assert_eq!(r.rewrite(&text), None, "left for the browser to reject"); + } + + /// A blob carrying no SPS says nothing about the stream, so it must not + /// settle the decision -- the first blob of a session is often a + /// continuation, and deciding on it would leave every keyframe unrewritten. + #[test] + fn a_blob_without_an_sps_does_not_decide_the_stream() { + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;"); + + let slice = + base64::engine::general_purpose::STANDARD.encode([0, 0, 0, 1, 0x41, 0x9a, 0x12, 0x34]); + assert_eq!(r.rewrite(&blob(7, &slice)), None); + assert_eq!(r.state, State::Undecided, "still undecided"); + + let out = r + .rewrite(&blob(7, BARE)) + .expect("and the keyframe still lands"); + assert!(decoded_signal(&payload_of(&out)).is_actionable()); + } + + /// The wire colour is reported once, from the first SPS, and describes + /// what the host sent -- not the description spliced into it. + #[test] + fn it_reports_the_wire_colour_once() { + let mut r = SpsRewriter::new(); + r.rewrite("4.h264,1.7,1.0,1.0;"); + assert_eq!(r.take_wire_colour(), None, "no SPS seen yet"); + + r.rewrite(&blob(7, BARE)); + let line = r.take_wire_colour().expect("reported at the first SPS"); + assert!( + line.contains("NO DESCRIPTION"), + "describes the host's SPS: {line}" + ); + + r.rewrite(&blob(7, BARE)); + assert_eq!(r.take_wire_colour(), None, "once a session"); + } +} diff --git a/src/h264_sps.rs b/src/h264_sps.rs new file mode 100644 index 00000000..1521a5d9 --- /dev/null +++ b/src/h264_sps.rs @@ -0,0 +1,1372 @@ +//! Reads the colour signalling out of an H.264 sequence parameter set. +//! +//! The picture a passthrough session paints depends on two things the wire +//! carries and nothing else logs: whether the samples are full range, and +//! whether the stream says so in a form the browser will accept. +//! +//! The second half is the trap. Chrome honours `video_full_range_flag` in +//! every shape but one: a colour description that is *present* and says +//! *unspecified* makes it discard the whole `video_signal_type` and fall back +//! to limited-range BT.709. Leaving the description out entirely does not. +//! Measured against x264 streams whose SPS was parsed by hand, and +//! pinned by `tests/h264-vui-range.mjs`: +//! +//! | `full_range` | colour description | `colorSpace.fullRange` | 16,16,16 painted as | +//! |---|---|---|---| +//! | 1 | absent | `true` | 15,17,14 | +//! | 1 | present, 2/2 (unspecified) | `false` | **0,1,0** | +//! | 1 | present, 1/1 (BT.709) | `true` | 14,17,14 | +//! | 0 | present, 1/1 (BT.709) | `false` | 13,16,13 | +//! +//! So a host that encodes full-range BT.709, as MS-RDPEGFX specifies, and +//! declares it beside an *unspecified* primaries value is rendered exactly as +//! though it had declared limited: blacks crushed to zero, chroma +//! over-saturated by 255/224. Saying less would have worked. Both ends believe +//! they are correct, and the only way to tell that case from a host that +//! genuinely signals limited is to read the SPS — which is what this does, +//! once per stream, at the first keyframe. +//! +//! The xrdp fork writes all four values (`xrdp_accel_assist_vaapi.c`), which is +//! why signalling the range there visibly fixed the colour. + +/// What an SPS says about colour, as far as it says anything. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ColourSignal { + /// Whether the SPS carries a VUI at all. Without one there is nowhere to + /// put colour signalling short of synthesising the whole block, which no + /// encoder feeding this proxy makes necessary. + pub vui_present: bool, + pub video_signal_type_present: bool, + pub full_range: bool, + /// Present only when `colour_description_present_flag` was set. + pub primaries: Option, + pub transfer: Option, + pub matrix: Option, +} + +impl ColourSignal { + /// Whether a browser will act on `full_range`, hardware decode included. + /// + /// Needs a description that is both present and specified, and the two + /// ways of failing that need different fixes: + /// + /// * **Absent** — honoured by Chrome's software decoder and ignored by its + /// hardware one. Confirmed against a Windows host, which sends exactly + /// this and rendered with crushed blacks until a description was spliced + /// in. `crate::h264_rewrite` repairs it in flight. + /// * **Present and "unspecified" (2)** — makes Chrome discard the whole + /// `video_signal_type` on either path. Not repairable from here: + /// replacing 2 with 1 asserts a colourimetry the host declined to claim, + /// so it has to be fixed at the encoder. + pub fn is_actionable(&self) -> bool { + self.video_signal_type_present + && matches!(self.primaries, Some(p) if p != 2) + && matches!(self.transfer, Some(t) if t != 2) + } + + /// Whether the missing half is one this proxy can supply. + pub fn needs_description(&self) -> bool { + self.vui_present && self.primaries.is_none() + } + + /// A one-line summary for the journal, saying what the browser will do + /// with it rather than only what it contains. + pub fn describe(&self) -> String { + if !self.video_signal_type_present { + return if self.vui_present { + "NO SIGNAL TYPE: the SPS says nothing about colour, so the \ + browser assumes limited-range BT.709 and renders a full-range \ + host with crushed blacks. Full-range BT.709 is spliced in on \ + the way past — MS-RDPEGFX defines the transform, and stock \ + xrdp names its own conversion 709fr" + } else { + "no VUI in the SPS at all — the browser assumes limited-range \ + BT.709. Not repaired here: synthesising a whole VUI is more \ + surgery than any encoder seen in the field calls for" + } + .to_string(); + } + + let fmt = |v: Option| match v { + None => "absent".to_string(), + Some(2) => "2 (unspecified)".to_string(), + Some(1) => "1 (BT.709)".to_string(), + Some(other) => other.to_string(), + }; + + format!( + "video_full_range_flag={} colour_primaries={} transfer={} matrix={} — {}", + u8::from(self.full_range), + fmt(self.primaries), + fmt(self.transfer), + fmt(self.matrix), + if self.is_actionable() { + "usable, so the browser will honour the range" + } else if self.needs_description() { + "NO DESCRIPTION: a hardware decoder ignores a bare range flag, \ + so this renders a full-range host with crushed blacks. A \ + BT.709 description is spliced in on the way past" + } else { + "UNUSABLE: an explicitly unspecified primaries or transfer \ + makes Chrome discard the range flag and assume limited, which \ + renders a full-range host with crushed blacks. Not repairable \ + here — replacing it would assert a colourimetry the host \ + declined to claim — so fix it at the encoder" + } + ) + } +} + +/// Reads bits big-endian, with the exp-Golomb codings the SPS is written in. +/// +/// Shared with `crate::h264_refs`, which reads slice headers out of the same +/// streams: the two parsers walk different syntax structures but the same +/// bit-level codings, and a second copy of these four methods is a second +/// place for an off-by-one to live. +pub(crate) struct BitReader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> BitReader<'a> { + pub(crate) fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + pub(crate) fn bit(&mut self) -> Option { + let byte = self.data.get(self.pos >> 3)?; + let bit = (byte >> (7 - (self.pos & 7))) & 1; + self.pos += 1; + Some(u32::from(bit)) + } + + pub(crate) fn bits(&mut self, count: u32) -> Option { + // Every field read here is at most 32 bits wide; a wider read is a bug + // in the caller rather than something to handle. + debug_assert!(count <= 32); + let mut value = 0u32; + for _ in 0..count { + value = (value << 1) | self.bit()?; + } + Some(value) + } + + /// Unsigned exp-Golomb. + pub(crate) fn ue(&mut self) -> Option { + let mut zeros = 0u32; + while self.bit()? == 0 { + zeros += 1; + // A run this long is corrupt data, not a large value: the largest + // legal codeword is 32 bits. + if zeros > 32 { + return None; + } + } + if zeros == 0 { + return Some(0); + } + Some((1u32 << zeros) - 1 + self.bits(zeros)?) + } + + /// Signed exp-Golomb. + pub(crate) fn se(&mut self) -> Option { + let k = self.ue()?; + Some(if k % 2 == 0 { + -((k / 2) as i32) + } else { + k.div_ceil(2) as i32 + }) + } +} + +/// Strips emulation prevention bytes: 00 00 03 in the payload means 00 00. +pub(crate) fn unescape(nal: &[u8]) -> Vec { + let mut out = Vec::with_capacity(nal.len()); + let mut i = 0; + while i < nal.len() { + if i + 2 < nal.len() && nal[i] == 0 && nal[i + 1] == 0 && nal[i + 2] == 3 { + out.push(0); + out.push(0); + i += 3; + } else { + out.push(nal[i]); + i += 1; + } + } + out +} + +/// Parses the colour signalling out of one SPS NAL payload (without its header +/// byte). Returns None if the SPS is malformed or truncated. +pub fn parse_sps(payload: &[u8]) -> Option { + parse_rbsp(&unescape(payload)).map(|parsed| parsed.signal) +} + +/// Rewrites an SPS payload with `gaps_in_frame_num_value_allowed_flag` set, or +/// `None` if it is already set or the SPS cannot be read. +/// +/// Needed by `crate::h264_aux_drop`. Dropping an auxiliary view removes a +/// reference picture, and every `frame_num` it consumed becomes a hole in the +/// sequence. With this flag clear a decoder is entitled to treat that as a +/// broken stream; with it set, the standard requires it to infer the missing +/// pictures (8.2.5.2) and carry on. The encoders seen here all clear it, +/// because none of them intends anything to be dropped. +/// +/// Unlike the colour splice this changes no lengths — one bit, in place — but +/// it still goes back through `escape()`, because flipping a bit can create a +/// `00 00 00` or `00 00 01` sequence that must be escaped to stay parseable. +pub fn allow_frame_num_gaps(payload: &[u8]) -> Option> { + let mut rbsp = unescape(payload); + let parsed = parse_rbsp(&rbsp)?; + + let byte = parsed.gaps_bit / 8; + let mask = 0x80u8 >> (parsed.gaps_bit % 8); + if rbsp.get(byte)? & mask != 0 { + return None; + } + + rbsp[byte] |= mask; + Some(escape(&rbsp)) +} + +/// Where colour signalling can be spliced into an SPS, and what is missing. +/// +/// The two sites need different edits, and both occur in the field: Windows +/// declares a range and no description, while stock xrdp writes neither -- +/// x264's defaults omit the whole `video_signal_type` block, since with +/// `video_format` at 5 and no colour description there is nothing to carry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SpliceSite { + /// `video_signal_type_present_flag` is 0 and sits at this bit. The whole + /// block has to be written: format, range, and the description. + SignalType(usize), + /// The block is present but `colour_description_present_flag` is 0, at this + /// bit. Only the description is missing. + Description(usize), + /// Nothing to add -- already complete, already explicitly unspecified, or + /// no VUI to put it in. + Nowhere, +} + +/// What the SPS says before `log2_max_frame_num_minus4`, for the readers that +/// need it: `chroma_array_type` sizes the weighted prediction tables a slice +/// header has to be walked past, and `seq_parameter_set_id` is what a PPS +/// names. +pub(crate) struct SpsPrefix { + pub(crate) seq_parameter_set_id: u32, + pub(crate) chroma_array_type: u32, + /// Set only by the 4:4:4 high profiles, and the reason a slice header + /// carries a `colour_plane_id`. + pub(crate) separate_colour_plane: bool, +} + +/// Walks an SPS RBSP from its first byte up to `log2_max_frame_num_minus4`. +/// +/// Shared with `crate::h264_refs` rather than copied into it: the high-profile +/// chroma format and the optional scaling lists are the fiddliest part of the +/// walk and the part whose cost of drifting is silent — both readers would +/// still return values, just from the wrong bits. +pub(crate) fn read_sps_prefix(r: &mut BitReader) -> Option { + let profile_idc = r.bits(8)?; + r.bits(8)?; // constraint flags + reserved + r.bits(8)?; // level_idc + let seq_parameter_set_id = r.ue()?; + + // The high profiles carry a chroma format and optional scaling lists that + // have to be walked past to reach the fields below. + let mut chroma_array_type = 1; + let mut separate_colour_plane = false; + if matches!( + profile_idc, + 100 | 110 | 122 | 244 | 44 | 83 | 86 | 118 | 128 | 138 | 139 | 134 | 135 + ) { + let chroma_format_idc = r.ue()?; + chroma_array_type = chroma_format_idc; + if chroma_format_idc == 3 { + // separate_colour_plane_flag: with the planes coded separately + // there is no chroma to weight, and ChromaArrayType is 0. + separate_colour_plane = r.bit()? == 1; + if separate_colour_plane { + chroma_array_type = 0; + } + } + r.ue()?; // bit_depth_luma_minus8 + r.ue()?; // bit_depth_chroma_minus8 + r.bit()?; // qpprime_y_zero_transform_bypass_flag + if r.bit()? == 1 { + let lists = if chroma_format_idc == 3 { 12 } else { 8 }; + for i in 0..lists { + if r.bit()? == 1 { + let size = if i < 6 { 16 } else { 64 }; + let mut last = 8i32; + let mut next = 8i32; + for _ in 0..size { + if next != 0 { + next = (last + r.se()? + 256).rem_euclid(256); + } + if next != 0 { + last = next; + } + } + } + } + } + } + + Some(SpsPrefix { + seq_parameter_set_id, + chroma_array_type, + separate_colour_plane, + }) +} + +/// Where `bitstream_restriction` can be added to an SPS that lacks it. +/// +/// Only ever offered for `pic_order_cnt_type` 2, where the SPS itself +/// guarantees that output order is decode order -- so declaring no reordering +/// asserts nothing the stream has not already said. See +/// `declare_no_reordering`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReorderSite { + /// `vui_parameters_present_flag` is 0 and sits at this bit: a whole VUI + /// has to be written, empty but for the restriction. + NoVui(usize), + /// `bitstream_restriction_flag` is 0 and sits at this bit. + Flag(usize), + /// Nothing to add -- already bounded, a POC type that can reorder, or a + /// VUI this walk could not get to the end of. + Nowhere, +} + +/// What one SPS says, and the places it can be edited. +struct Parsed { + signal: ColourSignal, + splice: SpliceSite, + /// Bit offset of `gaps_in_frame_num_value_allowed_flag` within the RBSP. + gaps_bit: usize, + reorder: ReorderSite, + max_num_ref_frames: u32, +} + +/// Walks `hrd_parameters()`, which sits between the timing info and the +/// bitstream restriction and has to be passed to reach it. +fn skip_hrd(r: &mut BitReader) -> Option<()> { + let cpb_cnt = r.ue()? + 1; + // Bounded by the standard at 32; anything larger is corrupt input. + if cpb_cnt > 32 { + return None; + } + r.bits(4)?; // bit_rate_scale + r.bits(4)?; // cpb_size_scale + for _ in 0..cpb_cnt { + r.ue()?; // bit_rate_value_minus1 + r.ue()?; // cpb_size_value_minus1 + r.bit()?; // cbr_flag + } + r.bits(5)?; // initial_cpb_removal_delay_length_minus1 + r.bits(5)?; // cpb_removal_delay_length_minus1 + r.bits(5)?; // dpb_output_delay_length_minus1 + r.bits(5)?; // time_offset_length + Some(()) +} + +/// Walks the VUI from `chroma_loc_info_present_flag` to +/// `bitstream_restriction_flag`, returning that flag's bit position if it is +/// clear, or `None` if it is set or the VUI cannot be read that far. +fn unrestricted_flag_bit(r: &mut BitReader) -> Option { + if r.bit()? == 1 { + r.ue()?; // chroma_sample_loc_type_top_field + r.ue()?; // chroma_sample_loc_type_bottom_field + } + if r.bit()? == 1 { + r.bits(32)?; // num_units_in_tick + r.bits(32)?; // time_scale + r.bit()?; // fixed_frame_rate_flag + } + let nal_hrd = r.bit()? == 1; + if nal_hrd { + skip_hrd(r)?; + } + let vcl_hrd = r.bit()? == 1; + if vcl_hrd { + skip_hrd(r)?; + } + if nal_hrd || vcl_hrd { + r.bit()?; // low_delay_hrd_flag + } + r.bit()?; // pic_struct_present_flag + + let at = r.pos; + (r.bit()? == 0).then_some(at) +} + +/// Parses an unescaped SPS, also reporting the bit position of +/// `colour_description_present_flag` — which is where a description has to be +/// spliced in, and is not recoverable from the parsed values. +/// +/// The position is `None` when the walk never reached that flag, which is the +/// case for an SPS with no VUI or no `video_signal_type`. +fn parse_rbsp(rbsp: &[u8]) -> Option { + let mut r = BitReader::new(rbsp); + + read_sps_prefix(&mut r)?; + + r.ue()?; // log2_max_frame_num_minus4 + let pic_order_cnt_type = r.ue()?; + if pic_order_cnt_type == 0 { + r.ue()?; // log2_max_pic_order_cnt_lsb_minus4 + } else if pic_order_cnt_type == 1 { + r.bit()?; // delta_pic_order_always_zero_flag + r.se()?; // offset_for_non_ref_pic + r.se()?; // offset_for_top_to_bottom_field + let cycle = r.ue()?; + // Bounded by the standard at 255; anything larger is corrupt input and + // would otherwise be a long loop over a truncated buffer. + if cycle > 255 { + return None; + } + for _ in 0..cycle { + r.se()?; + } + } + + let max_num_ref_frames = r.ue()?; + let gaps_bit = r.pos; + r.bit()?; // gaps_in_frame_num_value_allowed_flag + r.ue()?; // pic_width_in_mbs_minus1 + r.ue()?; // pic_height_in_map_units_minus1 + if r.bit()? == 0 { + r.bit()?; // mb_adaptive_frame_field_flag + } + r.bit()?; // direct_8x8_inference_flag + if r.bit()? == 1 { + r.ue()?; // frame_crop_left_offset + r.ue()?; // frame_crop_right_offset + r.ue()?; // frame_crop_top_offset + r.ue()?; // frame_crop_bottom_offset + } + + // POC type 2 derives picture order from decode order, so output order is + // decode order -- the one case where the SPS itself proves that nothing + // is reordered, and so the only one where saying so is safe. + let can_bound_reordering = pic_order_cnt_type == 2; + + let vui_flag_bit = r.pos; + if r.bit()? == 0 { + // vui_parameters_present_flag = 0 + return Some(Parsed { + signal: ColourSignal { + vui_present: false, + video_signal_type_present: false, + full_range: false, + primaries: None, + transfer: None, + matrix: None, + }, + splice: SpliceSite::Nowhere, + gaps_bit, + reorder: if can_bound_reordering { + ReorderSite::NoVui(vui_flag_bit) + } else { + ReorderSite::Nowhere + }, + max_num_ref_frames, + }); + } + + if r.bit()? == 1 { + // aspect_ratio_info_present_flag + if r.bits(8)? == 255 { + r.bits(16)?; // sar_width + r.bits(16)?; // sar_height + } + } + if r.bit()? == 1 { + r.bit()?; // overscan_appropriate_flag + } + + let signal_type_flag_bit = r.pos; + let (signal, splice) = if r.bit()? == 0 { + // video_signal_type_present_flag = 0 -- the whole block is missing, + // which is what stock xrdp's x264 defaults produce. + ( + ColourSignal { + vui_present: true, + video_signal_type_present: false, + full_range: false, + primaries: None, + transfer: None, + matrix: None, + }, + SpliceSite::SignalType(signal_type_flag_bit), + ) + } else { + r.bits(3)?; // video_format + let full_range = r.bit()? == 1; + + let description_flag_bit = r.pos; + let (primaries, transfer, matrix) = if r.bit()? == 1 { + ( + Some(r.bits(8)? as u8), + Some(r.bits(8)? as u8), + Some(r.bits(8)? as u8), + ) + } else { + (None, None, None) + }; + + ( + ColourSignal { + vui_present: true, + video_signal_type_present: true, + full_range, + primaries, + transfer, + matrix, + }, + if primaries.is_some() { + SpliceSite::Nowhere + } else { + SpliceSite::Description(description_flag_bit) + }, + ) + }; + + // The colour is settled by here; the rest of the VUI only matters to the + // reordering edit, so a VUI that cannot be walked to its end costs that + // edit and nothing else. + let reorder = match unrestricted_flag_bit(&mut r) { + Some(at) if can_bound_reordering => ReorderSite::Flag(at), + _ => ReorderSite::Nowhere, + }; + + Some(Parsed { + signal, + splice, + gaps_bit, + reorder, + max_num_ref_frames, + }) +} + +/// Writes bits big-endian, for splicing an SPS back together. +struct BitWriter { + data: Vec, + bits: usize, +} + +impl BitWriter { + fn new(capacity: usize) -> Self { + Self { + data: Vec::with_capacity(capacity), + bits: 0, + } + } + + fn bit(&mut self, value: u32) { + if self.bits.is_multiple_of(8) { + self.data.push(0); + } + if value & 1 == 1 { + let at = self.data.len() - 1; + self.data[at] |= 1 << (7 - (self.bits % 8)); + } + self.bits += 1; + } + + fn bits_of(&mut self, value: u32, count: u32) { + for i in (0..count).rev() { + self.bit((value >> i) & 1); + } + } + + /// Unsigned exp-Golomb, for values well short of `u32::MAX`. + fn ue(&mut self, value: u32) { + let coded = value + 1; + let width = 32 - coded.leading_zeros(); + self.bits_of(0, width - 1); + self.bits_of(coded, width); + } +} + +/// Re-inserts emulation prevention bytes: any 00 00 00/01/02/03 in the payload +/// becomes 00 00 03 followed by that byte. +fn escape(rbsp: &[u8]) -> Vec { + let mut out = Vec::with_capacity(rbsp.len() + 4); + let mut zeros = 0usize; + for &byte in rbsp { + if zeros >= 2 && byte <= 3 { + out.push(3); + zeros = 0; + } + out.push(byte); + if byte == 0 { + zeros += 1; + } else { + zeros = 0; + } + } + out +} + +/// Completes an SPS's colour signalling, returning the rewritten NAL payload. +/// +/// Two shapes are repaired, both seen in the field: +/// +/// * **A range with no description** (Windows). Chrome's hardware decoder +/// ignores a bare `video_full_range_flag`, so the description is added and +/// the declared range is left alone. +/// * **No `video_signal_type` at all** (stock xrdp, whose x264 defaults omit +/// the block). Full-range BT.709 is written, because that is what the +/// transport defines and what the encoder actually produced -- xrdp names its +/// own conversion `XRDP_yuv444_709fr`. +/// +/// Returns `None` when the SPS is malformed, or when it needs no help — it +/// already carries a description, or carries no `video_signal_type` for one to +/// sit beside. Inventing a range for a stream that declares none is not this +/// function's business: it only makes an existing declaration legible. +/// +/// **Why this is worth doing rather than telling the client.** Chrome's +/// hardware decode path acts on `video_full_range_flag` only when a colour +/// description accompanies it. Measured on one browser against two hosts: +/// xrdp, which writes `full_range=1` with primaries/transfer/matrix all +/// BT.709, is reported as full range; Windows, which writes `full_range=1` +/// with no description at all, is reported as limited and painted with +/// crushed blacks. Software decode honours both, which is why this took three +/// wrong theories to find. +/// +/// Making the second look like the first fixes it for every client and both +/// render paths at once — including `drawImage()`, which no flag of ours can +/// reach — and it is what the stream meant in the first place. BT.709 is not a +/// guess: MS-RDPEGFX defines the transform as BT.709, and `matrix_coefficients` +/// is the value the decoder was already assuming. +pub fn complete_colour_signalling(payload: &[u8]) -> Option> { + let rbsp = unescape(payload); + let site = parse_rbsp(&rbsp)?.splice; + + let mut w = BitWriter::new(rbsp.len() + 8); + let mut r = BitReader::new(&rbsp); + + let flag_at = match site { + SpliceSite::Nowhere => return None, + SpliceSite::SignalType(at) | SpliceSite::Description(at) => at, + }; + + for _ in 0..flag_at { + w.bit(r.bit()?); + } + + // The flag itself, now set. + w.bit(1); + + if let SpliceSite::SignalType(_) = site { + // The whole block, which was absent. video_format 5 is "unspecified", + // which is what x264 already meant by omitting it and is the only value + // here that asserts nothing extra. + w.bits_of(5, 3); // video_format + w.bits_of(1, 1); // video_full_range_flag + w.bit(1); // colour_description_present_flag + } + + // BT.709 three times over. + w.bits_of(1, 8); // colour_primaries + w.bits_of(1, 8); // transfer_characteristics + w.bits_of(1, 8); // matrix_coefficients + + // The flag being replaced, then everything after it verbatim. The tail + // includes rbsp_trailing_bits; shifting it is harmless, since the stop bit + // travels with it and trailing zeroes are padding either way. + r.bit()?; + while let Some(bit) = r.bit() { + w.bit(bit); + } + + Some(escape(&w.data)) +} + +/// Whether `declare_no_reordering` has anything to do for this SPS payload. +pub fn reordering_unbounded(payload: &[u8]) -> bool { + parse_rbsp(&unescape(payload)).is_some_and(|p| p.reorder != ReorderSite::Nowhere) +} + +/// Adds `bitstream_restriction` declaring `max_num_reorder_frames` 0 to an SPS +/// whose POC type already guarantees it, returning the rewritten NAL payload. +/// +/// **Without it Chrome holds a whole DPB of pictures before painting one.** +/// Its decoder (`H264Decoder::UpdateMaxNumReorderFrames()` in +/// `media/gpu/h264_decoder.cc`) takes the reorder depth from +/// `bitstream_restriction` when present, as zero for the High-family profiles +/// with `constraint_set3_flag`, and as the full DPB otherwise -- with no +/// shortcut for POC type 2, which is what makes the answer knowable. NVENC +/// writes exactly that shape (Main, `constraint_set1` only, POC type 2, a VUI +/// without the restriction), and at 2992x1648 level 5.0 the DPB is five +/// pictures: every picture emerged five pictures late, past the client's +/// 1000ms watchdog on an idle desktop, and nothing was ever painted. mstsc +/// decodes it without delay; ffmpeg does too. +/// +/// The fields beside the reorder depth are the values the standard infers +/// when the block is absent (E.2.1), so they claim nothing new. +/// `max_dec_frame_buffering` is `max_num_ref_frames`: with no reordering only +/// references stay in the DPB, which is what the xrdp fork's VA-API encoder +/// declares. +/// +/// Returns `None` when the SPS is malformed or there is nothing to do. +pub fn declare_no_reordering(payload: &[u8]) -> Option> { + let rbsp = unescape(payload); + let parsed = parse_rbsp(&rbsp)?; + + let at = match parsed.reorder { + ReorderSite::Nowhere => return None, + ReorderSite::NoVui(at) | ReorderSite::Flag(at) => at, + }; + + let mut w = BitWriter::new(rbsp.len() + 8); + let mut r = BitReader::new(&rbsp); + + for _ in 0..at { + w.bit(r.bit()?); + } + + // The flag being replaced -- vui_parameters_present_flag or + // bitstream_restriction_flag -- now set. + w.bit(1); + + if let ReorderSite::NoVui(_) = parsed.reorder { + // An otherwise empty VUI: every flag up to the restriction clear, + // which is what an absent VUI already meant. + w.bit(0); // aspect_ratio_info_present_flag + w.bit(0); // overscan_info_present_flag + w.bit(0); // video_signal_type_present_flag + w.bit(0); // chroma_loc_info_present_flag + w.bit(0); // timing_info_present_flag + w.bit(0); // nal_hrd_parameters_present_flag + w.bit(0); // vcl_hrd_parameters_present_flag + w.bit(0); // pic_struct_present_flag + w.bit(1); // bitstream_restriction_flag + } + + w.bit(1); // motion_vectors_over_pic_boundaries_flag + w.ue(2); // max_bytes_per_pic_denom + w.ue(1); // max_bits_per_mb_denom + w.ue(15); // log2_max_mv_length_horizontal + w.ue(15); // log2_max_mv_length_vertical + w.ue(0); // max_num_reorder_frames + w.ue(parsed.max_num_ref_frames); // max_dec_frame_buffering + + // Everything after the flag verbatim, rbsp_trailing_bits included. + r.bit()?; + while let Some(bit) = r.bit() { + w.bit(bit); + } + + Some(escape(&w.data)) +} + +/// Finds the first SPS in an Annex B byte stream and parses it. +/// +/// Access units from guacd arrive with start codes, and one blob may carry +/// several NALs; only the first SPS is of interest. The rewriter works on +/// the range rather than the parse, so outside the tests this has no caller. +#[cfg(test)] +pub fn find_sps(annexb: &[u8]) -> Option { + find_sps_range(annexb).and_then(|range| parse_sps(&annexb[range])) +} + +/// Byte range of the first SPS *payload* in an Annex B stream — the NAL header +/// byte excluded, so the range is what `parse_sps` and `add_colour_description` +/// take, and what a rewritten SPS replaces. +pub fn find_sps_range(annexb: &[u8]) -> Option> { + let mut i = 0usize; + while i + 4 < annexb.len() { + let header = if annexb[i..].starts_with(&[0, 0, 0, 1]) { + i + 4 + } else if annexb[i..].starts_with(&[0, 0, 1]) { + i + 3 + } else { + i += 1; + continue; + }; + + // Bit 8 is forbidden_zero_bit; a set one means this is not a NAL + // header and the match was three bytes of payload. + let byte = annexb[header]; + if byte & 0x80 == 0 && byte & 0x1f == 7 { + let end = next_start_code(annexb, header + 1).unwrap_or(annexb.len()); + return Some(header + 1..end); + } + + i = header + 1; + } + None +} + +pub(crate) fn next_start_code(data: &[u8], from: usize) -> Option { + let mut i = from; + while i + 3 <= data.len() { + if data[i] == 0 && data[i + 1] == 0 && (data[i + 2] == 1 || data[i + 2] == 3) { + if data[i + 2] == 1 { + return Some(i); + } + // An escape, not a boundary. + i += 3; + continue; + } + i += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Real SPS payloads from libx264, kept as bytes rather than built here: + /// the point of this parser is to agree with an encoder, and a fixture + /// written by the same understanding that reads it proves nothing. + /// + /// Produced by `ffmpeg -c:v libx264 -profile:v high` with the colour + /// options named, and cross-checked field by field against an independent + /// parser before being pasted in. + /// `full_range=1` beside an explicit "unspecified" (2) primaries and + /// transfer — the one shape Chrome discards. + const FULL_RANGE_UNSPECIFIED: &[u8] = &[ + 0x64, 0x00, 0x0b, 0xac, 0xd9, 0x41, 0x82, 0x69, 0xb8, 0x10, 0x10, 0x0a, 0x00, 0x00, 0x03, + 0x00, 0x02, 0x00, 0x00, 0x03, 0x00, 0x64, 0x1e, 0x28, 0x53, 0x2c, 0x00, + ]; + + /// `full_range=1` with a complete BT.709 description — the shape Chrome + /// honours, and what the xrdp fork writes. + const FULL_RANGE_COMPLETE: &[u8] = &[ + 0x64, 0x00, 0x0b, 0xac, 0xd9, 0x41, 0x82, 0x69, 0xb8, 0x08, 0x08, 0x0a, 0x00, 0x00, 0x03, + 0x00, 0x02, 0x00, 0x00, 0x03, 0x00, 0x64, 0x1e, 0x28, 0x53, 0x2c, 0x00, + ]; + + /// `full_range=0` with a complete BT.709 description. + const LIMITED_RANGE_COMPLETE: &[u8] = &[ + 0x64, 0x00, 0x0b, 0xac, 0xd9, 0x41, 0x82, 0x69, 0xa8, 0x08, 0x08, 0x0a, 0x00, 0x00, 0x03, + 0x00, 0x02, 0x00, 0x00, 0x03, 0x00, 0x64, 0x1e, 0x28, 0x53, 0x2c, 0x00, + ]; + + /// `full_range=1` with no colour description at all, which Chrome does + /// honour — the asymmetry that makes saying nothing safer than saying + /// "unspecified". + const FULL_RANGE_NO_DESCRIPTION: &[u8] = &[ + 0x64, 0x00, 0x0b, 0xac, 0xd9, 0x41, 0x82, 0x69, 0xb2, 0x00, 0x00, 0x03, 0x00, 0x02, 0x00, + 0x00, 0x03, 0x00, 0x64, 0x1e, 0x28, 0x53, 0x2c, 0x00, + ]; + + /// A VUI carrying no `video_signal_type` at all. + const NO_SIGNAL_TYPE: &[u8] = &[ + 0x64, 0x00, 0x0c, 0xac, 0xd9, 0x41, 0x41, 0xfb, 0x01, 0x10, 0x00, 0x00, 0x03, 0x00, 0x10, + 0x00, 0x00, 0x03, 0x01, 0x40, 0xf1, 0x42, 0x99, 0x60, 0x00, + ]; + + #[test] + fn reads_a_complete_full_range_description() { + let sps = parse_sps(FULL_RANGE_COMPLETE).expect("parses"); + assert!(sps.video_signal_type_present); + assert!(sps.full_range); + assert_eq!(sps.primaries, Some(1)); + assert_eq!(sps.transfer, Some(1)); + assert_eq!(sps.matrix, Some(1)); + assert!(sps.is_actionable()); + } + + #[test] + fn reads_a_complete_limited_range_description() { + let sps = parse_sps(LIMITED_RANGE_COMPLETE).expect("parses"); + assert!(sps.video_signal_type_present); + assert!(!sps.full_range); + assert_eq!(sps.primaries, Some(1)); + assert!(sps.is_actionable()); + } + + /// The case that renders a full-range host with crushed blacks while both + /// ends believe they agree: the flag is there, and unusable. + #[test] + fn full_range_beside_unspecified_primaries_is_not_actionable() { + let sps = parse_sps(FULL_RANGE_UNSPECIFIED).expect("parses"); + assert!(sps.video_signal_type_present); + assert!(sps.full_range, "the flag is set"); + assert_eq!(sps.primaries, Some(2), "and unusable beside this"); + assert_eq!(sps.transfer, Some(2)); + assert!(!sps.is_actionable()); + assert!( + !sps.needs_description(), + "it has a description; it is the wrong one" + ); + assert!(sps.describe().contains("UNUSABLE")); + } + + /// Absent and unspecified both fail, and are fixed differently: this one + /// is ours to repair, the other the encoder's. + #[test] + fn full_range_with_no_colour_description_needs_one() { + let sps = parse_sps(FULL_RANGE_NO_DESCRIPTION).expect("parses"); + assert!(sps.video_signal_type_present); + assert!(sps.full_range); + assert_eq!(sps.primaries, None); + assert_eq!(sps.transfer, None); + assert!( + !sps.is_actionable(), + "a hardware decoder ignores a bare range flag" + ); + assert!(sps.needs_description(), "and this one we can supply"); + assert!(sps.describe().contains("NO DESCRIPTION")); + } + + #[test] + fn reads_an_absent_video_signal_type() { + let sps = parse_sps(NO_SIGNAL_TYPE).expect("parses"); + assert!(sps.vui_present, "there is a VUI, just no colour in it"); + assert!(!sps.video_signal_type_present); + assert!(!sps.is_actionable()); + assert!(sps.needs_description(), "and it is ours to supply"); + assert!(sps.describe().contains("NO SIGNAL TYPE")); + } + + /// Stock xrdp 0.10.6: x264 is given no VUI parameters, so with + /// video_format 5 and no colour description it omits the whole + /// video_signal_type block. The samples are full-range BT.709 all the same + /// -- xrdp names its own conversion XRDP_yuv444_709fr -- so the browser's + /// fallback to limited crushes the blacks. + #[test] + fn writes_the_whole_signal_type_when_the_sps_omits_it() { + let rewritten = complete_colour_signalling(NO_SIGNAL_TYPE).expect("rewritten"); + let sps = parse_sps(&rewritten).expect("still parses"); + + assert!(sps.video_signal_type_present, "the block is now there"); + assert!(sps.full_range, "declared full, which is what xrdp encodes"); + assert_eq!(sps.primaries, Some(1)); + assert_eq!(sps.transfer, Some(1)); + assert_eq!(sps.matrix, Some(1)); + assert!(sps.is_actionable(), "and the browser will act on it"); + + // Only the colour block was added; the sequence header ahead of the + // VUI cannot have moved. + assert_eq!(rewritten[..8], NO_SIGNAL_TYPE[..8]); + + for window in rewritten.windows(3) { + assert!( + window != [0, 0, 0] && window != [0, 0, 1] && window != [0, 0, 2], + "unescaped sequence in the rewritten SPS: {rewritten:02x?}" + ); + } + } + + #[test] + fn finds_the_sps_in_an_access_unit() { + // AUD, then the SPS, as an encoder emits them. + let mut stream = vec![ + 0x00, 0x00, 0x00, 0x01, 0x09, 0x10, 0x00, 0x00, 0x00, 0x01, 0x67, + ]; + stream.extend_from_slice(FULL_RANGE_COMPLETE); + stream.extend_from_slice(&[0x00, 0x00, 0x00, 0x01, 0x68, 0xeb, 0xe3, 0xcb]); + + let sps = find_sps(&stream).expect("found"); + assert!(sps.full_range); + assert!(sps.is_actionable()); + } + + #[test] + fn ignores_a_stream_with_no_sps() { + assert!(find_sps(&[0x00, 0x00, 0x00, 0x01, 0x41, 0x9a, 0x00]).is_none()); + assert!(find_sps(&[]).is_none()); + assert!(find_sps(&[0xff; 64]).is_none()); + } + + #[test] + fn adds_a_bt709_description_to_a_bare_range_flag() { + let rewritten = complete_colour_signalling(FULL_RANGE_NO_DESCRIPTION).expect("rewritten"); + + let sps = parse_sps(&rewritten).expect("still parses"); + assert!(sps.video_signal_type_present); + assert!(sps.full_range, "the range it declared survives"); + assert_eq!(sps.primaries, Some(1)); + assert_eq!(sps.transfer, Some(1)); + assert_eq!(sps.matrix, Some(1)); + assert!(sps.is_actionable()); + + // Three bytes of description, plus the flag, spliced into the middle. + assert!( + rewritten.len() >= FULL_RANGE_NO_DESCRIPTION.len() + 3, + "grew by the description" + ); + } + + /// The rewrite must preserve everything ahead of the splice, or the + /// decoder gets a different picture size, profile or reference count and + /// the stream stops decoding rather than merely looking wrong. + #[test] + fn the_rewrite_changes_nothing_but_the_description() { + let rewritten = complete_colour_signalling(FULL_RANGE_NO_DESCRIPTION).expect("rewritten"); + + // Everything before the VUI is byte-identical: the splice is far + // enough in that the leading bytes cannot have moved. + assert_eq!( + rewritten[..8], + FULL_RANGE_NO_DESCRIPTION[..8], + "the sequence header is untouched" + ); + } + + #[test] + fn an_sps_that_needs_no_help_is_left_alone() { + // Already complete. + assert!(complete_colour_signalling(FULL_RANGE_COMPLETE).is_none()); + assert!(complete_colour_signalling(LIMITED_RANGE_COMPLETE).is_none()); + // Present but unspecified: it has a description, and replacing 2 with + // 1 would be asserting a colourimetry the host declined to claim. + assert!(complete_colour_signalling(FULL_RANGE_UNSPECIFIED).is_none()); + // NO_SIGNAL_TYPE is now repaired, not left alone -- see + // writes_the_whole_signal_type_when_the_sps_omits_it. + } + + #[test] + fn a_malformed_sps_is_not_rewritten() { + for len in 0..FULL_RANGE_NO_DESCRIPTION.len() { + let _ = complete_colour_signalling(&FULL_RANGE_NO_DESCRIPTION[..len]); + } + assert!(complete_colour_signalling(&[]).is_none()); + assert!(complete_colour_signalling(&[0xff; 40]).is_none()); + } + + /// Emulation prevention has to survive the splice: shifting the tail by 25 + /// bits can create a 00 00 00 that was not there before, and a decoder + /// reading that as a start code loses the rest of the SPS. + #[test] + fn the_rewrite_re_escapes_the_payload() { + let rewritten = complete_colour_signalling(FULL_RANGE_NO_DESCRIPTION).expect("rewritten"); + + for window in rewritten.windows(3) { + assert!( + window != [0, 0, 0] && window != [0, 0, 1] && window != [0, 0, 2], + "unescaped sequence in the rewritten SPS: {rewritten:02x?}" + ); + } + } + + /// The gaps flag is set, and nothing else in the SPS moves. + /// + /// Checked against ffmpeg's own reading rather than by parsing it back + /// here: a bit offset that is wrong in the same way in both the writer and + /// the reader agrees with itself perfectly. + #[test] + fn setting_the_gaps_flag_matches_ffmpegs_reading() { + use std::process::Command; + + let run = |args: &[&str]| -> Option { + let out = Command::new("ffmpeg").args(args).output().ok()?; + Some(String::from_utf8_lossy(&out.stderr).into_owned()) + }; + + if run(&["-version"]).is_none() { + eprintln!("SKIP: needs ffmpeg"); + return; + } + + let dir = std::env::temp_dir().join(format!("rustguac-gaps-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let clip = dir.join("in.264"); + + assert!(Command::new("ffmpeg") + .args([ + "-y", + "-v", + "error", + "-f", + "lavfi", + "-i", + "testsrc=size=128x96:rate=10:duration=1", + "-pix_fmt", + "yuv420p", + "-c:v", + "libx264", + "-profile:v", + "high", + "-f", + "h264", + ]) + .arg(&clip) + .status() + .expect("ffmpeg") + .success()); + + let annexb = std::fs::read(&clip).expect("clip"); + let range = find_sps_range(&annexb).expect("an SPS"); + + // x264 clears it, which is what makes this worth doing at all. + let trace = |path: &std::path::Path| -> String { + run(&[ + "-v", + "trace", + "-i", + &path.to_string_lossy(), + "-c", + "copy", + "-bsf:v", + "trace_headers", + "-f", + "null", + "-", + ]) + .unwrap_or_default() + }; + assert!( + trace(&clip) + .contains("gaps_in_frame_num_allowed_flag 0 = 0"), + "the fixture should start with the flag clear" + ); + + let rewritten = allow_frame_num_gaps(&annexb[range.clone()]).expect("a flag to set"); + assert_eq!( + rewritten.len(), + range.len(), + "one bit in place should not change the payload length" + ); + + let mut patched = annexb.clone(); + patched.splice(range.clone(), rewritten); + let out = dir.join("out.264"); + std::fs::write(&out, &patched).expect("write"); + + let after = trace(&out); + assert!( + after.contains("gaps_in_frame_num_allowed_flag 1 = 1"), + "ffmpeg should read the flag as set:\n{}", + after + ); + + // Everything else must survive, or the picture does not. Compared + // past the "[trace_headers @ 0x...]" prefix, whose address differs + // between runs. + let field_line = |trace: &str, field: &str| -> Option { + trace + .lines() + .find(|l| l.contains(field)) + .and_then(|l| l.split_once("] ")) + .map(|(_, rest)| rest.to_owned()) + }; + let before_trace = trace(&clip); + for field in [ + "log2_max_frame_num_minus4", + "pic_width_in_mbs_minus1", + "pic_height_in_map_units_minus1", + ] { + assert_eq!( + field_line(&before_trace, field), + field_line(&after, field), + "{} moved", + field + ); + } + + // A second pass has nothing to do. + assert!(allow_frame_num_gaps(&patched[range]).is_none()); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// NVENC's SPS as the xrdp fork's accel-assist sent it: Main, + /// `constraint_set1` only, level 5.0, POC type 2, three reference frames, + /// a full-range BT.709 description and timing info -- and no + /// `bitstream_restriction`, the shape Chrome holds a DPB of pictures for. + const NVENC_UNRESTRICTED: &[u8] = &[ + 0x4d, 0x40, 0x32, 0x95, 0x90, 0x02, 0xf0, 0x0d, 0x1e, 0x5a, 0xc0, 0x5b, 0x80, 0x80, 0x80, + 0xa0, 0x00, 0x00, 0x7d, 0x00, 0x00, 0x1d, 0x4c, 0x10, 0x80, + ]; + + /// The xrdp fork's VA-API SPS from the same day: High, POC type 2, and a + /// `bitstream_restriction` already declaring no reordering. + const VAAPI_RESTRICTED: &[u8] = &[ + 0x64, 0x00, 0x34, 0xac, 0xb4, 0x01, 0x78, 0x06, 0x8f, 0x2d, 0x4d, 0xc0, 0x40, 0x40, 0x41, + 0xe1, 0x10, 0x8d, 0x40, + ]; + + /// `NVENC_UNRESTRICTED` cut off after `frame_cropping`, with + /// `vui_parameters_present_flag` clear: the no-VUI shape, which no encoder + /// in the field sends but which the edit has to handle all the same. + fn nvenc_without_vui() -> Vec { + let rbsp = unescape(NVENC_UNRESTRICTED); + let at = { + let mut r = BitReader::new(&rbsp); + read_sps_prefix(&mut r).unwrap(); + r.ue().unwrap(); // log2_max_frame_num_minus4 + assert_eq!(r.ue().unwrap(), 2, "POC type"); + r.ue().unwrap(); // max_num_ref_frames + r.bit().unwrap(); // gaps + r.ue().unwrap(); + r.ue().unwrap(); + if r.bit().unwrap() == 0 { + r.bit().unwrap(); + } + r.bit().unwrap(); + if r.bit().unwrap() == 1 { + for _ in 0..4 { + r.ue().unwrap(); + } + } + r.pos + }; + let mut r = BitReader::new(&rbsp); + let mut w = BitWriter::new(rbsp.len()); + for _ in 0..at { + w.bit(r.bit().unwrap()); + } + w.bit(0); // vui_parameters_present_flag + w.bit(1); // rbsp_stop_one_bit + escape(&w.data) + } + + #[test] + fn only_an_unrestricted_poc_type_2_stream_is_bounded() { + assert!(reordering_unbounded(NVENC_UNRESTRICTED)); + assert!(reordering_unbounded(&nvenc_without_vui())); + + // Already restricted. + assert!(!reordering_unbounded(VAAPI_RESTRICTED)); + assert!(declare_no_reordering(VAAPI_RESTRICTED).is_none()); + + // x264 at its defaults uses POC type 0, which can reorder: declaring + // otherwise would be a claim the stream has not made. + for sps in [ + FULL_RANGE_COMPLETE, + NO_SIGNAL_TYPE, + FULL_RANGE_NO_DESCRIPTION, + ] { + assert!(!reordering_unbounded(sps)); + assert!(declare_no_reordering(sps).is_none()); + } + } + + #[test] + fn bounding_reordering_leaves_the_colour_alone() { + let before = parse_sps(NVENC_UNRESTRICTED).expect("parses"); + let rewritten = declare_no_reordering(NVENC_UNRESTRICTED).expect("rewritten"); + assert_eq!(parse_sps(&rewritten), Some(before)); + + // Idempotent, and composes with the other two edits in either order. + assert!(declare_no_reordering(&rewritten).is_none()); + let with_gaps = allow_frame_num_gaps(&rewritten).expect("gaps"); + assert!(!reordering_unbounded(&with_gaps)); + + for sps in [ + rewritten, + with_gaps, + declare_no_reordering(&nvenc_without_vui()).unwrap(), + ] { + for window in sps.windows(3) { + assert!( + window != [0, 0, 0] && window != [0, 0, 1] && window != [0, 0, 2], + "unescaped sequence in the rewritten SPS: {sps:02x?}" + ); + } + } + } + + /// The restriction lands where ffmpeg reads it, with the values the + /// standard would otherwise infer, and nothing else in the SPS moves -- + /// in both shapes. Checked against ffmpeg for the same reason as the gaps + /// flag: a bit offset wrong in the writer and the reader alike agrees with + /// itself perfectly. + #[test] + fn declaring_no_reordering_matches_ffmpegs_reading() { + use std::process::Command; + + if Command::new("ffmpeg").arg("-version").output().is_err() { + eprintln!("SKIP: needs ffmpeg"); + return; + } + + let dir = std::env::temp_dir().join(format!("rustguac-reorder-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + + // Field name to value, from a trace of an Annex B stream holding only + // this SPS. + let fields = |payload: &[u8], name: &str| -> Vec<(String, String)> { + let path = dir.join(name); + let mut annexb = vec![0, 0, 0, 1, 0x67]; + annexb.extend_from_slice(payload); + std::fs::write(&path, &annexb).expect("write"); + let out = Command::new("ffmpeg") + .args(["-v", "trace", "-f", "h264", "-i"]) + .arg(&path) + .args(["-c", "copy", "-bsf:v", "trace_headers", "-f", "null", "-"]) + .output() + .expect("ffmpeg"); + String::from_utf8_lossy(&out.stderr) + .lines() + .filter(|l| l.starts_with("[trace_headers")) + .filter_map(|l| { + let mut words = l.split_once("] ")?.1.split_whitespace(); + let _bit = words.next()?; + let name = words.next()?.to_owned(); + let value = words.last()?.to_owned(); + Some((name, value)) + }) + .collect() + }; + let get = |fields: &[(String, String)], name: &str| { + fields + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone()) + }; + + for (label, sps) in [ + ("vui", NVENC_UNRESTRICTED.to_vec()), + ("novui", nvenc_without_vui()), + ] { + let before = fields(&sps, &format!("{label}-in.264")); + let after = fields( + &declare_no_reordering(&sps).expect("rewritten"), + &format!("{label}-out.264"), + ); + + for (name, want) in [ + ("vui_parameters_present_flag", "1"), + ("bitstream_restriction_flag", "1"), + ("motion_vectors_over_pic_boundaries_flag", "1"), + ("max_bytes_per_pic_denom", "2"), + ("max_bits_per_mb_denom", "1"), + ("log2_max_mv_length_horizontal", "15"), + ("log2_max_mv_length_vertical", "15"), + ("max_num_reorder_frames", "0"), + ("max_dec_frame_buffering", "3"), + ] { + assert_eq!(get(&after, name).as_deref(), Some(want), "{label}: {name}"); + } + + // Everything the original said, it still says. The trailing bits + // are padding to a byte boundary and change with the length. + for (name, value) in &before { + if name == "vui_parameters_present_flag" + || name == "bitstream_restriction_flag" + || name.starts_with("rbsp_") + { + continue; + } + assert_eq!( + get(&after, name).as_ref(), + Some(value), + "{label}: {name} moved" + ); + } + } + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Truncation must end the parse, not spin or panic. Every prefix of a + /// real SPS is tried, since a short read can cut anywhere. + #[test] + fn truncation_is_not_fatal() { + for len in 0..NVENC_UNRESTRICTED.len() { + let _ = parse_sps(&NVENC_UNRESTRICTED[..len]); + let _ = declare_no_reordering(&NVENC_UNRESTRICTED[..len]); + } + for len in 0..FULL_RANGE_COMPLETE.len() { + let _ = parse_sps(&FULL_RANGE_COMPLETE[..len]); + } + } +} diff --git a/src/import.rs b/src/import.rs index 47cfc35b..be2b9051 100644 --- a/src/import.rs +++ b/src/import.rs @@ -165,6 +165,8 @@ pub async fn cmd_import_guacamole( enable_full_window_drag: None, force_lossless: None, enable_h264: None, + h264_chroma444: None, + native_resolution: None, container_image: None, container_cpu_limit: None, container_memory_limit: None, diff --git a/src/instruction.rs b/src/instruction.rs new file mode 100644 index 00000000..4b742be1 --- /dev/null +++ b/src/instruction.rs @@ -0,0 +1,122 @@ +//! Reading the Guacamole instruction stream as `guacd_to_ws` sees it. +//! +//! Elements are `LENGTH.VALUE`, separated by `,` and terminated by `;`. These +//! walk them by their declared lengths, which is both correct -- a value may +//! itself contain `;`, clipboard text for one -- and cheap, since a large blob +//! payload is skipped in one step rather than scanned. + +/// Yield each instruction start in `text` as a slice running to the end of the +/// buffer (callers only ever inspect the opcode and the first few arguments). +/// +/// Instruction boundaries are found by walking element length prefixes rather +/// than by splitting on `;`, because an element *value* may contain a `;` — +/// clipboard text, for instance. Walking is also the cheaper option: each +/// element is skipped by its declared length, so a multi-megabyte blob payload +/// costs one jump rather than a scan. +/// +/// `text` always ends on an instruction boundary (see `guacd_to_ws`). If a +/// malformed element is hit anyway, iteration stops rather than guessing. +pub(crate) fn instruction_starts(text: &str) -> impl Iterator { + InstructionStarts { rest: text } +} + +struct InstructionStarts<'a> { + rest: &'a str, +} + +impl<'a> Iterator for InstructionStarts<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option<&'a str> { + if self.rest.is_empty() { + return None; + } + + let start = self.rest; + let mut cursor = start; + loop { + match split_element(cursor) { + Some((_, rest, b';')) => { + self.rest = rest; + break; + } + Some((_, rest, _)) => cursor = rest, + None => { + // Malformed or truncated: yield what we have and stop, + // rather than looping. + self.rest = ""; + break; + } + } + } + + Some(start) + } +} + +/// Split one `LENGTH.VALUE` element off the front, returning the value, the +/// remainder past the separator, and the separator itself (`,` or `;`). +/// `None` if the element is malformed or truncated. +fn split_element(data: &str) -> Option<(&str, &str, u8)> { + let dot = data.find('.')?; + let len: usize = data[..dot].parse().ok()?; + let value_start = dot + 1; + let value_end = value_start.checked_add(len)?; + if value_end > data.len() || !data.is_char_boundary(value_end) { + return None; + } + let terminator = match data.as_bytes().get(value_end) { + Some(&sep @ (b',' | b';')) => sep, + _ => return None, + }; + Some(( + &data[value_start..value_end], + &data[value_end + 1..], + terminator, + )) +} + +/// The value and remainder of the leading element, discarding the separator. +fn next_element(data: &str) -> Option<(&str, &str)> { + split_element(data).map(|(value, rest, _)| (value, rest)) +} + +/// The elements of one instruction, in order, stopping at the first malformed +/// or truncated one. +/// +pub(crate) fn elements(data: &str) -> impl Iterator { + let mut rest = data; + std::iter::from_fn(move || { + let (value, remainder) = next_element(rest)?; + rest = remainder; + Some(value) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A `;` inside a value is data, not the end of the instruction. + #[test] + fn a_semicolon_in_a_value_does_not_split_an_instruction() { + let text = "9.clipboard,3.a;b;4.sync,1.5;"; + let starts: Vec<&str> = instruction_starts(text).collect(); + assert_eq!(starts.len(), 2); + assert!(starts[0].starts_with("9.clipboard,")); + assert!(starts[1].starts_with("4.sync,")); + // Each start runs to the end of the buffer; callers read only the + // leading elements. + assert_eq!( + elements(starts[0]).take(2).collect::>(), + vec!["clipboard", "a;b"] + ); + } + + /// A malformed element ends the walk instead of guessing past it. + #[test] + fn a_malformed_element_stops_iteration() { + assert_eq!(elements("4.h264,x.bad;").collect::>(), vec!["h264"]); + assert_eq!(instruction_starts("4.sync,9.12;").count(), 1); + } +} diff --git a/src/main.rs b/src/main.rs index 06f018b9..b8192100 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,17 @@ mod api; mod auth; +mod binary_blob; mod browser; mod config; mod db; mod drive; mod guacd; +mod h264_aux_drop; +mod h264_refs; +mod h264_rewrite; +mod h264_sps; mod import; +mod instruction; mod migrate; mod oidc; mod protocol; diff --git a/src/session.rs b/src/session.rs index ef28dcbb..87269b74 100644 --- a/src/session.rs +++ b/src/session.rs @@ -72,6 +72,14 @@ pub struct CreateSessionRequest { pub width: Option, pub height: Option, pub dpi: Option, + /// DPI scaling percentage to request from an RDP server, so a framebuffer + /// asked for in physical pixels renders at the right physical size rather + /// than half of it. RDP only; ignored by other protocols. + pub desktop_scale: Option, + /// Factor by which the framebuffer was scaled past the browser's CSS + /// pixels, if it was. Surfaced in `SessionInfo` so client.html can keep + /// its own resize requests in the same units the session was created in. + pub display_scale: Option, pub banner: Option, /// Override drive/file transfer setting for this session. pub enable_drive: Option, @@ -117,6 +125,12 @@ pub struct CreateSessionRequest { pub force_lossless: Option, /// Enable H.264 passthrough for RDP. pub enable_h264: Option, + /// Paint AVC444 in full 4:4:4 colour (RDP). Connection entries always + /// set it -- `Some(false)` is their Standard colour -- so `None` marks an + /// ad-hoc session, which differs only in how much evidence the + /// auxiliary-view drop wants first; see [`Session::aux_drop_setting`]. + /// Never reaches guacd: AVC444 is always offered. + pub h264_chroma444: Option, // VDI fields /// Docker image for VDI sessions (e.g. "myregistry/desktop:latest"). pub container_image: Option, @@ -253,10 +267,21 @@ pub struct SessionInfo { pub entry_display_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail_url: Option, + /// Factor by which this session's framebuffer was scaled past the + /// browser's CSS pixels, when the entry asked for a physical-pixel + /// framebuffer. Read by client.html from the /api/sessions/:id fetch so + /// its resize requests stay in the same units; omitted when unscaled. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_scale: Option, /// Open the client in fullscreen on connect (#154). Read by client.html /// from the /api/sessions/:id fetch; omitted when false/unset. #[serde(skip_serializing_if = "std::ops::Not::not")] pub fullscreen_on_connect: bool, + /// Never combine AVC444's two views into 4:4:4 chroma, whatever the + /// client's own gates decide: set for every session but one asking for + /// full colour. Read by client.html from the /api/sessions/:id fetch. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub h264_no_combine: bool, /// Auto-hide the clipboard/files side tabs when idle. Read by /// client.html from the /api/sessions/:id fetch; omitted when false. #[serde(skip_serializing_if = "std::ops::Not::not")] @@ -321,10 +346,15 @@ pub struct Session { /// Connections card hides its Share button. Does not block admin /// shadow (`/shadow`), which has its own audit trail. pub share_allowed: bool, + /// Factor by which the framebuffer was scaled past the browser's CSS + /// pixels, if the source entry asked for a physical-pixel framebuffer. + pub display_scale: Option, /// Copied from the source entry's `fullscreen_on_connect` flag /// (#154). Surfaced verbatim in `SessionInfo` so client.html can /// trigger fullscreen on first user gesture after CONNECTED. pub fullscreen_on_connect: bool, + /// Copied from the request's `h264_chroma444`. + pub h264_chroma444: Option, /// Copied from the source entry's `autohide_side_tabs` flag. /// Surfaced in `SessionInfo` so client.html can auto-hide the /// clipboard/files side tabs. @@ -637,7 +667,33 @@ pub(crate) fn dial_host(ip: std::net::IpAddr) -> String { } } +/// Whether the browser may combine AVC444's two views: only for full colour. +fn combines(h264_chroma444: Option) -> bool { + h264_chroma444 == Some(true) +} + +/// See [`Session::aux_drop_setting`]. +fn aux_drop_setting(h264_chroma444: Option) -> Option { + match h264_chroma444 { + Some(true) => Some(false), + Some(false) => Some(true), + None => None, + } +} + impl Session { + /// The setting handed to `crate::h264_aux_drop::AuxDropper::for_session`. + /// + /// Full colour needs the auxiliary view, so it is never dropped + /// (`Some(false)`). An entry at Standard colour drops it as soon as the + /// stream proves it can be spared, from the least evidence that can + /// answer (`Some(true)`); an ad-hoc session, which no admin has vouched + /// for, waits for the corroborated sample (`None`). Either way a stream + /// that cannot be proved is left alone, and paints 4:2:0 regardless. + fn aux_drop_setting(&self) -> Option { + aux_drop_setting(self.h264_chroma444) + } + pub fn info(&self) -> SessionInfo { SessionInfo { session_id: self.id, @@ -666,7 +722,9 @@ impl Session { address_book_folder: self.address_book_folder.clone(), entry_display_name: self.entry_display_name.clone(), thumbnail_url: Some(format!("/api/sessions/{}/thumbnail", self.id)), + display_scale: self.display_scale, fullscreen_on_connect: self.fullscreen_on_connect, + h264_no_combine: !combines(self.h264_chroma444), autohide_side_tabs: self.autohide_side_tabs, } } @@ -831,6 +889,9 @@ impl SessionManager { let width = raw_width.clamp(640, 8192); let height = raw_height.clamp(480, 8192); let dpi = raw_dpi.clamp(16, 384); + // MS-RDPBCGR 2.2.1.3.2 permits 100-500; a server discards the whole + // pair if it is out of range, so clamp rather than pass it through. + let desktop_scale = req.desktop_scale.map(|s| s.clamp(100, 500)); if width != raw_width || height != raw_height || dpi != raw_dpi { tracing::warn!( session_id = %session_id, @@ -1121,6 +1182,7 @@ impl SessionManager { enable_full_window_drag: req.enable_full_window_drag.unwrap_or(false), force_lossless: req.force_lossless.unwrap_or(false), enable_h264: req.enable_h264.unwrap_or(false), + desktop_scale, secondary_monitors: req.max_monitors.unwrap_or(1).saturating_sub(1), wol: wol.clone(), })); @@ -1634,6 +1696,7 @@ impl SessionManager { enable_full_window_drag: false, force_lossless: false, enable_h264: true, + desktop_scale, secondary_monitors: req.max_monitors.unwrap_or(1).saturating_sub(1), // VDI container on the Docker host — WoL not applicable. wol: guacd::WolParams::default(), @@ -1944,7 +2007,9 @@ impl SessionManager { login_script_handle, shadow_tokens: Vec::new(), share_allowed, + display_scale: req.display_scale, fullscreen_on_connect: req.fullscreen_on_connect.unwrap_or(false), + h264_chroma444: req.h264_chroma444, autohide_side_tabs: req.autohide_side_tabs.unwrap_or(false), }; @@ -2408,6 +2473,17 @@ impl SessionManager { } } + /// How this session's auxiliary-view drop is set; see + /// [`Session::aux_drop_setting`]. + /// + /// Read at WebSocket setup rather than carried on the connection + /// parameters: guacd never sees this, since the removal is rustguac's. + pub async fn h264_drop_aux(&self, id: Uuid) -> Option { + let sessions = self.sessions.read().await; + let session = sessions.get(&id)?; + let session = session.lock().await; + session.aux_drop_setting() + } /// Get recording metadata for a session (address_book_entry, max_recordings). pub async fn get_recording_meta(&self, id: Uuid) -> Option<(Option, Option)> { let sessions = self.sessions.read().await; @@ -2914,7 +2990,9 @@ mod tests { login_script_handle: None, shadow_tokens: Vec::new(), share_allowed: true, + display_scale: None, fullscreen_on_connect: false, + h264_chroma444: None, autohide_side_tabs: false, } } @@ -3154,6 +3232,28 @@ mod tests { .is_err()); } + /// Only full colour keeps the auxiliary view, and only full colour lets + /// the browser combine: Standard paints 4:2:0 whether or not the drop + /// gate qualifies the stream, rather than combining where it refuses. + #[test] + fn colour_setting_maps_onto_drop_and_combine() { + assert_eq!( + aux_drop_setting(Some(true)), + Some(false), + "full colour keeps the view" + ); + assert_eq!( + aux_drop_setting(Some(false)), + Some(true), + "entry: least evidence" + ); + assert_eq!(aux_drop_setting(None), None, "ad-hoc: corroborated sample"); + + assert!(combines(Some(true))); + assert!(!combines(Some(false))); + assert!(!combines(None)); + } + /// The fence flag is server-set; a request body must not switch it off. #[test] fn fence_flag_is_never_read_from_json() { diff --git a/src/vault.rs b/src/vault.rs index 1cc97ab8..c5ccd755 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -215,6 +215,28 @@ pub struct AddressBookEntry { /// Requires GFX enabled and xrdp with x264 on the target. Default: true when GFX enabled. #[serde(default, skip_serializing_if = "Option::is_none")] pub enable_h264: Option, + /// Paint AVC444 in full 4:4:4 colour (RDP, with H.264 passthrough). + /// + /// Unset or `Some(false)` is Standard colour: AVC444 is still offered -- + /// a Windows host needs that for hardware encoding, and FreeRDP advertises + /// the RDPGFX 10.x capability sets only alongside it -- but rustguac drops + /// the auxiliary chroma view in transit wherever the stream proves it can + /// be spared, and the browser never combines, so 4:2:0 is painted at the + /// lowest cost available. `Some(true)` keeps both views and lets the + /// browser combine them, at the price of a plane read-back per picture; + /// the browser still gives 4:4:4 up when that costs too much. + #[serde(skip_serializing_if = "Option::is_none")] + pub h264_chroma444: Option, + /// Request the framebuffer in the browser's physical pixels rather than its + /// CSS pixels, so text renders sharply on a HiDPI display. + /// + /// Per-entry rather than global because it is only safe where the target + /// also scales its UI to match. RDP does that automatically (guacd asks via + /// desktopScaleFactor); an X11 desktop behind xrdp has no per-connection DPI + /// negotiation, so enabling it there without a session-side scaling hook + /// just makes every icon and glyph smaller. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_resolution: Option, /// Docker image for VDI sessions (e.g. "myregistry/desktop:latest"). #[serde(default, skip_serializing_if = "Option::is_none")] pub container_image: Option, @@ -445,6 +467,12 @@ pub struct EntryInfo { /// Enable H.264 passthrough. #[serde(skip_serializing_if = "Option::is_none")] pub enable_h264: Option, + /// Paint AVC444 in full 4:4:4 colour. Unset means Standard (4:2:0). + #[serde(skip_serializing_if = "Option::is_none")] + pub h264_chroma444: Option, + /// Request the framebuffer in physical rather than CSS pixels. + #[serde(skip_serializing_if = "Option::is_none")] + pub native_resolution: Option, /// Docker image for VDI sessions. #[serde(skip_serializing_if = "Option::is_none")] pub container_image: Option, @@ -582,6 +610,8 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { enable_full_window_drag: e.enable_full_window_drag, force_lossless: e.force_lossless, enable_h264: e.enable_h264, + h264_chroma444: e.h264_chroma444, + native_resolution: e.native_resolution, container_image: e.container_image.clone(), container_cpu_limit: e.container_cpu_limit, container_memory_limit: e.container_memory_limit, @@ -2266,6 +2296,23 @@ mod tests { assert_eq!(entry3.enable_h264, Some(false)); } + #[test] + fn test_h264_chroma444_round_trips_and_is_absent_by_default() { + let json = r#"{"type":"rdp","hostname":"test","enable_h264":true}"#; + let entry: AddressBookEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.h264_chroma444, None); + assert!(!serde_json::to_string(&entry) + .unwrap() + .contains("h264_chroma444")); + + let json = r#"{"type":"rdp","hostname":"test","h264_chroma444":true}"#; + let entry: AddressBookEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.h264_chroma444, Some(true)); + assert!(serde_json::to_string(&entry) + .unwrap() + .contains("\"h264_chroma444\":true")); + } + // ── Path-traversal regression tests (v1.5.4 fix) ────────────────────── // Locks down the validate_name / validate_path invariants. Any future // relaxation of these rules (or a bug that re-opens `../` handling) must diff --git a/src/websocket.rs b/src/websocket.rs index a1bbc5e1..e8635fa0 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -25,6 +25,15 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_util::sync::CancellationToken; use uuid::Uuid; +/// The instruction rustguac sends the browser when the AVC444 auxiliary view +/// starts or stops being dropped in transit. +/// +/// Not a guacd opcode: it originates here, and guacd never sees it. Hyphenated +/// so it cannot collide with an upstream instruction, and unknown opcodes are +/// ignored by `Guacamole.Client`, so a cached client from before this existed +/// carries on as it did. ASCII, so the element length is its byte length. +const AUX_DROP_OPCODE: &str = "h264-aux"; + /// Which side terminated the proxy connection. enum ProxyResult { /// guacd closed the connection (with optional error). @@ -47,6 +56,11 @@ struct ProxyOutcome { #[derive(Deserialize)] pub struct WsQuery { pub token: Option, + /// Set by a client that can decode binary blob frames. Absent means the + /// client is older than this feature (or is not our client at all), and + /// it receives base64 text exactly as before. See `docs/binary-blobs.md`. + #[serde(rename = "binaryBlobs")] + pub binary_blobs: Option, } /// GET /ws/:session_id — Upgrade to WebSocket and proxy to guacd. @@ -134,6 +148,8 @@ pub async fn ws_handler( let identity_name = identity.as_ref().map(|id| id.display_name().to_string()); let database = database.map(|Extension(db)| db); + let binary_blobs = query.binary_blobs.as_deref().is_some_and(|v| v != "0"); + ws.protocols(["guacamole"]) .on_upgrade(move |socket| { handle_ws( @@ -141,6 +157,7 @@ pub async fn ws_handler( session_id, is_owner, query.token, + binary_blobs, socket, ip, identity_name, @@ -156,6 +173,7 @@ async fn handle_ws( session_id: Uuid, owner_verified: bool, token: Option, + binary_blobs: bool, ws: WebSocket, client_addr: IpAddr, identity_name: Option, @@ -243,7 +261,17 @@ async fn handle_ws( } }; - tracing::info!(session_id = %session_id, client_ip = %client_addr, "Starting proxy"); + tracing::info!( + session_id = %session_id, + client_ip = %client_addr, + binary_blobs, + "Starting proxy" + ); + + // How this session's colour setting maps onto dropping the AVC444 + // auxiliary view. Read here rather than sent to guacd: the removal + // happens in guacd_to_ws. + let drop_aux = manager.h264_drop_aux(session_id).await; // Set up recording file (only for owner connections, and only if recording is enabled) let is_recording_enabled = manager.is_recording_enabled(session_id).await; @@ -302,7 +330,16 @@ async fn handle_ws( // Run the bidirectional proxy let start = Instant::now(); - let proxy_outcome = proxy_ws_guacd(ws, guacd_stream, recording_file, cancel).await; + let proxy_outcome = proxy_ws_guacd( + ws, + guacd_stream, + recording_file, + cancel, + session_id, + binary_blobs, + drop_aux, + ) + .await; let elapsed = start.elapsed(); let server_disconnected = proxy_outcome.server_disconnected; let proxy_result = proxy_outcome.result; @@ -420,6 +457,9 @@ async fn proxy_ws_guacd( guacd: GuacdStream, recording_file: Option, cancel: CancellationToken, + session_id: Uuid, + binary_blobs: bool, + drop_aux: Option, ) -> ProxyOutcome { let (guacd_read, guacd_write) = tokio::io::split(guacd); let (ws_write, ws_read) = ws.split(); @@ -438,10 +478,18 @@ async fn proxy_ws_guacd( let recording_clone = recording.clone(); let sd_flag = server_disconnected.clone(); let ws_sink_g = ws_sink.clone(); - let guacd_to_browser = - tokio::spawn( - async move { guacd_to_ws(guacd_read, ws_sink_g, recording_clone, sd_flag).await }, - ); + let guacd_to_browser = tokio::spawn(async move { + guacd_to_ws( + guacd_read, + ws_sink_g, + recording_clone, + sd_flag, + session_id, + binary_blobs, + drop_aux, + ) + .await + }); // browser → guacd let ws_sink_b = ws_sink.clone(); @@ -495,15 +543,45 @@ const MAX_GUACD_CARRY: usize = 16 * 1024 * 1024; /// of instruction was not ';' nor ','". To prevent that, every Message::Text /// we emit ends at a true Guacamole instruction boundary; partial tail data /// is held in `carry` until the next read completes it. +#[allow(clippy::too_many_arguments)] async fn guacd_to_ws( mut guacd: tokio::io::ReadHalf, ws: WsSink, recording: Option>>, server_disconnected: Arc, + session_id: Uuid, + binary_blobs: bool, + drop_aux: Option, ) -> Result<(), Box> { let mut buf = vec![0u8; 65536]; let mut carry: Vec = Vec::new(); + // Only allocated when the client asked for binary blobs; without it the + // send path below stays the byte-for-byte passthrough it has always been. + let mut splitter = binary_blobs.then(crate::binary_blob::BlobSplitter::new); + + // Gives a host that declares its colour range without describing its + // colourimetry the shape Chrome's hardware decoder will act on, bounds + // picture reordering where the POC type already guarantees there is none, + // and permits frame_num gaps once the auxiliary view is being dropped. + // See crate::h264_rewrite. + let mut sps_rewriter = crate::h264_rewrite::SpsRewriter::new(); + + // Removes the AVC444 auxiliary view from streams that prove they can + // spare it, which is most of the bandwidth argument for AVC420 without + // giving up H.264 on a Windows host. Decides per stream and leaves alone + // anything it cannot prove; RUSTGUAC_H264_AUX_DROP=0 turns it off. The + // The slice-header analysis it gates on lives inside it, so the stream is + // parsed once rather than twice. + let mut aux_dropper = + crate::h264_aux_drop::AuxDropper::for_session(drop_aux).reporting_as(session_id); + + // What the browser has last been told about the drop. It cannot infer it: + // an auxiliary IDR is kept and switches 4:4:4 combining on, after which + // every main view pays the combine's plane read-back waiting for a view + // that has been removed from the wire. See AuxDropper::dropping. + let mut announced_aux_drop = false; + loop { let n = guacd.read(&mut buf).await?; if n == 0 { @@ -553,10 +631,98 @@ async fn guacd_to_ws( server_disconnected.store(true, std::sync::atomic::Ordering::Relaxed); } + // The auxiliary view, dropped where the stream has proved that nothing + // surviving predicts from it. After the recording tee above, so a + // recording keeps the full 4:4:4 stream; before the SPS rewrite below, + // which needs to know whether to permit frame_num gaps. + let (text, aux_lines) = aux_dropper.process(&text); + for line in aux_lines { + tracing::info!(session_id = %session_id, "H.264: {}", line); + } + sps_rewriter.set_allow_frame_num_gaps(aux_dropper.wants_frame_num_gaps()); + + // Said only when it changes, which is at most twice a session: once + // when the gate arms, and once more if a later slice contradicts the + // verdict and the auxiliary view comes back. A client that does not + // know the opcode ignores it and behaves as it always did. + let aux_drop_notice = { + let dropping = aux_dropper.dropping(); + (dropping != announced_aux_drop).then(|| { + announced_aux_drop = dropping; + format!( + "{}.{},1.{};", + AUX_DROP_OPCODE.len(), + AUX_DROP_OPCODE, + dropping as u8 + ) + }) + }; + + // Splice a colour description into the SPS where the host left one + // out, bound its reordering where the POC type allows, and permit + // frame_num gaps where the auxiliary view is about to start being + // dropped. + let text = match sps_rewriter.rewrite(&text) { + Some(rewritten) => rewritten, + // Cow, because the dropper borrows a chunk it did not have to + // change -- which is most of them. + None => text.into_owned(), + }; + + // What the stream says about colour, once per session, from the first + // SPS as the host sent it -- unaffected by the splice above, since the value + // of this line is that it describes the wire. The browser's rendering + // follows from it and nothing else logs it, and a full-range host with + // crushed blacks looks the same on screen whether the stream says + // limited or says full beside an unspecified description. + if let Some(line) = sps_rewriter.take_wire_colour() { + tracing::info!(session_id = %session_id, "H.264 colour: {}", line); + } + + // Which edit was made, rather than that one was. They are independent + // and a stream can need any one alone: an xrdp host describes its + // colour completely and needs only the gaps flag, and reporting that + // as a colour splice sends whoever reads it looking at the colour of a + // picture that is fine. Each is said once per session. + for edit in sps_rewriter.take_edits() { + tracing::info!(session_id = %session_id, "H.264: {}", edit.describe()); + } + + // The recording above saw the text form; only what goes to the + // browser is rewritten. WebSocket delivers text and binary frames in + // one order, so a blob lifted out of the run still arrives between + // the same neighbours it had. let mut sink = ws.lock().await; - sink.send(Message::Text(text.into())).await?; + + // Ahead of the chunk the transition happened in, so the client has + // stood the combiner down before the first main view that arrives + // without its auxiliary view -- and, on the way back, has been told + // the auxiliary view is returning before one does. + if let Some(notice) = aux_drop_notice { + sink.send(Message::Text(notice.into())).await?; + } + + match splitter.as_mut() { + Some(splitter) => { + for frame in splitter.split(&text) { + match frame { + crate::binary_blob::OutFrame::Text(s) => { + sink.send(Message::Text(s.into())).await? + } + crate::binary_blob::OutFrame::Binary(b) => { + sink.send(Message::Binary(b.into())).await? + } + } + } + } + None => sink.send(Message::Text(text.into())).await?, + } } + // The drop's summary is written by AuxDropper::drop, not here: this line + // is reached only when guacd closes first, and a session normally ends the + // other way round. + Ok(()) } diff --git a/static/client.html b/static/client.html index 055d1961..7a010bca 100644 --- a/static/client.html +++ b/static/client.html @@ -253,6 +253,35 @@

Session Ended

throw new Error('No session ID'); } + // ---- Native (device-pixel) framebuffer sizing ------------------- + // + // Set from the session's display_scale, which the server computed when + // the session was created: it, not this page, knows whether the + // connection entry asked for a physical-pixel framebuffer. The value + // matters here only so that resize requests stay in the same units the + // session was created in -- sending CSS pixels to a session negotiated + // in physical ones would shrink it back on the first resize. + // + // scaleDisplay() needs no knowledge of it: it fits whatever framebuffer + // arrives into the available CSS area, which lands on 1/displayScale on + // its own -- one framebuffer pixel per physical pixel, no resampling. + // Mouse input follows too, since sendMouseState(state, true) divides by + // that same scale. + var displayScale = 1; + + // Converts a CSS-pixel size into the size to ask the server for. Width + // is rounded down to a multiple of 8 (SPICE requires it, RDP prefers + // it) and both are kept positive, since a zero size reads as a + // monitor-close. + function deviceSize(cssW, cssH) { + var w = Math.round(cssW * displayScale) & ~0x7; + var h = Math.round(cssH * displayScale); + return { + width: w > 0 ? w : Math.max(8, cssW & ~0x7), + height: h > 0 ? h : Math.max(8, cssH) + }; + } + // Whether asking the server for this size would actually change // anything, keyed by monitor index. // @@ -377,7 +406,17 @@

Session Ended

}; } if (session && session.entry_display_name) entryDisplayName = session.entry_display_name; + if (session && session.display_scale > 1) displayScale = session.display_scale; if (session && session.fullscreen_on_connect) wantFullscreen = true; + // Set before startGuacamole(), so the decoder sees it as the + // window override on its first auxiliary view. The entry is at + // Standard colour, which paints 4:2:0 and never combines. Marked + // as the entry's, so the decoder does not report it as a + // hand-set override someone should go and check. + if (session && session.h264_no_combine) { + window.__h264Chroma444 = false; + window.__h264StandardColour = true; + } if (session && session.autohide_side_tabs) wantAutohideTabs = true; return showBanner(session.banner); }) @@ -394,6 +433,10 @@

Session Ended

var client = new Guacamole.Client(tunnel); // Expose for console debugging window.__guac_client = client; + // Exposed alongside the client so a live session can be inspected + // from the console -- tunnel.binaryFrames says whether blob + // payloads are arriving as binary rather than base64. + window.__guac_tunnel = tunnel; setupClient(client, tunnel); } @@ -444,7 +487,10 @@

Session Ended

// whose rectangle covers that pixel. Append ?debug=draw to the URL // for live per-op logging. var DRAW_RING = []; - var DRAW_RING_MAX = 2000; + // Deep enough to still hold the failure after you notice it and + // reach the console: AVC444 records two entries per picture, so at + // 30fps this is a bit over a minute rather than half of one. + var DRAW_RING_MAX = 5000; var drawLiveLog = /[?&]debug=draw\b/.test(location.search); // Decode the draw opcodes we care about into {op, layer, rect, note}. @@ -470,6 +516,33 @@

Session Ended

return { op: 'dispose', layer: +a[0] }; case 'size': // layer,w,h return { op: 'size', layer: +a[0], note: a[1] + 'x' + a[2] }; + case 'h264': { // stream,layer,keyframe,x,y,w,h,view,numrects,[x,y,w,h]... + // Returns one entry per region rect, so a dump filtered + // by pixel finds the frame that painted it. Without the + // rects this tells us nothing useful: the picture is + // always full-surface sized, and it is the rects that + // decide what actually reaches the layer. + var view = a.length > 7 ? +a[7] : 0; + var num = a.length > 8 ? +a[8] : 0; + var note = (view === 0 ? 'main' : 'aux v' + view) + + (+a[2] ? ' KEY' : '') + + ' picture ' + a[5] + 'x' + a[6]; + if (!num) + return { op: 'h264', layer: +a[1], + rect: [+a[3], +a[4], +a[5], +a[6]], + note: note + ' (no rects, whole picture)' }; + var frames = []; + for (var hr = 0; hr < num; hr++) { + var hb = 9 + hr * 4; + if (hb + 3 >= a.length) break; + frames.push({ op: 'h264', layer: +a[1], + rect: [+a[hb], +a[hb + 1], + +a[hb + 2], +a[hb + 3]], + note: note + ' rect ' + (hr + 1) + + '/' + num }); + } + return frames.length ? frames : null; + } default: return null; } @@ -482,8 +555,12 @@

Session Ended

seenOpcodes[opcode] = true; if (typeof console !== 'undefined') console.log('[rustguac] instruction: ' + opcode + (args.length ? ' (' + args.length + ' args)' : '')); } - var d = decodeDraw(opcode, args); - if (d) { + var decoded = decodeDraw(opcode, args); + // An h264 frame decodes to one entry per region rect, so this + // takes either a single op or a list of them. + var ops = decoded ? (Array.isArray(decoded) ? decoded : [decoded]) : []; + for (var di = 0; di < ops.length; di++) { + var d = ops[di]; d.t = Date.now(); DRAW_RING.push(d); if (DRAW_RING.length > DRAW_RING_MAX) DRAW_RING.shift(); @@ -902,8 +979,10 @@

Session Ended

// (SPICE wants width as a multiple of 8). Contiguous index. Guard // against a not-yet-laid-out popup reporting 0 (a positive size is // required or the server reads it as a monitor-close). - var reqW = ((win.innerWidth || 1280) & ~0x7) || 1280; - var reqH = win.innerHeight || 800; + var popSize = deviceSize(win.innerWidth || 1280, + win.innerHeight || 800); + var reqW = popSize.width; + var reqH = popSize.height; if (reqW < 8) reqW = 1280; if (reqH < 8) reqH = 800; client.sendSize(reqW, reqH, index, 0); @@ -947,10 +1026,11 @@

Session Ended

win.addEventListener('resize', function() { if (popResizeTimer) win.clearTimeout(popResizeTimer); popResizeTimer = win.setTimeout(function() { - var w = (win.innerWidth || 0) & ~0x7; - var h = win.innerHeight || 0; - if (w >= 8 && h >= 8 && sizeWouldChange(w, h, index)) - client.sendSize(w, h, index, 0); + var rs = deviceSize(win.innerWidth || 0, + win.innerHeight || 0); + if (rs.width >= 8 && rs.height >= 8 + && sizeWouldChange(rs.width, rs.height, index)) + client.sendSize(rs.width, rs.height, index, 0); }, 500); }); @@ -1599,9 +1679,9 @@

Session Ended

var fitToWindow = function() { scaleDisplay(); var anyPanelOpen = panelOpen || filePanelOpen; - var sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth; - var sh = window.innerHeight; - if (sw > 0 && sh > 0) { + var cssW = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth; + var fit = deviceSize(cssW, window.innerHeight); + if (fit.width > 0 && fit.height > 0) { // Sent whether or not it would change // anything: the repeats above are // deliberate, covering a window where the @@ -1610,8 +1690,8 @@

Session Ended

// Recorded all the same, so that a user // resize inside the same 16px band // afterwards is correctly skipped. - sizeWouldChange(sw, sh); - client.sendSize(sw, sh); + sizeWouldChange(fit.width, fit.height); + client.sendSize(fit.width, fit.height); } }; setTimeout(fitToWindow, 800); @@ -1674,11 +1754,26 @@

Session Ended

// overlay, and the browser kept uploading thumbnails into // 404 responses for hours. Forwarding tunnel events into the // existing client.onerror closes that loop. + // Set once an error has been shown, so that the CLOSED transition + // which always follows does not replace a specific message with + // a generic one. + var tunnelErrorShown = false; tunnel.onerror = function(status) { if (typeof console !== 'undefined') console.log('[rustguac] tunnel error:', status && status.message, 'code=' + (status && status.code)); + // An exception in an instruction handler closes the tunnel + // from this end, which the server logs as an ordinary browser + // disconnect. Say what threw and where: the exception, stack + // included, here, and its message on the overlay. + var ex = tunnel.lastException; + if (ex) { + tunnel.lastException = null; + console.error('[rustguac] instruction handler threw; ' + + 'closing the tunnel:', ex); + } + tunnelErrorShown = true; if (client.onerror) client.onerror(status || { code: 519, message: 'Connection lost' @@ -1688,7 +1783,7 @@

Session Ended

if (state === Guacamole.Tunnel.State.CLOSED) { if (typeof console !== 'undefined') console.log('[rustguac] tunnel closed'); - if (client.onerror) client.onerror({ + if (client.onerror && !tunnelErrorShown) client.onerror({ code: 519, message: 'Connection lost' }); @@ -1747,10 +1842,15 @@

Session Ended

var headers = { 'Content-Type': 'application/json' }; if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey; + // CSS pixels plus the browser's pixel ratio: whether the + // framebuffer is sized in physical pixels is the connection + // entry's "Native Resolution" setting to decide, and the server + // applies it. var body = { width: window.innerWidth, height: window.innerHeight, - dpi: Math.round((window.devicePixelRatio || 1) * 96) + dpi: Math.round((window.devicePixelRatio || 1) * 96), + device_pixel_ratio: window.devicePixelRatio || 1 }; var url = '/api/addressbook/folders/' + encodeURIComponent(scope) @@ -1990,6 +2090,11 @@

Session Ended

} else { // Single display: scale the whole framebuffer to fit. + // With the framebuffer requested in device pixels this + // lands on 1/devicePixelRatio, i.e. one framebuffer pixel + // per physical pixel -- no resampling. It stays correct + // when a panel opens or the window is resized ahead of the + // debounced sendSize, just at a softer intermediate scale. var scale = Math.min(availW / dw, availH / dh); display.scale(scale); var sw = dw * scale, sh = dh * scale; @@ -2025,10 +2130,10 @@

Session Ended

clearTimeout(resizeTimer); resizeTimer = setTimeout(function() { var anyPanelOpen = panelOpen || filePanelOpen; - var sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth; - var sh = window.innerHeight; - if (sizeWouldChange(sw, sh)) - client.sendSize(sw, sh); + var cssW = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth; + var rz = deviceSize(cssW, window.innerHeight); + if (sizeWouldChange(rz.width, rz.height)) + client.sendSize(rz.width, rz.height); }, 500); }); diff --git a/static/connections.html b/static/connections.html index 8802f42e..df0a5dc1 100644 --- a/static/connections.html +++ b/static/connections.html @@ -857,6 +857,14 @@

New Entry

H.264 Passthrough
Passes H.264 video direct to browser (low latency, hardware decoded). Requires GFX enabled and xrdp with x264 on the target server.
+ +
Desktop appearance. Off by default to save bandwidth; enable for a normal-looking Windows desktop.