Skip to content

feat: Moonlight-host (Sunshine/Apollo/Wolf) client support - #46

Open
emir-hasanbegovic wants to merge 14 commits into
mainfrom
feat/moonlight-host-support
Open

feat: Moonlight-host (Sunshine/Apollo/Wolf) client support#46
emir-hasanbegovic wants to merge 14 commits into
mainfrom
feat/moonlight-host-support

Conversation

@emir-hasanbegovic

@emir-hasanbegovic emir-hasanbegovic commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What and why

Adds a second connection path to the Dish client: alongside the existing
Satellite (protocol-1) flow, a controller can now stream to any Moonlight
(GameStream) host, i.e. Sunshine, Apollo/Vibepollo, or Wolf. The Moonlight path
sits beside the Satellite session code as a sibling and shares the same UI and
controller plumbing.

Scope: discovery, PIN pairing, the app pick, session launch, controller input
out, and rumble / trigger-rumble / motion-request / RGB-LED events back. There
is no video or audio decoding: the media streams are negotiated at the floor
bitrate and their payloads are discarded: the client only hole-punches the media
ports so the host sees them open, and drains whatever arrives without decoding
it.

The work lands in two passes, both in this PR: a protocol-parity pass against
the faults dish-android measured on a live Sunshine host, and a UX restructure
that puts pairing and the session where the protocol actually puts them.

Protocol scope

  • HTTP 47989 / HTTPS 47984: serverinfo, the 5 pairing phases, applist, launch,
    resume, cancel. The client presents a self-signed cert; the host's cert is
    pinned at pairing time and every later TLS reply is checked against it.
  • RTSP 48010: OPTIONS, DESCRIBE, SETUP x3, ANNOUNCE, PLAY, one TCP connection
    per message. Ports and the control connect-data are taken from the SETUP
    responses, never hardcoded.
  • Control over ENet (UDP, negotiated port): AES-128-GCM sealed packets both
    ways. CONTROLLER_MULTI is the hot path; PERIODIC_PING keeps the session alive;
    TERMINATION on quit. Inbound RUMBLE_DATA, RUMBLE_TRIGGERS, MOTION_EVENT and
    RGB_LED are handled; unknown types are ignored gracefully.
  • Video RTP / audio RTP (negotiated ports): hole-punch pings only, from a
    long-lived socket per stream, started the moment SETUP names the port and
    repeated every 500 ms until teardown. Inbound media is drained and discarded.
  • Discovery: mDNS _nvstream._tcp.local. plus manual host entry.

Protocol parity pass

dish-android found six real protocol faults against a live Sunshine host
(2026.516.143833). Every one was checked against this client as it actually is
before anything was changed. Four applied and are fixed, two were already
correct and are now pinned by tests, and one does not apply to this repo at all.

Fixed here

  • RTSP is one message per TCP connection. The transport reused a single
    socket for the whole handshake, so everything after OPTIONS was written into a
    socket the host had already closed. Every request now dials its own socket.
    The reply is framed by Content-length when the host sends one and by the
    close when it does not, which is the shape the DESCRIBE reply arrives in.
  • The ANNOUNCE SDP needs the full attribute set. It carried 17; a host looks
    each attribute up by name and a lookup that misses is fatal, so a minimal set
    is answered 400 BAD REQUEST. It now carries the full 32, matching what a
    real client sends.
  • CONTROLLER_ARRIVAL is read from a naturally aligned struct. The body was
    seven bytes. The u32 button mask starts at offset 4 and offset 3 is reserved,
    so seven bytes shifted every field after the type by one and the host logged
    capabilities [FF03] and supportedButtonFlags [000000FF] instead of
    [0003] and [0000FFFF]. The body is eight bytes now, packet_len 16 and
    data_size 12.
  • The media pings started too late. They only began once the ENet control
    link came up, but the host counts its initial-ping deadline from its own
    session start. The sockets now open and the pings start at SETUP.
  • localAudioPlayMode was 0. That asks the host not to play audio locally,
    which is right for a remote viewer and wrong for a dish, whose user is sitting
    at the host with the pad in their hands: it silenced their own speakers for
    the length of the session. It is 1 now. sops stays 0.
  • The launch mode was a fixed 1280x720x30. A virtual-display host follows
    what the client asks for, so a small request resized the user's desktop under
    them. serverinfo's SupportedDisplayMode rows are parsed and the largest is
    requested, with the fastest refresh offered at that size, falling back to
    1920x1080x60 when a host advertises none.
  • A host says no in the BODY, not the status line. A second /launch is
    answered HTTP 200 carrying status_code="400", status_message="An app is already running on this host" and a <resume> flag. The body is parsed now:
    a resumable refusal promotes the attempt to /resume, an unresumable one ends
    it with its own reason token and a message that says what happened, and the
    manager exposes /cancel for the flow that needs it. Every XML endpoint was
    audited for the same mistake, and a reply that names no status_code at all
    is read as success, which is what a plainly-answering host sends. A launch
    that never reaches Streaming is handed back with /cancel so no app is
    stranded for the next attempt to trip over.
  • TLS session resumption. Qt shares and persists TLS sessions across the
    connections one QNetworkAccessManager makes, and a resumed session skips a
    Moonlight host's verify callback, which Sunshine answers with a fatal
    internal_error alert (RFC 8446 alert 80) and no log line at all. Session
    tickets, sharing and persistence are all off now.

