Skip to content

Remote Link Internals

How PadForge PCs share devices: UDP discovery, the X25519 and Ed25519 pairing handshake with a six-digit code, the sealed datagram transport, input streaming, the reverse output relay, and the internet lane that reaches a peer with no VPN and no port forward.

This is the developer-side companion to Remote Link (the user guide), issue #138, and issue #294.


Layout

The engine logic lives in PadForge.Engine/RemoteLink/ (namespace PadForge.Engine.RemoteLink). The App wires it up and provides the capture taps.

Area Files
Discovery LinkDiscovery.cs
Transport and lifecycle LinkServer.cs, LinkConnection.cs, LinkConnectionLifetime.cs, LinkAdmissionCoordinator.cs, OwnedLinkCallbacks.cs, LinkSession.cs, TcpControlChannel.cs, UdpControlChannel.cs, AntiReplayWindow.cs
Internet lane (#294) LinkCode.cs, StunClient.cs, NatProfile.cs, PortPredictor.cs, HolePuncher.cs, PunchedConnection.cs, RendezvousProtocol.cs, IrohRelayClient.cs, Dht/
Crypto LinkHandshake.cs, PeerCrypto.cs, PeerIdentity.cs, IdentityProtector.cs
Trust PeerTrustStore.cs, PeerTrust.cs
Devices and codecs RemotePeerDevice.cs, CustomInputStateCodec.cs, OutputEffectCodec.cs
Remote assignments LinkAssignments.cs, PadForge.App/Services/RemoteAssignmentService.cs, PadForge.App/Views/RemoteAssignmentsDialog.xaml.cs
App relay and wiring PadForge.App/Common/Input/RemoteLinkOutputRouter.cs, PadForge.App/Services/InputService.cs, SettingsService.cs
Capture taps InputManager.Step2/Step4b/Step5/Step1.*.cs, PlayStationEffectWriter.cs, HapticToneService.cs, AudioPassthroughService.cs

The transport is abstracted behind ILinkControlChannel, so an in-memory test harness, the TCP socket (TcpControlChannel), and the punched UDP path (UdpControlChannel) share one path.


Discovery

LinkDiscovery is a UDP broadcast beacon, not mDNS. It binds IPAddress.Any:27501, announces every two seconds to IPAddress.Broadcast:27501, and prunes a peer after ten seconds of silence. Because it broadcasts, it is same-subnet only. The beacon carries a four-byte magic, a version, the link port, the sender's identity fingerprint (hex), and the self-asserted machine name. A forged beacon can only put a name in the Nearby PCs (Not Paired) list. The crypto gate still controls admission. The receive loop filters out the PC's own fingerprint and raises PeersChanged. The discovered-peer table caps at 256 entries so a beacon flood with rotating fingerprints cannot grow it without bound. A known peer keeps refreshing and is never evicted by the cap.

Discovery is a convenience, not the only way in. The Dashboard also carries a manual "Or Connect by Address (Advanced)" field with a "Pair / Connect" button, which raises DashboardViewModel.ConnectToPeerRequested into InputService.OnConnectToPeerRequested. That handler enables and starts the link server if it is off, splits an optional :port suffix off the typed host (falling back to the configured link port), builds the exposed-device list, and calls LinkServer.ConnectAsync. The auto-reconnect dial below runs through the same handler, so a hand-typed address and a discovered peer take an identical path across a subnet boundary.

The same field also accepts a connection code, which is the internet lane (#294). LinkCode.LooksLikeCode and LinkCode.TryParseSelfContained sort the two apart before anything else: an entry with a dot or a colon is never a code, and a bare name that does not parse as a self-contained code goes to the address path.


Server, connection, and the sealed transport

LinkServer runs a TcpListener for the pairing handshake and a UDP socket for data, both on the link port (default 27500). It caps pending handshakes, times them out after three minutes, sends keepalives, and reaps idle connections. A reconnecting peer replaces its prior connection.

It holds a List<LinkPeerConnection>, so one PC can be linked to many peers at once. The mesh is many-to-many: pairing is pairwise (one handshake and one six-digit code per pair), but PushLocalFrame fans a shared device's input out to every consuming peer, and PushOutputEffect and PushAudio send the reverse frame through the connection that advertised the device, checked against that device's link slot and id, so each device's feedback routes back to the right owner. There is no two-PC cap.

LinkConnection runs the handshake (initiator or responder) and returns the data key, and LinkServer builds the connection's LinkSession from it. LinkSession seals every datagram: a 14-byte authenticated header (message type and epoch, slot, a 32-bit sequence, a microsecond timestamp) plus ChaCha20-Poly1305 ciphertext and a 16-byte tag. The sequence doubles as the nonce counter, and the two directions use disjoint salts so both peers share one key without a nonce collision. Receive is verify-then-window: check the tag, then run AntiReplayWindow, so a forged sequence never advances the replay state. The session refuses to seal once the 32-bit sequence would wrap, so going on takes a fresh handshake.

LinkMessageType values: Input (an absolute device-state frame, owner to consumer), Keepalive, Output (a tagged effect frame, consumer to owner), Audio (a speaker PCM block), DeviceList (the owner's exposed-device set, resent every two seconds for hot-plug sync), SourceDemand (a consumer-to-owner report that a live mapping on the consumer polls a demand-gated source on this device, #241, see below), PathOffer (either side's current candidate endpoints, sent over a relayed session only so a relay-started session can upgrade to a direct path, #294), and Assignments (a device owner's queries and edits of which virtual controllers its shared devices feed on the peer, which the peer applies only when its trust entry for the owner allows remote assignments). A legacy Haptic type is superseded by Output and unused. The initial device lists are also exchanged during the handshake under a separate control key. Each V1 device entry carries the stable slot, a stable id, the name, VID and PID, capability counts, a capability bitmask, and the input-device type. The capability bitmask packs eight flags into one byte: HasRumble (value 1), HasRumbleTriggers (2), HasGyro (4), HasAccel (8), HasTouchpad (16), HasHaptic (32) so a remote wheel or FFB stick's force-feedback pipeline runs, Online (64) so active/inactive propagates, and HasAccelAux (128) so an aux/left-side accelerometer (Nunchuk, left Joy-Con, #199) exposes. HasAccelAux (value 128) exhausts the caps byte, so the capabilities that arrived later ride a second capability byte in the 0xE4 device-list tail below rather than another bit.

The source-demand lane (#241)

Demand latches are machine-local. SourceCoercion stamps them where the mapping evaluates, so a consumer's NFC binding could never arm the owner's reader on its own. The SourceDemand datagram closes that gap. Payload byte 0 is the demand kind: 1 is the NFC reader, 2 the Ring-Con (DemandKindRingCon), and 3 the right Joy-Con's IR camera (DemandKindIr). An owner that predates a kind ignores it.

  • The consumer ships demand (RemoteLinkOutputRouter.ShipNfcDemand) for every online reader-capable peer:// device while its own NFC demand latch is fresh or tag registration is capturing. The router rate-bounds it to one datagram per device per second, since the owner treats each arrival as a fresh stamp.
  • The owner (InputService.OnRemoteFrameReceived, on LinkServer.FrameReceived) resolves the frame's device id to the shared device and stamps a per-device wall-clock mark. The arming cadence reads that mark exactly like the local latch, inside the same ten-second demand window, so the same teardown and Bluetooth gate apply.
  • There is no off message. A lapsed demand is the off signal, matching the local latch's own expiry contract.
  • The Ring-Con and the camera follow the same pattern (hifihedgehog/SDL#33). While the consumer's own "Ring-Con Squeeze" / "Ring-Con Pull" or "IR Brightness" latch is fresh, InputService ships kind 2 (ShipRingConDemand) or kind 3 (ShipIrDemand) for every online right Joy-Con or combined pair that is a peer:// device, each on its own one-second clock. The owner stamps per-device marks (_remoteRingConDemandMs, _remoteIrDemandMs) that arm its Ring-Con and camera hints exactly like a local read. A tag registration capture on the owner still holds its own camera off.

Device-list metadata extension

After the V1 records, EncodeDeviceList appends a metadata extension marked by a magic byte (0xE2), one section per V1 record in the same order. It carries the serial number, the touchpad shape (NumTouchpads plus a finger count per pad), and the forwarded DeviceObjects (the owner's named inputs: Name, InputIndex, Offset, an ObjectType flags word, and the ObjectTypeGuid). So serial, touchpad shape, and named inputs now cross the link, and a remote device lists on the Devices page and in the mapping picker identically to a local one.

The extension uses the same appended-tail compatibility as the input codec. An old decoder reads its count V1 records and never looks at the tail. A new decoder facing an old sender finds no magic byte. A malformed extension tail falls back to the V1 result: any parse failure resets every extension-carried field on every record, so a mid-record throw leaves no half-applied garbage, and the consumer synthesizes generic names as before.

Objects are forwarded only for device types whose names the consumer cannot synthesize locally: Consumer Control (per-session dynamic slots), NFC (tags named from the owner's registry), Microphone (#317, buttons named from the owner's phrase registry), Handheld Buttons (#343, every button a name from the owner's learned set), Head Tracker, Tablet, VR Controller, and Logitech G-Keys (key names from the owner's Logitech software). A gamepad, joystick, keyboard, mouse, or MIDI shape is synthesized identically on the consumer, so shipping its objects would spend budget for nothing. The consumer builds a keyboard's and a mouse's objects with the local wrappers' builders (SdlKeyboardWrapper.KeyObjects, SdlMouseWrapper.MouseObjects). It used to name them as a gamepad's, so a shared keyboard's key 0 read "A". The push travels as one UDP datagram into a 4 KB receive buffer on old peers, so the encoder caps the payload at 3800 bytes and gives no one device more than half of it. Each device ships the named inputs that fit its share, so one large row cannot starve the rows after it. The cap covers the whole payload: the room for a device's names leaves out what the devices after it and every tail still need. It once counted only what was already written, so a list whose names filled it went past it by every later section and tail. Once the budget runs out, the remaining devices get empty object sections and the consumer falls back to synthesized names for them, rather than the whole list becoming undeliverable. The budget targets old peers only: the current receive loop (LinkServer.UdpLoopAsync) takes datagrams into a 64 KB buffer, the UDP maximum, so an oversized frame can no longer vanish into a receive error.

Eleven more tails follow the 0xE2 extension, each marked by its own magic byte and appended in order:

Magic Tail Carries
0xE3 (DeviceListExtV2Magic) Raw button count One clamped byte per device: RawButtonCount, so the consumer's mapping picker offers the device's native buttons past the 22 standardized gamepad slots. RemotePeerDeviceInfo.RawButtonCount and RemotePeerDevice.SupportedButtonIndices carry it through the pipeline.
0xE4 (DeviceListExtV3Magic) Second capability byte The post-exhaustion capability flags: HasNfcReader (value 1, #241), HasGyroAux (2, #252), and HasExtraGenericAxes (4, #193 over the wire), plus the touchpad's pressure and click answers (8 marks TouchpadPressureSupported known and 16 carries it, 32 marks TouchpadClickSupported known and 64 carries it). 128 marks a device whose SDL gamepad layout is not what its type says (RemotePeerDeviceInfo.GamepadLayout): one of another type that the owner reads in the layout, or one typed a gamepad that the owner reads outside it. With the bit clear the type answers, which is all a peer from before the bit reads. The byte is now full.
0xE5 (DeviceListExtV4Magic) Raw axis count One clamped byte per device: RawAxisCount, the axis twin of RawButtonCount, so a remote fight stick's or DS3's extra analog axes list in the picker.
0xE6 (DeviceListExtV5Magic) Sender machine name One string for the whole list, not per device. Device rows are suffixed with the owning peer's name, and that name used to arrive only through LAN discovery, so the code and punch paths left every remote device unlabeled.
0xE7 (DeviceListExtV6Magic) Supported button set Per device: [maskLen][maskLen bytes], an LSB-first bitmask over button indices. maskLen 0 means dense (every index below the raw count). NumButtons is the standardized 22-slot space, not a physical count, so only the owner knows which of slots 11-21 exist.
0xE8 (DeviceListExtV7Magic) Supported axis set plus SDL GUID Per device: the same bitmask shape over axis indices, then the SdlGuid string. NumAxes is pinned to 6 for every SDL gamepad, so without this a stickless pad advertised phantom stick axes and the default profile auto-bound to them.
0xE9 (DeviceListExtV8Magic) Explicit-empty flags One byte per device: bit 0 set when the supported button set is empty on purpose, bit 1 when the supported axis set is. The 0xE7 and 0xE8 masks spell dense and empty as the same single 0 byte, so without this flag a pad the owner knows has no sticks arrived advertising all six.
0xEA (DeviceListExtV9Magic) Analog key order For the rows that carry one (#468): [records], then per record [record index][u16 count][u16 code]*, codes 1 to 0x6FF, each once. The owner's key list for an analog keyboard, its route's and every key the row has reported since, so a peer's copy lists the keys the keyboard has instead of the catalog's list for its VID and PID.
0xEB (DeviceListExtV10Magic) Bliss-Box rest mask For the Bliss-Box ports the owner reads raw (#469): [records], then per record [record index][mask], bit N set when native axis N rests at 0 because the controller in the owner's port names it a trigger (BlissBoxRuntime.NativeRestMask).
0xEC (DeviceListExtV11Magic) DualShock 3 identity For the DualShock 3 rows whose identity the owner knows: [records], then per record [record index][string], the owner's Ds3UnitIdentity.Identity ("address/source", at most 64 characters). A peer's copy hands it to its gyro calibration (RemotePeerDeviceInfo.Ds3Identity, which Ds3UnitIdentity.Identity answers for a RemotePeerDevice), so the receiving PC keys and bounds a calibration as the owner does. The reconcile copies a changed identity onto the existing row (LinkServer.ReconcileRemoteDevices).
0xED (DeviceListExtV12Magic) Capacitive sensors For the pads that have any: [records], then per record [record index][mask], the owner's UserDevice.CapSenseChannelMask, bit N for SDL_GAMEPAD_CAPSENSE channel N (the stick tops of the Steam Deck and the 2026 Steam Controller, and the 2026 pad's grips). The sensors' state already crosses in the input codec's CapSense block. A peer's copy lists a sensor under the pad, and its Valve automap binds it, only where this mask has it (RemotePeerDeviceInfo.CapSenseChannelMask). Without it a shared pad offered its sensors under (Any Device) alone.

The last four are written only when some row carries one, so a list without an analog keyboard, a raw port, a known DualShock 3 or a pad with capacitive sensors is byte for byte what it was before them, and only while the list with them fits its budget without a single name: the 3800-byte cap on the connect-time list, the one list an older peer can receive, and the full-list limit on a publication to a peer that reads full lists (EncodeDeviceList's tailBudget). They are admitted in order, the rest masks first, then the identities, then the capacitive sensor masks, then the key lists. The names get the room the 3800-byte budget leaves them either way, so a tail only a full-list peer takes never costs a device its names. The identity tail is read only while the cursor through the metadata sections stayed trustworthy. A malformed one clears every identity rather than half of them and leaves the capacitive sensor tail after it unread. They never push a list an old peer could read past what it can. Each parses whole before any row takes a value, and refuses an index past the device count, a repeated index, and a code out of range or repeated.

Each update after connect travels as one UDP datagram, so the list a peer receives depends on its version. A peer older than 3.6.0 reads the datagram into 4 KB and parses the v1 records alone. The metadata extension and the 64 KB receive buffer both first shipped in 3.6.0, so a peer whose connect-time list carries 0xE2 reads full lists (LinkConnectionResult.PeerReadsFullLists, handed to the LinkConnectionLifetime). A peer without it gets the records alone (EncodeBasicDeviceList) within the 3800-byte cap. A full list is bounded by the UDP payload limit less the seal (MaxListPayload). A list past either bound is not sent: PublishLocalInventory returns TooLarge, the peer keeps its list and its input bindings, and a rekey counts the result as ready. The slots the list reserved for new device ids go back (LinkSlotReservations.Release), since the peer never saw them and the 256 slots bound a session. A list past 4 KB used to go to an old peer anyway, which dropped every update after connect. The diagnostic log names the peer once when its list first stops fitting (DEVLIST too large for peer ...) and once when it fits again (LinkServer.ListSizeNote, per connection).

Each tail decodes only after everything before it parsed cleanly, under its own try/catch. A malformed tail costs only its own fields and marks the cursor unreliable, so later tails are skipped rather than misread. An old peer that stops earlier simply leaves the fields at their defaults (flags false, counts zero).

The periodic push also refreshes an already-registered device in place. ReconcileRemoteDevices keys by the stable device id and copies the relayed metadata (serial, touchpad shape, DeviceObjects), every capability flag, the standardized and raw axis and button counts, the hat count, HasExtraGenericAxes, and the input-device type onto the existing record, so a capability that appears after connect (a Joy-Con pair joining, a reader arming rule flipping) reaches the consumer without a re-register. The raw counts travel with the standardized ones because the raw axis count is the larger of the two, and refreshing only the standardized one moved half of one answer. The supported index sets are refreshed too, and RemotePeerDevice.RefreshSupportedSets rebuilds the device's own arrays from them, since a Bliss-Box port the owner's switch reopens changes shape (#469). The analog key order, the rest mask, the capacitive sensor mask and the gamepad layout answer are copied on every push, and a key order that changed raises DeviceKeyOrderChanged, on which InputService rebuilds every slot's input picker and the Devices preview's order, as it does when a local keyboard reports a key its list lacked. The SDL GUID is set at first registration and never refreshed. Identity fields, the ids and the peer-labeled name, stay untouched.

A change to the axis inventory registers the device again, as a new slot, type, touchpad capability, or rumble or force feedback capability does. The consumer's row records the axes at registration (UserDevice.LoadFromDevice), and an "(Any Device)" read takes a 0 on an axis the row lacks for the filler of a device without it, so an axis the owner's device gained lost its full deflection until a reconnect. The inventory is the standardized and raw axis counts, HasExtraGenericAxes, the supported axis set, and the axes and sliders of a replaced DeviceObjects list. An owner whose row reads a gamepad-opened device in Force Raw Joystick Mode ships that read's span, the six from 0 or every raw axis with generic extras, as the supported axis set (InputService.RawModeAxes), because the stream is the row's published state, which then holds the joystick's own axes rather than the gamepad layout. It also types the device a joystick when it would otherwise go out as a gamepad (InputService.ExposedDeviceType), so the copy answers no "(Any Device)" Gamepad name, takes numbered input names (MappingDisplayResolver.UseRawNumberedNaming), gets no gamepad auto-map and falls outside the gamepad trigger rest rule. A gamepad-opened wheel, flight stick or instrument pad keeps its own type, and its copy answers the Gamepad names by the layout bit in the 0xE4 tail, which a raw read leaves clear. An owner from 4.3.0 through 4.5.3 ships the gamepad layout's set there, and the stream carries each axis as its difference from rest with no mark of which are live. A consumer still reads that pad's stick positions (InputManager.PeerStickAxis) and every axis the owner listed, and skips a raw axis the owner left off its list, on a trigger position or past the sixth, while it holds exactly 0, until the owner updates. PeerStickAxis applies only to a copy typed a gamepad, which an owner on an older build still sends for a pad it reads raw. An owner older than 4.3.0 sends no axis set, and the consumer lists every axis below the count.


Reaching a peer over the internet (#294)

Everything above is one subnet. #294 adds internet reach with no VPN, no port forward, and no PadForge-run server. Nothing in the handshake or the sealed data plane changes. Only how the two sockets find each other does, and every lane ends in the same LinkConnection handshake over an ILinkControlChannel.

Learning our own address. StunClient sends RFC 5389 Binding requests (magic cookie 0x2112A442) from the very socket LinkServer already bound, because a NAT mapping is per socket and a fresh socket would learn a different one. It queries three free servers from three operators (stun.l.google.com:19302, stun.cloudflare.com:3478, stun.nextcloud.com:3478). LinkServer.ProbePublicEndpointAsync drives it, LinkServer.Nat holds the verdict, and StartNatKeepalive holds the mapping open, since a measured Verizon CGNAT mapping dies after 40 seconds idle. PublicEndpointChanged fires when the mapping moves.

Classifying the NAT. NatProfile.Classify reads the external ports several STUN servers reported, in probe order. All equal means Cone. Differing but stepping by a small consistent delta means SymmetricSequential (measured: Verizon 5G CGNAT increments by 2). Erratic means SymmetricRandom, the residual that truly needs a relay. IsPunchable is true for the first two.

Codes. LinkCode uses Crockford base32 (no I, L, O, U) so a code reads aloud unambiguously, and it comes in two shapes. A short code is ShortCodeLength (8) characters and is an opaque rendezvous key. Only tests mint one, and the app mints and accepts only the long form. A long self-contained code packs 28 bytes (version, flags, public endpoint, private endpoint, an 8-byte fingerprint prefix, an expiry in minutes, and the NAT kind plus delta) and is emitted grouped in fives. The code is not a secret and authenticates nobody. The fingerprint prefix only buys an early mismatch abort, and the SAS plus Ed25519 handshake still gates trust. TwoWayPunchNonce derives the shared 16-byte punch nonce from both fingerprint prefixes in sorted order, and IsHandshakeInitiator gives the lexicographically smaller fingerprint the initiator role, so the two sides agree with no extra exchange.

Predicting ports. PortPredictor.BuildSprayTargets puts the peer's raw advertised endpoints first (a cone peer or a shared LAN connects with no prediction), then, for a sequential-symmetric peer, a forward window of DefaultWindowSteps (512) delta-steps on its public IP, each surrounded by a DefaultNeighborhood (2) radius to absorb jitter from other subscribers on the same CGNAT pool. De-duplicated on the full address string, since MapToIPv4 on a real IPv6 candidate keeps only four bytes and silently dropped distinct v6 candidates.

Punching. HolePuncher sprays authenticated probes at every candidate simultaneously so both NATs open. A probe is ProbeLen (25) bytes: TagPing 0xC2 or TagPong 0xC3, then PrefixLen (8) bytes of the sender's fingerprint, then the 16-byte nonce. Carrying the sender prefix is what lets an unsolicited receiver derive the shared nonce and answer without the user clicking anything. MaxProbeBudget caps a call at 20,000 probes. PunchAsync drops this PC's own endpoints and sprays private (LAN) addresses first, then the rest in the order given. PunchedConnection then runs the unmodified handshake over the won path, both sides spraying and listening, and yields the same LinkConnectionResult the TCP path produces.

The reliable channel over UDP. UdpControlChannel is ILinkControlChannel over datagrams: a stop-and-wait-per-message ARQ, TagData 0xC0 and TagAck 0xC1, each followed by a big-endian u32 channel id and u32 sequence. On a punched path the channel id derives from the punch nonce (the relay lanes derive it from the code or from the pair's capability), so a delayed datagram from a previous attempt on the same endpoint pair can never occupy a fresh channel's sequence space. These tags demux cleanly from sealed data, whose first byte is (type << 4) | epoch with type 1 through 9. A send that faults or is canceled poisons the channel rather than leaving the peer's expected sequence unknowable.

Signaling. A punch needs both sides sending at once, and an idle host cannot answer probes its own router drops. Dht.CodeRendezvous supplies the missing channel over the public BitTorrent DHT. Everything is derived from the host's code by HKDF: a seed to an Ed25519 signing key, a BEP 44 salt, a ChaCha20-Poly1305 key, and target = SHA1(pub || salt). The caller puts an encrypted "I am calling, here are my addresses" record there, the host polls its own slot on a 5-second timer (InputService.PollCodeRendezvousAsync) and punches back. The record is encrypted, so DHT nodes never see endpoints, and the code itself is never published.

Presence for paired peers. Dht.PresenceRecord and Dht.PresenceService keep a paired peer findable after either side moves. The slot's salt and the record's encryption key derive from the pairwise 32-byte rendezvous capability established at pairing and stored on PeerTrust, never from the Ed25519 public key, because BEP 44 publishes that key in the clear to every storage node. The record is signed with the PC's identity key, so the slot's DHT target is SHA1(identity public key || salt). One slot per pair per direction. Dht.DhtPresenceStore does the iterative Kademlia lookup (K 8, Alpha 8, MaxRounds 20, read-only per BEP 43) against router.bittorrent.com, dht.transmissionbt.com, router.utorrent.com (all port 6881) and dht.libtorrent.org:25401. A publish counts as healthy at 8 or more acks. Each record carries a one-hour expiry, inside the roughly 2-hour BEP 44 storage expiry. Dht.RemoteLinkInternetService is the policy loop that ties it together, driven from InputService on a 60-second maintain tick, and every pass republishes presence for each paired peer that has a rendezvous capability. PresenceService.NextRepublishDelay defines a 30-to-45-minute jittered cadence that only tests call.

Relay fallback. Where both sides sit behind symmetric or carrier-grade NAT there is no direct path at all, so IrohRelayClient speaks the open iroh relay protocol to the free n0 relays (use1-1, usw1-1, euc1-1, aps1-1 at .relay.n0.iroh.link). WebSocket to wss://host/relay with subprotocol iroh-relay-v2, then a challenge and response handshake signing blake3::derive_key("iroh-relay handshake v1 challenge signature", challenge) with the client's Ed25519 relay key: ephemeral for an outgoing dial, derived from the host's code for the code listener, and derived from the PC's identity public key for the identity listener. A peer is addressed by that key, so no IP, port, or NAT is involved. LinkServer layers a HELLO exchange on top (TagRelayHello 0xC4, TagRelayHelloAck 0xC5) so a caller knows someone is listening before it starts the handshake. PadForge hosts nothing, and the relay sees no secret: the handshake crosses it as public keys, nonces, and signatures, and everything after the handshake is sealed by LinkSession AEAD.

Lane order on a code connect. InputService.OnConnectToPeerRequested arms the relay (EnsureDialRelayAsync), publishes the DHT call request, tries LinkServer.ConnectByPunchAsync with a 20-second punch timeout, and on failure dials the relay identity the host's code derives (ConnectByCodeRelayAsync, 45 seconds). A paired peer reconnecting instead uses ConnectByIdentityRelayAsync (25 seconds) against the stable identity relay, which needs no DHT lookup and no direct path. A session that started on the relay does not stay there: PathOffer datagrams exchange fresh candidates over the relay, which is the ideal signal for a punch since both peers are already reachable and authenticated there.


Pairing handshake

LinkHandshake is an authenticated key exchange in four messages: commit, reveal from the responder, reveal from the initiator, confirm. It runs unmodified over whichever ILinkControlChannel won, TCP on the LAN or UdpControlChannel on a punched or relayed path.

  • Ephemeral X25519 per side gives forward secrecy.
  • Commit before reveal. The commit is SHA256 over the commit label, the ephemeral public key, a nonce, the capabilities, and the static public key. Binding the static key into the commit closes the one SAS input a relay attacker could otherwise grind. The responder recomputes and checks it with a constant-time compare before trusting the reveal.
  • Ed25519 static-key signatures over the transcript hash. The transcript hash is SHA256 over the three handshake messages, each field length-prefixed so boundaries are unambiguous. Both sides sign it and verify the other. Capabilities and version are folded into the signed transcript, so a downgrade fails at the signature check.
  • Six-digit SAS. A hash of the transcript reduced modulo one million, formatted to six digits. It is compared out of band on first pairing only.
  • Session key via HKDF over the shared secret, salted by the transcript hash. The connection then derives separate control and data keys.
  • Fail-closed. Every malformed, out-of-order, or unauthenticated input throws, and no device is created on a failed handshake.

Admission runs PeerTrustStore.Decide(peerStaticKey). First contact prompts the user (the RemoteLinkPairDialog). A known key reconnects with no prompt, and the signature still proves possession.


Trust and identity

PeerTrust persists per peer as XML attributes: the pinned Ed25519 public key (an identity, not a secret), the name, the self-asserted host name, the pairing time, a reconnect-enabled flag (default true), the gamepad-only flag, AllowRemoteAssignments (default false, this PC's consent for that peer to assign its shared devices here), and RendezvousCapabilityBase64 (the 32-byte pairwise DHT rendezvous capability from #294, empty for a peer paired before the internet lane existed until its next reconnect fills it). The fingerprint is the SHA-256 of the public key.

PeerTrustStore is the in-memory authority. Decide returns first-contact (unknown, never auto-trusted), known-auto-select, or known-manual. Lookups use a constant-time compare. An unknown key defaults to gamepad-only restricted, which is the fail-safe.

IdentityProtector stores this PC's own identity in one of three modes. Secure (the default) wraps the private key with DPAPI at machine scope, so any Windows user on the PC can use it but it does not move to another machine. Portable, password protected wraps it with PBKDF2-SHA256 (600,000 iterations) and AES-256-GCM. Portable, open stores it in the clear. The blob is self-describing, with a version and mode header. LoadOrMint mints only when the store is empty or corrupt and never overwrites a locked identity, so a password prompt or a wrong-machine state is surfaced to the caller rather than clobbered. A mode switch re-wraps the same key, so the fingerprint and all pairings survive.


Input streaming (owner to consumer)

On the owner, a stream timer running near 125 Hz calls LinkServer.PushLocalFrame per exposed device, which encodes the state through CustomInputStateCodec and seals an Input datagram. The codec is absolute: every frame is the full state through a present-block bitmask with neutral-omit, so packet loss self-heals and a centered idle pad is three bytes.

On the consumer, the device list yields a RemotePeerDevice per exposed device. The link server's DeviceConnected event calls InputManager.RegisterPeerDevice, which runs it through FindOrCreateUserDevice and LoadFromExternalDevice. A RemotePeerDevice is an ISdlInputDevice, so it flows through the normal six-step pipeline like any local source. Inbound Input datagrams decode into a back buffer that swaps under a lock, newest-wins by timestamp. After a three-second stale window the device reads as offline and releases any held inputs. The device identity is salted by the peer fingerprint, but not through the path. DevicePath uses an eight-character short form (peer://{Short(fingerprint)}/{deviceId}), which alone would collide across peers whose fingerprints share a first eight hex chars. The full fingerprint salts the collision-safe identity: InstanceGuid is an MD5 over pflink-dev:{fingerprint}:{deviceId} and ProductGuid an MD5 over pflink-prod:{fingerprint}:{VID}:{PID}:{type}. So two peers sharing the same controller model never alias through the non-peer-namespaced ProductGuid reconnect fallback.

Present-block table

A 16-bit present-block bitmask (Block, a [Flags] enum in CustomInputStateCodec) follows the one-byte codec Version at the head of the payload. A block appears only when its data is non-neutral, and the decoder resets to neutral first and applies only the blocks the frame carries. Sixteen blocks are defined, and Nfc at bit 15 exhausts the u16 mask:

Bit Block Carries
0 Axis Stick and trigger axes (non-neutral only)
1 Sliders Slider axes
2 Povs POV hats
3 Buttons Button bitmask
4 Gyro Gyro triple (capability-gated)
5 Accel Accel triple (capability-gated)
6 Battery Percent byte plus the charging flag byte
7 Touchpad Per-pad finger contacts
8 Midi Notes, CC, pitch bend
9 Ir Wii IR pointer X/Y (#146)
10 JoyConIr Joy-Con NIR intensity (#151)
11 JoyCon2Mouse Joy-Con 2 mouse deltas DX/DY (#154)
12 AccelAux Aux/left-side accel triple (Nunchuk, left Joy-Con) (#199)
13 MouseRaw Unclamped Raw Input mouse counts DX/DY (#200)
14 CapSense Capsense touch channels (stick tops and grips, SDL fork API): one byte, one bit per channel
15 Nfc NFC tag buttons (#241): a span byte plus a button bitmask

Battery sends when the percent is known or the pad reports charging, and always carries both the percent byte and the charging flag, so a "charging, percent unknown" pad keeps its flag on the wire. Battery crosses the link.

CapSense carries four wire channels (CapSenseWireSlots, low bit = channel 0) and sends only when something is touched, so an all-untouched frame omits the block. Nfc is variable-length: a span byte (stored as span minus one, so a tag on button 255 still fits) then ceil(span/8) bitmask bytes. Button 0 is "Any NFC Tag" and is registry-independent, so it is always meaningful remotely. Per-tag buttons resolve against the consumer's own NfcTagRegistry, the shared-config assumption every remote binding already makes. The demand that arms the owner's reader travels the other way, on the source-demand lane above.

Bits 9–15 are the post-3.5.0 additions, appended strictly after every older block. That ordering is what keeps mixed-version peers compatible without a Version bump: an old decoder reads its known blocks in order, never reaches the new bits, and its final o <= payload.Length check tolerates the trailing bytes. A new decoder facing an old sender simply finds those bits clear.

The 0xE6 extension tail

Bit 15 fills the present mask, so post-Nfc blocks ride an appended extension rather than a widened mask (widening would move the payload start and break every peer): the ExtMagic byte (0xE6, unrelated to the device-list tail that shares the value, since the two live in different frames), a u16 extension mask, then the payloads in BlockExt order. The read is positional, a "maybe" read, never a required one. The tail exists only when the magic byte is the next thing in the frame, so a peer that predates it never writes one, and a frame that ends before it is complete. It is the same appended-tail compatibility rule the device-list tails use.

The first extension block is BlockExt.GyroAux (#252): the gyro triple of a combined pair's left Joy-Con, capability-gated like Gyro, Accel, and AccelAux because a zeroed reading is a still controller, not an absent sensor. Decode fails closed on a non-finite float, resetting the frame to neutral so a hostile NaN never reaches the tuning chain. On the consumer the block feeds the aux gyro sources and SourceCoercion.GravityProviderAux on the tuning path, and the HasGyroAux capability (the 0xE4 device-list tail) gates discoverability, mirroring a local left Joy-Con.

The second is BlockExt.RingCon (hifihedgehog/SDL#33): RingConStrain as one float, written after GyroAux so a decoder that knows only GyroAux reads its block and leaves this one in the tail. Presence comes from the state, as the JoyConIr block's does: a strain of exactly 0 is rest or no Ring-Con, the neutral an omitted block decodes to, so only a nonzero strain rides the wire. Decode fails closed on a non-finite float like GyroAux, and the consumer's read clamps each direction to 0..1 again.

The third is BlockExt.AnalogKeys (#468): how far each key an analog keyboard reports as down is pressed. It is a count byte, then a little-endian u16 key code and a u16 depth per key, the depth 0 at rest and 65535 at the bottom of the press, written after RingCon under the same tail rule. Presence comes from the state: no key down omits the block, and an omitted block decodes to no key down. The encoder writes at most AnalogKeyInputState.MaxKeys (64) keys, and a count past that fails the frame closed to neutral as a hostile or corrupt one. The decoder hands each key to AnalogKeyInputState.Set, which ignores a code outside 1 to 0x6FF and keeps the last depth of a repeated code. The keys a keyboard has travel separately, in the device list's 0xEA tail above.

BlockExt.IrCalibrated (#485), the bit after AnalogKeys, carries no payload. It marks the frame's Wii IR aim as already mapped through the owner's light-gun calibration window, so the consumer's IR read adds no Pointer-tab sensor-bar offset, as a local calibrated remote's read does not. A GunCon 2's on-screen aim carries it too, since its beam window already places the aim. The encoder sets it only with the Ir block, and the decoder honors it only when that block arrived. A peer that predates the bit leaves it unread, and a tail the bit opens alone is the three-byte header that MaxEncodedSize already budgets with the Ring-Con block.


Reverse output relay (consumer game to owner hardware)

The consumer runs the full config-applied output pipeline for a peer:// device. The final hardware write fails (the handle is zero), so each write chokepoint hands its config-baked payload to RemoteLinkOutputRouter instead:

  • ShipSonyEffect (in PlayStationEffectWriter) strips the report id and encodes the DualSense or DualShock 4 effect body.
  • ShipVibration (Step 2) encodes the full Vibration (motors, impulse triggers, directional and condition force). For a device without trigger motors, the consumer's Fold Trigger Rumble into Main Motors goes into the motors first, since the owner replays the frame with a neutral setting.
  • ShipIdentify (in InputService.IdentifyDevice) sends Identify's pulses for a peer row in no slot as vibration frames, past the record ShipVibration dedups against. Step 2 sends such a row a stop whenever that record shows a level.
  • ShipWheel (Step 2) encodes a semantic wheel frame (force, condition, periodic, range, auto-center, RPM-LED mask). Its flag byte carries the condition (1), directional (2) and LED-valid (4) bits, and bit 3 (8) marks a range the consumer leaves to the wheel (#512), which the owner then never sends. The range still rides the frame, so an owner from before that bit sets it as before.
  • ShipHapticTone (in HapticToneService) encodes the HD haptic tone (#147): one (dominant frequency Hz, amplitude 0..1) pair per rumble tick, slot volume already applied.
  • ShipPlayerIndex encodes the consumer's winning 1-based player number (the smallest displayed number across the slots the pad feeds) for a shared Nintendo pad (0x057E), DS3 (0x054C/0x0268), or PS Move (0x054C/0x03D5, whose owner-side lane routes the number into the sphere's default color, #277). #191. A DualSense or DualShock 4 peer carries its player LED in the Sony effect body, so it never ships this kind.
  • ShipGuideLed encodes the consumer's configured Guide/Home LED brightness (0-100 percent) for a shared Xbox GIP pad, 2015 Steam Controller (#209), or Switch HOME-LED pad (Pro Controller, right Joy-Con, pair, charging grip, #226). It has ShipPlayerIndex's shape, dedups per path, and addresses one peer device by its path.
  • ShipAudio (in AudioPassthroughService) ships the speaker PCM block on the Audio datagram.

OutputEffectCodec.Kind has six values: SonyEffect = 1, Vibration = 2, Wheel = 3, HapticTone = 4, PlayerIndex = 5, GuideLed = 6.

The router maps the peer:// path to a target (owner fingerprint, link slot, the advertising connection, and the device id, registered again when the device's slot moves) and sends through PushOutputEffect or PushAudio on that connection. Dedup varies per channel. Sony effects, scalar vibration, wheel frames, player index, and Guide LED dedup exact repeats. A vibration frame with directional or condition data always ships. The haptic tone dedups only the silent steady state, because an active tone must ship every rumble tick to keep refreshing the owner's hangover expiry.

On the owner, the FrameReceived handler OnRemoteFrameReceived decodes the frame, resolves its device id to the physical source device, and hands an Output frame to ApplyRemoteOutput, which takes the sole-writer lease (except for a PlayerIndex or GuideLed frame, see below), and applies by kind: a Sony effect through SDL_SendGamepadEffect, a vibration through ForceFeedbackState.SetDeviceForces, a wheel frame through ApplyRemoteWheel (which re-encodes with the owner's own vendor writers, see Wheel Force Feedback Internals), a haptic tone (HapticToneHz/HapticToneAmp) through HapticToneService.ApplyRemoteTone, re-encoded by the owner's own per-family writer (Joy-Con HD Rumble, Steam 0x8f, Triton 0x83, Deck), a player index through SetPlayerIndex (0-based) for a Nintendo pad (VID 0x057E), Ds3DirectService.TrySetPlayerNumber for a Bluetooth DS3 (0x054C/0x0268, falling back to SetPlayerIndex for a wired one), or PsMoveDirectService.TrySetPlayerNumber for a PS Move (0x054C/0x03D5), a Guide/Home LED brightness through XboxGipGuideLedWriter.TrySetBrightness for an Xbox GIP pad, SteamHomeLedSetter.TrySet for a 2015 Steam Controller, or SwitchHomeLedSetter.TrySet for a Switch HOME-LED pad (the same writers and gate order as the local apply path). An Audio frame takes no lease and goes straight to AudioPassthroughService.FeedRemoteAudio (see Controller Audio Internals). The vibration apply has three special cases. A Fanatec pedal re-routes to its raw-HID pedal-rumble writer. A Padix PlayStation converter (#440) records through TryRecordMotorSnapshot and writes the converter's own report via PadixConverterRawHidWriter.Write. An Xbox One+ impulse-trigger pad, gated by XboxControllerIdentity.IsImpulseTriggerDevice, records through TryRecordXboxImpulseSnapshot and writes all four channels (large and small plus LT and RT) via XboxImpulseHidWriter.Write, with SDL rumble suppressed so the impulse triggers fire. The owner synthesizes nothing here: for the wheel and the haptic tone it is a transport transcoder that re-frames the semantic payload with the writer that owns the real hardware.

Sole-writer guard. A device can be both shared out and mapped locally, and output cannot merge. An inbound relay frame stamps the local path with a three-second lease, and the owner's local chokepoints skip their own writes while the lease is fresh. A device that is only lent out (not also mapped locally) has no local writer, so there is no contention. A PlayerIndex or GuideLed frame is exempt from the lease. Both are cosmetic LED updates, not effect claims, so neither blanks a locally-mapped device's rumble or lightbar for the lease window.


Gamepad-only enforcement

InputManager.SetDeviceRestricted populates the restricted-device set from each peer's gamepad-only flag, set before the device goes online. IsSlotRestricted(slot) is true when any restricted device maps to that slot, and it early-outs when no peer is restricted (RestrictedSnapshot returns null and the caches rebuild only when the set changes). Five chokepoints honor it:

  • The KBM virtual controller (Step 5) submits a neutral state for a restricted slot, releasing anything held.
  • The VR controller pair (Step 5) submits a default VrRawState for a restricted slot, so a gamepad-only peer never reaches SteamVR.
  • Macros (Step 4b) are restricted when the slot is restricted or a restricted device is a macro trigger.
  • The five Win32 SendInput emitters (key, text, mouse move, mouse button, scroll, in Step 4b) early-return while the macro slot is restricted.
  • The menu overlay's key lane (CollectMenuDirectOutputs) suppresses key cells per restricted device via RestrictedSnapshot(). It deliberately gates per device, not per slot, so a restricted peer sharing a slot does not mute a local controller's key cells.

So a gamepad-only peer can drive gamepad slots but never reach the keyboard, mouse, or scroll. RemoteAssignmentService applies the same line to remote assignments: a gamepad-only peer can assign its devices only to Xbox, PlayStation, Nintendo, and Extended slots.


Remote devices and disconnect

A peer:// device relays its owner's real VID and PID, so a linked pad can look like a Bluetooth or Switch 2 controller that the disconnect action would otherwise target. But the disconnecting machine has no radio link to it, so the action could only no-op. BluetoothLinkHelper.IsDisconnectTarget(devicePath, vendorId, productId, serial) returns false first for any peer:// path, before the BthPS3, Switch 2, Joy-Con pair, and Bluetooth-path checks. The macro evaluator uses that gate, so a linked remote device is never a disconnect target (issue #162).


Settings and auto-reconnect

AppSettingsData persists RemoteLinkIdentityPrivate and RemoteLinkIdentityPublic, RemoteLinkIdentityProtection (default Secure), RemoteLinkPeers (the PeerTrust array), EnableRemoteLink, RemoteLinkAutoReconnect (default true), and RemoteLinkPort (default 27500, clamped to 1024-65535). These load into a runtime record on SettingsService and into the Dashboard view model.

The auto-reconnect dial runs when a peer is discovered. It requires a trusted entry with reconnect enabled, skips a peer already connected, applies a lower-fingerprint-dials rule so only one side initiates, and throttles per peer with a five-second cooldown. A passing peer triggers a connect with the PC's exposed devices.



Last updated for PadForge 5.2.0.