# PyroWave streaming with ArtLight ArtLight streams [PyroWave](https://github.com/Themaister/pyrowave), Hans-Kristian Arntzen's intra-only GPU wavelet codec, over its normal video stream. To use PyroWave or VRR playback with ArtLight, install Nonary's [VRR Moonlight Client fork](https://github.com/Nonary/moonlight-qt). Select PyroWave explicitly in that client's codec settings; stock Moonlight does not support it. Every frame is independently decodable, so a lost frame costs one frame and never needs a keyframe request. PyroWave trades bandwidth for latency: encode and decode take well under a millisecond, but a clean picture needs hundreds of Mbps, so it is meant for wired LANs. The same document lives in both repositories (`moonlight-qt/docs/pyrowave-protocol.md` and `ArtLight/docs/pyrowave-protocol.md`). Change both together. ## Codec library and bitstream version Both ends vendor upstream PyroWave at the same commit through `pyrowave/vendor-pyrowave.ps1` (moonlight-qt) and `third-party/pyrowave` (ArtLight); see `VENDOR.txt`. The PyroWave bitstream has no version field, so the host advertises the vendored commit (below) and the client warns on a mismatch. | Name | Value | |---|---| | `PYROWAVE_BITSTREAM_ID` | `186f0393` (first 8 hex digits of the vendored pyrowave commit) | Local patches (buffer pool, 4:4:4 payload sizing, zero-length block guard) do not change the bitstream. ## Negotiation ArtLight and the VRR Moonlight Client use the capability and format constants below to negotiate PyroWave profiles and framing. See "Compatibility" for client requirements. ### `/serverinfo` The host ORs these bits into `ServerCodecModeSupport` when PyroWave encoding works on the capture adapter: For a paired HTTPS `/serverinfo` request, a capable host also returns `PyroWaveHostLinkMbps` (zero if its outbound route is not a known physical wired link) and `PyroWaveBandwidthProbeBytes=33554432`. The link number is the host's local transmit speed, not measured end-to-end throughput. Linux and Windows resolve the route to the requesting client; Linux ignores virtual, wireless, half-duplex and inactive interfaces. The paired client can GET `/pyrowave-bandwidth-probe` over its pinned HTTPS connection. It receives exactly 32 MiB of fixed binary payload to time. The client discards a warm-up and uses the slowest of three measurements, then reserves 20% for protocol overhead and contention. The result is a bulk host-to-client throughput estimate. It does not prove that live UDP bursts will avoid packet loss, so calibration remains a recommendation rather than a stream quality guarantee. | Bit | Value | Meaning | |---|---|---| | `SCM_PYROWAVE` | `0x00800000` | 8-bit 4:2:0 | | `SCM_PYROWAVE_444` | `0x01000000` | 8-bit 4:4:4 | | `SCM_PYROWAVE_HDR10` | `0x02000000` | 10-bit 4:2:0 (HDR10 when the display is HDR) | | `SCM_PYROWAVE_HDR10_444` | `0x04000000` | 10-bit 4:4:4 | `SCM_MASK_10BIT` gains the two HDR10 bits and `SCM_MASK_YUV444` the two 4:4:4 bits. Client-local format constants (moonlight-common-c `Limelight.h`, never on the wire): | Constant | Value | |---|---| | `VIDEO_FORMAT_PYROWAVE` | `0x010000` | | `VIDEO_FORMAT_PYROWAVE_444` | `0x020000` | | `VIDEO_FORMAT_PYROWAVE_HDR10` | `0x040000` | | `VIDEO_FORMAT_PYROWAVE_HDR10_444` | `0x080000` | | `VIDEO_FORMAT_MASK_PYROWAVE` | `0x0F0000` | | `VIDEO_FORMAT_MASK_10BIT` | `0xCAA00` (adds `0x40000 \| 0x80000`) | | `VIDEO_FORMAT_MASK_YUV444` | `0xACC04` (adds `0x20000 \| 0x80000`) | Extending the two masks makes the existing SDP and `/launch` code emit `chromaSamplingType=1`, `dynamicRangeMode=1` and `hdrMode=1` for the matching PyroWave profiles without further changes. ### RTSP DESCRIBE (host to client) ``` a=rtpmap:99 PYROWAVE/90000 a=x-ss-pyrowave.bitstream:186f0393 ``` The `rtpmap` line is a capability marker only; no RTP payload type 99 is sent. The VRR Moonlight Client selects PyroWave only when the user chose the PyroWave codec and `ServerCodecModeSupport` has `SCM_PYROWAVE`. It picks the best mutual profile: HDR10 4:4:4, HDR10, 4:4:4, then 8-bit 4:2:0. PyroWave is never chosen automatically, since it needs a wired link with hundreds of Mbps to spare. ### RTSP ANNOUNCE (client to host) | Attribute | Value | |---|---| | `x-nv-vqos[0].bitStreamFormat` | `3` (PyroWave; 0/1/2 are H.264/HEVC/AV1) | | `x-ss-video[0].chromaSamplingType` | `1` for 4:4:4, else `0` (stock attribute) | | `x-nv-video[0].dynamicRangeMode` | `1` for 10-bit, else `0` (stock attribute) | | `x-ss-video[0].pyrowaveAdaptiveFec` | `0` (its presence selects record framing) | | `x-ss-video[0].pyrowaveAdaptiveBitrate` | `0` | | `x-ss-video[0].pyrowaveFeatures` | bitmask, below | `pyrowaveFeatures` bits: | Bit | Name | Meaning | |---|---|---| | `0x1` | `PYROWAVE_FEATURE_RECORD_FRAMING` | Client parses record framing with padding records. | `0x2` was once reserved for partial-frame decoding. No bit is needed: record-framed frames use the partial-recovery layout when the negotiated packet size supports record alignment (see "Record framing"), and ArtLight ignores `0x2`. The host rejects `bitStreamFormat=3` with `400 BAD REQUEST` when PyroWave is unavailable, like HEVC/AV1. Colour: the stock `x-nv-video[0].encoderCscMode` selects range and SDR matrix exactly as for the other codecs. 10-bit streams on an HDR display use BT.2020 PQ unless 10-bit SDR is preferred or SDR is forced; those streams use the SDR matrix. The host keeps sending the usual HDR mode and metadata control messages. ## Frames PyroWave frames use ArtLight's existing video transport: RTP, the `NV_VIDEO_PACKET` header and FEC block layout (up to four blocks), optional AES-GCM, and the 8-byte short frame header in front of the first payload. The frame header `frameType` is always `2` (IDR). moonlight-common-c trims the last payload to `lastPayloadLen` as it does for AV1. The host ignores IDR and reference-invalidation requests for PyroWave sessions. Every frame is independent, so a lost packet costs at most that frame, and with record framing usually only the detail it carried (see "Partial frames"). The coarsest wavelet level is required for decoding. With aligned record framing and `pyrowave_critical_fec_percentage > 0`, its leading shards receive parity at that percentage, with at least two parity shards. Protection is skipped if the critical data and parity cannot fit in one Reed-Solomon block of 255 shards. Length-prefixed frames and record frames without shard alignment have no critical prefix and receive no parity. `fec_percentage` does not apply to PyroWave. Finer detail normally has no parity, but the host can protect it alongside the critical data when sustained low frame rates make loss-induced flicker more visible. For record framing with `pyrowave_critical_fec_percentage > 0`, the encoded image budget is capped at the encoder bitrate divided by the negotiated frame rate. At session setup, the host derives the encoder bitrate from the client's requested bandwidth budget after allowing for audio, packet overhead and control traffic. A frame-rate drop leaves bandwidth available for protection instead of making each encoded image larger. For example, an encoder bitrate of 800 Mbps at 120 FPS allows about 0.83 MB of encoded data per frame; that ceiling remains about 0.83 MB at 60 or 30 FPS. Faster-than-negotiated submissions still receive a smaller elapsed-time budget. The adaptive policy requires at least 250 ms of frames below negotiated FPS with at least 75% unchanged encoded record bytes. It compares records by block ID, ignoring sequence numbers, padding and packing order. This is a conservative proxy for picture activity, not a pixel motion measurement: changes in encoder quantization count as changes too. A 1% timing tolerance ignores small cadence noise. The requested detail parity percentage is `min(50, 100 * (negotiated FPS / observed FPS - 1))`. Thus a 120 FPS session requests about 9% at 110 FPS, 20% at 100 FPS, 33% at 90 FPS and 50% at 80 FPS or below. This cap is independent of the critical block's configured parity rate; the critical rate remains unchanged. Motion or a return to negotiated cadence disables extra protection immediately; cadence smoothing and the entry delay avoid reacting to isolated hitches. Extra parity uses only unused bytes from the current bitrate allowance, after accounting for data, baseline parity, network headers and encryption. The allowance uses the smaller of the current and smoothed frame intervals, capped at 50 ms, without accumulating idle credit. A frame that already fills its allowance gets no extra parity. This limits overhead against the configured bitrate; it does not estimate available downstream network capacity. Unused bandwidth beyond useful parity is left idle; the sender does not try to fill the configured bitrate after a cadence drop. The 50% cap corresponds to an ideal one-third missing-packet tolerance within a fully protected block, not a guarantee at one-third average network loss. The planner may select less parity or protect only part of the detail to satisfy the wire budget and the four-block limit. The planner prefers protecting all finer detail, reducing its parity percentage if necessary. Frames too large for that receive partial coverage: first by extending the critical block, then adding protected detail blocks where space allows. Every protected block stays within the Reed-Solomon limit of 255 data plus parity packets, and the frame still fits four blocks. The announced critical packet count continues to describe only the required coarse data. No new client protocol is needed. Setting `pyrowave_critical_fec_percentage=0` also disables adaptive detail FEC; length-prefixed clients keep their existing behavior. The debug statistic `PyroWave: detail packets protected` reports actual coverage after budget and block limits, rather than just the requested rate. The frame is split into at most four blocks. The host caps a frame at 3000 packets when critical FEC is on and 4000 when it is off. The codec budget and record padding honor this limit and the negotiated packet size. PyroWave refreshes the routed link speed every two seconds for pacing; if it is unavailable, pacing follows packet demand and stream bitrate. `pyrowave_send_rate_mbps` is ignored. Other codecs retain their existing pacing and FEC settings. When sending falls behind, a newly encoded PyroWave frame replaces that session's pending frame in the send queue. The frame already being sent completes, and other sessions keep their queue positions. ### Record framing (host default for PyroWave-aware clients) Used when the client sent `x-ss-video[0].pyrowaveAdaptiveFec` or `pyrowaveFeatures & 0x1`. The frame payload is a concatenation of 32-bit little-endian records: 1. A PyroWave `BitstreamSequenceHeader` (8 bytes, `extended = 1`, `code = 0`), first in the frame, with the negotiated width, height and chroma resolution and `total_blocks` equal to the number of block records in the frame. 2. PyroWave block records (`BitstreamHeader` + payload, `payload_words` words including the header, `sequence` equal to the sequence header's), in any order. PyroWave places blocks by `block_index`. 3. Padding records, anywhere between other records: `0xFFFFFFFF`, a word count `N`, then `N` zero words (`8 + 4N` bytes). Decoded as a sequence header this would be an impossible 16384-pixel width with code 3, so it cannot be confused with real data. Layout: the RTP layer splits the frame into payloads of `packetSize - 16` bytes (1376 for the usual 1392-byte packets); the first payload also carries the 8-byte frame header, so its frame data ends 8 bytes early. Alignment requires this payload size to be a multiple of four and at least 24 bytes. Otherwise (for example, with 1390-byte packets), the host copies the encoder's records without reordering or padding and reports no critical prefix. The layout below applies when alignment is available. After the sequence header our host sends two groups: 1. PyroWave's coarsest wavelet level: block indices below `12 * ceil(W / 32) * ceil(H / 32)`, where `W` and `H` are the frame's width and height rounded up to 32 pixels (at least 128), divided by 32. PyroWave indexes these first. They end in the last critical packet. 2. All other records. Within each group, oversized records (too large to share a payload with a padding record: more than a payload less 8 bytes) come first, in encoder order, and span payloads; a minimal padding record goes before one that would otherwise end 4 bytes before a payload boundary. The group's other records follow, packed first-fit: when the next record does not fit the rest of a payload, a later record of the group that does fills it, and padding fills it only when none fits (well under 1% of the frame in practice, against 16-21% if records kept strict encoder order). None of them crosses a payload boundary, and no 4-byte remainder (too small for a padding record) is left, so every payload after the second group's oversized records starts with a record. Receivers must not rely on this layout to parse a complete frame: they parse records sequentially and accept straddling records and padding anywhere. Only partial-frame recovery depends on it. Receivers must reject a block record whose `payload_words` is smaller than 2 (the header itself), a record that runs past the end of the frame, a sequence header whose size or chroma resolution differs from the negotiated stream, a second sequence header, a block record before the sequence header or with another `sequence`, and `block_index` values outside the frame. A rejected frame is dropped; the next frame is independent. ArtLight sends complete, independent frames. It never sends conditional replenishment records (sequence code 1 "keep previous" frames or header-only zero blocks), which upstream PyroWave does not decode. ### Length-prefixed framing (compatibility) Used for clients that request PyroWave without either attribute above (the azafrob/andygrundman/dimizago Moonlight PyroWave clients, including Moonlight PyroWave for Xbox): ``` [u32 LE packet_count] { [u32 LE size] [size bytes: PyroWave packet] } * packet_count ``` Each PyroWave packet is the output of `pyrowave_encoder_packetize` with a 1024-byte boundary; packet 0 starts with the sequence header. Our client detects the framing per frame: a record-framed frame starts with a sequence header, whose first word has bit 31 (`extended`) set, while a packet count never does. ## Decoding The decoder is cleared before every frame (`pyrowave_decoder_clear`), all records are pushed, and the frame is decoded when `pyrowave_decoder_decode_is_ready` reports it complete. Clearing per frame keeps PyroWave's 3-bit sequence counter from discarding frames after four or more consecutive network drops. ### Partial frames Our client decodes a record-framed frame that lost packets. moonlight-common-c first repairs what parity can (critical packets and any protected detail). It does not drop a PyroWave frame whose FEC block cannot complete: once the next block or frame starts arriving, each missing data packet is replaced by zeros and delivered as a `BUFFER_TYPE_LOST` buffer. The frame is still dropped when its first packet (sequence header) or a whole FEC block is missing, which parity on the critical block makes rare. Packets flagged `0x80` arrive as `BUFFER_TYPE_RECORD_START` buffers, and the critical packet count as `DECODE_UNIT.pyrowaveCriticalPackets`. The parser skips every record that lost a byte. A record whose header arrived but whose payload did not has a known end, so parsing continues after it. When a header itself was lost, parsing resumes at the next received payload flagged as starting with a record. Without these flags, it resumes at the next received payload only once a finer record of ordinary size (one that fits a payload with 8 bytes to spare) was seen inside a single payload, which the layout above makes a record boundary; before that the rest of the frame is given up. The coarsest level is intact when none of the announced critical packets was lost (without an announcement: when no loss came before the first finer record). The frame is then decoded if more than 90% of its records arrived (`pyrowave_decoder_decode_is_ready_with_sideband` with no pristine-band requirement); missing finer blocks decode as zero coefficients, which blurs their area for that frame. Losing the packets right after the critical ones blurs the most, since they hold the next-coarsest level. PyroWave's own pristine-band check is not used because it cannot tell a lost block from an all-zero block that was never sent. Length-prefixed frames with any loss lose the frame. Record-framed partial recovery depends on the alignment and record-start flags described above. Output planes are three single-channel UNORM images (R8 for 8-bit streams, R16 for 10-bit): full-resolution Y, and Cb/Cr at half resolution in each direction for 4:2:0. Sample values are normalized code values `code / (2^N - 1)` with `N` = 8 or 10, in the negotiated range and matrix. 4:2:0 chroma is sited at the center of each 2x2 luma quad. ## Rate control The client's configured bitrate (`x-ml-video.configuredBitrateKbps`) is used exactly as for the other codecs; the host subtracts audio and control overhead, but not FEC. The initial per-frame byte budget uses the negotiated frame rate. With record framing and FEC enabled, subsequent image budgets use the smaller of that interval and the elapsed time between encoding attempts, including repeated images. Slower submissions therefore keep the same encoded image ceiling and make room for adaptive detail FEC. Disabling FEC or using length-prefixed compatibility framing retains elapsed-time image budgets. Dynamic bitrate changes update the ceiling using the new bitrate and the original negotiated FPS. The negotiated transport capacity also bounds the budget. Budgets are rounded down to codec words; a budget too small for the codec headers skips that attempt. Framing and network headers still add overhead to the codec bitrate. When no new capture arrives, the host re-encodes the last image. By default the wait is one negotiated frame interval, so a static screen can recover promptly from a lost frame. An explicit `minimum_fps_target` can reduce the repeat cadence and is capped at the negotiated frame rate. Capture mutex waits are also bounded by the negotiated frame interval. Packetizer storage follows the codec's reported bitstream buffer size plus its sequence header. Guidance: about 1.6 bits per pixel is visually clean for 4:2:0 SDR (Themaister's reference point, 200 Mbps at 1080p60). 4:4:4 costs about 1.6x, and 10-bit about 1.15x. ## Compatibility | Client connecting to ArtLight | Result | |---|---| | Nonary's [VRR Moonlight Client fork](https://github.com/Nonary/moonlight-qt) | Recommended for PyroWave and VRR playback. Uses record framing and partial-frame recovery with the matching vendored bitstream. | | Aurora client | Negotiates PyroWave, record framing. Its decoder is an older WiVRn-derived PyroWave; frames decode only if its bitstream matches `186f0393`. | | Xbox / azafrob-protocol client | Negotiates PyroWave, length-prefixed framing. Bitstream compatibility depends on its PyroWave commit. | | Stock Moonlight | Never sees PyroWave; negotiates H.264/HEVC/AV1 as before. |