RDP frontend phase 3: cursor and resize¶
Master plan: PLAN-rdp-frontend.md.
Planning effort: high, as the master plan's guidance asks for a phase that turns on protocol details (RDP pointer wire formats, IronRDP's capability exchange and reactivation) and a correctness question (a desktop size negotiated before the display handler can serve it). Review at high effort: step 3 changes when the client is told which desktop size, and a mismatch there drops the connection.
Prompt¶
Read the master plan's Prompt first; everything there applies. For this phase in particular:
- The RDP side is still IronRDP 0.13.0 (
ironrdp-server0.13.0,ironrdp-pdu0.9.0,ironrdp-acceptor0.10.0,ironrdp-displaycontrol0.8.0,ironrdp-graphics0.9.0).make fetchputs their source under.cargo-cache/registry/src/index.crates.io-*/in the worktree, and every IronRDP file and line cited below is relative to the crate directory there. Read it; never build or run anything from inside it. - The SPICE reference is spice-gtk at
/srv/src-reference/spice/spice-gtk(commit112f480, 2026-10-20).src/channel-cursor.cset_cursor(:340-480) andsrc/spice-util.cspice_mono_edge_highlightare the reference for every cursor format this phase decodes. - Phase 5 (lifecycle and the
rdp-smokelane) is planned and run in parallel by another session. Both phases touchshakenfist-spice-rdp/src/{lib,display,server}.rsand the master plan's Execution table; whichever merges second rebases, and neither closes out the other. - Builds run in Docker through the Makefile, never with a host toolchain.
Scope¶
In scope:
- Renderer: decode SPICE
MONO,COLOR4andCOLOR16cursors, and honour the AND mask that followsCOLOR32pixels, as spice-gtk does. Every mode benefits. - RDP cursor shapes: the session's current cursor shape
reaches the RDP client as
DisplayUpdate::RGBAPointer, scaled down to fit 96x96 when larger; SPICE hide becomesHidePointer, and show resends the shape. - RDP resize: the client's desktop size from its Client
Core Data, and later display-control monitor layouts, become
DisplaySize::Requestedon the session's resize channel, aligned as the GUI aligns them. The guest's answer already becomesDisplayUpdate::Resize(phase 2). - The loopback test extended to see a pointer update decoded by IronRDP's own client, and a Core Data size reach the resize channel.
- A manual FreeRDP test against the desktop guest, recorded in Results.
- Documentation of the user-visible changes.
Out of scope:
PointerPosition(the master plan leaves it out on qemu-rdp's experience; the manual test records server mouse mode's behaviour so a later phase can decide).LargePointer(IronRDP 0.13.0 never sends it; see the survey) and the pointer cache (CachedPointer): every shape goes out with cache index 0.COLOR8cursors, which spice-gtk does not decode either.- Multi-monitor layouts: only the primary monitor of a layout is used (master plan Future work).
- Headless cursor tracking (
docs/multi-mode-parity.mdmarks it missing; unchanged). - The two input items phase 2 deferred (Future work there).
What the survey found¶
The master plan's phase 3 section was written before phase 2
ran. Checked against the tree and IronRDP 0.13.0 on
2026-10-22; corrections are made at the source (the master
plan's gap 6 and phase 3 section, and the index.md row) in
the planning commit, so later steps need not redo them.
- The renderer refuses more than gap 6 says.
decode_cursor_pixels(shakenfist-spice-renderer/src/channels/cursor.rs:537-548) decodes onlyALPHA,COLOR24andCOLOR32;MONO,COLOR4,COLOR8andCOLOR16logcursor: unsupported cursor typeand emit nothing, so every mode keeps whatever shape it had. Gap 6 names only mono and 4-bit. The testtest_unsupported_cursor_types_return_noneasserts the refusal of all four and must change. COLOR32ignores its AND mask. spice-gtk reads a 1-bit mask after the pixels forCOLOR32,COLOR16andCOLOR4(channel-cursor.c:360-460): a set bit makes the pixel transparent, or, where the colour is white, an inverting pixel that RGBA cannot express and spice-gtk approximates with a checker (get_pix_hack,:329-332). Ryll treatsCOLOR32's fourth byte as padding and makes every pixel opaque (cursor.rs:541,:572-578), so aCOLOR32cursor shows its transparent area as an opaque block. Not in the master plan; fixed here because it is the same mask code. spice-gtk indexes the colour formats' mask by pixel number with no per-row padding (get_pix_mask,:324-327), while the mono masks have a row stride of(width + 7) / 8(mono_cursor,:308-322); the two agree only for widths that are multiples of 8.- No shared cursor state exists.
SurfaceMirrorignores cursor events (surface_mirror.rs:269-272); the GUI reads its own channel (ryll/src/app/mod.rs:1770-1783) and--websubscribes a relay to the event bus (ryll/src/web/cursor.rs:70, subscribed atryll/src/main.rs:754).ryll/src/rdp.rscreates the bus (:213) and subscribes nothing for cursors, andSessionHandles(shakenfist-spice-rdp/src/lib.rs:64-78) has no cursor field. A decoded shape isChannelEvent::CursorShape(CursorImage)with top-down, straight-alpha RGBA pixels (channels/mod.rs:566-574); hide and show areChannelEvent::CursorPosition { visible }, sent on every move as well (cursor.rs:253-278). - IronRDP's
RGBAPointeris a 32 bpp TS_POINTERATTRIBUTE, not RGBA.rgba_pointer(ironrdp-server-0.13.0/src/encoder/mod.rs:185-205) sendsdataunchanged as the XOR mask withxor_bpp: 32and an empty AND mask. IronRDP's own client decoder reads it as bottom-up rows (ironrdp-graphics-0.9.0/src/pointer.rs:194,:220-224) of B, G, R, A with straight alpha (:401-404). So the RDP adapter must flip rows and swap red and blue. - An oversized pointer drops the client. The only size
check is the wire format's: each mask at most
u16::MAXbytes with an even scanline length (ironrdp-pdu-0.9.0/src/basic_output/pointer/mod.rs:70-109), so 32 bpp tops out near 127x127. A larger one fails to encode, and the error aborts the client loop (ironrdp-server-0.13.0/src/server.rs:1060). The server does not advertise the Large Pointer capability set (capabilities.rs:5-17) and never sendsLargePointer, so by MS-RDPBCGR the client expects at most 96x96. The master plan's "larger than 96x96 needsLargePointeror scaling" leaves only scaling on 0.13.0, and it is mandatory. request_initial_sizedoes not see the client's size by default. It is called from the capability loop only when the client's Bitmap capability set hasdesktop_resize_flag(server.rs:1407-1417), with the size from the client's Confirm Active, which echoes the server's own Demand Active size (MS-RDPBCGR 2.2.1.13.2;builder.rs:239-243). The size the client wants is only in its Client Core Data, which the acceptor reads when the builder setswith_honor_client_desktop_size(true)(builder.rs:236-263;ironrdp-acceptor-0.10.0/src/connection.rs:516-541, range 200..=8192). The master plan's "the client's initial desktop size viarequest_initial_size" needs that flag. The encoder is still built fromsize()(server.rs:1482), so the display handler must reconcile the two.request_initial_sizeruns again on every reactivation (server.rs:1739-1749rebuilds the acceptor and rerunsclient_accepted), with the client's echo of the size it was last told. Forwarding that to the guest would replace the size the client asked for with the guest's old size.- Display control needs no opt-in. The DVC is attached in
attach_channels(server.rs:683-688) with caps of one monitor;request_layoutreceives aDisplayControlMonitorLayout(ironrdp-displaycontrol-0.8.0/src/pdu/mod.rs:213) whose entries haveis_primary()anddimensions(). A layout outside 200..=8192 or with an odd width fails to decode and never arrives (:367-379). It is called onspawn_blockingwith the display mutex held (server.rs:352-356), so it must not await. - Phase 2's adapter already reconciles one mismatch.
ClientSize(shakenfist-spice-rdp/src/display.rs:44-91) records the size the client was told;MirrorUpdatesstarts from it and makes its first update a resize when the surface differs (display.rs:225-234). Honouring the Core Data size is the same situation, providedClientSizerecords the negotiated size rather than the onesize()reported before the handshake. - Resize alignment differs by mode. The GUI and the
control socket round down to a multiple of 8, minimum 8
(
align_monitor_dimension,shakenfist-spice-renderer/src/channels/main_channel.rs:135-137);--webrounds to even (ryll/src/web/inputs.rs:238-258). The main channel debounces requests for 200 ms and holds one made before the agent connects (main_channel.rs:1360-1460). - The loopback test turns pointers off
(
enable_server_pointer: false,tests/loopback.rs:206-209) and sets a client desktop size (:173) that the server currently ignores.
The master plan's other claims for this phase hold: hide maps
to HidePointer, layouts arrive through request_layout, and a
guest-driven size change is already DisplayUpdate::Resize.
Decisions¶
- Decode as spice-gtk does, inverting pixels included.
MONOthroughspice_mono_edge_highlight;COLOR4with its 16-entry palette;COLOR16as 15-bit RGB;COLOR32with its mask. Where the mask marks a white pixel as inverting, use spice-gtk's checker approximation, sinceCursorImageis RGBA and no frontend can draw XOR. Matching spice-gtk pixel for pixel means a cursor looks the same in ryll as in remote-viewer, which is the comparison users make.COLOR8stays refused. The mask row stride follows spice-gtk exactly (no padding for colour formats); the step's tests use widths that are and are not multiples of 8 so the choice is pinned. - Cursor state reaches the RDP crate as a watch channel on
SessionHandles. A newcursor: watch::Receiver<CursorState>field, whereCursorStateholds the latest shape (if any) and whether it is visible. A relay inshakenfist-spice-rdp(pub fn spawn_cursor_relay(broadcast::Receiver<ChannelEvent>)) turns the event bus into it, changing the watch only when the shape or visibility changes, not on every move. It lives in the RDP crate rather than inryllso that Kerbside, which builds its ownSessionHandles, gets it too. A watch coalesces: a client that is behind gets the latest shape, never a backlog. A lagged broadcast receiver logs once and carries on; the next shape corrects it. - Pointer updates ride the display update stream. IronRDP
has no other path.
MirrorUpdateschecks the watch on each poll and queues the pointer update ahead of that poll's bitmaps. A new stream (every connection and every reactivation) sends the current shape first, orHidePointerif hidden, because a reactivated client starts from its default pointer. - Scale large cursors down to fit 96x96, keeping the aspect
ratio, nearest-neighbour, with the hotspot scaled the same
way and clamped inside the image. 96x96 rather than the
wire format's ~127x127 because the server does not
advertise Large Pointer support, so a conforming client may
refuse anything bigger. Rows are flipped to bottom-up and
red and blue swapped; straight alpha is kept; the scanline
(
width * 4) is always even. Cache index 0 every time. - Honour the client's Core Data size with
with_honor_client_desktop_size(true), and makeMirrorDisplay::request_initial_sizedo three things: record the negotiated size inClientSize(so the stream's first update is a resize to the surface's size whenever they differ, which is phase 2's existing path), sendDisplaySize::Requestedfor it, aligned, on the session's resize channel, and return it. This is the decision most likely to be argued with. Without an agent in the guest it costs one extra deactivation-reactivation per connection (client size, then the guest's size) compared with leaving the flag off. The alternative, never learning the client's size until it sends a display-control layout, means a fullscreen client gets the guest's size and keeps it unless the client happens to send a layout, which mstsc and FreeRDP do not promise to do at connect. A reactivation at connect is cheap; a session stuck at the wrong size is the visible failure resize exists to fix. - Only the first
request_initial_sizeof a connection asks the guest. Later calls are reactivation echoes (survey item 7) and only updateClientSize. The first call is told apart by a per-connection flag reset whereClientSizeis forgotten (on accept,server.rs:510). - Layouts use the primary monitor, or the first entry when
none is flagged primary (decoding does not insist on one),
aligned the same way, sent with
try_sendbecause the callback cannot await. A full resize channel drops the request and logs once; the next layout supersedes it anyway. - Align as the GUI does:
align_monitor_dimension, a multiple of 8, which also satisfies RDP's even-width rule. The guest may then be up to 7 pixels narrower or shorter than the client window; the client shows the server's size, which is what it does today. - A client without
desktop_resize_flagis not handled specially. IronRDP's acceptor adopts the Core Data size whatever the flag says, but then never callsrequest_initial_size, soClientSizekeeps the guest's size and the stream sends nothing to correct the client. Such a client cannot follow a resize anyway; every client in the master plan's list sets the flag. Recorded under Risks. - Hold a connection's first resize for two seconds (added
after step 5; Mikal's choice). A reactivation during the
dynamic-channel handshake strands those channels for the
whole connection, and IronRDP gives no signal that the
handshake has finished. So for
FIRST_RESIZE_GRACEafter accept the update stream holds a resize for a size mismatch, and the bitmaps that would not fit, while still sending pointer updates; if the guest adopts the size the client was told meanwhile, no resize is needed at all. The rejected alternative was serving the guest clipped into the client's size with no initial resize: more invasive, and a client without an agent would keep a wrong-size desktop.
Step plan¶
Steps run in order, in the phase worktree, because each runs
make test in the same tree. Each step is one commit, with the
subject given.
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1 | high | opus | none | Renderer: decode MONO, COLOR4, COLOR16 and the COLOR32 mask. Commit: "Decode mono, 4-bit and 16-bit SPICE cursors." See brief 1. |
| 2 | high | opus | none | RDP cursor shapes: CursorState, the relay, SessionHandles.cursor, pointer updates in MirrorUpdates, wiring in ryll/src/rdp.rs. Commit: "Send the guest's cursor shape to RDP clients." See brief 2. |
| 3 | high | opus | none | RDP resize: honour the Core Data size, request_initial_size, request_layout. Commit: "Ask the guest for the RDP client's desktop size." See brief 3. |
| 4 | high | opus | none | Loopback test: a pointer update decoded by IronRDP's client, and a Core Data size reaching the resize channel. Needs steps 2 and 3. Commit: "Prove RDP pointers and resize with the loopback client." See brief 4. |
| 5 | - | - | - | Manual FreeRDP test against the desktop guest, run by the management session with Mikal, recorded in Results. Needs steps 1-3. No commit of its own; the results go in with step 6. |
| 6 | medium | sonnet | none | Documentation and the master plan. Needs step 5's results. Commit: "Document RDP cursors and resize." See brief 6. |
Brief 1: renderer cursor formats¶
Files: shakenfist-spice-renderer/src/channels/cursor.rs
(decode_cursor_pixels, :527-588, and its tests, from
:589).
- Port spice-gtk's
set_cursor(/srv/src-reference/spice/spice-gtk/src/channel-cursor.c:340-480) forMONO,COLOR4,COLOR16, and the mask part ofCOLOR32, plusmono_cursor(:308-322),get_pix_mask(:324-327),get_pix_hack(:329-332) andspice_mono_edge_highlight(src/spice-util.c). Read them in full; do not reconstruct them from this summary. Note spice-gtk works in native-endianguint32(0xAARRGGBBread as BGRA bytes on little-endian, then swapped to RGBA at:472-477); ryll's output is RGBA bytes, so get the channel order right and test it with asymmetric colours. - Keep ryll's safety rules: size buffers with
limits::rgba_len, and refuse a message shorter than the format needs (pixels plus mask, plus the palette forCOLOR4) before allocating, as:554-568does for the current formats. Compute each format's needed length with checked arithmetic. COLOR24is not in spice-gtk; leave its decoding as it is.COLOR8stays refused with the existing warning.- Tests: one per new format with a small hand-built image
(asymmetric colours, a transparent pixel, an inverting
pixel), for a width that is a multiple of 8 and one that is
not; a short message per format;
COLOR32with its mask making a pixel transparent. Replacetest_unsupported_cursor_types_return_nonewith a test thatCOLOR8and an unknown type are refused. - Mutation-check in a copy (restore from a copy, never with
git checkout), undertimeout: dropping the mask, the palette lookup's nibble order, the edge highlight, theCOLOR16channel shifts. Report the script path. make test,make lint,pre-commit run --all-files.
Brief 2: RDP cursor shapes¶
Files: shakenfist-spice-rdp/src/lib.rs (SessionHandles,
:64-78), a new shakenfist-spice-rdp/src/cursor.rs,
shakenfist-spice-rdp/src/display.rs (MirrorUpdates,
:171-300), ryll/src/rdp.rs (the event bus at :213, the
SessionHandles built at :237), and every test that builds a
SessionHandles (auth.rs, input.rs, server.rs tests,
tests/loopback.rs).
CursorState { shape: Option<Arc<CursorImage>>, visible: bool }(public,Clone,Default= no shape, visible).SessionHandlesgainspub cursor: watch::Receiver<CursorState>.pub fn spawn_cursor_relay(events: broadcast::Receiver<ChannelEvent>) -> (watch::Receiver<CursorState>, JoinHandle<()>)incursor.rs:CursorShapereplaces the shape;CursorPosition { visible, .. }changes only visibility, viasend_if_modifiedso a move that changes nothing wakes no one.RecvError::Laggedlogs once withwarn_once!and continues;Closedends the task. Inryll/src/rdp.rssubscribe before the session is spawned, as the comment at:210-212requires, and abort the task on shutdown as the other bus tasks are.- In
MirrorUpdates, keep a clone of the session's watch receiver (taken from the slot's session, re-taken if the session changes). A new stream marks the watch as changed so its first poll sends the current state. On a change, queue before the poll's bitmaps:HidePointerwhen not visible or no shape has arrived (sendDefaultPointerfor "no shape, visible" instead), otherwiseRGBAPointer. Keepnext_update's cancel-safety rule (the comment atdisplay.rs:256-262): read the watch where nothing awaited can lose it. - The conversion is a pure function, tested on its own:
RGBA top-down to BGRA bottom-up, straight alpha; images
larger than 96 in either dimension scaled down to fit 96x96
keeping the aspect ratio (nearest-neighbour), hotspot scaled
and clamped to
< width,< height; a zero-sized image yields no update. Survey items 4 and 5 give the wire facts; re-readencoder/mod.rs:185-205andironrdp-graphics-0.9.0/src/pointer.rs:190-260before relying on them. - Tests: the conversion (a 2x2 with four distinct colours
checks both the flip and the swap; a 200x100 scales to 96x48
with its hotspot scaled; 96x96 passes unscaled); the relay
(shape, hide, show, a move with no change sends nothing);
the stream (first poll sends the current shape before the
whole-surface bitmap; a change mid-stream queues one pointer
update; hidden sends
HidePointer). - Mutation-check: no row flip, no channel swap, no scaling, the relay waking on every move. Same rules as brief 1.
make test,make lint,pre-commit run --all-files.
Brief 3: RDP resize¶
Files: shakenfist-spice-rdp/src/display.rs (MirrorDisplay,
ClientSize), shakenfist-spice-rdp/src/server.rs (the
builder at :435-470, ConnectionLog::on_accept at :500-511).
with_honor_client_desktop_size(true)on the builder, in both security modes. Read its precondition (ironrdp-server-0.13.0/src/builder.rs:236-263) and survey items 6, 7 and 9 before starting.MirrorDisplay::request_initial_size(client_size): alwaysClientSize::set(client_size)(it is the size the client was just told, overriding whatsize()recorded before the handshake). On the first call of a connection only, sendDisplaySize::Requested(w, h)with both sides passed throughshakenfist_spice_renderer::channels::main_channel::align_monitor_dimension(public,main_channel.rs:135-137) on the slot's session'sresize_txwithtry_send. Returnclient_size. "First call of a connection" is a flag besideClientSize, reset by the sameforget()the connection handler already calls on accept, so the two cannot drift apart.request_layout(layout): the primary monitor'sdimensions(), or the first entry's when none is primary; aligned;try_sendon the slot's session'sresize_tx; a full or closed channel or an empty slot logs once (warn_once!) and drops it. It runs on a blocking thread with the display mutex held: no.await, noblocking_lockon the surface mirror.- Do not change
MirrorUpdates' resize logic: withClientSizeholding the negotiated size, its existing first-poll comparison sends the resize to the surface's size. Add a comment whererequest_initial_sizesets it saying so. - Tests (unit, in
display.rs): the first call sends one alignedRequestedand records the size; a second call of the same connection records but sends nothing; afterforget()the next call sends again; a stream built afterrequest_initial_size(1280x720)with a 64x48 surface starts withResize(64x48)and no bitmap before it; a layout with a non-primary first entry uses the primary; a layout with no primary uses the first entry; an empty slot drops a layout without panicking. - Mutation-check: sending on every call, not recording the size, ignoring the primary flag, skipping alignment.
make test,make lint,pre-commit run --all-files.
Brief 4: loopback test¶
File: shakenfist-spice-rdp/tests/loopback.rs. Read the whole
file first; the test at :445 is the pattern.
- Turn on
enable_server_pointerin the client config (:206-209) and update its comment. Before connecting, put a known shape in the session's cursor watch (an asymmetric 4x4); assert the client receives a pointer update and that IronRDP's client decodes it to the same pixels (the client's pointer decoding is inironrdp-session/ironrdp-graphics::pointer; find which update the connector exposes and assert on decoded RGBA, so the test proves the flip and swap end to end rather than restating the encoder). - Set the client's desktop size (
:173) to one that differs from the surface (e.g. 800x600 against the 64x48 test surface) and assert thatDisplaySize::Requested(800, 600)arrives on the session's resize receiver, and that the session is still served at the surface's size after the reactivation (the existing reactivation assertion). - If the connector cannot expose pointer updates without a large harness change, stop and report rather than building one; the management session decides.
- Mutation-check: the stream sending no pointer, the honour flag off. Same rules as brief 1.
make test,make lint,pre-commit run --all-files.
Brief 6: documentation and the master plan¶
Files: docs/multi-mode-parity.md (the RDP row of the mode
table, :26), docs/web-frontend.md and
docs/spice-protocol.md only where they state which cursor
formats are decoded or how resize requests are made,
docs/configuration.md only if a flag changed (none is
planned), docs/plans/PLAN-rdp-frontend.md (the phase 3
section's acceptance and any open question this phase
answers), and this plan's Results.
- The mode table's RDP row says display and input only; make it name cursor shapes and resize too. The per-feature RDP column is phase 6's (it lands with the operator guide), so do not add it here; record the cells it will need (cursor shape scaled to fit 96x96, position not sent, hide and show, resize from the client's initial size and display-control layouts with vdagent in the guest) in this plan's Results for phase 6 to copy.
- The mono, 4-bit, 16-bit and masked 32-bit cursor decoding is user-visible in every mode; say so where cursor support is described.
- No phase or decision citations in code or docs outside
docs/plans; no dependency versions indocs/. pre-commit run --all-files.
Risks and mitigations¶
- The negotiated size and the encoder disagree and the
client drops. Honouring Core Data makes the client's
desktop the client's size while the encoder is built from
the surface's. Mitigation:
ClientSizerecords the negotiated size so the first update is the resize (brief 3's "no bitmap before the resize" test), the loopback test runs the whole sequence with IronRDP's client (brief 4), and the management session runs FreeRDP at a size different from the guest's (step 5). - A client without
desktop_resize_flagis told its own size and sent the surface's bitmaps (decision 9). Mitigation: none in code; the manual test notes the flag FreeRDP sends, and if a tested client lacks it, step 3 is revisited before the PR. - Pointer bytes in the wrong order show a garbled or upside-down cursor that unit tests restating the encoder would not catch. Mitigation: brief 4 asserts on IronRDP's client-side decode, and step 5 checks the cursor visually.
- Mono cursors are untested against a real guest. The desktop guest (XFCE) sends alpha cursors. Mitigation: unit tests against spice-gtk's algorithm; step 5 records which formats the guest sent (the renderer's debug log), and a Windows guest test is recorded as not done if none is available.
- Reactivation drops the display-control channel and later layouts never arrive. Mitigation: step 5 resizes the FreeRDP window after the first reactivation and checks the guest follows.
- Conflicts with phase 5, which edits the same files. Mitigation: whichever merges second rebases; the conflict is in code both sessions understand, and neither phase closes out the other.
Definition of done¶
-
grep -n 'COLOR8\|unsupported cursor type' shakenfist-spice-renderer/src/channels/cursor.rsshowsCOLOR8as the only named refused format, and cursor tests forMONO,COLOR4,COLOR16and maskedCOLOR32pass undermake test. -
SessionHandleshas acursorfield,ryll/src/rdp.rssubscribes a cursor relay before spawning the session, andgrep -n 'RGBAPointer\|HidePointer' shakenfist-spice-rdp/src/display.rsfinds both. -
grep -n 'with_honor_client_desktop_size(true)' shakenfist-spice-rdp/src/server.rsfinds it, andMirrorDisplayimplementsrequest_initial_sizeandrequest_layout. - The loopback test enables server pointers, asserts a
decoded pointer and a
Requestedsize, and fails when the stream's pointer update or the honour flag is removed (mutation, management session). - Every mutation listed in briefs 1-4 is killed; the scripts' paths and counts are in Execution notes.
-
make build,make test,make lint,make check-windows,make package-checkandpre-commit run --all-filespass. - Results records, with FreeRDP's version and the date:
the cursor changing shape over a text field and a window
edge, hiding where the guest hides it, FreeRDP started at
a size other than the guest's and the guest adopting it,
a window resize after that adopting too, the
desktop_resize_flagFreeRDP sent, the cursor formats the guest sent, and server mouse mode's cursor behaviour. Closed out unmet on 2026-10-25, by Mikal's decision: the guest adopting a requested size and server mouse mode's cursor were not observed (see Execution notes); both are under Future work. -
docs/multi-mode-parity.md's RDP row (:26) names cursor shapes and resize, and Results lists the RDP cursor and resize cells phase 6's column will need. -
git grep -n -i 'phase 3\|decision [0-9]\|survey item' -- shakenfist-spice-rdp shakenfist-spice-renderer/src ryll/src docs ':!docs/plans'finds nothing.
Results¶
Manual test: FreeRDP against the desktop guest¶
2026-10-23, 07:40 to 12:52 AEDT, on the Kasm host, with Mikal
watching and driving the pointer. Guest: debian-xfce:13 under
tools/start-desktop-qemu.sh (QXL, vdagent) on SPICE port
5910, in client mouse mode, at 1024x768. Server: a debug build of
ryll --rdp --rdp-port 0 --direct 127.0.0.1:5910, with
--verbose for the later runs. Client: xfreerdp3 (FreeRDP
3.15.0) on the same host, /cert:tofu /dynamic-resolution,
started at 1600x900, 1440x900, 1280x960 and 1152x864 on
successive connections.
- Cursor shapes: correct. Over the desktop, a terminal and a
window edge the pointer took the guest's arrow, I-beam and
resize shapes, the right way up and in the right colours. The
guest sent only
ALPHAcursors (type 0), all 64x64, so none was scaled; the mono, 4-bit and 16-bit decoders and the scaling are covered by unit tests only, and no Windows guest was available. - Initial size: FreeRDP sets
desktop_resize_flag. Each client was told the size it asked for, the guest was asked for it (aligned to 8, so 1600x900 became 1600x896), and the client was then resized to the guest's 1024x768: FreeRDP's window shrank to match. - The guest never changed resolution, for ryll's requests or for remote-viewer's "resize guest to window": this guest image does not apply client-driven monitors configs, so the guest adopting the requested size could not be observed end to end. The loopback test covers the RDP side.
- Defect found and fixed: the first runs showed every dynamic virtual channel (FreeRDP's Advanced Input, DisplayControl, ECHO) left pending for the whole connection, so window drags sent nothing. The server's log showed the client's dynamic-channel replies "received and lost" during the reactivation that the immediate first resize caused. IronRDP starts those channels only on the first activation and drops client PDUs while reactivating. Fixed by decision 10; after it the channels open, the resize follows two seconds after accept, and no PDU is lost.
- Layout echo found and fixed: FreeRDP reports its window's
new size as a layout after every server resize, which passed
the guest's own old size back as a request. Layouts of the
size the client already has are now ignored (
139dc98). After both fixes, Mikal's window drag reached the guest as a 1488x968 monitors config. - Middle click worked through FreeRDP's Advanced Input channel once that channel opened.
- Server mouse mode: not tested this phase.
- mstsc and a Windows guest: not tested (none available).
Cells for phase 6's RDP column¶
For docs/multi-mode-parity.md's per-feature RDP column, which
lands with the operator guide:
| Feature | RDP |
|---|---|
| Server cursor rendering (SET/INIT) | available (the shape is sent as an RDP pointer; larger than 96x96 is scaled down to fit) |
| Cursor position tracking | n/a (the client draws its own pointer where its mouse is; the guest's position is not sent) |
| Cursor hide/show | available |
| Default arrow fallback cursor | available (the client's own default pointer until the guest sends a shape) |
| Guest resolution follows viewport | available (the client's initial size and its display-control layouts, with vdagent in the guest; a connection's first resize waits up to 2 s) |
Execution notes¶
- Back brief, 2026-10-23 AEDT. Mikal confirmed decision 5
(honour the client's Core Data size); the management session
may start the desktop guest with
make test-qemu-desktopfor step 5 and stop it afterwards; neither a Windows guest nor mstsc is available, so those Results cells read "not tested"; this session may push and open the pull request once the definition of done passes. - Steps 1-3 ran in order as planned, each an Opus sub-agent
at high effort, reviewed and committed by the management
session. Mutation scripts, run on copies under
timeout: step 1, 23 mutants, all killed; step 2, 12, all killed; step 3, 9, all killed (scripts under the session scratchpad'sp03/step1-3/mutate.py). The cargo helper the scripts used ran cargo as the container's PID 1, which ignorestimeout's TERM, so one step 2 run hung until stopped by hand; the helper now passes--init. - Step 3 departure:
ironrdp-serverdoes not re-exportDisplayControlMonitorLayout, soshakenfist-spice-rdpnow depends onironrdp-displaycontrol0.8 directly; it was already inCargo.lockthroughironrdp-server, so the lock gains an edge, not a crate. - Step 3 edge case, accepted: a client whose Core Data size is outside RDP's 200..=8192 range is told the size reported before the handshake (in TLS mode the fallback, since the slot is empty then), and that echo is taken for the size it asked for, so the guest is asked for it. Rare, and the client's next display-control layout supersedes it.
- Step 4 ran as planned (Opus, high); five mutants, all killed. The loopback client decodes pointers itself, so no harness change was needed.
- Step 5, 2026-10-23 AEDT, found two defects, fixed before
step 6 as step 3b (
fca07c7, Opus, high: decision 10, seven mutants, all killed) and a management-session fix for FreeRDP's layout echo (139dc98, one mutant, killed). See Results. Mikal chose the hold over serving the guest at the client's size or deferring the fix. An issue for IronRDP describing the dropped dynamic-channel replies was drafted for Mikal to file. - Step 5 interruption: the desktop guest was stopped part
way through by another session, most likely through the
/tmp/ryll-test-qemu.pidfile every checkout'smake test-qemu-*targets share. It was restarted withQEMU_PID_FILE=/tmp/ryll-p03-qemu.pidandQEMU_QMP_SOCKET=/tmp/ryll-p03-qmp.sock, and stopped by its own PID at the end, sincemake test-qemu-stopalso removes a shared OVMF vars copy. - Rebased onto
origin/develop(12 commits ahead, including #608-#610) before pushing; the one conflict, both sides adding a row todocs/plans/index.md, was resolved by keeping both. - Definition of done, checked 2026-10-23 13:04 AEDT on the
rebased tree:
make build,make check-windowsandmake package-checkexit 0 (the Windows check's four warnings are insession.rsandbugreport.rs, which this phase does not touch);make test1630 passed, 0 failed;make lintandpre-commit run --all-filespass.COLOR8is the only named refused cursor format;SessionHandles.cursorexists andryll/src/rdp.rssubscribes the relay (:219) before spawning the session (:295);display.rssendsRGBAPointerandHidePointer; the server setswith_honor_client_desktop_size(true)andMirrorDisplayimplements both resize hooks; the loopback test enables server pointers and its mutants are killed; every step's mutants are killed (steps 1, 2, 3, 3b and 4: 23, 12, 9, 7 and 5, plus the layout echo's one); the parity row names cursor and resize and Results carries phase 6's cells; the citation grep finds nothing. Not met: the Results item, which asks for the guest adopting the requested size and for server mouse mode's cursor behaviour. The guest image ignores client resizes from any client, and server mouse mode was not run; both are under Future work.
Future work¶
PointerPositionin server mouse mode, if step 5 shows the client's pointer drifting from the guest's.- The pointer cache (
CachedPointer) for guests that switch between a few shapes often. LargePointeronce an IronRDP release sends it, so HiDPI cursors need not be scaled.- Drop the first-resize hold once IronRDP keeps the client's dynamic-channel replies across a reactivation (or restarts unfinished channels); the hold costs a client of another size up to two seconds of blank desktop at connect.
- A desktop guest image that applies client-driven monitors
configs, so resize can be tested end to end:
debian-xfce:13ignores them from ryll and from remote-viewer alike. - Per-checkout PID and QMP paths for the Makefile's
test-qemu-*targets, so concurrent sessions cannot stop each other's guests. - Server mouse mode's cursor behaviour, untested this phase.
Back brief¶
Before executing any step of this plan, back brief the operator as to your understanding of the plan and how the work you intend to do aligns with it. Gates that need Mikal's answer before the steps that depend on them:
- Decision 5 (honouring the client's Core Data size, at the cost of one reactivation per connection without an agent); confirm before step 3.
- The desktop guest on SPICE port 5910, with vdagent running,
for step 5; FreeRDP 3 (
xfreerdp3,sdl-freerdp3) is installed on this host. - Whether a Windows guest (for mono cursors) or a Windows machine with mstsc is available for step 5; if not, those cells in Results say "not tested".
- Whether this session may push and open the pull request at the end.