RdpClient

Architecture

Layout

src/
  RdpClient.Protocol/    Protocol core - no UI dependency, plain .NET
  RdpClient/             WinUI 3 front end - Windows only
  RdpClient.Cli/         rdpprobe: console handshake harness
tests/
  RdpClient.Protocol.Tests/
tools/                   rdp-tap.mjs and friends
docs/

Why the core is a separate assembly

RdpClient.Protocol has no reference to WinUI, Windows App SDK, or any UI type. It targets plain net10.0. That boundary exists for one reason: WinUI 3 only exists on Windows, so a UI-coupled protocol layer could never be reused by another front end. Keeping the codec and PDU encoders UI-free means a cross-platform client is a matter of writing a different shell, not rewriting the protocol.

The WinUI project references the core through an ordinary ProjectReference. Nothing in the core knows the reference exists.

Module responsibilities

ByteCodec.cs

ByteWriter and ByteReader. Everything in the project that touches bytes goes through these two types, because RDP mixes several integer encodings and a field written at the wrong width shifts everything after it.

The one non-obvious rule: TPKT lengths are big-endian, everything else is little-endian. U16BigEndian exists solely for that field.

MCS integers are plain fixed-width values with no T.125 selector bits, so McsVariableByte writes the narrowest representation and the reader takes an explicit width. An earlier version implemented PerEncoding selectors; that was wrong for RDP and was removed.

Rdp.cs

Constants transcribed from MS-RDPBCGR, grouped by section with the section number in a comment. Keeping them in one file makes them auditable against the published tables without reading encoder code.

Negotiation.cs

TPKT framing, the X.224 class 0 request/confirm envelopes, and the RDP_NEG_REQ / RDP_NEG_RSP exchange. Tpkt and X224ConnectionRequest are the only places that build TPKT, so the endianness rule has exactly one home.

The connection-confirm parser deliberately skips a fixed five bytes after the X.224 code rather than trusting the length indicator, because RDP servers send 0x0E regardless of what follows.

Mcs.cs

Connect Initial/Response, the GCC Conference Create Request, and the domain-attach / user-attach / channel-join PDUs.

ClientPdu.cs

TS_UD_CS_CORE, SECURITY, NET and CLUSTER. All of them begin with the same 4-byte TS_UD_HEADER - type and length, with no flags field - and the declared length is the size of the whole block including that header. The length is patched in once the variable-length fields are written, because a reader walks it to find the next block and a declared length that disagrees with the real one desynchronises everything after it rather than failing.

CORE is 234 octets and carries three fixed-width NUL-padded fields (clientName, imeFileName, clientDigProductId).

There is no TS_UD_CS_USER in MS-RDPBCGR. BuildUser produces the classic-RDP structure of that name, which predates the specification, sends a password in the clear, and is ignored by xrdp; it is opt-in through RdpConnectionOptions.SendClassicUserBlock.

RdpConnection.cs

Sequences the exchange over TCP and reports progress through OnEvent.

Events fire on the socket’s continuation thread. MainPageViewModel captures the UI DispatcherQueue and marshals before touching observable state.

RdpConnectionOptions.UseCorrelationId and LogTx exist because the framing defects were only diagnosable with a byte dump, and the fixes needed to be reproducible.

The rest of the handshake

ClientInfo.cs builds the Client Info PDU, Capability.cs builds and parses the Demand Active / Confirm Active exchange, and ConnectionFinalisation.cs holds the Synchronize, Control and Font List PDUs plus the Share Data PDU framing that every later update arrives inside. Security/ carries TlsChannel, CredSsp, Ntlm/NtlmAuthenticator, Md4 and Rc4.

The graphics path

Six files, in the order a pixel travels through them:

File Responsibility
BitmapUpdate.cs Reads an update body’s updateType and dispatches it; owns the rectangle type the surface paints
UncompressedBitmap.cs The raw bitmap form
RleBitmap.cs The bitmap RLE form (MS-RDPBCGR 2.2.2.1)
BulkDecompressor.cs The RDP 6.0 bulk-compressed stream
OrderDecoder.cs MS-RDPEGDI drawing orders, walked field by field against a SpanReader
ScreenSurface.cs In-memory desktop: fill, copy, cached-bitmap blit, ToBmp

OrderDecoder is the one that does not yet work — see “The order stream from xrdp does not decode yet” in AGENTS.md. It is also the one whose failures are hardest to see: a decoder one octet out of step keeps producing plausible order names, and OrdersRendered can rise for several hundred orders before the stream drifts. So it also reports OrdersInLastUpdate and BytesLeftAfterLastUpdate, and the two checks that actually catch a desync are whether the decoded count equals numberOrders on the wire and whether the last order’s type is one MS-RDPEGDI defines.

Design decisions worth knowing

Stop at a protocol boundary rather than guess. When the server selects CredSSP, ConnectAsync returns Failed with an explanation. Sending an MCS Connect Initial anyway would be a protocol violation, and the resulting reset would look like a bug rather than a missing feature.

A stage is not complete until the reply has been read. Each step waits for the server’s Confirm and parses it, and a refusal is reported with the server’s own reason. The alternative is worse than useless: an earlier version sent the Erect Domain, Attach User and Channel Join requests, read none of the replies, and reported a completed channel join against a server that had rejected the first PDU. A client that cannot tell a refused connection from an accepted one cannot be diagnosed at all.

The Attach User Confirm is not optional bookkeeping, either. It is the only place the server states which user id it assigned, and the Channel Join Request has to carry that id - a conventional 1001 is rejected by a server that did not attach that user.

Credentials never touch the log. rdpprobe reads the password from RDP_PASSWORD and never echoes it. .gitignore excludes *.rdp and .env.

The password is a string. TS_UD_CS_USER needs the UTF-16 byte length before the bytes, so the secret must exist as a string. A client that implemented CredSSP would not hold it at all — the TLS channel would. This is recorded as a known limitation in the README’s security section.

Testing

Wire-format tests are pinned to bytes captured from mstsc.exe. Each framing defect found during development now has a test named after the rule it broke, so a regression points straight at the specification section.

Tests assert structure and exact bytes, not behaviour against a live server, so they run offline in under a second.

Not implemented, by design

The RSA key exchange and session-key derivation (MS-RDPBCGR 5.3.2), encryption of the MCS traffic, and the whole input stack are absent. Security/Rc4.cs exists for the NTLM session key and nothing wires it into the connection yet. Each of these is a substantial subsystem, and stopping before one is more useful than half-building it — the stopping point is reported accurately rather than hidden.

Two things that used to be listed here are implemented now: the capability exchange and connection finalization, both verified against xrdp. The graphics pipeline is implemented too, but it does not produce a picture — updates are read and composited into a ScreenSurface, while the drawing orders xrdp sends do not decode. That is recorded as a failure with its measurements in AGENTS.md rather than as progress.