Already correct, now pinned

  • The control-stream GCM IV keeps only the low byte of the sequence, the way
    the host builds it. A test now asserts byte-for-byte that sequences 256 apart
    seal identically, which a four-byte IV would fail. That is the fault that ends
    a session just past two minutes.
  • X-SS-Connect-Data already parsed unsigned. It now reads 64 bits wide and
    narrows to the 32 ENet puts on the wire, rather than rejecting an over-wide
    value into a silent zero, and is pinned above and below INT32_MAX including
    the live host's own 4270471497.

Does not apply

  • ENet unknown-command acknowledgement. That was a fault in a hand-written
    port. This repo vendors the real MIT ENet, whose protocol.c handles
    BANDWIDTH_LIMIT explicitly and queues an acknowledgement for every command
    carrying ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE, and the service loop
    already runs continuously on a 2 ms tick rather than only when input changes,
    so peer->lastReceiveTime keeps advancing.

UX restructure: connecting is pairing, and the session is per host

Two things were modelled wrongly, and the UI promised both of them.

Connecting to a Moonlight host is PAIRING. Moonlight has no bidirectional
liveness. Pairing is one-time trust, checkable only when the client asks (a
/serverinfo PairStatus, or a mutual-TLS handshake that succeeds, which is
itself proof), and a host-side unpair is discovered on the next call and never
announced. The old hosts section drew that as a pulsing status dot beside a
Connect button, which promised a link nobody can observe.

The session belongs to the HOST, not to a binding. One Moonlight session
carries up to four controllers behind a single launch. Two pads on one host used
to mean two /launch calls, the second of which the host answers with "an app
is already running".

The hosts screen

The Moonlight inventory leaves ConnectionsPage for MoonlightHostsPage.qml,
matching dish-windows: the two host kinds pair differently, so one merged list
made "Pair" and the status column each mean two things. Connections keeps a
labelled Kit.RowButton doorway to it.

That page now owns pairing, forgetting, and one Quit session escape hatch, and
nothing else. The emulated-device segmented control, the app sheet and Connect
are gone. Connect is implicit: binding a controller starts or joins the host's
session, and unbinding the last one ends it.

A row states TRUST, in three words, and never liveness: Paired when this visit
confirmed it, Remembered when a certificate is stored and the host did not
answer, Not paired otherwise. It is re-asked on entering the screen and before
a session starts, and never polled. Amber is kept for problems: a handshake in
flight reads neutral, not warning. The row glyph is no longer the satellite
silhouette, and the PIN is minted in C++ rather than by Math.random().

The binding flow

The wizard gains a conditional sixth page, WizardSessionPage.qml, between Type
and Feel, shown only for a Moonlight destination and skipped entirely otherwise.
lastStep is 5, pageForStep shifts Feel and Review, and Kit.WizardBanner
takes a subStepCount so stage 3 draws four pips for a Moonlight binding and
three for a satellite one. Configure binding renders the same file inline, so
the two editors cannot tell a user two different stories about one host.

It renders exactly one of twenty-one states, derived in C++ as lowercase tokens
(core/moonlight/MoonlightSessionUi.h) and localized in QML, the same rule the
capability solver and the link vocabulary already follow. The states are:
checking, not paired, pairing PIN, pairing refused, unreachable, remembered,
trust lost, host replaced, apps loading, new session, no apps, app list
unreadable, joining, host full, refused by another device, rejoin refused,
refused for the host's own reason, stream setup failed, live, dropped, ended by
host.

Applying is never blocked by any of them except a host already carrying four
controllers.
A binding is a durable intent: the session is attempted when the
pad is used, not when the user presses Apply, so an unpaired, unreachable,
refusing or dropped host is a state with an action in it and not a refusal of
the answer the user gave. The four-pad ceiling is a hard protocol limit and says
so, and it is judged before anything the network could change, so it can never
sit behind a spinner while Apply is enabled for a bind that will refuse.

The app is per SESSION. The binding that creates one picks it from /applist;
every later binding shows what is running and is offered no picker at all,
because a disabled picker implies a choice that does not exist. The controller
type is per BINDING, so one host can carry a PlayStation pad and an Xbox pad at
once.

No state is modal: a flapping session would raise a dialog the user cannot
outrun.

The capability table, and why it is hard-coded

No Moonlight host reports what its emulated controllers can do. /serverinfo
carries no controller element; plaintext /applist answers 404 and its rows
carry only title, id and an HDR flag; Wolf's serverinfo() signature has no
field to put it in. The data flows the other way: the host builds its virtual
device FROM the type byte and capability bitfield the client declares, never
reports the choice back, and may override it silently.

So the table is client-side knowledge, in core/moonlight/MoonlightPadSlots.h,
taken from what the reference host actually builds per type. Xbox and Nintendo
are sticks, buttons, analog triggers and body rumble; PlayStation is the only
one carrying trigger rumble, touch, motion, battery and an LED. Nintendo has
no motion over Moonlight
, unlike the satellite switchpro type: the two are
different type systems that happen to share names. The type cards show the same
three rows every existing card shows, through the same Kit.CapabilityTable
and the same solver, and the copy never promises the host will honour the pick.

Auto resolves on the CLIENT, before the wire: a pad reporting gyro or an
accelerometer becomes PlayStation, everything else Xbox. It is the only rule
that both matches the reference host's own promotion of an unknown-with-motion
pad and lets the card state what the pad will support. The declared bitfield is
the type's ceiling intersected with what the source can actually deliver,
because declaring a capability the pad does not have makes the host request
reports that never arrive.

Reference counting

MoonlightSession owns the controller numbers and the CONTROLLER_MULTI active
mask. The first binding on a host starts or joins the session and announces its
pad; later bindings make no HTTP call at all and only send their own
CONTROLLER_ARRIVAL. An unbind clears the pad's bit and sends the unplug; the
last one hands the app back with /cancel. A live session with controllers
still on it is left alone: closing somebody's game out from under them is worse
than the tidying is worth.

The mask is published to the hot path through one atomic, so
sendControllerState still costs no lookup and no allocation. Host events carry
the controller number through, because one session drives four pads and an event
that named none of them could only be applied to the wrong one.

A Moonlight binding is now a binding everywhere: the slot list reports its
boundConnectionId, the host slot accounting counts it, and Unbind means the
same thing for both destination kinds. A Moonlight host refuses a fifth pad
rather than displacing one, so the copy that names a pad about to be unbound is
suppressed for it.

Two wire changes

  • supportedButtonFlags is 0x0000FFFF, not 0xF7FF. Bit 0x0800 is named in
    neither Wolf's button table nor its control.hpp, the live host logs the
    whole low half back, and three Dish clients advertising three different masks
    is drift nothing gains from. dish-android's value is the live-proven one.
  • The Auto sentinel is 0xFF in the store, and a persisted 0 migrates on
    read. 0 is CONTROLLER_TYPE_UNKNOWN on the wire, which asks the HOST to pick:
    a different promise from "match the pad". The same value is exposed to QML so
    the picker cannot fork it.

/applist has a caller

It is HTTPS and paired-only, so a refusal is reported as a failure rather than
as an empty list: reading a 404 as "no apps" would present it as a fact about
the host, and the two states say different things to the user. A readable reply
is also proof of trust, because the mutual-TLS handshake behind it is exactly
what pairing establishes; a 401 is recorded as trust lost.

No radio buttons

The ban is on radio LISTS and controls. This repo has none of the offenders (no
RadioButton, RadioDelegate, ButtonGroup or Kit.RadioTile), and none were
introduced. Kit.RadioMark stays inside the type card: it is a selection dot
inside an ordinary AbstractButton, and removing it from the Moonlight cards
alone would fork the one component both host kinds render through.
Accessible.role: Accessible.RadioButton stays on the cards and rows, because
screen readers need one-of-N semantics and that is invisible to sighted users.

On-screen controller

There is none in this repo. The README says so outright ("Physical controllers
only. There is no on-screen touch gamepad"), and no virtual slot exists in the
model. Its equivalent here is the wizard's destination step, which now offers a
Moonlight section of its own with the trust word on each row, and Configure
binding offers the same rows inline.

Session and RTSP logging

One dish.moonlight logging category at the QtInfoMsg floor covers the whole
path: every RTSP request and reply with the step it belongs to, the step a
mid-handshake hang-up died on, each pairing phase with what the host said in the
body, the launch status and the parsed ports, the control link coming up and
going down, and every give-up branch by name.

Key modules

  • src/core/moonlight/ (Qt-free, unit-tested): protocol constants, byte-exact
    wire encoders/decoders, the AES-GCM control cipher (contexts reused, no
    per-packet allocation), the pairing crypto (AES-128-ECB, SHA-256, RSA-SHA256,
    self-signed identity), the pairing state machine, XML and RTSP
    parsing/formatting, the session-launch reducer FSM, and the button map.
  • src/source/moonlight/: the HTTP/RTSP/ENet transports, discovery, the pairing
    flow, the session coordinator (runs the FSM's effects), the shared logging
    category, and the manager that owns identity, hosts, discovery, pairing and
    the live sessions.
  • src/repository/: the per-install client identity and the remembered-host
    store, co-tenant in the connection-store settings.
  • third_party/enet/: the vendored cgutman ENet fork (MIT), built as
    dish_enet.
  • src/core/moonlight/MoonlightPadSlots.h: controller-number allocation, the
    active mask, and the hard-coded capability table, all pure so every bind
    decision is testable without a socket or a pad.
  • src/core/moonlight/MoonlightSessionUi.h: the twenty-one-state render
    contract as one pure total function, plus the trust and phase vocabularies.
  • src/qml/pages/MoonlightHostsPage.qml and
    src/qml/wizard/WizardSessionPage.qml: the hosts inventory and the session
    step, the second of which both editors render.
  • AppModel / AppViewModel: the subsystem wired in beside the satellite pool,
    with Moonlight bindings reported through the same slot and accounting surfaces
    the satellite path uses.

Hot path

CONTROLLER_MULTI encode + encrypt + send performs no per-packet heap allocation:
the encoder writes at fixed offsets into a caller buffer, the cipher seals into a
preallocated ring slot, and ENet is handed that slot with
ENET_PACKET_FLAG_NO_ALLOCATE. One GCM context is reused across packets.

Test coverage

Fourteen Catch2 suites. Both subsets were built and run on this host: the
portable (Qt-free) core is 137 test cases and 1161 assertions, and the Qt-side
subset (RTSP framing over a real socket, the TLS configuration, the host
repository and the binding reference count) is 34 cases and 204 assertions.

Protocol:

  • Byte-exact encoder fixtures from the protocol docs' "network" rows and from a
    captured session; decoder tests for all four host to client events including
    short and malformed buffers.
  • The 20-byte SS_PING built from the live host's own X-SS-Ping-Payload,
    asserted to be those 16 ASCII bytes verbatim and not their hex decoding, plus
    a regression that the encoder never emits a datagram in the 5..19 byte dead
    zone a host drops silently.
  • The CONTROLLER_ARRIVAL body read back the way the host reads it, across every
    emulated type and the whole capability bitfield, with the advertised button
    word pinned at 0x0000FFFF.
  • GCM seal/open against real-session known-answer packets, the IV wrap across
    the 256 boundary asserted byte-for-byte, tamper and misframing rejection.
  • The connect token above and below INT32_MAX, including 4270471497 and
    UINT32_MAX, and its narrowing above that.
  • The whole ANNOUNCE attribute set, line by line and in order.
  • The in-body refusal in every shape: the busy one with and without <resume>,
    a non-busy refusal, a 2xx that merely mentions the phrase, and a reply with no
    status_code at all; plus the display-mode pick.
  • A socket-level suite driving the transport against a loopback host: three
    requests are three connections, a reply with no Content-length is read to
    the close, a declared body split across writes is not truncated, a
    mid-handshake hang-up and a half-sent head are refused rather than misparsed.
  • The mutual-TLS configuration, since a resumed session leaves no trace anywhere
    else: all three resumption switches off, peer verification off because trust
    is the pairing pin, and an unparsable identity dropped rather than
    half-applied.
  • Pairing crypto against published vectors (FIPS-197 AES, SHA-256 "abc") plus a
    full 5-phase handshake round-trip in both directions, with wrong-PIN,
    substituted-cert and tampered-signature rejection.
  • RTSP request/response, serverinfo/applist/launch XML, the button map, and the
    host repository contract.

The session reducer, now covering the states the flow needs:

  • The happy path, stop-from-anywhere, and the no-op guarantee for every stale
    late completion.
  • Not paired and trust lost split on what is remembered; a host answering with a
    uniqueid we do not remember named before pairing is judged at all.
  • A failed /resume distinguished from a refused /launch, and the launch to
    resume promotion with its no-loop guard.
  • A link that dies after going live reported as a drop, and one that dies before
    as a setup failure, so Reconnect is never offered where it cannot work.

The binding flow:

  • All twenty-one states walked one by one, each asserting the token it renders,
    that no two states share a token, and that exactly one of them blocks Apply.
  • A host nobody has asked yet reads as checking, never as silent.
  • The capability ceiling per emulated type, the Nintendo motion rule, and the
    intersection with what the source can deliver, including a pad with no motors
    still declaring its analog triggers.
  • Auto resolution both ways, the 0xFF sentinel, and migration from a persisted
    0 and from anything out of the picker's range, at both the pure-logic and
    the repository layer.
  • Controller numbers: the lowest free index, no reuse while a pad is live, the
    active mask across attach and release, and the fifth pad refused.
  • The reference count against the real coordinator: four pads on one host, one
    session object, a second binding that joins rather than launching again, three
    unbinds that leave the launch alone and a fourth that stops it, a freed index
    handed to the next binding, a re-bind of a live slot treated as a restart, a
    binding to an unpaired host still recorded, and forgetting a host dropping the
    bindings that rode it.
  • The /applist caller reporting a paired-only refusal as failed rather than
    empty, and the remembered app seeding the session that a later binding joins.

Licensing

Wolf (MIT) was the protocol reference; adapted logic is attributed in
THIRD_PARTY.md. The GPL-3 Moonlight sources (moonlight-common-c, moonlight-qt,
moonlight-android, Sunshine, Apollo) were deliberately not consulted, to keep
this LGPL-3 project clear. ENet is vendored under MIT; OpenSSL libcrypto is
linked from the system for the AES/RSA/X.509 primitives libsodium does not offer.

CI status

Every check on the head commit passes. Both Build, test, lint, format
matrix legs (gcc and clang), Sanitizers (address+undefined),
Sanitizers (thread), Coverage, Package (.deb) and launch it, CodeQL, the
version-consistency gate, and all five security checks including gitleaks. That
covers the full Debug and Release builds, the complete ctest run under both,
qmllint with the generated Dish.Chrome module resolved, the QML literal
scanner, the translation sync and completeness gate, clang-tidy, the
hardening-flag assertions, and installing the built .deb and launching it.

GitHub Actions was in a declared major outage for most of this work, which is
why earlier runs on this branch show startup_failure or sat queued for hours.
One run did land mid-way, on b021dc5, and caught a real fault the development
host could not: #include <QSignalSpy> in the new binding-refcount test, because
DishTests links Catch2 and not Qt6::Test. It is fixed with the same local spy
struct every other suite in this repo uses, and the local harness that had masked
it no longer links Qt6::Test either.

Verified locally as well

This is a Linux-target repo and the development host is Windows with no WSL
installed, so the following stand in for the parts of CI that cannot run there:

  • The portable core test suite: 137 test cases, 1161 assertions, built and run
    with msys2 gcc 16 + OpenSSL 3.6.2.
  • A Qt-side subset: 34 cases, 204 assertions, against msys2 Qt 6.11, covering
    the RTSP framing against a real loopback QTcpServer, the TLS configuration,
    the host repository, and the binding reference count driven through the real
    MoonlightManager and MoonlightSession.
  • clang-format 22.1.4, the version CI pins, and clang-tidy 22.1.8 with the fleet
    .clang-tidy and --warnings-as-errors='*'.
  • The CI warning set with -Werror over every changed source, including
    AppModel.cpp and AppViewModel.cpp compiled through a POSIX-header shim.
    It caught one real fault: a signed qsizetype returned as std::size_t.
  • The QML literal scanner in error mode, with both new QML files tracked so they
    are actually scanned, and the translation gate: lupdate plus the
    source-language seed is idempotent, and all six catalogues are 1079/1079
    translated. Every one of the 117 new strings has real words in Bosnian,
    German, Spanish, French and Brazilian Portuguese.

Still not covered by any gate

  • No live-host verification of this client. The Sunshine host on this machine
    was in use by other clients' testing throughout, so nothing in either pass was
    confirmed on the wire from dish-linux. This client remains unproven against a
    live host.
  • No QML was rendered. Every QML change is lint-clean and qmllint-clean on CI,
    but was never drawn, so layout and binding behaviour on the new and changed
    pages is unverified by eye.

Known gaps and risks

  • The live-host interop test is the only remaining primary risk. The wire
    format is pinned byte for byte, the RTSP framing is proven against a loopback
    host, and the reference count is proven against the real coordinator, but no
    end-to-end pairing plus stream from this client has been run against a real
    Sunshine, Apollo or Wolf host. In particular the TLS session-resumption fix,
    the audio play mode, the display-mode request, the four-pad session and the
    CONTROLLER_ARRIVAL capability table have never been observed on the wire.
  • No QML was rendered. Every QML change is reviewed, lint-clean and qmllint-clean
    on CI, but was never drawn, so layout and binding behaviour on the six new and
    changed pages is unverified by eye.
  • The concurrency in the ENet control stream (service thread vs. hot-path sends,
    the active mask published through an atomic, slot lifecycle) passes the TSan
    leg, but only over the code paths the suite exercises: it still wants a
    device-in-the-loop pass with a real session up.
  • Video and audio are discarded by design; the RTP ports get hole-punch pings
    only, never a decoder.
  • CONTROLLER_TOUCH is still a typed stub, and Home/Guide has no internal source
    bit yet, so it is not mapped.
  • A Moonlight session does not count toward streamingSlotCount, so it does not
    inhibit the display sleep the way a satellite session does. Separate change.
  • The Satellite protocol-1 path is untouched. Two existing tests were updated to
    the new contract (the advertised button mask, and a post-live control loss now
    reported as a drop); nothing was weakened or removed.

The IPv4/IPv6-capable ENet fork Moonlight hosts speak on the control
stream, at the commit Wolf pins (4cde9cc). Unmodified upstream sources;
wired into the build by a follow-up commit.
…XML, session FSM

The GameStream client protocol as pure, testable modules under
core/moonlight/:

- MoonlightProtocol.h: ports, packet/input types, controller types,
  capability and button bitfields, per Wolf's docs and control.hpp.
- MoonlightWire: fixed-offset encoders for CONTROLLER_MULTI (the hot
  path), CONTROLLER_ARRIVAL/MOTION/BATTERY, MOUSE_MOVE_REL, the periodic
  ping and TERMINATION, plus decoders for the four host->client events
  (rumble, trigger rumble, motion request, RGB LED). No allocations.
- MoonlightControlCipher: AES-128-GCM seal/open of control packets with
  both EVP contexts reused across packets; IV construction matches the
  host side byte-for-byte and is pinned by captured-session fixtures.
- MoonlightPairingCrypto + MoonlightPairing: the 5-phase PIN handshake
  client-side (AES-128-ECB, SHA-256, RSA-SHA256, self-signed identity
  generation), with all randomness injected so tests are deterministic.
- MoonlightXml: serverinfo/applist/launch/pair response parsing.
- MoonlightRtsp: request formatting, response parsing, the ANNOUNCE SDP.
- MoonlightSessionMachine: the launch lifecycle as a total reducer FSM
  in the UsbPathMachine mould.

Crypto semantics are ported from Wolf (MIT); THIRD_PARTY.md follows in
the build wiring commit. 65 test cases pin byte-exact fixtures from the
protocol docs and a captured session, malformed-input handling, tamper
rejection and the FSM's full decision space.
…ild wiring

The Qt/IO layer that turns the protocol core into a working second
connection path, sitting beside the satellite pool as a sibling:

- source/moonlight: MoonlightHttp (GameStream HTTP/HTTPS with the client
  cert and pairing-time cert pinning), MoonlightRtspClient (the RTSP
  handshake transport), MoonlightControlStream (the ENet control link;
  the CONTROLLER_MULTI hot path seals into a preallocated ring slot and
  hands ENet a NO_ALLOCATE packet, reusing one GCM context), a one-shot
  _nvstream._tcp mDNS discovery, the 5-phase PIN pairing flow, the
  MoonlightSession launch coordinator that runs the reducer FSM's
  effects, and MoonlightManager that owns identity, hosts, discovery,
  pairing and the live sessions.
- repository: MoonlightIdentityRepository (one self-signed client
  identity per install) and MoonlightHostRepository (remembered hosts
  with their pairing anchor and picks), co-tenant in the connection
  store, with the host repo passing the shared repository contract.
- Build: vendored ENet as the dish_enet static library, OpenSSL
  libcrypto and Qt Concurrent linked, all new sources wired into
  dish_core, and THIRD_PARTY.md documenting ENet (MIT), OpenSSL and the
  Wolf protocol reference plus the deliberately-avoided GPL sources.

Local verification (this Windows host has no Qt-Linux build; WSL absent):
every new Qt-free and Qt-based module below the app layer syntax-checks
clean against msys2's gcc 16 + Qt 6.11, and the ENet C sources compile.
…l and the UI

- AppModel owns the MoonlightManager beside the satellite pool, kicks a
  scan on start, folds its changes into stateChanged(), and routes the
  hot path: a slot bound to a Moonlight host falls through the satellite
  routing table into a Moonlight sender (CONTROLLER_MULTI + motion),
  guarded by the same routingMtx_. Inbound rumble and RGB LED reuse the
  SDL output plumbing the satellite path uses.
- AppViewModel exposes the host list, scan/pairing state and the pair,
  connect, disconnect, forget, bind and emulated-type intents.
- ConnectionsPage gains a Moonlight section: discovered plus manually
  added hosts, the PIN-to-type-into-the-host callout, and per-row pair/
  connect/disconnect, all through the existing design kit.
- Translations: the ten new user-facing strings, complete across all six
  catalogues (the sync + completeness gate passes locally).
…them periodically

The media-port hole punch had two robustness gaps:

- The SETUP responses' X-SS-Ping-Payload was parsed but never used, so a
  host that matches sessions by it (Wolf's IP-less-connection extension,
  SS_PING{payload[16], seq}) could not attribute our pings. The ping now
  echoes that payload when the host supplied one, zero-padded/truncated
  to the fixed 16-byte field with the sequence little-endian behind it;
  the legacy 4-byte "PING" remains the fallback when none was given.
- A single datagram per port meant one lost packet left the host blind
  to our media address. The session now re-pings both ports every 500 ms
  from a QTimer until teardown (off the input hot path), owns the two
  UDP sockets as members, and drains inbound datagrams without copying.

encodeRtpPing lives in the Qt-free wire module; three new test cases pin
the fallback bytes, the SS_PING layout and the pad/truncate behaviour
(574 assertions in 72 cases green in the portable harness).
…e build jobs

Two remote-CI failures on the Moonlight branch, neither reproducible on
the Windows dev host that wrote it:

- clang-tidy (gcc leg, exit 123). AppModel::bindMoonlightSlot moved into
  QHash::insert, whose value parameter is a const reference, so the move
  never happened and performance-move-const-arg flagged both calls. Now
  assigns through operator[], which returns T& and does move. The three
  ENet forward declarations tripped bugprone-reserved-identifier and its
  two cert aliases. Including <enet/enet.h> is not open to us: dish_enet
  is a PRIVATE link dependency of dish_core, so its include directory
  does not reach the library's consumers, and this header rides up to
  AppModel.h, which would pull the socket headers into most of the tree.
  Suppressed the way SDLGamepadBridge.h already suppresses SDL2's struct
  tags, with the reasoning written down beside it.
- Package (.deb) configure failed on "Could NOT find OpenSSL": the job's
  apt list was never updated when libcrypto became a dependency. Added
  libssl-dev there, in the shared setup-build-deps action, in CodeQL and
  in all three release build jobs (openssl-devel for the Fedora/RPM one),
  and libssl-dev to the README's dependency list. The .deb's runtime
  Depends needs no change: CPACK_DEBIAN_PACKAGE_SHLIBDEPS derives libssl3
  from DT_NEEDED exactly as it already does for libsodium and SDL2.

Local verification: clang-tidy 22.1.8 against the fleet .clang-tidy with
--warnings-as-errors='*' reproduces all three reserved-identifier errors
on the old header and reports none on the new one; a QHash-shaped
reproducer shows insert(k, std::move(v)) flagged and h[k] = std::move(v)
clean. The header syntax-checks under gcc and clang alongside the real
<enet/enet.h> in the .cpp's include order. Portable Moonlight harness 574
assertions in 72 cases green; clang-format 22.1.4 clean over src and
tests; QML literal scanner, translation sync/completeness and the action
pin lint all pass.
The remaining five findings from the same CI sweep, none of which the
first pass covered:

- MoonlightHostRepository::load moved into QHash::insert on both the
  object and the legacy-array branch, where the const-reference value
  parameter turns the move into a copy. Assigns through operator[]
  instead, hoisting the key on the array branch so it is not read out of
  the host the same statement moves from.
- MoonlightManager took its shared QSettings by value and then only
  copied it into three collaborators. Takes it by const reference now;
  it shares the pointer rather than sinking it, unlike the repositories.
- stepStreamId's default arm repeated its return type; returns {}.
- pinFromRandom dropped snprintf's return. Marked (void) with the reason,
  matching main.cpp's (void)std::fprintf and UnixSignalWatcher's
  (void)::signal: "%04u" of a value below 10000 writes exactly four
  digits into a five-byte buffer, so there is nothing to check.

Local verification: clang-tidy 22.1.8 now runs on the real sources here,
driving it at the mingw target over msys2's gcc 16 and Qt 6.11 headers.
All five files come back clean under the fleet .clang-tidy with
--warnings-as-errors='*', as do MoonlightControlStream.{h,cpp}. The four
files also compile clean under -Wall -Wextra -Wold-style-cast -Wshadow
-Wconversion -Werror against that Qt. Portable harness still 574
assertions in 72 cases green, which covers pinFromRandom; clang-format
22.1.4, the QML literal scanner and the translation gates all pass.
dish-android hit six real protocol bugs against a live Sunshine host and
fixed them there. Each one was checked against this client as it actually
is; four applied, two were already right, and one does not apply at all.

WHAT WAS BROKEN HERE

RTSP reused one socket for the whole handshake. A Moonlight host answers
exactly one message per TCP connection and then hangs up on its own, so
everything after OPTIONS was written into a socket the host had already
closed. Every request now dials its own socket, and the reply is framed
by Content-length when the host sends one and by the close when it does
not, which is the shape the DESCRIBE reply arrives in.

The ANNOUNCE SDP carried 17 attributes. A host looks each one up by name
and a lookup that misses is fatal: a minimal set is answered 400 BAD
REQUEST. It now carries the full 32, matching what a real client sends.

CONTROLLER_ARRIVAL was seven body bytes. The host reads it out of a
naturally aligned struct, so the u32 button mask starts at offset 4 and
offset 3 is reserved. Seven bytes shifted every field after the type by
one and the host logged capabilities [FF03] and supportedButtonFlags
[000000FF] instead of [0003] and [0000FFFF].

The RTP media pings only started once the ENet control link came up. The
host counts its initial-ping deadline from its own session start, so the
sockets now open and the pings start the moment SETUP names the ports.

localAudioPlayMode was 0, which asks the host not to play audio locally.
That is right for a remote viewer and wrong for a dish, whose user is
sitting at the host with the pad in their hands: it silenced their own
speakers for the length of the session. It is 1 now. sops stays 0.

The launch mode was a fixed 1280x720x30. A virtual-display host follows
what the client asks for, so a small request resized the user's desktop
under them. serverinfo's SupportedDisplayMode rows are parsed and the
largest is requested, with the fastest refresh offered at that size.

A refusal was read from the HTTP status line. A Moonlight host says no in
the BODY: a second /launch is answered HTTP 200 carrying
status_code="400" and "An app is already running on this host", plus a
<resume> flag. The body is parsed now: a resumable refusal promotes the
attempt to /resume, an unresumable one ends it with its own reason token
and a message that says what happened. Every XML endpoint was audited for
the same mistake, and a reply that names no status_code at all is read as
success, which is what a plainly-answering host sends.

Qt shares and persists TLS sessions across the connections one
QNetworkAccessManager makes, and a resumed session skips a Moonlight
host's verify callback, which Sunshine answers with a fatal
internal_error alert and no log line at all. Session tickets, sharing and
persistence are all off now.

WHAT WAS ALREADY RIGHT, NOW PINNED

The control-stream GCM IV already kept only the low byte of the sequence,
the way the host builds it. There is now a byte-exact test that sequences
256 apart seal identically, which a four-byte IV would fail; that is the
fault that ends a session just past two minutes.

X-SS-Connect-Data already parsed unsigned. It now reads 64 bits wide and
narrows to the 32 ENet puts on the wire rather than rejecting an
over-wide value into a silent zero, and is pinned above and below
INT32_MAX including the live host's own 4270471497.

WHAT DOES NOT APPLY

ENet unknown-command acknowledgement. That was a fault in a hand-written
port. This repo vendors the real MIT ENet, whose protocol.c handles
BANDWIDTH_LIMIT explicitly and queues an acknowledgement for any command
carrying the ACKNOWLEDGE flag, and the service loop already runs
continuously rather than only when input changes.

LOGGING

One dish.moonlight category at the QtInfoMsg floor covers the whole path:
every RTSP request and reply with the step it belongs to, the step a
mid-handshake hang-up died on, each pairing phase with what the host said
in the body, the launch status and the parsed ports, the control link
coming up and going down, and every give-up branch by name.

TESTS

Byte-exact wire fixtures for the 20-byte SS_PING built from the live
host's own payload, with a regression that the encoder never emits a
datagram in the 5..19 dead zone the host drops silently; the
CONTROLLER_ARRIVAL alignment read back the way the host reads it; the GCM
IV wrap across the 256 boundary; the connect token above and below
INT32_MAX; the whole ANNOUNCE attribute set line by line; the in-body
refusal in every shape including the resumable one; the display-mode
pick; the reducer's launch-to-resume promotion and its no-loop guard; and
a new socket-level suite that drives the transport against a loopback
host, asserting three requests are three connections and that a reply
with no Content-length is read to the close.
The one guarantee the mutual-TLS calls make that leaves no trace anywhere
else. A resumed session skips a Moonlight host's verify callback, and
Sunshine answers that with a fatal internal_error alert and no log line
at all, so a regression here would look exactly like an unreachable host.

Lifts the configuration out of perform() into a static
MoonlightHttp::tlsConfiguration so it can be asserted directly: the three
switches are set, peer verification is off because trust is the pairing
pin, and an unparsable identity is dropped rather than half-applied. The
non-vacuity check asks only that Qt's own default would still offer a
session by at least one route, so which switch Qt leaves on stays Qt's
business.
…e binding

Connecting to a Moonlight host was modelled as a connection, and it is
not one. Moonlight has no bidirectional liveness: pairing is one-time
trust, checkable only when the client asks (a /serverinfo PairStatus, or
a mutual-TLS handshake that succeeds, which is itself proof), and a
host-side unpair is discovered on the next call and never announced. The
hosts screen drew that as a pulsing status dot beside a Connect button,
which promised a link nobody can observe.

The session was modelled per binding, and it is not that either. One
Moonlight session carries up to four controllers behind a single launch,
so it belongs to the HOST. Two pads on one host used to mean two
/launch calls, the second of which the host answers with "an app is
already running".

WHAT MOVED

The Moonlight inventory leaves ConnectionsPage for a page of its own,
the way it already is in dish-windows: the two host kinds pair
differently, so one merged list made "Pair" and the status column each
mean two things. Connections keeps a labelled doorway to it.

That page now owns pairing, forgetting, and one Quit session escape
hatch, and nothing else. The emulated-device picker, the app picker and
Connect are gone from it. Connect is implicit: binding a controller
starts or joins the host's session, and unbinding the last one ends it.

A row states TRUST, in three words, and never liveness: paired when this
visit confirmed it, remembered when a certificate is stored and the host
did not answer, not paired otherwise. It is re-asked on entering the
screen and before a session starts, and never polled.

THE BINDING FLOW

The wizard gains a conditional sixth page, WizardSessionPage, between
Type and Feel, shown only for a Moonlight destination and skipped
entirely otherwise. Configure binding renders the same file inline, so
the two editors cannot tell a user two different stories about one host.

It renders exactly one of twenty-one states, and applying is NEVER
blocked by any of them except a host already carrying four controllers.
A binding is a durable intent: the session is attempted when the pad is
used, not when the user presses Apply, so an unpaired, unreachable,
refusing or dropped host is a state with an action in it and not a
refusal of the answer the user gave.

The app is per SESSION. The binding that creates one picks it from
/applist; every later binding shows what is running and is offered no
picker at all, because a disabled picker implies a choice that does not
exist. The controller type is per BINDING, so one host can carry a
PlayStation pad and an Xbox pad at once.

/applist finally has a caller. It is HTTPS and paired-only, so a refusal
is reported as a failure rather than as an empty list: reading a 404 as
"no apps" would present it as a fact about the host, and the two states
say different things to the user.

WHAT THE HOST WILL NOT TELL US

No Moonlight host reports what its emulated controllers can do.
/serverinfo carries no controller element; plaintext /applist answers
404 and its rows carry only title, id and an HDR flag; Wolf's
serverinfo() signature has no field to put it in. The data flows the
other way: the host builds its virtual device FROM the type byte and
capability bitfield we declare, and never reports the choice back, and
it may override it silently.

So the capability table is client-side knowledge, hard-coded in
core/moonlight/MoonlightPadSlots.h from what the reference host actually
builds per type. Xbox and Nintendo are sticks, buttons, analog triggers
and body rumble; PlayStation is the only one that carries trigger
rumble, touch, motion, battery and an LED. Nintendo has NO motion over
Moonlight, unlike the satellite switchpro type: the two are different
type systems that happen to share names. The type cards say so, and the
copy never promises the host will honour the pick.

Auto resolves on the CLIENT, before the wire: a pad reporting gyro or an
accelerometer becomes PlayStation, everything else Xbox. It is the only
rule that both matches the reference host's own promotion of an
unknown-with-motion pad and lets the card state what the pad will
support. The declared bitfield is the type's ceiling intersected with
what the source can actually deliver, because declaring a capability the
pad does not have makes the host request reports that never arrive.

TWO WIRE CHANGES

supportedButtonFlags is 0x0000FFFF, not 0xF7FF. Bit 0x0800 is named in
neither Wolf's table nor its control.hpp, and the live host logs the
whole low half back; three Dish clients advertising three different
masks is drift nothing gains from.

The Auto sentinel is 0xFF in the store, and a persisted 0 migrates on
read. 0 is CONTROLLER_TYPE_UNKNOWN on the wire, which asks the HOST to
pick: a different promise from "match the pad".

REFERENCE COUNTING

MoonlightSession owns the controller numbers and the CONTROLLER_MULTI
active mask. The first binding on a host starts or joins the session and
announces its pad; later bindings make no HTTP call at all and only send
their own CONTROLLER_ARRIVAL. An unbind clears the pad's bit and sends
the unplug; the last one hands the app back with /cancel, because
nothing is riding it any more. A live session with controllers still on
it is left alone: closing somebody's game out from under them is worse
than the tidying is worth.

The mask is published to the hot path through one atomic, so
sendControllerState still costs no lookup and no allocation.

Host events now carry the controller number through, because one session
drives four pads and an event that named none of them could only be
applied to the wrong one.

TESTS

The twenty-one states walked one by one with the token each renders, and
the guarantee that exactly one of them blocks Apply. The capability
ceiling per type, including the Nintendo motion rule and the intersection
with the source. Auto resolution both ways, the 0xFF sentinel and the
migration from a persisted 0 and from anything out of range. Controller
numbers: the lowest free index, no reuse while live, the mask, and the
fifth pad refused. The reference count end to end against the real
coordinator: four pads on one host, one session object, three unbinds
that leave the launch alone and a fourth that stops it. And the /applist
caller reporting a paired-only refusal as failed rather than empty.
…the QHash

QHash::size() is a signed qsizetype, so returning it as the std::size_t
the refcount contract is written in trips -Wsign-conversion, which this
repo builds with -Werror. The controller numbers are the authority for
how many pads ride a session anyway; the declaration map is a lookup
beside it, and the two can only ever agree.
…s own title

DishTests links Catch2 and not Qt6::Test, so <QSignalSpy> does not
resolve there; every other suite that watches a signal counts it with a
small local struct instead, and the applist test now does the same. The
local Qt harness this was written against had linked Qt6::Test, which is
exactly why the fault reached CI, and it no longer does.

The session section also drew a heading above the empty state and the
error banner, both of which already carry a title of their own, so those
four states said the same sentence twice on one screen.
A Moonlight host refuses for reasons of its own and phrases them itself,
in the status_message of a body it answers HTTP 200 with. The refused
state paraphrased that away and printed only which host had said no,
dropping the one detail the user could act on. The session keeps the
message verbatim now, falling back to the status code when a host names
one without words, and the banner reads it.

Also stops four states saying the same sentence twice: the empty state,
the error banner and the loading spinner each draw their own title, so
the heading above them stands down rather than repeating it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant