Compare commits
@@ -61,6 +61,43 @@ jobs:
|
||||
- name: Test (unit + loopback + proptest + C ABI harness)
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
|
||||
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
|
||||
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
|
||||
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
|
||||
# `cargo build`, where warnings are not errors, so pf-encode's own
|
||||
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
|
||||
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
|
||||
# `default = ["pyrowave"]`, so the steps above already cover it.)
|
||||
#
|
||||
# `--all-targets` is load-bearing, not decoration: without it the feature-gated
|
||||
# `#[cfg(test)]` modules are never compiled, which is exactly how all ten
|
||||
# `NvencCudaEncoder::open` call sites in nvenc_cuda.rs's tests drifted to the wrong arity
|
||||
# (E0061 x10) without any job noticing.
|
||||
#
|
||||
# GPU-free: every test needing real hardware is `#[ignore]`d, and NVENC/CUDA resolve their
|
||||
# entry points at RUNTIME (dlopen), so the test binary links without a driver present.
|
||||
# (On MSVC the same crate link-imports those symbols instead, which is why windows-host.yml
|
||||
# can only type-check these tests via clippy — see the note there.)
|
||||
#
|
||||
# Scoped to `-p pf-encode` with ITS OWN feature names: punktfunk-host has no code gated on
|
||||
# `nvenc`/`vulkan-encode` (its only `cfg(feature)` sites are the two `pyrowave` ones in
|
||||
# capture.rs, and pyrowave is default-on, so the steps above already cover them). Going
|
||||
# through `--features punktfunk-host/...` would force punktfunk-host into the selection and
|
||||
# re-run its entire test suite a second time for no extra coverage.
|
||||
#
|
||||
# `pyrowave` is listed explicitly even though it is punktfunk-host's default: selecting only
|
||||
# `-p pf-encode` takes the host out of the resolution, and pf-encode's own default is empty.
|
||||
# Naming it keeps this the SHIPPED Linux feature set — deb.yml builds
|
||||
# `--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode` WITHOUT
|
||||
# `--no-default-features`, so the .deb carries nvenc + vulkan-encode + pyrowave together, and
|
||||
# that combination is what deserves the lint.
|
||||
- name: Clippy + test the feature-gated Linux encode backends
|
||||
run: |
|
||||
cargo clippy -p pf-encode --all-targets --locked \
|
||||
--features nvenc,vulkan-encode,pyrowave -- -D warnings
|
||||
cargo test -p pf-encode --locked --features nvenc,vulkan-encode,pyrowave
|
||||
|
||||
- name: C ABI harness (standalone link proof)
|
||||
run: bash crates/punktfunk-core/tests/c/run.sh
|
||||
|
||||
|
||||
@@ -50,6 +50,23 @@ on:
|
||||
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
|
||||
- 'crates/pf-encode/**'
|
||||
- 'crates/libvpl-sys/**'
|
||||
# …and the rest of the W6 subsystem crates this build compiles. pf-encode was listed while
|
||||
# the crates it speaks (pf-frame's CapturedFrame/PixelFormat/dxgi vocabulary, pf-gpu's
|
||||
# adapter selection, pf-zerocopy, pf-host-config) were not, so a change that broke the
|
||||
# Windows host through one of THEM reached main with no Windows build at all — the same
|
||||
# Cargo.lock-luck gap the two lines above were added to close.
|
||||
- 'crates/pf-frame/**'
|
||||
- 'crates/pf-gpu/**'
|
||||
- 'crates/pf-zerocopy/**'
|
||||
- 'crates/pf-host-config/**'
|
||||
- 'crates/pf-capture/**'
|
||||
- 'crates/pf-win-display/**'
|
||||
- 'crates/pf-vdisplay/**'
|
||||
- 'crates/pf-inject/**'
|
||||
- 'crates/pf-paths/**'
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'crates/pf-clipboard/**'
|
||||
- 'crates/pyrowave-sys/**'
|
||||
- 'packaging/windows/**'
|
||||
- 'scripts/windows/**'
|
||||
- 'web/**'
|
||||
@@ -154,8 +171,23 @@ jobs:
|
||||
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
|
||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||
# pf-vkhdr-layer's clippy below runs --release.
|
||||
#
|
||||
# pf-encode is linted SEPARATELY with --all-targets so its Windows `#[cfg(test)]` modules
|
||||
# are type-checked — the AMF C-ABI layout assertions (`variant_layout_matches_c` and
|
||||
# friends, which are the only guard on a hand-mirrored vtable ABI), the QSV tests, and the
|
||||
# PyroWave-Windows smoke test. The host lint above cannot cover them: `-p punktfunk-host`
|
||||
# only builds pf-encode as a dependency, so its test targets are never compiled, and that
|
||||
# blind spot is what let the Linux twin's tests rot to the wrong arity unnoticed.
|
||||
# NOTE: clippy (a check, no link step) is deliberately the vehicle here — `cargo test`
|
||||
# with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk link-imports
|
||||
# NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve only against
|
||||
# the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can and does
|
||||
# run the tests there.) Running them here would need an `--features amf-qsv,qsv` build
|
||||
# without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 — not
|
||||
# worth it while ci.yml executes the same tests.
|
||||
run: |
|
||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
|
||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
|
||||
+3
-3
@@ -5106,7 +5106,7 @@
|
||||
"properties": {
|
||||
"audio_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the audio stream thread is running."
|
||||
"description": "True while audio is streaming on either plane (same rule as `video_streaming`)."
|
||||
},
|
||||
"conflicts": {
|
||||
"type": "array",
|
||||
@@ -5150,7 +5150,7 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionInfo",
|
||||
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
|
||||
"description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -5160,7 +5160,7 @@
|
||||
},
|
||||
"video_streaming": {
|
||||
"type": "boolean",
|
||||
"description": "True while the video stream thread is running."
|
||||
"description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2652,6 +2652,18 @@ mod pipewire {
|
||||
build_default_format_obj(preferred)
|
||||
};
|
||||
|
||||
// gamescope trap — the Steam overlay's presence in the stream is decided HERE by omission:
|
||||
// gamescope's `paint_pipewire()` composites the overlay (Shift+Tab / Quick Access Menu) into
|
||||
// the node it hands us ONLY when the consumer-negotiated `gamescope_focus_appid` is 0 — the
|
||||
// default, and the "mirror the focused window + overlay" branch (gamescope ≥ 3.16.23; see
|
||||
// `MIN_GAMESCOPE_OVERLAY`). None of the EnumFormat pods below advertise the
|
||||
// `SPA_FORMAT_VIDEO_gamescope_focus_appid` property, so gamescope reads 0 and paints the
|
||||
// overlay for us for free. DO NOT add a non-zero focus-appid (e.g. to "isolate the game" in a
|
||||
// dedicated session) — that flips gamescope into the Remote-Play branch that deliberately
|
||||
// drops the overlay (and all host chrome) back out of the capture. The cursor, external
|
||||
// overlay (MangoHUD), and notifications are excluded from the node on EVERY gamescope
|
||||
// version and are composited host-side instead (see `xfixes_cursor.rs`).
|
||||
//
|
||||
// When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers
|
||||
// (offering shm too makes the compositor pick shm). The modifier list is advertised with
|
||||
// DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in
|
||||
|
||||
@@ -7,6 +7,45 @@
|
||||
use anyhow::Result;
|
||||
use pf_frame::CapturedFrame;
|
||||
|
||||
/// Whether an encoder fed `format` must be built 10-bit — decided by **the pixels that actually
|
||||
/// arrive**, never by the negotiated `bit_depth`.
|
||||
///
|
||||
/// The three Windows backends each derived this as `bit_depth >= 10 || matches!(format, P010 |
|
||||
/// Rgb10a2)`, i.e. the *negotiated* depth could force a 10-bit encoder over an 8-bit capture. That
|
||||
/// combination is not hypothetical: a client advertises 10-bit, the handshake negotiates
|
||||
/// `bit_depth = 10`, and then enabling advanced colour on the IDD virtual display fails — at which
|
||||
/// point the capturer says so and delivers 8-bit NV12 anyway (`pf-capture`'s idd_push logs "10-bit
|
||||
/// HDR was negotiated but enabling advanced color on the virtual display FAILED — encoding 8-bit
|
||||
/// SDR"). Every backend then lost the session, each in its own way: native AMF and native QSV
|
||||
/// `bail!` at open because the format does not match the P010 they derived, and the libavcodec
|
||||
/// path accepted the open and then failed EVERY submit forever (its per-frame depth check
|
||||
/// recomputes from the frame, which never matches), where `reset()` could not help because the
|
||||
/// rebuild re-derived the same wrong depth.
|
||||
///
|
||||
/// Following the pixels keeps the stream alive and, more importantly, keeps it HONEST: the depth
|
||||
/// also selects the colour signalling (BT.2020 PQ vs BT.709) and the staging surface format, so an
|
||||
/// 8-bit capture now yields an 8-bit stream that says it is SDR — which is what the capturer
|
||||
/// already reported it is sending. The negotiated depth remains an upper bound; the session label
|
||||
/// may still claim HDR, and that mismatch belongs to the negotiation, not to the encoder.
|
||||
/// Windows-only: the three backends that derive an encoder depth from a capture live there
|
||||
/// (native AMF, native QSV, libavcodec AMF/QSV). The Linux backends take the depth from the
|
||||
/// negotiated `bit_depth` alone because their capture formats carry it unambiguously.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn ten_bit_input(format: pf_frame::PixelFormat, negotiated_depth: u8) -> bool {
|
||||
use pf_frame::PixelFormat;
|
||||
let ten = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
if negotiated_depth >= 10 && !ten {
|
||||
tracing::warn!(
|
||||
?format,
|
||||
negotiated_depth,
|
||||
"session negotiated 10-bit but the capturer delivers an 8-bit format — encoding 8-bit \
|
||||
SDR (the stream's colour signalling follows the pixels; check whether advanced colour \
|
||||
failed to enable on the virtual display)"
|
||||
);
|
||||
}
|
||||
ten
|
||||
}
|
||||
|
||||
/// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization.
|
||||
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
||||
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
|
||||
@@ -73,7 +112,7 @@ impl AuChunk {
|
||||
}
|
||||
|
||||
/// Codec selection negotiated with the client.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Codec {
|
||||
H264,
|
||||
H265,
|
||||
@@ -313,8 +352,15 @@ pub trait Encoder: Send {
|
||||
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
|
||||
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
|
||||
/// serializing on the encode wait. Returns whether pipelined retrieve is (now) active; the
|
||||
/// switch may be deferred to the next safe point internally. `false` from the default impl =
|
||||
/// unsupported — the session loop stops asking. De-escalation is a v2 item everywhere.
|
||||
/// switch may be deferred to the next safe point internally. `set_pipelined(true)` returning
|
||||
/// `false` (the default impl) = unsupported — the session loop stops asking.
|
||||
///
|
||||
/// `set_pipelined(false)` requests the wind-back (de-escalation, latency recovery): the
|
||||
/// backend restores its sync-retrieve mode — and the latency features that mode carries
|
||||
/// (IO-stream binding, sub-frame chunking) — at its next safe point, usually via a session
|
||||
/// rebuild whose first frame is an IDR. The return is still "is pipelined retrieve active":
|
||||
/// the caller polls until it reads `false`. Backends that never escalate return `false`
|
||||
/// trivially. An operator pin (`PUNKTFUNK_NVENC_ASYNC=1`) refuses the wind-back.
|
||||
fn set_pipelined(&mut self, _on: bool) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -360,6 +406,16 @@ pub trait Encoder: Send {
|
||||
fn reconfigure_bitrate(&mut self, _bps: u64) -> bool {
|
||||
false
|
||||
}
|
||||
/// The bitrate (bps) the encoder is ACTUALLY running at (or will open at, for a lazily-opened
|
||||
/// backend) — the encoder-side truth after any internal clamp, e.g. the direct-NVENC
|
||||
/// codec-level ceiling search. The session loop reads this after every open/reconfigure and
|
||||
/// stores IT, not the requested rate, as the live bitrate — so the send pacer, the console
|
||||
/// and the client controller's ack all track what the ASIC really targets (a controller fed
|
||||
/// the requested rate keeps climbing from a phantom base, §ABR overdrive). `None` (the
|
||||
/// default) = the backend doesn't track an applied rate; the caller keeps the requested one.
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
/// Wire-chunk the encoder's AUs at the session's shard payload size (the PyroWave
|
||||
/// datagram-aligned mode, plan §4.4): every `shard_payload` window of the emitted AU
|
||||
/// starts a fresh self-delimiting codec packet, zero-padded to the window — so a lost
|
||||
@@ -413,6 +469,17 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel rate (luma samples/s) at or above which NVENC split-frame encoding is FORCED 2-way —
|
||||
/// one number shared by the direct-SDK selector (`nvenc_core::resolve_split_mode`) and the libav
|
||||
/// `split_encode_mode` option author (`linux::NvencEncoder`), so the two paths can never disagree
|
||||
/// about which modes split. A single NVENC engine tops out ~1 Gpix/s on HEVC, and AUTO doesn't
|
||||
/// engage below ~2112 px height, so the sessions that need the second engine must be forced. Set
|
||||
/// BELOW 1 Gpix/s deliberately: 4K120 — the mode this threshold exists for — is 3840×2160×120 =
|
||||
/// 995,328,000, which a `> 1_000_000_000` gate missed by 0.47% and left on AUTO (pinned ~107 fps
|
||||
/// on a 4090). 950 M keeps margin for fractional refresh rates while leaving 1440p240 (884.7 M,
|
||||
/// comfortably single-engine) on AUTO.
|
||||
pub const SPLIT_FORCE_PIXEL_RATE: u64 = 950_000_000;
|
||||
|
||||
/// `PUNKTFUNK_VBV_FRAMES` — HRD/VBV size in frame intervals (default 1.0, the strict low-latency
|
||||
/// shape every backend ships: each frame must fit its rate share, keeping frame sizes uniform for
|
||||
/// the pacer). The AMF/VAAPI/QSV paths parse the same variable locally; this helper brings the
|
||||
@@ -454,6 +521,26 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
|
||||
(use HEVC/AV1 above 4096, or lower the client resolution)"
|
||||
);
|
||||
}
|
||||
// PyroWave's vendored rate controller packs the 32×32 block index into the low 16 bits of
|
||||
// `RDOperation::block_offset_saving` (pyrowave-sys `patches/0002-rdo-saving-clamp.patch`).
|
||||
// Past `u16::MAX` blocks the index collides with the `saving` field, the resolve over-credits,
|
||||
// and the emitted payload can overshoot the buffer `pyrowave_encoder_packetize` writes into —
|
||||
// whose only bounds check is an `assert` that the Release (NDEBUG) vendored build compiles out.
|
||||
// So this is a hard cap, not a quality knob.
|
||||
//
|
||||
// Checked against 4:2:0, the *most permissive* chroma: a mode that cannot fit even there can
|
||||
// fit no PyroWave session at all, so it belongs at this single chokepoint (which both the
|
||||
// negotiator and `open_video_backend` run) rather than only in the per-backend opens. 4:4:4
|
||||
// has twice the block count and is checked again at open, where the real chroma is known —
|
||||
// and the negotiator's 4:4:4 → 4:2:0 downgrade means an oversized mode arrives at the encoder
|
||||
// as 4:2:0, which is exactly the case the old open-time guard skipped.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
if codec == Codec::PyroWave && !crate::pyrowave_mode_fits_rdo(width, height, false) {
|
||||
anyhow::bail!(
|
||||
"invalid PyroWave resolution {width}x{height}: exceeds the rate controller's 16-bit \
|
||||
block index (pyrowave-sys patches/0002) — lower the client resolution"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -477,6 +564,26 @@ mod tests {
|
||||
assert!(validate_dimensions(Codec::H264, 3840, 4098).is_err());
|
||||
}
|
||||
|
||||
/// PyroWave's hard cap is the rate controller's 16-bit block index, not just
|
||||
/// `max_dimension()`. Checked at 4:2:0 (the most permissive chroma), because a mode that
|
||||
/// cannot fit there cannot fit at any chroma — and because the negotiator's 4:4:4 → 4:2:0
|
||||
/// downgrade delivers oversized modes to the encoder AS 4:2:0. HEVC/AV1 at the same
|
||||
/// dimensions must stay unaffected.
|
||||
#[cfg(feature = "pyrowave")]
|
||||
#[test]
|
||||
fn pyrowave_rejects_modes_past_the_rdo_block_index() {
|
||||
// Fits: 8K 4:2:0 is 49125 blocks.
|
||||
assert!(validate_dimensions(Codec::PyroWave, 7680, 4320).is_ok());
|
||||
// Does not fit at 4:2:0 (73728 / 98304 blocks) — must be refused even though both are
|
||||
// within `Codec::PyroWave.max_dimension()` (8192).
|
||||
assert!(validate_dimensions(Codec::PyroWave, 8192, 6144).is_err());
|
||||
assert!(validate_dimensions(Codec::PyroWave, 8192, 8192).is_err());
|
||||
// The same modes remain legal for the H.26x/AV1 codecs, which have no such rate
|
||||
// controller — the cap must not leak across codecs.
|
||||
assert!(validate_dimensions(Codec::H265, 8192, 8192).is_ok());
|
||||
assert!(validate_dimensions(Codec::Av1, 8192, 6144).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_and_av1_allow_up_to_8192() {
|
||||
for c in [Codec::H265, Codec::Av1] {
|
||||
|
||||
@@ -180,7 +180,6 @@ pub struct NvencEncoder {
|
||||
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
||||
want_444: bool,
|
||||
src_format: PixelFormat,
|
||||
expand: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
@@ -413,57 +412,6 @@ impl NvencEncoder {
|
||||
None
|
||||
};
|
||||
|
||||
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
||||
// input frame. Two users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag) and HDR
|
||||
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
||||
// through the matrix untouched). Skipped on the zero-copy path (`cuda`): the worker's GPU
|
||||
// convert already delivers ready CUDA frames — no CPU pixels exist to scale.
|
||||
let sws_csc = if (want_444 || want_hdr10) && !cuda {
|
||||
let src_av = pixel_to_av(sws_src_pixel(format)?);
|
||||
let dst_av = pixel_to_av(nvenc_pixel);
|
||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
|
||||
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
|
||||
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
||||
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
||||
// null-checked below.
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
dst_av,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
};
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
||||
}
|
||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The
|
||||
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for HDR
|
||||
// — matching the VUI written above) are process-lifetime libswscale statics, reused for
|
||||
// src+dst matrices; `sws_setColorspaceDetails` only reads them and writes scalar CSC
|
||||
// settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
|
||||
unsafe {
|
||||
let cs = ffi::sws_getCoefficients(if want_hdr10 {
|
||||
super::libav::SWS_CS_BT2020
|
||||
} else {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
let dst_range = i32::from(full_range_444);
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
Some(sws)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Low-latency NVENC tuning (plan §7 / linux-setup doc).
|
||||
let mut opts = Dictionary::new();
|
||||
opts.set("preset", "p1"); // fastest
|
||||
@@ -491,15 +439,19 @@ impl NvencEncoder {
|
||||
}
|
||||
|
||||
// Split-frame encode across both NVENC engines (GB203 has 2) when the pixel rate exceeds
|
||||
// a single engine's HEVC capacity (~1 Gpix/s); e.g. 5120x1440@240 = 1.77 Gpix/s needs it,
|
||||
// @120 = 0.88 Gpix/s does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
||||
// a single engine's HEVC capacity; e.g. 5120x1440@240 = 1.77 Gpix/s needs it, @120
|
||||
// (0.88 Gpix/s) does not. HEVC/AV1 only (not H.264). AUTO won't engage below ~2112px
|
||||
// height, so we force `2`; below the threshold we leave it AUTO (split costs ~2% BD-rate).
|
||||
// Output is standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
|
||||
// Threshold shared with the direct-SDK selector ([`super::SPLIT_FORCE_PIXEL_RATE`] — set
|
||||
// so 4K120 = 995.3 Mpix/s forces, which `> 1e9` famously missed by 0.47%). Output is
|
||||
// standard HEVC — transparent to the client. Override with PUNKTFUNK_SPLIT_ENCODE.
|
||||
let pix_rate = width as u64 * height as u64 * fps as u64;
|
||||
let split = std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok();
|
||||
match split.as_deref() {
|
||||
Some(mode) => opts.set("split_encode_mode", mode),
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1) && pix_rate > 1_000_000_000 => {
|
||||
None if matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& pix_rate >= super::SPLIT_FORCE_PIXEL_RATE =>
|
||||
{
|
||||
opts.set("split_encode_mode", "2");
|
||||
tracing::info!(
|
||||
pix_rate,
|
||||
@@ -549,6 +501,84 @@ impl NvencEncoder {
|
||||
);
|
||||
}
|
||||
|
||||
// Built HERE, below the fallible encoder open, NOT above it. `sws_getContext` returns a raw
|
||||
// pointer whose only free is `Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED
|
||||
// `Self`, which does not exist on `open`'s early returns (the intra-refresh-unsupported
|
||||
// retry, which recurses into `Self::open`, and the plain error return). Creating the
|
||||
// context above them leaked one per failed attempt, and `open_nvenc_probed`'s EINVAL
|
||||
// bitrate ladder calls `open` up to ~10 times, so a host stepping its bitrate down leaked a
|
||||
// context per step. Nothing between here and the `Ok(NvencEncoder { … })` below can return,
|
||||
// so this placement makes the leak unrepresentable rather than merely unlikely.
|
||||
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
||||
// input frame. THREE users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag), HDR
|
||||
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
||||
// through the matrix untouched), and the packed 3-bpp expand (RGB24/BGR24→rgb0/bgr0).
|
||||
//
|
||||
// The expand used to be a hand-written per-pixel loop in `submit_cpu`: `w*h` iterations,
|
||||
// each building two bounds-checked sub-slices for a 3-byte copy — a shape LLVM will not
|
||||
// vectorise into the byte shuffle it is, on the COMMON CPU path (the portal and wlroots
|
||||
// both commonly fixate packed 24-bit RGB, and pf-capture offers it first). swscale's
|
||||
// packed-RGB expanders are SIMD, the sibling VAAPI backend already routes RGB24/BGR24
|
||||
// through them, and this file already owned the context lifecycle — so the change is net
|
||||
// subtractive. The three are mutually exclusive by construction: `expand` is only ever
|
||||
// true on the packed-RGB 4:2:0 path (see `nvenc_pixel`/`expand` above), never with
|
||||
// `want_444`, so one context serves whichever applies.
|
||||
//
|
||||
// Skipped on the zero-copy path (`cuda`): the worker's GPU convert already delivers ready
|
||||
// CUDA frames — no CPU pixels exist to scale.
|
||||
let sws_csc = if (want_444 || want_hdr10 || expand) && !cuda {
|
||||
let src_av = pixel_to_av(sws_src_pixel(format)?);
|
||||
let dst_av = pixel_to_av(nvenc_pixel);
|
||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dims + pixel
|
||||
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
|
||||
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
||||
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
||||
// null-checked below.
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
dst_av,
|
||||
SWS_POINT,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
};
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
||||
}
|
||||
// Colour math applies to the CSC users ONLY. The expand is a pure byte shuffle —
|
||||
// packed 3-bpp RGB/BGR to the same channels in 4 bytes, `nvenc_pixel` being `rgb0`/
|
||||
// `bgr0` — and NVENC does the RGB→YUV itself downstream. Handing it a matrix + range
|
||||
// here would silently range-convert every packed-RGB session, which is exactly what the
|
||||
// module header promises does not happen ("no colour math").
|
||||
if want_444 || want_hdr10 {
|
||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The
|
||||
// coefficient tables from `sws_getCoefficients` (ITU-709 for 4:4:4, BT.2020 NCL for
|
||||
// HDR — matching the VUI written above) are process-lifetime libswscale statics,
|
||||
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads them and writes
|
||||
// scalar CSC settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||
// PUNKTFUNK_444_FULLRANGE experiment; HDR is always limited). No Rust memory is passed.
|
||||
unsafe {
|
||||
let cs = ffi::sws_getCoefficients(if want_hdr10 {
|
||||
super::libav::SWS_CS_BT2020
|
||||
} else {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
let dst_range = i32::from(full_range_444);
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
}
|
||||
Some(sws)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let frame = if cuda {
|
||||
None
|
||||
} else {
|
||||
@@ -561,7 +591,6 @@ impl NvencEncoder {
|
||||
sws_csc,
|
||||
want_444,
|
||||
src_format: format,
|
||||
expand,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
@@ -695,8 +724,10 @@ impl NvencEncoder {
|
||||
bytes.len(),
|
||||
src_row * h
|
||||
);
|
||||
// 4:4:4: swscale the packed RGB straight into the planar YUV444P input frame (BT.709 limited),
|
||||
// then send it — no byte-expand. The 4:2:0 RGB path (below) feeds NVENC packed RGB directly.
|
||||
// swscale the packed RGB straight into the encoder's input frame, then send it. Serves all
|
||||
// three CSC users (see `open`): 4:4:4 → planar YUV444P, HDR → P010, and the packed 3-bpp
|
||||
// expand → `rgb0`/`bgr0`. The remaining branch below is the 4-bpp source, which needs no
|
||||
// conversion at all — just a row copy honouring the destination stride.
|
||||
if let Some(sws) = self.sws_csc {
|
||||
let frame = self
|
||||
.frame
|
||||
@@ -705,10 +736,13 @@ impl NvencEncoder {
|
||||
// SAFETY: `format == self.src_format` and `bytes.len() >= src_row * h` (the `ensure!`s
|
||||
// above), so `sws_scale` reads `h` rows of `src_row` bytes from `src_data[0] = bytes`
|
||||
// (packed RGB is single-plane; the other src planes are null/0) — all in bounds. `sws` is
|
||||
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`: its
|
||||
// `data`/`linesize` in-struct arrays were sized for YUV444P by `VideoFrame::new`, and the
|
||||
// 3 planes are each `width`×`height`. All pointers are live locals for this synchronous
|
||||
// call; the encoder runs only on this thread (`unsafe impl Send`), so no aliasing/race.
|
||||
// the non-null context built in `open`. The dst is `frame`'s underlying `AVFrame`, whose
|
||||
// `data`/`linesize` in-struct arrays were sized by `VideoFrame::new` for the very
|
||||
// `nvenc_pixel` this context was built to output — 3 planes of `width`×`height` for
|
||||
// YUV444P, 2 for P010, 1 packed plane for `rgb0`/`bgr0` — so swscale writes exactly the
|
||||
// planes it allocated, at the strides it reports. All pointers are live locals for this
|
||||
// synchronous call; the encoder runs only on this thread (`unsafe impl Send`), so no
|
||||
// aliasing/race.
|
||||
unsafe {
|
||||
let dst_av = frame.as_mut_ptr();
|
||||
let src_data: [*const u8; 4] =
|
||||
@@ -724,7 +758,7 @@ impl NvencEncoder {
|
||||
(*dst_av).linesize.as_ptr(),
|
||||
);
|
||||
if r < 0 {
|
||||
bail!("sws_scale(RGB→YUV444P) failed ({r})");
|
||||
bail!("sws_scale(CPU CSC → encoder input) failed ({r})");
|
||||
}
|
||||
}
|
||||
frame.set_pts(Some(pts));
|
||||
@@ -733,7 +767,7 @@ impl NvencEncoder {
|
||||
} else {
|
||||
ffmpeg::picture::Type::None
|
||||
});
|
||||
self.enc.send_frame(frame).context("send_frame(444)")?;
|
||||
self.enc.send_frame(frame).context("send_frame(swscale)")?;
|
||||
return Ok(());
|
||||
}
|
||||
let frame = self
|
||||
@@ -742,18 +776,9 @@ impl NvencEncoder {
|
||||
.context("CPU frame missing (encoder opened in CUDA mode)")?;
|
||||
let stride = frame.stride(0); // dst is 4-bpp, aligned
|
||||
let dst = frame.data_mut(0);
|
||||
if self.expand {
|
||||
// packed 3-bpp RGB/BGR → 4-bpp *0 (copy 3 bytes, zero the pad byte)
|
||||
for y in 0..h {
|
||||
let s = &bytes[y * src_row..y * src_row + src_row];
|
||||
let drow = &mut dst[y * stride..y * stride + w * 4];
|
||||
for x in 0..w {
|
||||
drow[x * 4..x * 4 + 3].copy_from_slice(&s[x * 3..x * 3 + 3]);
|
||||
drow[x * 4 + 3] = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride
|
||||
{
|
||||
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride. The 3-bpp expand that used
|
||||
// to live here as a per-pixel loop is now swscale's job (see the branch above).
|
||||
for y in 0..h {
|
||||
dst[y * stride..y * stride + src_row]
|
||||
.copy_from_slice(&bytes[y * src_row..y * src_row + src_row]);
|
||||
|
||||
@@ -61,8 +61,9 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, resolve_slices,
|
||||
resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, LowLatencyConfig, NvStatusExt,
|
||||
RFI_DPB,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
@@ -595,6 +596,12 @@ pub struct NvencCudaEncoder {
|
||||
/// (escalate-and-hold, like the depth escalation); the switch itself happens at the next
|
||||
/// safe point via [`maybe_engage_async`](Self::maybe_engage_async).
|
||||
want_async: bool,
|
||||
/// A de-escalation request ([`Encoder::set_pipelined(false)`]) waiting for its safe point:
|
||||
/// the next drained moment tears the session down and lazily re-inits SYNC (IO-stream
|
||||
/// binding and sub-frame chunking re-arm at that re-init). Distinct from `!want_async` —
|
||||
/// an operator-forced async session (`PUNKTFUNK_NVENC_ASYNC=1`) also has `want_async`
|
||||
/// false, and a de-escalation must never tear THAT down.
|
||||
want_sync: bool,
|
||||
/// Boxed `CUstream` the session's IO-stream binding points at (`NvEncSetIOCudaStreams` takes
|
||||
/// POINTERS to `CUstream`, and this struct moves — the pointee needs a stable heap address for
|
||||
/// the session's lifetime). Null when stream-ordering is off; freed in `teardown` AFTER the
|
||||
@@ -702,6 +709,7 @@ impl NvencCudaEncoder {
|
||||
last_rfi_range: None,
|
||||
async_rt: None,
|
||||
want_async: false,
|
||||
want_sync: false,
|
||||
io_stream: ptr::null_mut(),
|
||||
stream_ordered: false,
|
||||
slices: 1,
|
||||
@@ -735,6 +743,29 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`maybe_engage_async`](Self::maybe_engage_async)'s inverse — wind the escalated pipelined
|
||||
/// retrieve back at a safe point: nothing in flight, then a clean session rebuild whose lazy
|
||||
/// SYNC re-init restores the IO-stream binding and re-arms sub-frame chunking (the two
|
||||
/// latency features the escalation traded away). No-op until
|
||||
/// [`want_sync`](Self::want_sync) is set and `pending` drains.
|
||||
fn maybe_disengage_async(&mut self) {
|
||||
if !self.want_sync || self.async_rt.is_none() || !self.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.want_sync = false;
|
||||
if self.inited {
|
||||
// SAFETY: encode thread, `pending` empty ⇒ no encode in flight (and nothing queued
|
||||
// to the retrieve thread); `teardown` joins the retrieve thread and handles exactly
|
||||
// this live-session state — the next submit lazily re-inits sync.
|
||||
unsafe { self.teardown() };
|
||||
tracing::info!(
|
||||
"NVENC pipelined-retrieve de-escalation: rebuilding the session with the sync \
|
||||
retrieve (IO-stream binding and sub-frame chunking restored); next frame opens \
|
||||
with an IDR"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
|
||||
unsafe fn teardown(&mut self) {
|
||||
if self.encoder.is_null() {
|
||||
@@ -1000,6 +1031,23 @@ impl NvencCudaEncoder {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// This session config's identity in the process-lifetime bitrate-ceiling cache
|
||||
/// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the process-global shared
|
||||
/// `CUcontext` pointer — one context per process, stable for its lifetime; only valid once
|
||||
/// `cu_ctx` is bound (`init_session` start), which every caller is downstream of.
|
||||
fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
|
||||
CeilingKey {
|
||||
gpu: self.cu_ctx as u64,
|
||||
codec: self.codec,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
fps: self.fps,
|
||||
bit_depth: self.bit_depth,
|
||||
chroma_444: self.chroma_444,
|
||||
split_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
|
||||
/// Returns the session handle, or destroys it and returns the error.
|
||||
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
|
||||
@@ -1074,58 +1122,71 @@ impl NvencCudaEncoder {
|
||||
}
|
||||
const FLOOR_BPS: u64 = 10_000_000;
|
||||
let requested_bps = self.bitrate_bps;
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override
|
||||
// PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. HEVC/AV1 only.
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — shared selector, see
|
||||
// [`resolve_split_mode`] for the precedence (env override / 10-bit / pixel rate).
|
||||
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
|
||||
let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
|
||||
{
|
||||
Some("0") | Some("disable") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
Some("1") | Some("auto") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
|
||||
}
|
||||
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
||||
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
_ if self.bit_depth >= 10 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
_ if pixel_rate > 1_000_000_000 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
}
|
||||
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||
};
|
||||
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||
const CLAMP_TOL_BPS: u64 = 20_000_000;
|
||||
|
||||
let mut probe = self.try_open_session(requested_bps, split_mode);
|
||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection.
|
||||
// Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
|
||||
// this config's max accepted rate — open straight AT the ceiling instead of paying
|
||||
// the ~6-open binary search (and its session churn) on every ABR overshoot.
|
||||
let mut target_bps = requested_bps;
|
||||
if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
|
||||
if requested_bps > ceiling {
|
||||
tracing::info!(
|
||||
requested_mbps = requested_bps / 1_000_000,
|
||||
ceiling_mbps = ceiling / 1_000_000,
|
||||
"NVENC (Linux): requested bitrate above the cached codec-level ceiling — \
|
||||
opening at the ceiling"
|
||||
);
|
||||
target_bps = ceiling;
|
||||
}
|
||||
}
|
||||
|
||||
let mut probe = self.try_open_session(target_bps, split_mode);
|
||||
// The cache is advisory: a stale entry (driver change, identity collision) must not
|
||||
// wedge the open — retry the requested rate and let the search below rediscover.
|
||||
if probe.is_err() && target_bps < requested_bps {
|
||||
target_bps = requested_bps;
|
||||
probe = self.try_open_session(requested_bps, split_mode);
|
||||
}
|
||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection. `used_split`
|
||||
// tracks the mode sessions ACTUALLY open with from here on — it feeds
|
||||
// `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
|
||||
let mut used_split = split_mode;
|
||||
let split_on =
|
||||
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if probe.is_err() && split_on {
|
||||
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if let Ok(e) = self.try_open_session(requested_bps, no_split) {
|
||||
if let Ok(e) = self.try_open_session(target_bps, no_split) {
|
||||
tracing::warn!(
|
||||
"NVENC (Linux): split-encode rejected by codec/config — disabled"
|
||||
);
|
||||
split_mode = no_split;
|
||||
used_split = no_split;
|
||||
probe = Ok(e);
|
||||
}
|
||||
}
|
||||
|
||||
let enc = match probe {
|
||||
Ok(enc) => {
|
||||
self.bitrate_bps = requested_bps;
|
||||
self.bitrate_bps = target_bps;
|
||||
enc
|
||||
}
|
||||
// Only a parameter/caps rejection means "the bitrate is above the codec-level
|
||||
// ceiling". A transient failure (busy engine, session limit, OOM, device loss,
|
||||
// version skew) must propagate — a search steered by it would discover, and
|
||||
// cache, a bogus ceiling.
|
||||
Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
|
||||
Err(_) => {
|
||||
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
||||
let mut lo = FLOOR_BPS;
|
||||
let mut hi = requested_bps;
|
||||
let mut hi = target_bps;
|
||||
let mut best: *mut c_void = ptr::null_mut();
|
||||
let mut best_bps = 0u64;
|
||||
while hi > lo + CLAMP_TOL_BPS {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
match self.try_open_session(mid, split_mode) {
|
||||
match self.try_open_session(mid, used_split) {
|
||||
Ok(e) => {
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
@@ -1134,18 +1195,30 @@ impl NvencCudaEncoder {
|
||||
best_bps = mid;
|
||||
lo = mid;
|
||||
}
|
||||
Err(_) => hi = mid,
|
||||
Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
|
||||
Err(e) => {
|
||||
// Environmental mid-search failure: don't let it shrink the
|
||||
// search — release the partial result and propagate.
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.is_null() {
|
||||
let no_split =
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
best = self
|
||||
.try_open_session(FLOOR_BPS, split_mode)
|
||||
.or_else(|_| self.try_open_session(FLOOR_BPS, no_split))
|
||||
.context(
|
||||
best = match self.try_open_session(FLOOR_BPS, used_split) {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
let e = self.try_open_session(FLOOR_BPS, no_split).context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
used_split = no_split;
|
||||
e
|
||||
}
|
||||
};
|
||||
best_bps = FLOOR_BPS;
|
||||
}
|
||||
tracing::warn!(
|
||||
@@ -1153,15 +1226,13 @@ impl NvencCudaEncoder {
|
||||
clamped_mbps = best_bps / 1_000_000,
|
||||
"NVENC (Linux): requested bitrate above the GPU codec-level ceiling — clamped"
|
||||
);
|
||||
store_ceiling(self.ceiling_key(used_split), best_bps);
|
||||
self.bitrate_bps = best_bps;
|
||||
best
|
||||
}
|
||||
};
|
||||
self.encoder = enc;
|
||||
// (Best effort: the floor fallback above may have succeeded split-disabled without
|
||||
// updating `split_mode` — a later reconfigure then presents the forced mode, NVENC
|
||||
// rejects it, and the caller's rebuild fallback covers the mismatch.)
|
||||
self.split_mode = split_mode;
|
||||
self.split_mode = used_split;
|
||||
|
||||
// Output bitstream pool.
|
||||
for _ in 0..POOL {
|
||||
@@ -1345,6 +1416,10 @@ impl NvencCudaEncoder {
|
||||
mbps = self.bitrate_bps / 1_000_000,
|
||||
codec = ?self.codec_guid,
|
||||
fmt = ?self.buffer_fmt,
|
||||
// The FINAL split mode (post any rejection fallback) at INFO — journals run
|
||||
// INFO+, and "did 4K120 actually split across engines?" was undiagnosable from
|
||||
// a user log without it (Windows only had a debug! at selection time).
|
||||
split_mode = self.split_mode,
|
||||
"NVENC CUDA session ready"
|
||||
);
|
||||
Ok(())
|
||||
@@ -1426,9 +1501,10 @@ impl Encoder for NvencCudaEncoder {
|
||||
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
|
||||
),
|
||||
};
|
||||
// A pending pipelined-retrieve escalation engages here, at the submit-side safe point
|
||||
// (nothing in flight after the previous poll drained).
|
||||
// A pending pipelined-retrieve escalation — or de-escalation — engages here, at the
|
||||
// submit-side safe point (nothing in flight after the previous poll drained).
|
||||
self.maybe_engage_async();
|
||||
self.maybe_disengage_async();
|
||||
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
||||
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
||||
let new_fmt = buffer_format(buf);
|
||||
@@ -1736,12 +1812,25 @@ impl Encoder for NvencCudaEncoder {
|
||||
|
||||
fn set_pipelined(&mut self, on: bool) -> bool {
|
||||
if !on {
|
||||
// v1 is escalate-and-hold (no de-escalation), mirroring the depth escalation.
|
||||
// De-escalation (the v2 of escalate-and-hold): latch the wind-back intent; the
|
||||
// switch itself happens at the next drained safe point
|
||||
// ([`maybe_disengage_async`](Self::maybe_disengage_async)) — the caller polls
|
||||
// this same method until it reports inactive.
|
||||
if async_retrieve_env() == Some(true) {
|
||||
// Operator pinned async on — de-escalation must not undo an explicit choice.
|
||||
return self.want_async || self.async_rt.is_some();
|
||||
}
|
||||
if self.want_async || self.async_rt.is_some() {
|
||||
self.want_async = false;
|
||||
self.want_sync = true;
|
||||
self.maybe_disengage_async();
|
||||
}
|
||||
return self.want_async || self.async_rt.is_some();
|
||||
}
|
||||
if async_retrieve_env() == Some(false) {
|
||||
return false; // operator veto: PUNKTFUNK_NVENC_ASYNC=0 means NEVER
|
||||
}
|
||||
self.want_sync = false; // latest intent wins — cancel a pending wind-back
|
||||
if !self.want_async && self.async_rt.is_none() {
|
||||
self.want_async = true;
|
||||
self.maybe_engage_async();
|
||||
@@ -2061,6 +2150,15 @@ impl Encoder for NvencCudaEncoder {
|
||||
self.bitrate_bps = bps;
|
||||
return true;
|
||||
}
|
||||
// Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
|
||||
// overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
|
||||
// the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
|
||||
// ABR overshoot on the pre-cache path). The caller reads the clamp back through
|
||||
// [`Encoder::applied_bitrate_bps`].
|
||||
let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
|
||||
Some(ceiling) => bps.min(ceiling),
|
||||
None => bps,
|
||||
};
|
||||
// SAFETY: `inited` ⟹ `self.encoder` is the live session and every call here runs on the
|
||||
// encode thread with no NVENC call in flight (the session loop calls this between
|
||||
// submit/poll). `build_config` only queries the preset on that session; `cfg` outlives
|
||||
@@ -2108,6 +2206,12 @@ impl Encoder for NvencCudaEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
// `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
|
||||
// reconfigure path's cache clamp both write what the session ACTUALLY targets.
|
||||
Some(self.bitrate_bps)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
@@ -2165,6 +2269,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2241,6 +2346,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv444,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA 4:4:4 session");
|
||||
|
||||
@@ -2286,6 +2392,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2343,6 +2450,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
) else {
|
||||
eprintln!(
|
||||
"skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)"
|
||||
@@ -2369,6 +2477,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA encoder")
|
||||
}
|
||||
@@ -2403,6 +2512,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open");
|
||||
for f in 0..4u32 {
|
||||
@@ -2540,6 +2650,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
@@ -2581,6 +2692,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
// Steady sync frames first (stream-ordered mode).
|
||||
@@ -2660,6 +2772,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
@@ -2765,6 +2878,7 @@ mod tests {
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
|
||||
@@ -227,12 +227,19 @@ impl PyroWaveEncoder {
|
||||
if !chroma.is_444() && (width % 2 != 0 || height % 2 != 0) {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||
}
|
||||
if chroma.is_444() && !crate::pyrowave_mode_fits_rdo(width, height, true) {
|
||||
// The negotiator downgrades these modes to 4:2:0 pre-Welcome; refuse if one
|
||||
// slips through (e.g. the lab override) rather than wrap the RDO block index.
|
||||
// Checked against the chroma actually being opened, NOT hardcoded 4:4:4. The 4:2:0 block
|
||||
// count is ~half of 4:4:4's but still unbounded (8192×6144 4:2:0 = 73728 > u16::MAX), and
|
||||
// the negotiator's 4:4:4 → 4:2:0 downgrade hands oversized modes to this open AS 4:2:0 —
|
||||
// so a `chroma.is_444()`-gated check is skipped exactly when it is needed. Wrapping the
|
||||
// index lets the resolve over-credit and `packetize` overshoot our bitstream buffer
|
||||
// (its own bounds `assert` is compiled out by the Release vendored build).
|
||||
// `validate_dimensions` rejects the impossible-at-any-chroma modes earlier; this is the
|
||||
// 4:4:4-specific half plus defence in depth for the lab override.
|
||||
if !crate::pyrowave_mode_fits_rdo(width, height, chroma.is_444()) {
|
||||
bail!(
|
||||
"pyrowave 4:4:4 at {width}x{height} exceeds the rate controller's 16-bit \
|
||||
block index (see pyrowave-sys patches/0002 note) — use 4:2:0 at this size"
|
||||
"pyrowave {} at {width}x{height} exceeds the rate controller's 16-bit block \
|
||||
index (see pyrowave-sys patches/0002 note) — lower the resolution",
|
||||
if chroma.is_444() { "4:4:4" } else { "4:2:0" }
|
||||
);
|
||||
}
|
||||
// SAFETY: `open_inner` only issues Vulkan/pyrowave calls whose preconditions it
|
||||
|
||||
@@ -39,11 +39,6 @@ use super::libav::{
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
/// `fourcc(a,b,c,d)` — DRM FourCC packing (`a | b<<8 | c<<16 | d<<24`).
|
||||
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
||||
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||||
}
|
||||
|
||||
/// The render node a VAAPI/DRM device should open, from [`pf_gpu::linux_render_node`]: a
|
||||
/// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU
|
||||
/// default.
|
||||
|
||||
@@ -221,6 +221,92 @@ impl NativeProfileStack {
|
||||
|
||||
/// The Vulkan codec-operation bit for our codec selection (shared by open and the per-import
|
||||
/// profile rebuilds — the two must agree, profile identity is by value).
|
||||
/// The physical device + encode queue family a session runs on: the FIRST device exposing a
|
||||
/// `VIDEO_ENCODE` queue family that advertises `codec_op` (llvmpipe advertises none, so it drops
|
||||
/// out implicitly). Shared by [`VulkanVideoEncoder::open_inner`] and [`probe_encode_support`].
|
||||
///
|
||||
/// # Safety
|
||||
/// `instance` must be a live `ash::Instance` and `devices` handles enumerated from it.
|
||||
unsafe fn find_encode_device(
|
||||
instance: &ash::Instance,
|
||||
devices: &[vk::PhysicalDevice],
|
||||
codec_op: vk::VideoCodecOperationFlagsKHR,
|
||||
) -> Option<(vk::PhysicalDevice, u32)> {
|
||||
for &pd in devices {
|
||||
let qf_len = instance.get_physical_device_queue_family_properties2_len(pd);
|
||||
let mut video = vec![vk::QueueFamilyVideoPropertiesKHR::default(); qf_len];
|
||||
let mut qf = vec![vk::QueueFamilyProperties2::default(); qf_len];
|
||||
for i in 0..qf_len {
|
||||
qf[i].p_next = &mut video[i] as *mut _ as *mut c_void;
|
||||
}
|
||||
instance.get_physical_device_queue_family_properties2(pd, &mut qf);
|
||||
for i in 0..qf_len {
|
||||
if qf[i]
|
||||
.queue_family_properties
|
||||
.queue_flags
|
||||
.contains(vk::QueueFlags::VIDEO_ENCODE_KHR)
|
||||
&& video[i].video_codec_operations.contains(codec_op)
|
||||
{
|
||||
return Some((pd, i as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Can this GPU + driver open a Vulkan Video **encode** session for `codec` at all?
|
||||
///
|
||||
/// This is the verdict the native-NV12 capture negotiation needs BEFORE it commits
|
||||
/// ([`crate::linux_native_nv12_ok`]): once the producer hands over two-plane NV12 there is no VAAPI
|
||||
/// fallback — libav would import it as packed RGB — so [`crate::open_video`] deliberately makes
|
||||
/// that open-failure fatal, and a Mesa built without `VK_KHR_video_encode_h265` therefore kills the
|
||||
/// session at its first frame instead of streaming on VAAPI.
|
||||
///
|
||||
/// Deliberately the FIRST check `open_inner` performs and hard-fails on, and nothing more: the same
|
||||
/// [`find_encode_device`] scan, run against the same codec op. That makes it provably no stricter
|
||||
/// than the open, so a failure here can only ever name a session that would have died anyway — it
|
||||
/// can never talk a working host out of the fast path. It is also cheap: one instance plus
|
||||
/// physical-device queries, no logical device, no video session, no VRAM. The later stages
|
||||
/// (`create_device`, `create_video_session`, the capability query) can still fail for reasons this
|
||||
/// does not model; those keep the session on the packed-RGB negotiation the ordinary way, because
|
||||
/// the capture format is only committed once this said yes.
|
||||
///
|
||||
/// Probed PER CODEC, not once: `codec_op_for` selects a different queue-family bit for AV1, and
|
||||
/// HEVC-encode-without-AV1-encode is the common VCN/ANV configuration.
|
||||
pub(crate) fn probe_encode_support(codec: Codec) -> Result<(), &'static str> {
|
||||
if !matches!(codec, Codec::H265 | Codec::Av1) {
|
||||
return Err("the Vulkan Video backend encodes HEVC + AV1 only");
|
||||
}
|
||||
let codec_op = codec_op_for(codec == Codec::Av1);
|
||||
// SAFETY: creates one Vulkan instance and issues only physical-device queries against it, then
|
||||
// destroys it on EVERY path below before returning — no handle derived from it escapes, and
|
||||
// nothing outside this call observes it. `Entry::load` only dlopens the loader (a missing
|
||||
// libvulkan returns `Err`), touching no process state the rest of the crate relies on.
|
||||
// `find_encode_device` gets a live instance and handles enumerated from it, as it requires.
|
||||
unsafe {
|
||||
let Ok(entry) = ash::Entry::load() else {
|
||||
return Err("no Vulkan loader");
|
||||
};
|
||||
let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_3);
|
||||
let Ok(instance) = entry.create_instance(
|
||||
&vk::InstanceCreateInfo::default().application_info(&app),
|
||||
None,
|
||||
) else {
|
||||
return Err("vkCreateInstance failed");
|
||||
};
|
||||
let found = match instance.enumerate_physical_devices() {
|
||||
Ok(devices) => find_encode_device(&instance, &devices, codec_op).is_some(),
|
||||
Err(_) => false,
|
||||
};
|
||||
instance.destroy_instance(None);
|
||||
if found {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("no VK_KHR_video_encode queue for this codec on any device")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn codec_op_for(av1: bool) -> vk::VideoCodecOperationFlagsKHR {
|
||||
if av1 {
|
||||
vk::VideoCodecOperationFlagsKHR::from_raw(
|
||||
@@ -524,7 +610,9 @@ impl VulkanVideoEncoder {
|
||||
/// `open` with the RGB-direct request explicit instead of read from the env — the smoke
|
||||
/// tests use this (env mutation races parallel tests). `want_rgb` engages the RGB-direct
|
||||
/// source only if [`probe_rgb_direct`] also passes; otherwise the session opens on the CSC
|
||||
/// path with the verdict logged.
|
||||
/// path with the verdict logged. Test-only: the production entry point is [`Self::open`],
|
||||
/// which resolves `want_rgb` from the env + the `cursor_blend` hint.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn open_opts(
|
||||
codec: Codec,
|
||||
width: u32,
|
||||
@@ -602,34 +690,13 @@ impl VulkanVideoEncoder {
|
||||
|
||||
let vq_inst = ash::khr::video_queue::Instance::new(&entry, &instance);
|
||||
|
||||
// pick the physical device + encode queue family (skip llvmpipe)
|
||||
let (pd, encode_family) = {
|
||||
let mut found = None;
|
||||
for pd in instance.enumerate_physical_devices()? {
|
||||
let qf_len = instance.get_physical_device_queue_family_properties2_len(pd);
|
||||
let mut video = vec![vk::QueueFamilyVideoPropertiesKHR::default(); qf_len];
|
||||
let mut qf = vec![vk::QueueFamilyProperties2::default(); qf_len];
|
||||
for i in 0..qf_len {
|
||||
qf[i].p_next = &mut video[i] as *mut _ as *mut c_void;
|
||||
}
|
||||
instance.get_physical_device_queue_family_properties2(pd, &mut qf);
|
||||
for i in 0..qf_len {
|
||||
if qf[i]
|
||||
.queue_family_properties
|
||||
.queue_flags
|
||||
.contains(vk::QueueFlags::VIDEO_ENCODE_KHR)
|
||||
&& video[i].video_codec_operations.contains(codec_op)
|
||||
{
|
||||
found = Some((pd, i as u32));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if found.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
found.context("no VK_KHR_video_encode queue for the requested codec on any device")?
|
||||
};
|
||||
// pick the physical device + encode queue family (skip llvmpipe). The scan itself lives in
|
||||
// [`find_encode_device`] so the negotiation-time probe asks the SAME question this open
|
||||
// asks — a probe that MIRRORS a dispatch goes stale the first time the dispatch grows a
|
||||
// case, which is the failure `open_video`'s backend-label note already records.
|
||||
let (pd, encode_family) =
|
||||
find_encode_device(&instance, &instance.enumerate_physical_devices()?, codec_op)
|
||||
.context("no VK_KHR_video_encode queue for the requested codec on any device")?;
|
||||
let mem_props = instance.get_physical_device_memory_properties(pd);
|
||||
|
||||
// a compute queue family for the CSC (usually family 0) + its timestamp support (the
|
||||
|
||||
@@ -67,6 +67,155 @@ pub(super) fn resolve_subframe(default_on: bool) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved NVENC split-frame encode mode for a session — ONE selector shared by the Windows and
|
||||
/// Linux direct-SDK backends (they had drifted into byte-identical duplicates, one of which
|
||||
/// logged and one didn't). Precedence:
|
||||
/// 1. `PUNKTFUNK_SPLIT_ENCODE` = `0`/`disable` | `1`/`auto` (AUTO_FORCED) | `2` | `3` — operator
|
||||
/// override, always wins.
|
||||
/// 2. 10-bit → DISABLE: 2-way split is measurably SLOWER on Ada for Main10 — at 5120×1440@240
|
||||
/// forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine (the split/merge
|
||||
/// overhead dominates), and a single engine handles 5K@240 Main10 well under budget. This was
|
||||
/// the "broken animations in HDR" cap at ~131 fps.
|
||||
/// 3. Pixel rate ≥ [`super::SPLIT_FORCE_PIXEL_RATE`] → force 2-way (AUTO never engages below
|
||||
/// ~2112 px height, so 4K120 must be forced onto the second engine).
|
||||
/// 4. Else AUTO (the ~2% BD-rate split cost isn't worth it at low pixel rates).
|
||||
///
|
||||
/// The caller still owns the rejection fallback (retry split-disabled) — a codec/config that
|
||||
/// rejects the chosen mode downgrades at open, not here.
|
||||
pub(super) fn resolve_split_mode(bit_depth: u8, pixel_rate: u64) -> u32 {
|
||||
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
let mode = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() {
|
||||
Some("0") | Some("disable") => M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
Some("1") | Some("auto") => M::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32,
|
||||
Some("3") => M::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
||||
Some("2") => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
_ if bit_depth >= 10 => M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
_ if pixel_rate >= super::SPLIT_FORCE_PIXEL_RATE => M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
_ => M::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||
};
|
||||
tracing::debug!(
|
||||
split_mode = mode,
|
||||
bit_depth,
|
||||
pixel_rate,
|
||||
"NVENC split-encode mode selected"
|
||||
);
|
||||
mode
|
||||
}
|
||||
|
||||
/// One session config's identity for the process-lifetime bitrate-ceiling cache
|
||||
/// ([`cached_ceiling`]/[`store_ceiling`]). Everything the driver's codec-level validation keys
|
||||
/// off: the GPU (different NVENC generations have different level ceilings), dims/fps (the luma
|
||||
/// rate selects the level), depth/chroma (they select the profile) and the split mode the
|
||||
/// sessions ACTUALLY opened with (a split session budgets per engine).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(super) struct CeilingKey {
|
||||
/// GPU identity — Linux: the process-global shared `CUcontext` pointer; Windows: the render
|
||||
/// adapter LUID (0 when unresolved). Best effort: the cache is advisory (see
|
||||
/// [`cached_ceiling`]), so a colliding identity costs one failed open + re-search, never a
|
||||
/// wrong session.
|
||||
pub gpu: u64,
|
||||
pub codec: Codec,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub bit_depth: u8,
|
||||
pub chroma_444: bool,
|
||||
pub split_mode: u32,
|
||||
}
|
||||
|
||||
fn ceilings() -> &'static std::sync::Mutex<std::collections::HashMap<CeilingKey, u64>> {
|
||||
static CEILINGS: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<CeilingKey, u64>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
CEILINGS.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The codec-level bitrate ceiling (bps) a previous clamp search discovered for `key` this
|
||||
/// process lifetime, if any. ADVISORY: the consumer must treat a failed open at the cached value
|
||||
/// as a stale entry (fall back to the full search, which rewrites it via [`store_ceiling`]) —
|
||||
/// that self-healing is what lets the key's GPU identity be best-effort. What this buys: an ABR
|
||||
/// overshoot on a config whose ceiling is already known opens (or in-place reconfigures) straight
|
||||
/// AT the ceiling instead of re-running the ~6-open binary search and its ~half-second of session
|
||||
/// churn per rebuild.
|
||||
pub(super) fn cached_ceiling(key: &CeilingKey) -> Option<u64> {
|
||||
ceilings().lock().unwrap().get(key).copied()
|
||||
}
|
||||
|
||||
/// Record the clamp search's discovered max accepted bitrate (bps) for `key`.
|
||||
pub(super) fn store_ceiling(key: CeilingKey, bps: u64) {
|
||||
ceilings().lock().unwrap().insert(key, bps);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nv::NV_ENC_SPLIT_ENCODE_MODE as M;
|
||||
|
||||
// These assume PUNKTFUNK_SPLIT_ENCODE is unset (CI); an operator override deliberately wins.
|
||||
|
||||
#[test]
|
||||
fn split_forces_two_way_at_4k120() {
|
||||
// The regression this threshold constant exists for: 3840×2160×120 = 995,328,000 sat
|
||||
// 0.47% under the old `> 1_000_000_000` gate and stayed AUTO — pinned ~107 fps on a
|
||||
// 4090 because AUTO never engages at 2160 px height.
|
||||
let four_k_120 = 3840u64 * 2160 * 120;
|
||||
assert_eq!(
|
||||
resolve_split_mode(8, four_k_120),
|
||||
M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_leaves_1440p240_auto() {
|
||||
// 884.7 Mpix/s is comfortably single-engine — the threshold move must not drag it in.
|
||||
let qhd_240 = 2560u64 * 1440 * 240;
|
||||
assert_eq!(
|
||||
resolve_split_mode(8, qhd_240),
|
||||
M::NV_ENC_SPLIT_AUTO_MODE as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_disabled_for_10bit_even_at_high_pixel_rate() {
|
||||
// The measured Main10 rule: split/merge overhead dominates 10-bit on Ada (7.6 ms forced-2
|
||||
// vs 2.8 ms single-engine at 5K240) — 10-bit precedes the pixel-rate arm.
|
||||
let five_k_240 = 5120u64 * 1440 * 240;
|
||||
assert_eq!(
|
||||
resolve_split_mode(10, five_k_240),
|
||||
M::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ceiling_cache_round_trips_and_keys_precisely() {
|
||||
let key = CeilingKey {
|
||||
gpu: 0xB0B0,
|
||||
codec: Codec::H265,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
fps: 120,
|
||||
bit_depth: 8,
|
||||
chroma_444: false,
|
||||
split_mode: M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
};
|
||||
assert_eq!(cached_ceiling(&key), None);
|
||||
store_ceiling(key, 794_000_000);
|
||||
assert_eq!(cached_ceiling(&key), Some(794_000_000));
|
||||
// Any config-identity change is a different ceiling — a miss, never a wrong clamp.
|
||||
assert_eq!(cached_ceiling(&CeilingKey { fps: 60, ..key }), None);
|
||||
assert_eq!(
|
||||
cached_ceiling(&CeilingKey {
|
||||
split_mode: M::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||
..key
|
||||
}),
|
||||
None
|
||||
);
|
||||
// A re-search overwrites (the advisory-cache stale-entry path).
|
||||
store_ceiling(key, 620_000_000);
|
||||
assert_eq!(cached_ceiling(&key), Some(620_000_000));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
|
||||
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
|
||||
/// P-frame single-reference for low latency. Also the window the backends' `invalidate_ref_frames`
|
||||
|
||||
@@ -72,9 +72,41 @@ pub(super) fn explain(status: nv::NVENCSTATUS) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed root of a failed NVENC entry-point call: carries the raw status so callers can classify
|
||||
/// the failure class, not just print it — the bitrate-clamp search must only read a
|
||||
/// parameter/caps rejection as "above the codec-level ceiling"; a transient failure shrinking the
|
||||
/// search would discover (and cache) a bogus ceiling. Recover it through an `anyhow` chain with
|
||||
/// `err.downcast_ref::<NvCallError>()` (see [`is_param_rejection`]).
|
||||
#[derive(Debug)]
|
||||
pub(super) struct NvCallError(pub(super) nv::NVENCSTATUS);
|
||||
|
||||
impl std::fmt::Display for NvCallError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?} — {}", self.0, explain(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NvCallError {}
|
||||
|
||||
/// Whether `err` is an NVENC parameter/capability rejection: the driver understood the request
|
||||
/// and says THIS config is not encodable — the clamp search's "bitrate above the ceiling"
|
||||
/// evidence. Everything else (busy engine, session limit, OOM, device loss, version skew) is
|
||||
/// environmental and must propagate instead of steering the search.
|
||||
pub(super) fn is_param_rejection(err: &anyhow::Error) -> bool {
|
||||
matches!(
|
||||
err.downcast_ref::<NvCallError>(),
|
||||
Some(NvCallError(
|
||||
nv::NVENCSTATUS::NV_ENC_ERR_INVALID_PARAM
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_UNSUPPORTED_PARAM
|
||||
| nv::NVENCSTATUS::NV_ENC_ERR_UNIMPLEMENTED,
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/// Build an actionable `anyhow::Error` for a failed NVENC entry-point call. `call` names the API
|
||||
/// (e.g. `"open_encode_session_ex"`); the message carries both the raw status and its real-world
|
||||
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)".
|
||||
/// (e.g. `"open_encode_session_ex"`); the chain carries both the raw status and its real-world
|
||||
/// cause, so triage never again reads a version mismatch as "(no NVIDIA GPU?)". The
|
||||
/// [`NvCallError`] root keeps the status downcastable for failure-class checks.
|
||||
pub(super) fn call_err(call: &str, status: nv::NVENCSTATUS) -> anyhow::Error {
|
||||
anyhow::anyhow!("NVENC {call} failed: {status:?} — {}", explain(status))
|
||||
anyhow::Error::new(NvCallError(status)).context(format!("NVENC {call} failed"))
|
||||
}
|
||||
|
||||
@@ -246,6 +246,17 @@ mod tests {
|
||||
assert!(block_count_32x32(3840, 2160, true) <= u16::MAX as u32);
|
||||
assert!(block_count_32x32(7680, 4320, true) > u16::MAX as u32);
|
||||
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
||||
// …and 4:2:0 wraps it too, just later — the hole the old 4:4:4-only open guard left.
|
||||
// `Codec::max_dimension()` allows PyroWave 8192px per axis, so these modes were
|
||||
// reachable from a client-requested `mode=WxHxFPS`, and the negotiator's 4:4:4 → 4:2:0
|
||||
// downgrade routed oversized modes straight into the unguarded branch.
|
||||
// `validate_dimensions` now rejects them against this 4:2:0 count.
|
||||
assert_eq!(block_count_32x32(8192, 6144, false), 73728);
|
||||
assert_eq!(block_count_32x32(8192, 8192, false), 98304);
|
||||
assert!(block_count_32x32(8192, 6144, false) > u16::MAX as u32);
|
||||
assert!(block_count_32x32(8192, 8192, false) > u16::MAX as u32);
|
||||
// The largest 4:2:0 mode that still fits, for the boundary the validator enforces.
|
||||
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1272,7 +1272,11 @@ impl AmfEncoder {
|
||||
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
|
||||
bail!("this GPU/driver declined AV1 encode (RDNA3+ required) — native AMF probe");
|
||||
}
|
||||
let ten_bit = bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
// Depth follows the delivered pixels, not the negotiated depth ([`crate::ten_bit_input`]).
|
||||
// With the old `bit_depth >= 10 || …` shape a 10-bit-negotiated session over an 8-bit
|
||||
// capture derived `expected = P010`, tripped the format check below and ended the session
|
||||
// at open — on exactly the configuration the capturer had already downgraded on purpose.
|
||||
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||
// Zero-copy by construction: the input ring is NV12/P010 fed by same-format
|
||||
// CopySubresourceRegion. Any other capture format (Bgra/Rgb10a2 video-processor fallback,
|
||||
// CPU frames) has no native input path — and since Phase 3 no ffmpeg readback to degrade
|
||||
@@ -2760,6 +2764,10 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The `p`-quantile of `samples` (µs), sorting in place. `0` when empty.
|
||||
/// Gated like its only caller, the `amf-qsv`-only §5.2 latency A/B below — otherwise a
|
||||
/// `--features nvenc,qsv` build compiles this helper with the benchmark cfg'd out and trips
|
||||
/// `dead_code` (which the crate root no longer blanket-allows).
|
||||
#[cfg(feature = "amf-qsv")]
|
||||
fn percentile(samples: &mut [u128], p: f64) -> u128 {
|
||||
if samples.is_empty() {
|
||||
return 0;
|
||||
@@ -2778,6 +2786,8 @@ mod tests {
|
||||
/// so its submit→AU is the bare ASIC time. The last ~2 unflushed frames on the ffmpeg path
|
||||
/// are left unmeasured (dropped with the encoder) so every recorded sample is a genuine paced
|
||||
/// submit→AU.
|
||||
/// Gated like its only caller (see [`percentile`]).
|
||||
#[cfg(feature = "amf-qsv")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drive_and_measure(
|
||||
enc: &mut dyn Encoder,
|
||||
|
||||
@@ -95,6 +95,13 @@ struct AVD3D11VAFramesContext {
|
||||
/// AMD AMF vs Intel QSV — the two libavcodec vendor backends this module covers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WinVendor {
|
||||
/// Benchmark-only, as the module header explains: native AMF replaced the libavcodec AMF path
|
||||
/// in production, and the only remaining CONSTRUCTOR is the `#[cfg(feature = "amf-qsv")]`
|
||||
/// latency A/B in `amf.rs` — the measurement that justifies the native backend existing. That
|
||||
/// is test code, so the *lib* target constructs it nowhere and `dead_code` fires on it (the
|
||||
/// crate root no longer blanket-allows that). Kept deliberately rather than deleted; the arms
|
||||
/// below are what the benchmark drives.
|
||||
#[allow(dead_code)]
|
||||
Amf,
|
||||
Qsv,
|
||||
}
|
||||
@@ -151,8 +158,14 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
|
||||
}
|
||||
|
||||
/// Does this captured format imply a 10-bit encode (P010 / Rgb10a2)?
|
||||
fn is_10bit_format(format: PixelFormat, bit_depth: u8) -> bool {
|
||||
bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
||||
///
|
||||
/// Depth follows the PIXELS, not the negotiated `bit_depth` — see
|
||||
/// [`crate::ten_bit_input`] for why, and for the failure this shape used to produce here in
|
||||
/// particular: a 10-bit-negotiated session over an 8-bit capture built a P010 encoder whose every
|
||||
/// `submit_d3d11` then failed the depth check below, forever, with `reset()` unable to help
|
||||
/// because the rebuild re-derived the same wrong answer.
|
||||
fn is_10bit_format(format: PixelFormat) -> bool {
|
||||
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
|
||||
}
|
||||
|
||||
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
|
||||
@@ -271,6 +284,11 @@ pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Gated to the builds that can actually reach it: `lib.rs`'s only caller sits under
|
||||
/// `cfg(all(not(feature = "qsv"), feature = "amf-qsv"))`, because with the native VPL backend
|
||||
/// compiled in it is `qsv::probe_can_encode` that answers. So in the SHIPPED Windows combo
|
||||
/// (`nvenc,amf-qsv,qsv`) this function has no caller at all.
|
||||
#[cfg(not(feature = "qsv"))]
|
||||
pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
|
||||
// Deliberately NOT pinned to the selected render adapter (unlike `nvenc::probe_can_encode_444`):
|
||||
// the system-input probe passes no hwdevice, and the AMF/QSV runtimes only ever bind their own
|
||||
@@ -350,7 +368,7 @@ impl SystemInner {
|
||||
bitrate_bps: u64,
|
||||
bit_depth: u8,
|
||||
) -> Result<Self> {
|
||||
let ten_bit = is_10bit_format(format, bit_depth);
|
||||
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||
let sw_av = if ten_bit {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
} else {
|
||||
@@ -472,7 +490,10 @@ impl SystemInner {
|
||||
pts: i64,
|
||||
idr: bool,
|
||||
) -> Result<()> {
|
||||
let fmt_10 = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
// Same predicate the encoder was built from (`ten_bit_input`), so this can only fire on a
|
||||
// genuine MID-STREAM depth change — never, as it used to, on every frame of a session that
|
||||
// merely negotiated 10-bit over an 8-bit capture.
|
||||
let fmt_10 = is_10bit_format(format);
|
||||
anyhow::ensure!(
|
||||
fmt_10 == self.ten_bit,
|
||||
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
|
||||
@@ -851,7 +872,7 @@ impl ZeroCopyInner {
|
||||
bit_depth: u8,
|
||||
device: &ID3D11Device,
|
||||
) -> Result<Self> {
|
||||
let ten_bit = is_10bit_format(format, bit_depth);
|
||||
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||
let sw_av = if ten_bit {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
|
||||
} else {
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, codec_guid, resolve_slices, resolve_subframe,
|
||||
LowLatencyConfig, NvStatusExt, RFI_DPB,
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, resolve_slices,
|
||||
resolve_split_mode, resolve_subframe, store_ceiling, CeilingKey, LowLatencyConfig, NvStatusExt,
|
||||
RFI_DPB,
|
||||
};
|
||||
use super::nvenc_status;
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
@@ -754,6 +755,27 @@ impl NvencD3d11Encoder {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// This session config's identity in the process-lifetime bitrate-ceiling cache
|
||||
/// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the selected render
|
||||
/// adapter's LUID — the adapter the capturer's device (and so this session) lives on; `0`
|
||||
/// when unresolved. Best effort by design: the cache is advisory, a colliding identity costs
|
||||
/// one failed open + re-search, never a wrong session.
|
||||
fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
|
||||
let gpu = pf_gpu::resolve_render_adapter_luid()
|
||||
.map(|l| ((l.HighPart as u32 as u64) << 32) | l.LowPart as u64)
|
||||
.unwrap_or(0);
|
||||
CeilingKey {
|
||||
gpu,
|
||||
codec: self.codec,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
fps: self.fps,
|
||||
bit_depth: self.bit_depth,
|
||||
chroma_444: self.chroma_444,
|
||||
split_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
|
||||
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
|
||||
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
|
||||
@@ -834,43 +856,14 @@ impl NvencD3d11Encoder {
|
||||
// gets the highest the GPU can actually do, not a coarse fraction of it.
|
||||
const FLOOR_BPS: u64 = 10_000_000;
|
||||
let requested_bps = self.bitrate_bps;
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever
|
||||
// the Linux host enables via libavcodec `split_encode_mode`. A single Ada NVENC session tops
|
||||
// out ~0.8 Gpix/s, so at high motion a 5K@240 (1.77 Gpix/s) frame takes ~8 ms to encode and
|
||||
// the rate caps ~125 fps; splitting across both engines roughly halves that. Force 2-way
|
||||
// above ~1 Gpix/s (matching encode/linux.rs), AUTO below (the ~2% BD-rate cost isn't worth
|
||||
// it at low pixel rates). Env override PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3.
|
||||
// HEVC/AV1 only; the init-failure fallback below disables it if a codec/config rejects it.
|
||||
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever.
|
||||
// A single Ada NVENC session tops out ~0.8-1 Gpix/s, so at high motion a 5K@240
|
||||
// (1.77 Gpix/s) frame takes ~8 ms to encode and the rate caps ~125 fps; splitting across
|
||||
// both engines roughly halves that. Shared selector — see [`resolve_split_mode`] for the
|
||||
// precedence (env override / the measured Main10 don't-split rule / pixel rate).
|
||||
// The init-failure fallback below disables it if a codec/config rejects it.
|
||||
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
|
||||
let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
|
||||
{
|
||||
Some("0") | Some("disable") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
Some("1") | Some("auto") => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
|
||||
}
|
||||
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
|
||||
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
|
||||
// Main10 (10-bit / HDR): 2-way split is measurably SLOWER on Ada — at 5120x1440@240
|
||||
// Main10, forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine
|
||||
// (the split/merge overhead dominates for 10-bit). A single Ada NVENC engine already
|
||||
// handles 5K@240 Main10 well under the 4.17 ms budget, so DON'T split — splitting was
|
||||
// the "broken animations in HDR" (the stream capped at ~131 fps). Env still overrides.
|
||||
_ if self.bit_depth >= 10 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
|
||||
}
|
||||
_ if pixel_rate > 1_000_000_000 => {
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|
||||
}
|
||||
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
|
||||
};
|
||||
tracing::debug!(
|
||||
split_mode,
|
||||
bit_depth = self.bit_depth,
|
||||
pixel_rate,
|
||||
"NVENC split-encode mode selected"
|
||||
);
|
||||
let split_mode: u32 = resolve_split_mode(self.bit_depth, pixel_rate);
|
||||
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
|
||||
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
|
||||
// 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced
|
||||
@@ -884,39 +877,69 @@ impl NvencD3d11Encoder {
|
||||
// built in the right mode from the start.
|
||||
let use_async = self.async_supported && async_retrieve_requested();
|
||||
|
||||
let mut probe = self.try_open_session(device, requested_bps, split_mode, use_async);
|
||||
// Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
|
||||
// this config's max accepted rate — open straight AT the ceiling instead of paying
|
||||
// the ~6-open binary search (and its session churn) on every ABR overshoot.
|
||||
let mut target_bps = requested_bps;
|
||||
if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
|
||||
if requested_bps > ceiling {
|
||||
tracing::info!(
|
||||
requested_mbps = requested_bps / 1_000_000,
|
||||
ceiling_mbps = ceiling / 1_000_000,
|
||||
"NVENC: requested bitrate above the cached codec-level ceiling — opening \
|
||||
at the ceiling"
|
||||
);
|
||||
target_bps = ceiling;
|
||||
}
|
||||
}
|
||||
|
||||
let mut probe = self.try_open_session(device, target_bps, split_mode, use_async);
|
||||
// The cache is advisory: a stale entry (driver change, identity collision) must not
|
||||
// wedge the open — retry the requested rate and let the search below rediscover.
|
||||
if probe.is_err() && target_bps < requested_bps {
|
||||
target_bps = requested_bps;
|
||||
probe = self.try_open_session(device, requested_bps, split_mode, use_async);
|
||||
}
|
||||
// Disambiguate a forced-split rejection from a bitrate-cap rejection: retry once at the
|
||||
// requested rate with split disabled — if THAT succeeds, split was the problem, not bitrate.
|
||||
// ANY non-disabled mode can be the rejection — AUTO included: AV1 rejects the whole
|
||||
// init with INVALID_PARAM on drivers/configs where auto split isn't valid for it,
|
||||
// which then masqueraded as a bitrate cap and failed "even at the floor".
|
||||
// `used_split` tracks the mode sessions ACTUALLY open with from here on — it feeds
|
||||
// `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
|
||||
let mut used_split = split_mode;
|
||||
let split_on =
|
||||
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if probe.is_err() && split_on {
|
||||
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
if let Ok(e) = self.try_open_session(device, requested_bps, no_split, use_async) {
|
||||
if let Ok(e) = self.try_open_session(device, target_bps, no_split, use_async) {
|
||||
tracing::warn!("NVENC: split-encode rejected by codec/config — disabled");
|
||||
split_mode = no_split;
|
||||
used_split = no_split;
|
||||
probe = Ok(e);
|
||||
}
|
||||
}
|
||||
|
||||
let enc = match probe {
|
||||
Ok(enc) => {
|
||||
self.bitrate_bps = requested_bps;
|
||||
self.bitrate_bps = target_bps;
|
||||
enc
|
||||
}
|
||||
// Only a parameter/caps rejection means "the bitrate is above the codec-level
|
||||
// ceiling". A transient failure (busy engine, session limit, OOM, device loss,
|
||||
// version skew) must propagate — a search steered by it would discover, and
|
||||
// cache, a bogus ceiling.
|
||||
Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
|
||||
Err(_) => {
|
||||
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
|
||||
// `lo` is the highest known-good rate (FLOOR is assumed to fit), `hi` the lowest
|
||||
// rejected; `best` holds the live session at `lo` so we end up with the clamped one.
|
||||
let mut lo = FLOOR_BPS;
|
||||
let mut hi = requested_bps;
|
||||
let mut hi = target_bps;
|
||||
let mut best: *mut c_void = ptr::null_mut();
|
||||
let mut best_bps = 0u64;
|
||||
while hi > lo + CLAMP_TOL_BPS {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
match self.try_open_session(device, mid, split_mode, use_async) {
|
||||
match self.try_open_session(device, mid, used_split, use_async) {
|
||||
Ok(e) => {
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
@@ -925,7 +948,15 @@ impl NvencD3d11Encoder {
|
||||
best_bps = mid;
|
||||
lo = mid;
|
||||
}
|
||||
Err(_) => hi = mid,
|
||||
Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
|
||||
Err(e) => {
|
||||
// Environmental mid-search failure: don't let it shrink the
|
||||
// search — release the partial result and propagate.
|
||||
if !best.is_null() {
|
||||
let _ = (api().destroy_encoder)(best);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.is_null() {
|
||||
@@ -933,14 +964,19 @@ impl NvencD3d11Encoder {
|
||||
// trying split-disabled in case a forced split (not the bitrate) is the blocker.
|
||||
let no_split =
|
||||
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
best = self
|
||||
.try_open_session(device, FLOOR_BPS, split_mode, use_async)
|
||||
.or_else(|_| {
|
||||
self.try_open_session(device, FLOOR_BPS, no_split, use_async)
|
||||
})
|
||||
best = match self.try_open_session(device, FLOOR_BPS, used_split, use_async)
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
let e = self
|
||||
.try_open_session(device, FLOOR_BPS, no_split, use_async)
|
||||
.context(
|
||||
"NVENC initialize_encoder rejected even at the floor bitrate",
|
||||
)?;
|
||||
used_split = no_split;
|
||||
e
|
||||
}
|
||||
};
|
||||
best_bps = FLOOR_BPS;
|
||||
}
|
||||
tracing::warn!(
|
||||
@@ -948,21 +984,19 @@ impl NvencD3d11Encoder {
|
||||
clamped_mbps = best_bps / 1_000_000,
|
||||
"NVENC: requested bitrate above the GPU codec-level ceiling — clamped to the max accepted"
|
||||
);
|
||||
store_ceiling(self.ceiling_key(used_split), best_bps);
|
||||
self.bitrate_bps = best_bps;
|
||||
best
|
||||
}
|
||||
};
|
||||
self.encoder = enc;
|
||||
// Session init params a later `reconfigure_bitrate` must re-present verbatim. (Best
|
||||
// effort: the floor fallback above may have succeeded split-disabled without updating
|
||||
// `split_mode` — a reconfigure then presents the forced mode, NVENC rejects it, and
|
||||
// the caller's rebuild fallback covers the mismatch.)
|
||||
self.split_mode = split_mode;
|
||||
// Session init params a later `reconfigure_bitrate` must re-present verbatim.
|
||||
self.split_mode = used_split;
|
||||
self.session_async = use_async;
|
||||
// Session-budget accounting (Stage W3): record what this open holds so admission can
|
||||
// decline a parallel display the hardware can't afford. Weighted by the FINAL split
|
||||
// mode (a split session occupies one hardware session per engine).
|
||||
self.session_units = split_mode_units(split_mode);
|
||||
self.session_units = split_mode_units(used_split);
|
||||
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
|
||||
// (The clamp path above already logs the requested→clamped bitrate at warn; no second
|
||||
// info line for the same event here.)
|
||||
@@ -1552,6 +1586,15 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.bitrate_bps = bps;
|
||||
return true;
|
||||
}
|
||||
// Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
|
||||
// overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
|
||||
// the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
|
||||
// ABR overshoot on the pre-cache path). The caller reads the clamp back through
|
||||
// [`Encoder::applied_bitrate_bps`].
|
||||
let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
|
||||
Some(ceiling) => bps.min(ceiling),
|
||||
None => bps,
|
||||
};
|
||||
// SAFETY: `inited` ⟹ `self.encoder` is the live session and this runs on the encode
|
||||
// thread between submit/poll (`nvEncReconfigureEncoder` is a submit-side call, the
|
||||
// sanctioned side of the two-thread async split — the retrieve thread only ever locks
|
||||
@@ -1600,6 +1643,12 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
}
|
||||
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
// `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
|
||||
// reconfigure path's cache clamp both write what the session ACTUALLY targets.
|
||||
Some(self.bitrate_bps)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
|
||||
}
|
||||
|
||||
@@ -189,12 +189,15 @@ impl PyroWaveEncoder {
|
||||
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||
}
|
||||
if chroma444 && !crate::pyrowave_mode_fits_rdo(width, height, true) {
|
||||
// The negotiator downgrades these modes to 4:2:0 pre-Welcome; refuse if one
|
||||
// slips through (e.g. the lab override) rather than wrap the RDO block index.
|
||||
// Checked against the chroma actually being opened, NOT hardcoded 4:4:4 — see the Linux
|
||||
// twin (`enc/linux/pyrowave.rs`) for the full rationale: the negotiator's 4:4:4 → 4:2:0
|
||||
// downgrade hands oversized modes to this open AS 4:2:0, so the old `chroma444`-gated
|
||||
// check was skipped exactly when it was needed.
|
||||
if !crate::pyrowave_mode_fits_rdo(width, height, chroma444) {
|
||||
bail!(
|
||||
"pyrowave 4:4:4 at {width}x{height} exceeds the rate controller's 16-bit \
|
||||
block index (see pyrowave-sys patches/0002 note) — use 4:2:0 at this size"
|
||||
"pyrowave {} at {width}x{height} exceeds the rate controller's 16-bit block \
|
||||
index (see pyrowave-sys patches/0002 note) — lower the resolution",
|
||||
if chroma444 { "4:4:4" } else { "4:2:0" }
|
||||
);
|
||||
}
|
||||
let fps = fps.max(1);
|
||||
|
||||
@@ -774,7 +774,12 @@ impl QsvEncoder {
|
||||
if codec == Codec::Av1 && !probe_can_encode(Codec::Av1) {
|
||||
bail!("this GPU/driver declined AV1 encode (DG2/Arc or MTL+ required) — QSV probe");
|
||||
}
|
||||
let ten_bit = bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
|
||||
// Depth follows the delivered pixels, not the negotiated depth ([`crate::ten_bit_input`]).
|
||||
// With the old `bit_depth >= 10 || …` shape a 10-bit-negotiated session over an 8-bit
|
||||
// capture derived `expected = P010` and hit the bail below, ending the session at open —
|
||||
// and taking the ffmpeg fallback with it, since that path had the same defect in a worse
|
||||
// form (it accepted the open and then failed every frame).
|
||||
let ten_bit = crate::ten_bit_input(format, bit_depth);
|
||||
if ten_bit && codec == Codec::H264 {
|
||||
bail!("native QSV: 10-bit is HEVC/AV1-only (H.264 High10 is not negotiated)");
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
//! VA surface). One [`Encoder`] trait, selected in [`open_video`]. Extracted into a subsystem crate
|
||||
//! (plan §W6): depends on the shared frame vocabulary (`pf-frame`) + zero-copy plumbing
|
||||
//! (`pf-zerocopy`), never on capture — the capture→encode edge is one-way.
|
||||
// Scaffold: some backend paths + trait defaults are defined ahead of the per-feature build that
|
||||
// uses them (mirrors the host crate root's allow before the extraction).
|
||||
#![allow(dead_code)]
|
||||
// NOTE: no crate-wide `#![allow(dead_code)]`. It was inherited from the pre-extraction host crate
|
||||
// root as scaffolding for backend paths defined ahead of the build that used them, but a census
|
||||
// across every feature combination on both platforms found it was hiding exactly two items — so it
|
||||
// bought nothing and blinded the crate to future rot. Genuinely test-only helpers carry
|
||||
// `#[cfg(test)]` instead.
|
||||
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||
// program). As a parent module this also covers the child modules (windows/linux backends).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
@@ -241,6 +243,12 @@ impl Encoder for TrackedEncoder {
|
||||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||||
self.inner.reconfigure_bitrate(bps)
|
||||
}
|
||||
// Forwarded (the same trap class as `set_wire_chunking`): the unforwarded default `None`
|
||||
// would tell the session loop "no encoder truth here" and it would keep pacing/acking the
|
||||
// requested rate even where NVENC clamped to the codec-level ceiling.
|
||||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||||
self.inner.applied_bitrate_bps()
|
||||
}
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
@@ -853,18 +861,31 @@ fn vulkan_encode_enabled() -> bool {
|
||||
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
|
||||
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
|
||||
/// escapes at the capture gate).
|
||||
///
|
||||
/// This verdict is load-bearing in a way the other capability helpers are not: once the producer
|
||||
/// has been asked for two-plane NV12 there is **no fallback**. [`open_video`] deliberately makes a
|
||||
/// failed Vulkan open FATAL for an NV12 capture rather than degrading to libav VAAPI, because VAAPI
|
||||
/// would import that buffer as packed RGB and stream silent garbage. So a wrong `true` here does
|
||||
/// not cost quality — it kills the session at its first frame. Hence the real device probe below,
|
||||
/// and hence the conjuncts are ordered cheapest-first so it only runs when everything else already
|
||||
/// said yes.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref
|
||||
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`].
|
||||
&& !matches!(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
"nvenc" | "nvidia" | "cuda" | "pyrowave"
|
||||
)
|
||||
// Which backend this host actually resolves to. This used to be a denylist of the
|
||||
// EXPLICIT prefs that skip Vulkan Video ("nvenc"|"nvidia"|"cuda"|"pyrowave"), which
|
||||
// silently missed the one that matters: the DEFAULT `encoder_pref` is `""`, and `""`
|
||||
// resolves to `auto`, which on an NVIDIA box opens NVENC. So a stock NVIDIA host passed
|
||||
// this gate. `linux_zero_copy_is_vaapi` layers the pref on top of the same auto decision
|
||||
// `open_video` makes, which is exactly what the note on `linux_auto_is_vaapi` says a
|
||||
// capability probe must consult — and it is what the downstream consumer of this very
|
||||
// verdict already uses to pick its zero-copy path.
|
||||
&& linux_zero_copy_is_vaapi()
|
||||
// …and only then ask the GPU. Ordered last on purpose: it opens a Vulkan instance.
|
||||
&& vulkan_encode_available(codec)
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
@@ -873,6 +894,42 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per
|
||||
/// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
|
||||
///
|
||||
/// Only [`linux_native_nv12_ok`] consults this, and only for the no-fallback decision described
|
||||
/// there. It is deliberately NOT wired into [`open_video`]'s dispatch: that path already degrades
|
||||
/// to VAAPI on a failed open for every non-NV12 capture, so making it pay for a probe would buy
|
||||
/// nothing. Prediction and truth stay separate — the probe answers "would it open", the open itself
|
||||
/// reports what actually did.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
fn vulkan_encode_available(codec: Codec) -> bool {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||||
let key = (pf_gpu::selection_key(), codec.label());
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||||
return *v;
|
||||
}
|
||||
let verdict = vulkan_video::probe_encode_support(codec);
|
||||
let ok = verdict.is_ok();
|
||||
match &verdict {
|
||||
Ok(()) => tracing::info!(
|
||||
?codec,
|
||||
"Vulkan Video encode probed OK — producer-native NV12 capture is eligible"
|
||||
),
|
||||
Err(why) => tracing::info!(
|
||||
?codec,
|
||||
why = *why,
|
||||
"Vulkan Video encode unavailable — keeping the packed-RGB capture negotiation \
|
||||
(the native-NV12 path has no VAAPI fallback)"
|
||||
),
|
||||
}
|
||||
cache.lock().unwrap().insert(key, ok);
|
||||
ok
|
||||
}
|
||||
|
||||
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
||||
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
||||
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
||||
|
||||
@@ -1139,18 +1139,67 @@ fn dm_survives_masked_unit(dm: &str) -> bool {
|
||||
dm == "sddm.service"
|
||||
}
|
||||
|
||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see
|
||||
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the
|
||||
/// caller degrades to attach.
|
||||
fn try_stop_display_manager(dm: &str) -> bool {
|
||||
Command::new("systemctl")
|
||||
.args(["stop", dm])
|
||||
/// The packaged privileged fallback for the display-manager takeover verbs: a root helper behind
|
||||
/// its own polkit action (`io.unom.punktfunk.dm-helper`, `allow_any` — the mechanism these
|
||||
/// distros use for their own session switcher, e.g. Nobara's `os-session-select`), so the managed
|
||||
/// takeover works out of the box on mask-fragile DM flavors with no hand-installed polkit rule.
|
||||
/// The helper derives the DM unit from the `display-manager.service` symlink itself, so this
|
||||
/// process never gets to name an arbitrary unit across the privilege boundary. Two layouts: the
|
||||
/// rpm/deb `libexec` path (what the shipped policy annotates) and Arch's `/usr/lib/<pkg>` (its
|
||||
/// PKGBUILD rewrites the annotation to match).
|
||||
const DM_HELPER_PATHS: &[&str] = &[
|
||||
"/usr/libexec/punktfunk/pf-dm-helper",
|
||||
"/usr/lib/punktfunk/pf-dm-helper",
|
||||
];
|
||||
|
||||
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't
|
||||
/// installed (tarball/old package), pkexec is missing, or polkit denies the action.
|
||||
fn dm_helper(verb: &str) -> bool {
|
||||
let Some(helper) = DM_HELPER_PATHS
|
||||
.iter()
|
||||
.find(|p| std::path::Path::new(p).exists())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
Command::new("pkexec")
|
||||
.arg(helper)
|
||||
.arg(verb)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
|
||||
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
|
||||
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
|
||||
/// attach.
|
||||
fn try_stop_display_manager(dm: &str) -> bool {
|
||||
let direct = Command::new("systemctl")
|
||||
.args(["stop", dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
direct || dm_helper("stop")
|
||||
}
|
||||
|
||||
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
|
||||
/// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin
|
||||
/// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an
|
||||
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
|
||||
/// two steps as root.
|
||||
fn restore_display_manager(dm: &str) -> bool {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", dm])
|
||||
.status();
|
||||
let direct = Command::new("systemctl")
|
||||
.args(["restart", dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
direct || dm_helper("restore")
|
||||
}
|
||||
|
||||
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
|
||||
/// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so
|
||||
/// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but
|
||||
@@ -1219,10 +1268,13 @@ fn honor_session_select_switch(dm: String) {
|
||||
clear_takeover();
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", &dm])
|
||||
.status();
|
||||
let _ = Command::new("systemctl").args(["start", &dm]).status();
|
||||
if !restore_display_manager(&dm) {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
"gamescope: display-manager start was denied — the desktop switch may need a manual \
|
||||
`systemctl restart` of the DM"
|
||||
);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
let active = Command::new("systemctl")
|
||||
@@ -1343,8 +1395,10 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
if !try_stop_display_manager(&dm) {
|
||||
bail!(
|
||||
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
||||
session unit, and stopping it needs privilege — install the punktfunk \
|
||||
display-manager polkit rule (see docs) to enable the managed takeover"
|
||||
session unit, and stopping it needs privilege — the packaged pf-dm-helper \
|
||||
polkit action is missing or was denied (reinstall the punktfunk package, or \
|
||||
install the display-manager polkit rule from the docs) so the managed takeover \
|
||||
is unavailable"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
@@ -1668,14 +1722,7 @@ fn do_restore_tv_session() {
|
||||
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
|
||||
// session Exec starts the gamescope unit itself.
|
||||
if let Some(dm) = dm {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", &dm])
|
||||
.status();
|
||||
let restart = Command::new("systemctl")
|
||||
.args(["restart", &dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
let restart = restore_display_manager(&dm);
|
||||
if restart {
|
||||
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
|
||||
} else if crate::try_recover_session() {
|
||||
|
||||
@@ -340,9 +340,19 @@ pub(crate) fn is_available() -> bool {
|
||||
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
|
||||
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
|
||||
|
||||
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing
|
||||
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a
|
||||
/// gate. Returns the parsed version when it could read one.
|
||||
/// First gamescope that paints the Steam overlay (Shift+Tab / Quick Access Menu) into its built-in
|
||||
/// PipeWire node. `paint_pipewire()` is a *separate, reduced* composite from the display scanout;
|
||||
/// the overlay-window paint (gated on the consumer negotiating `gamescope_focus_appid == 0`, which
|
||||
/// we do by never advertising that property — see the capturer's EnumFormat builders) first ships
|
||||
/// in 3.16.23 (gamescope commits `ccd62074` + `f8b33d38`). Below this the overlay is *never* in the
|
||||
/// node, so it cannot appear in the stream no matter what the host does. The cursor and
|
||||
/// external-overlay / notification layers are excluded on *every* version (handled host-side).
|
||||
const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
|
||||
|
||||
/// Best-effort: warn if the installed gamescope is older than [`MIN_GAMESCOPE`] (capture is
|
||||
/// unreliable) or than [`MIN_GAMESCOPE_OVERLAY`] (capture works but the Steam overlay can't reach
|
||||
/// the stream). Parsing failures are silent (don't block a possibly-fine custom build) — this is a
|
||||
/// diagnostic, not a gate. Returns the parsed version when it could read one.
|
||||
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
||||
let out = Command::new("gamescope").arg("--version").output().ok()?;
|
||||
// gamescope prints the version banner to stderr on some builds, stdout on others.
|
||||
@@ -360,6 +370,18 @@ pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
||||
capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \
|
||||
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
|
||||
);
|
||||
} else if ver < MIN_GAMESCOPE_OVERLAY {
|
||||
// Capture is fine; the Steam overlay just won't be in the frame gamescope hands us.
|
||||
tracing::warn!(
|
||||
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
|
||||
min = %format!(
|
||||
"{}.{}.{}",
|
||||
MIN_GAMESCOPE_OVERLAY.0, MIN_GAMESCOPE_OVERLAY.1, MIN_GAMESCOPE_OVERLAY.2
|
||||
),
|
||||
"gamescope is older than the first version that paints the Steam overlay (Shift+Tab / \
|
||||
Quick Access Menu) into its PipeWire node — the overlay will be absent from the \
|
||||
stream until you upgrade gamescope (the cursor is composited host-side regardless)"
|
||||
);
|
||||
}
|
||||
Some(ver)
|
||||
}
|
||||
@@ -379,7 +401,7 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE};
|
||||
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY};
|
||||
|
||||
#[test]
|
||||
fn parses_steam_appid_from_launch() {
|
||||
@@ -425,4 +447,15 @@ mod tests {
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_threshold_brackets_the_fix() {
|
||||
// 3.16.22 captures fine but predates the overlay-in-pipewire paint — it sits in the
|
||||
// "capture works, overlay absent" window `[MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY)`, which is
|
||||
// exactly the `else if` warn arm; 3.16.23 is the first to include the overlay.
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() < MIN_GAMESCOPE_OVERLAY);
|
||||
assert!(parse_version("gamescope version 3.16.23").unwrap() >= MIN_GAMESCOPE_OVERLAY);
|
||||
assert!(parse_version("gamescope version 3.16.25").unwrap() >= MIN_GAMESCOPE_OVERLAY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
//! its rolling baseline: standing queue growth, the *pre-loss* signature of a saturated link
|
||||
//! (bufferbloat) — this is the early-warning signal loss-based control lacks;
|
||||
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
|
||||
//! evidence there is.
|
||||
//! evidence there is;
|
||||
//! - **host-encode-latency rise** — the host's per-AU 0xCF `encode_us` climbing above its rolling
|
||||
//! baseline: the ENCODER falling behind its frame budget (the compute knee), the one failure a
|
||||
//! fat LAN never surfaces as loss/OWD/decode. Paired with the host's own climb refusal (a
|
||||
//! behind-cadence host acks climbs at the current rate) and short-ack cap learning
|
||||
//! ([`BitrateController::on_ack`]), this is what stops an Automatic session from driving the
|
||||
//! encoder off a cliff the network could carry.
|
||||
//!
|
||||
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
|
||||
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
|
||||
@@ -94,6 +100,25 @@ const UTILIZATION_DEN: u64 = 4;
|
||||
/// cap always leaves ≥ ~12 % of climbing room — the two gates can't deadlock.
|
||||
const PROVEN_HEADROOM_NUM: u32 = 3;
|
||||
const PROVEN_HEADROOM_DEN: u32 = 2;
|
||||
/// How far the window's mean HOST-ENCODE latency (the 0xCF `HostStages::encode_us` the host
|
||||
/// already ships per AU) may rise above its rolling baseline before the window is bad. This is
|
||||
/// the down-driver for the ENCODER's compute knee — the failure loss/OWD/decode are all blind
|
||||
/// to: on a fat LAN the controller can climb to a rate the link carries fine but the ASIC
|
||||
/// can't encode inside the frame budget (4K120 HEVC at ~800 Mbps ≈ 9.3 ms against 8.33), and
|
||||
/// the only symptom is encode time. Baseline-RELATIVE on purpose: an escalated host reports
|
||||
/// encode_us inflated by its retrieve-queue depth (~a frame), so an absolute budget threshold
|
||||
/// would read permanently-red and drive the rate to the floor; a rise above the session's own
|
||||
/// baseline survives that offset. ~half a 120 Hz frame budget of standing rise is real.
|
||||
const ENCODE_RISE_US: i64 = 4_000;
|
||||
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
|
||||
/// queue is growing past the knee; skip the two-window confirmation.
|
||||
const ENCODE_SEVERE_US: i64 = 12_000;
|
||||
/// Clean windows parked at the learned [`host cap`](BitrateController::host_cap_kbps) before
|
||||
/// re-probing above it (~60 s at the 750 ms tick). A cadence-refusal cap is scene-dependent
|
||||
/// evidence, not a spec limit — without a re-probe, one heavy scene would cap the whole
|
||||
/// session. A still-standing limit just re-teaches itself in two short acks, which the host
|
||||
/// pre-clamps without touching the encoder — the re-probe costs no rebuild, no IDR.
|
||||
const CAP_REPROBE_WINDOWS: u32 = 80;
|
||||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||||
const BASELINE_WINDOWS: usize = 40;
|
||||
@@ -121,6 +146,27 @@ pub(crate) struct BitrateController {
|
||||
/// keeping-up baseline. Empty on embedders that don't report decode latency (the decode
|
||||
/// signal is then simply absent — identical to the pre-decode-signal behavior).
|
||||
decode_means: VecDeque<i64>,
|
||||
/// Recent window mean host-encode latencies (µs, from the 0xCF datagrams); rolling-min
|
||||
/// baseline like the decode signal. Cleared whenever OUR OWN rate decrease changes the
|
||||
/// encode regime (see [`on_ack`](Self::on_ack)) and on a mode switch.
|
||||
encode_means: VecDeque<i64>,
|
||||
/// The host-taught rate cap (§ABR overdrive): latched when the host acks BELOW what we
|
||||
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
|
||||
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
|
||||
/// the probe-measured link authority survives a mode switch's reset. Slowly re-probed
|
||||
/// ([`CAP_REPROBE_WINDOWS`]) so scene-dependent evidence can't cap the session forever.
|
||||
host_cap_kbps: Option<u32>,
|
||||
/// The rate the last [`request`](Self::request) asked for — the reference an ack is judged
|
||||
/// short against. Taken (not kept) by the ack, so one request is judged at most once.
|
||||
last_requested_kbps: Option<u32>,
|
||||
/// Consecutive short-ack streak: the value and how many times in a row it was acked. Two
|
||||
/// identical short acks latch [`host_cap_kbps`](Self::host_cap_kbps) — one can be a
|
||||
/// transient (a failed host rebuild keeping the old rate); the host's resolves are
|
||||
/// deterministic min()s, so a persistent limit reproduces exactly.
|
||||
short_ack_kbps: u32,
|
||||
short_acks: u32,
|
||||
/// Clean windows spent parked at the learned cap (the re-probe clock).
|
||||
cap_probe_windows: u32,
|
||||
/// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat
|
||||
/// decode latency — the known-good high-water mark climbs are bounded against. Never decays;
|
||||
/// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On
|
||||
@@ -147,6 +193,12 @@ impl BitrateController {
|
||||
probing: true,
|
||||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
encode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||||
host_cap_kbps: None,
|
||||
last_requested_kbps: None,
|
||||
short_ack_kbps: 0,
|
||||
short_acks: 0,
|
||||
cap_probe_windows: 0,
|
||||
proven_kbps: 0,
|
||||
bad_windows: 0,
|
||||
clean_windows: 0,
|
||||
@@ -168,22 +220,68 @@ impl BitrateController {
|
||||
|
||||
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
|
||||
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
||||
///
|
||||
/// A SHORT ack (below what we asked) is the host telling us about a limit the network
|
||||
/// signals can't see — its encoder's codec-level ceiling, or a climb refusal while encode
|
||||
/// can't hold cadence. Two consecutive short acks at the SAME value latch it as
|
||||
/// [`host_cap_kbps`](Self::host_cap_kbps), stopping the AIMD sawtooth from re-poking a
|
||||
/// limit the host already refused; ONE is not enough — a failed host rebuild also acks
|
||||
/// short once, and latching a transient would cap the session on a hiccup.
|
||||
pub(crate) fn on_ack(&mut self, kbps: u32) {
|
||||
if kbps > 0 {
|
||||
if kbps < self.current_kbps {
|
||||
// Our own decrease changes the encode-time regime (less work per frame; on an
|
||||
// escalated host the queue offset shifts too) — judging the new regime against
|
||||
// the old baseline would train-fire the encode down-driver. Re-seed it.
|
||||
self.encode_means.clear();
|
||||
}
|
||||
if let Some(req) = self.last_requested_kbps.take() {
|
||||
if kbps < req {
|
||||
if self.short_ack_kbps == kbps {
|
||||
self.short_acks += 1;
|
||||
} else {
|
||||
self.short_ack_kbps = kbps;
|
||||
self.short_acks = 1;
|
||||
}
|
||||
if self.short_acks >= 2 && self.host_cap_kbps.is_none_or(|c| kbps < c) {
|
||||
tracing::info!(
|
||||
cap_kbps = kbps,
|
||||
"adaptive bitrate: host cap learned (encoder ceiling or cadence \
|
||||
refusal) — climbs stop here until it lifts"
|
||||
);
|
||||
self.host_cap_kbps = Some(kbps.max(self.floor_kbps));
|
||||
self.cap_probe_windows = 0;
|
||||
}
|
||||
} else {
|
||||
self.short_acks = 0;
|
||||
}
|
||||
}
|
||||
self.current_kbps = kbps;
|
||||
}
|
||||
self.unacked = 0;
|
||||
}
|
||||
|
||||
/// An accepted mode switch: the encoder's ceiling and compute knee are properties of the
|
||||
/// MODE (4K120 caps where 1080p60 never would) — drop the mode-scoped learned state. The
|
||||
/// probe-measured `ceiling_kbps` (a LINK property) survives.
|
||||
pub(crate) fn on_mode_switch(&mut self) {
|
||||
self.host_cap_kbps = None;
|
||||
self.short_acks = 0;
|
||||
self.cap_probe_windows = 0;
|
||||
self.encode_means.clear();
|
||||
}
|
||||
|
||||
/// Feed one report window; returns the rate to request now, if any. `dropped` = frames that
|
||||
/// went FEC-unrecoverable in the window, `loss_ppm` the window's [`crate::quic::LossReport`]
|
||||
/// figure, `owd_mean_us` the window's mean skew-corrected capture→received latency (`None`
|
||||
/// without a clock handshake), `decode_mean_us` the window's mean client decode-stage latency
|
||||
/// (`None` on an embedder that doesn't report it — the signal is then absent), `actual_kbps`
|
||||
/// the window's ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline
|
||||
/// really carried, as opposed to the target it was allowed; feeds the utilization climb gate
|
||||
/// and the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in
|
||||
/// the window.
|
||||
/// (`None` on an embedder that doesn't report it — the signal is then absent),
|
||||
/// `encode_mean_us` the window's mean HOST encode-stage latency (from the per-AU 0xCF
|
||||
/// datagrams; `None` on an old host that doesn't send them), `actual_kbps` the window's
|
||||
/// ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline really
|
||||
/// carried, as opposed to the target it was allowed; feeds the utilization climb gate and
|
||||
/// the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in the
|
||||
/// window.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn on_window(
|
||||
&mut self,
|
||||
@@ -192,6 +290,7 @@ impl BitrateController {
|
||||
loss_ppm: u32,
|
||||
owd_mean_us: Option<i64>,
|
||||
decode_mean_us: Option<i64>,
|
||||
encode_mean_us: Option<i64>,
|
||||
actual_kbps: u32,
|
||||
flushed: bool,
|
||||
) -> Option<u32> {
|
||||
@@ -242,6 +341,23 @@ impl BitrateController {
|
||||
}
|
||||
None => (false, false),
|
||||
};
|
||||
// Host-encode latency: the same rolling-min-baseline treatment, measuring the HOST'S
|
||||
// encoder — the compute-knee down-driver (see [`ENCODE_RISE_US`]). This is the only
|
||||
// signal that can push an already-too-high rate back under the knee: the host refuses
|
||||
// further climbs while behind cadence, but nothing else ever DESCENDS on a clean LAN.
|
||||
let (encode_bad, encode_severe) = match encode_mean_us {
|
||||
Some(mean) => {
|
||||
let base = self.encode_means.iter().min().copied();
|
||||
let bad = base.is_some_and(|b| mean > b + ENCODE_RISE_US);
|
||||
let severe = base.is_some_and(|b| mean > b + ENCODE_SEVERE_US);
|
||||
if self.encode_means.len() == BASELINE_WINDOWS {
|
||||
self.encode_means.pop_front();
|
||||
}
|
||||
self.encode_means.push_back(mean);
|
||||
(bad, severe)
|
||||
}
|
||||
None => (false, false),
|
||||
};
|
||||
// The proven-throughput high-water mark: this window's delivered rate is now demonstrably
|
||||
// digestible (decode latency stayed flat while it was carried). Loss doesn't disqualify —
|
||||
// the bytes that DID arrive still went through the decoder; what loss means for the rate
|
||||
@@ -253,8 +369,9 @@ impl BitrateController {
|
||||
// deep decode-latency excursion) or loss far past any blip — one window is enough.
|
||||
// Ordinary congestion (heavy-but-recoverable loss, an OWD rise, a decode-latency rise)
|
||||
// still needs two consecutive windows.
|
||||
let severe = dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe;
|
||||
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad;
|
||||
let severe =
|
||||
dropped > 0 || flushed || loss_ppm >= SEVERE_LOSS_PPM || decode_severe || encode_severe;
|
||||
let bad = severe || loss_ppm >= HEAVY_LOSS_PPM || owd_bad || decode_bad || encode_bad;
|
||||
if bad {
|
||||
self.bad_windows += 1;
|
||||
self.clean_windows = 0;
|
||||
@@ -264,6 +381,29 @@ impl BitrateController {
|
||||
self.clean_windows += 1;
|
||||
self.bad_windows = 0;
|
||||
}
|
||||
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS`]): after ~60 s of clean
|
||||
// windows parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a
|
||||
// scene-dependent refusal can't quietly cap the whole session — a still-standing limit
|
||||
// just re-latches from the next pair of short acks, at zero encoder cost.
|
||||
if let Some(cap) = self.host_cap_kbps {
|
||||
if bad {
|
||||
self.cap_probe_windows = 0;
|
||||
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||||
self.cap_probe_windows += 1;
|
||||
if self.cap_probe_windows >= CAP_REPROBE_WINDOWS {
|
||||
self.cap_probe_windows = 0;
|
||||
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||||
if lifted > cap {
|
||||
tracing::debug!(
|
||||
from_kbps = cap,
|
||||
to_kbps = lifted,
|
||||
"adaptive bitrate: re-probing above the learned host cap"
|
||||
);
|
||||
self.host_cap_kbps = Some(lifted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let cooled = self
|
||||
.last_change
|
||||
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
|
||||
@@ -284,10 +424,14 @@ impl BitrateController {
|
||||
// utilized window after a long-enough clean run climbs immediately.
|
||||
let utilized =
|
||||
actual_kbps as u64 * UTILIZATION_DEN >= self.current_kbps as u64 * UTILIZATION_NUM;
|
||||
let cap = self
|
||||
// The effective ceiling folds in the host-taught cap: the probe measured the LINK, but
|
||||
// the host's short acks measured the ENCODER — whichever binds first is the limit.
|
||||
let eff_ceiling = self
|
||||
.ceiling_kbps
|
||||
.min(self.host_cap_kbps.unwrap_or(u32::MAX));
|
||||
let cap = eff_ceiling
|
||||
.min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN);
|
||||
if self.current_kbps < self.ceiling_kbps && utilized && cap > self.current_kbps {
|
||||
if self.current_kbps < eff_ceiling && utilized && cap > self.current_kbps {
|
||||
// Slow start: double on every cooled clean window until the first congestion signal
|
||||
// (this is how an Automatic session reaches a probe-measured ceiling in seconds).
|
||||
// Congestion avoidance: +~6 % after a sustained clean run.
|
||||
@@ -308,6 +452,7 @@ impl BitrateController {
|
||||
fn request(&mut self, kbps: u32, now: Instant) -> Option<u32> {
|
||||
self.last_change = Some(now);
|
||||
self.unacked += 1;
|
||||
self.last_requested_kbps = Some(kbps);
|
||||
// `current_kbps` is NOT updated here — the host's ack is authoritative. A lost/ignored
|
||||
// request just recomputes from the same base next time (and counts toward MAX_UNACKED).
|
||||
Some(kbps)
|
||||
@@ -331,7 +476,16 @@ mod tests {
|
||||
fn run_clean(c: &mut BitrateController, start: Instant, from: u32, n: u32) -> Option<u32> {
|
||||
let mut out = None;
|
||||
for i in from..from + n {
|
||||
out = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false);
|
||||
out = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
);
|
||||
if out.is_some() {
|
||||
return out;
|
||||
}
|
||||
@@ -345,7 +499,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(0);
|
||||
let now = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(now, 5, 900_000, Some(500_000), None, 1_000_000, true),
|
||||
c.on_window(now, 5, 900_000, Some(500_000), None, None, 1_000_000, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -356,22 +510,58 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
// Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 25_000, None, None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 0),
|
||||
0,
|
||||
25_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
// The second consecutive bad window backs off ×0.7.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 1), 0, 25_000, None, None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 1),
|
||||
0,
|
||||
25_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 6), 0, 25_000, None, None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 6),
|
||||
0,
|
||||
25_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
); // bad #1 again
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 7), 0, 25_000, None, None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 7),
|
||||
0,
|
||||
25_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(9_800)
|
||||
);
|
||||
}
|
||||
@@ -382,19 +572,28 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
Some(14_000)
|
||||
);
|
||||
// …and so does a jump-to-live flush.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, None, None, 1_000_000, true),
|
||||
c.on_window(ticks(start, 0), 0, 0, None, None, None, 1_000_000, true),
|
||||
Some(14_000)
|
||||
);
|
||||
// …and ≥6 % window loss.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 80_000, None, None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 0),
|
||||
0,
|
||||
80_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
@@ -404,18 +603,18 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
|
||||
// boundary (tick 2 = 1.5 s) it fires.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 1), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false),
|
||||
Some(9_800)
|
||||
);
|
||||
}
|
||||
@@ -426,17 +625,17 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
Some(5_000)
|
||||
);
|
||||
c.on_ack(5_000);
|
||||
// At the floor, further bad windows request nothing.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 6), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 7), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -446,7 +645,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, 1_000_000, false),
|
||||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false),
|
||||
Some(14_000)
|
||||
);
|
||||
c.on_ack(14_000);
|
||||
@@ -469,9 +668,16 @@ mod tests {
|
||||
// Every cooled clean window doubles until the ceiling caps the climb, then quiet.
|
||||
let mut got = Vec::new();
|
||||
for i in 0..14 {
|
||||
if let Some(k) =
|
||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false)
|
||||
{
|
||||
if let Some(k) = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
) {
|
||||
c.on_ack(k);
|
||||
got.push(k);
|
||||
}
|
||||
@@ -485,20 +691,47 @@ mod tests {
|
||||
c.set_ceiling(300_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 0), 0, 0, Some(10_000), None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 0),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(40_000)
|
||||
);
|
||||
c.on_ack(40_000);
|
||||
// Severe window → immediate ×0.7, and slow start is over.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 2), 1, 0, Some(10_000), None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 2),
|
||||
1,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(28_000)
|
||||
);
|
||||
c.on_ack(28_000);
|
||||
// Clean again — but the next climb is additive, after the 6-window clean run.
|
||||
let mut next = None;
|
||||
for i in 3..12 {
|
||||
next = c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false);
|
||||
next = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
);
|
||||
if next.is_some() {
|
||||
assert!(i >= 8, "additive climb must wait for the clean run");
|
||||
break;
|
||||
@@ -512,7 +745,7 @@ mod tests {
|
||||
let mut c = BitrateController::new(0);
|
||||
c.set_ceiling(1_000_000);
|
||||
assert_eq!(
|
||||
c.on_window(Instant::now(), 0, 0, None, None, 1_000_000, false),
|
||||
c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false),
|
||||
None
|
||||
);
|
||||
let mut c = BitrateController::new(20_000);
|
||||
@@ -527,17 +760,44 @@ mod tests {
|
||||
// Establish a ~10 ms baseline over a few clean windows.
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
// Delay climbs 40 ms above baseline with ZERO loss — bufferbloat. Two windows → back off.
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 4), 0, 0, Some(50_000), None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(50_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, 5), 0, 0, Some(52_000), None, 1_000_000, false),
|
||||
c.on_window(
|
||||
ticks(start, 5),
|
||||
0,
|
||||
0,
|
||||
Some(52_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
@@ -557,6 +817,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -572,6 +833,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(38_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -584,6 +846,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(40_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -605,6 +868,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -620,6 +884,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(38_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -634,6 +899,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(40_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -657,6 +923,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
2_000,
|
||||
false
|
||||
),
|
||||
@@ -673,6 +940,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
18_000,
|
||||
false
|
||||
),
|
||||
@@ -696,6 +964,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
20_000,
|
||||
false
|
||||
),
|
||||
@@ -710,6 +979,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
30_000,
|
||||
false
|
||||
),
|
||||
@@ -732,6 +1002,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
20_000,
|
||||
false
|
||||
),
|
||||
@@ -741,7 +1012,16 @@ mod tests {
|
||||
// A long calm stretch (2 % utilization, decoder idle): the controller stays silent.
|
||||
for i in 2..30 {
|
||||
assert_eq!(
|
||||
c.on_window(ticks(start, i), 0, 0, Some(10_000), Some(4_000), 600, false),
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(4_000),
|
||||
None,
|
||||
600,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -761,6 +1041,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(8_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -776,6 +1057,7 @@ mod tests {
|
||||
0,
|
||||
Some(10_000),
|
||||
Some(60_000),
|
||||
None,
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
@@ -783,6 +1065,235 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_identical_short_acks_latch_the_host_cap() {
|
||||
// The 4K120 field failure: the encoder ceilings at ~794 Mbps while the link carries
|
||||
// more — the host acks short. TWO identical short acks teach the cap; climbs then stop
|
||||
// poking a limit the host already refused (the rebuild-storm driver).
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||
// First short ack: current follows (authoritative), but one short ack is not a cap.
|
||||
c.on_ack(794_000);
|
||||
assert!(c.host_cap_kbps.is_none());
|
||||
// The next climb overshoots again and is short-acked at the SAME value: latch.
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||
// Parked AT the learned cap, nothing left to climb to — no more requests.
|
||||
assert_eq!(run_clean(&mut c, start, 20, 12), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_short_ack_is_a_transient_not_a_cap() {
|
||||
// A failed host rebuild acks short once (it kept the old rate) — latching THAT would
|
||||
// cap the session on a driver hiccup. The streak must survive only identical repeats.
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||
c.on_ack(400_000); // rebuild failed, host kept the old rate
|
||||
assert!(c.host_cap_kbps.is_none());
|
||||
// The retry applies fully: streak broken, still no cap, full authority kept.
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(800_000));
|
||||
c.on_ack(800_000);
|
||||
assert!(c.host_cap_kbps.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_switch_clears_the_learned_cap() {
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||
// 4K120's ceiling means nothing at the new mode — the cap must not survive the switch
|
||||
// (the probe-measured link ceiling does).
|
||||
c.on_mode_switch();
|
||||
assert!(c.host_cap_kbps.is_none());
|
||||
assert_eq!(c.ceiling_kbps, 1_400_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learned_cap_reprobes_after_a_sustained_clean_run() {
|
||||
// A cadence-refusal cap is scene evidence, not a spec limit: after ~60 s parked clean
|
||||
// at the cap, lift one step so a one-time heavy scene can't cap the session forever. A
|
||||
// still-standing limit just re-latches from the next short-ack pair, at zero cost.
|
||||
let mut c = BitrateController::new(400_000);
|
||||
c.set_ceiling(1_400_000);
|
||||
let start = Instant::now();
|
||||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||||
c.on_ack(794_000);
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||||
for i in 0..CAP_REPROBE_WINDOWS {
|
||||
let _ = c.on_window(
|
||||
ticks(start, 20 + i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
None,
|
||||
1_000_000,
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_encode_latency_rise_backs_off() {
|
||||
// The compute knee: link pristine, client decoder fine — only HOST encode time moves
|
||||
// (the 4K120 case: ~9.3 ms against an 8.33 ms budget shows up nowhere else). Two risen
|
||||
// windows → ×0.7, exactly like an OWD/decode rise.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(11_500),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 6),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(12_000),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_encode_excursion_is_severe() {
|
||||
// Encode time shooting ≈1.5 frame budgets over baseline = the queue is growing past
|
||||
// the knee right now — no two-window confirmation.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(20_000),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_decrease_rebases_the_encode_baseline() {
|
||||
// After OUR OWN decrease the encode regime legitimately changes (less work per frame;
|
||||
// an escalated host's reported encode_us also carries a queue offset) — the old
|
||||
// baseline must not train-fire repeated backoffs down to the floor.
|
||||
let mut c = BitrateController::new(20_000);
|
||||
let start = Instant::now();
|
||||
for i in 0..4 {
|
||||
let _ = c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(7_000),
|
||||
1_000_000,
|
||||
false,
|
||||
);
|
||||
}
|
||||
let _ = c.on_window(
|
||||
ticks(start, 4),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(12_000),
|
||||
1_000_000,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, 6),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(12_500),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
Some(14_000)
|
||||
);
|
||||
// The decrease applies → rebase. The new regime's ~15 ms means (an escalated host's
|
||||
// queue offset) would be far over the OLD 7 ms baseline, but must now read clean.
|
||||
c.on_ack(14_000);
|
||||
for i in 8..11 {
|
||||
assert_eq!(
|
||||
c.on_window(
|
||||
ticks(start, i),
|
||||
0,
|
||||
0,
|
||||
Some(10_000),
|
||||
None,
|
||||
Some(15_000),
|
||||
1_000_000,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ack_silence_disables_the_controller() {
|
||||
let mut c = BitrateController::new(20_000);
|
||||
@@ -791,7 +1302,7 @@ mod tests {
|
||||
let mut i = 0;
|
||||
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
|
||||
while i < 60 {
|
||||
if c.on_window(ticks(start, i), 1, 0, None, None, 1_000_000, false)
|
||||
if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false)
|
||||
.is_some()
|
||||
{
|
||||
sent += 1;
|
||||
|
||||
@@ -224,6 +224,20 @@ pub(crate) struct DecodeLatAcc {
|
||||
pub(crate) count: u32,
|
||||
}
|
||||
|
||||
/// Host encode-stage latency accumulator — [`DecodeLatAcc`]'s mirror for the HOST side of the
|
||||
/// pipeline. The datagram task adds one sample per 0xCF `HostStages::encode_us` (host encoder
|
||||
/// submit → bitstream ready) and the pump drains a window mean into
|
||||
/// [`crate::abr::BitrateController::on_window`]'s encode signal. Host encode time was measured,
|
||||
/// shipped and drawn on the overlay, but never an ABR input — which is how a fat-LAN Automatic
|
||||
/// session drove the encoder past its compute knee with nothing to stop it (§ABR overdrive).
|
||||
/// Its own accumulator rather than the overlay's `host_timing` channel: that channel is a lossy
|
||||
/// `try_send` the embedder may never drain, and the controller must not depend on it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct EncodeLatAcc {
|
||||
pub(crate) sum_us: u64,
|
||||
pub(crate) count: u32,
|
||||
}
|
||||
|
||||
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
|
||||
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
|
||||
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
|
||||
|
||||
@@ -112,6 +112,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
// Adaptive bitrate ack slot: the control task parks the latest BitrateChanged here; the
|
||||
// pump's controller drains it on its report tick (`take()` — an ack is consumed once).
|
||||
let bitrate_ack: Arc<Mutex<Option<u32>>> = Arc::new(Mutex::new(None));
|
||||
// Host-encode-latency accumulator (the ABR encode signal, see [`EncodeLatAcc`]): the
|
||||
// datagram task adds one sample per 0xCF; the pump drains a window mean per report tick.
|
||||
let encode_lat = Arc::new(Mutex::new(super::frame_channel::EncodeLatAcc::default()));
|
||||
// Bumped by the control task on every accepted mode switch (the `clock_gen` pattern): the
|
||||
// pump resets the controller's mode-scoped learned state (host cap, encode baseline).
|
||||
let mode_gen = Arc::new(AtomicU32::new(0));
|
||||
|
||||
// Control task (see [`control_task`]): the handshake stream stays open for mid-stream
|
||||
// renegotiation, speed tests, clock re-sync, and clipboard metadata.
|
||||
@@ -128,6 +134,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
clock_gen: clock_gen.clone(),
|
||||
clip_event_tx: clip_event_tx.clone(),
|
||||
cursor_shape_tx,
|
||||
mode_gen: mode_gen.clone(),
|
||||
}
|
||||
.run(),
|
||||
);
|
||||
@@ -141,6 +148,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
hidout_tx,
|
||||
hdr_meta_tx,
|
||||
host_timing_tx,
|
||||
encode_lat.clone(),
|
||||
cursor_state_tx,
|
||||
));
|
||||
|
||||
@@ -176,6 +184,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
||||
clock_offset,
|
||||
clock_gen,
|
||||
decode_lat,
|
||||
encode_lat,
|
||||
mode_gen,
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
bitrate_ack,
|
||||
|
||||
@@ -24,6 +24,10 @@ pub(super) struct ControlTask {
|
||||
/// Host cursor shapes ([`CursorShape`], sent on pointer-bitmap change) → the embedder's
|
||||
/// shape plane ([`NativeClient::next_cursor_shape`]).
|
||||
pub(super) cursor_shape_tx: std::sync::mpsc::SyncSender<crate::quic::CursorShape>,
|
||||
/// Bumped on every ACCEPTED mode switch (the `clock_gen` pattern): the pump watches it and
|
||||
/// resets the bitrate controller's mode-scoped learned state — the encoder ceiling / compute
|
||||
/// knee it was taught belong to the OLD mode.
|
||||
pub(super) mode_gen: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl ControlTask {
|
||||
@@ -40,6 +44,7 @@ impl ControlTask {
|
||||
clock_gen,
|
||||
clip_event_tx,
|
||||
cursor_shape_tx,
|
||||
mode_gen,
|
||||
} = self;
|
||||
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||
@@ -88,6 +93,7 @@ impl ControlTask {
|
||||
if let Ok(ack) = Reconfigured::decode(&msg) {
|
||||
if ack.accepted {
|
||||
*mode_slot.lock().unwrap() = ack.mode;
|
||||
mode_gen.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
|
||||
} else {
|
||||
tracing::warn!(active = ?ack.mode, "host rejected mode switch");
|
||||
|
||||
@@ -19,6 +19,12 @@ pub(super) struct DataPump {
|
||||
pub(super) clock_offset: Arc<std::sync::atomic::AtomicI64>,
|
||||
pub(super) clock_gen: Arc<AtomicU32>,
|
||||
pub(super) decode_lat: Arc<Mutex<DecodeLatAcc>>,
|
||||
/// Host encode-stage latency window accumulator (the ABR encode signal — see
|
||||
/// [`super::super::frame_channel::EncodeLatAcc`]); fed by the datagram task.
|
||||
pub(super) encode_lat: Arc<Mutex<super::super::frame_channel::EncodeLatAcc>>,
|
||||
/// Accepted-mode-switch generation (control task bumps): a change resets the controller's
|
||||
/// mode-scoped learned state ([`BitrateController::on_mode_switch`]).
|
||||
pub(super) mode_gen: Arc<AtomicU32>,
|
||||
pub(super) frames_dropped: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) fec_recovered: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
|
||||
@@ -41,6 +47,8 @@ impl DataPump {
|
||||
clock_offset: pump_clock_offset,
|
||||
clock_gen: pump_clock_gen,
|
||||
decode_lat: pump_decode_lat,
|
||||
encode_lat: pump_encode_lat,
|
||||
mode_gen: pump_mode_gen,
|
||||
frames_dropped,
|
||||
fec_recovered,
|
||||
bitrate_ack,
|
||||
@@ -127,6 +135,7 @@ impl DataPump {
|
||||
let mut clock_detector_armed = true;
|
||||
let mut resync_wanted = false;
|
||||
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||
let mut seen_mode_gen = pump_mode_gen.load(Ordering::Relaxed);
|
||||
// Standing-latency bleed (see StandingLatency): the third detector, for the small,
|
||||
// constant, loss-free OWD elevation the two jump-to-live detectors deliberately
|
||||
// tolerate (< QUEUE_HIGH frames, < FLUSH_LATENCY behind) — a sub-frame standing
|
||||
@@ -338,9 +347,15 @@ impl DataPump {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
|
||||
// feed the controller this window's congestion signals; a decision becomes a
|
||||
// SetBitrate on the control stream.
|
||||
// Adaptive bitrate: an accepted mode switch first (it invalidates the
|
||||
// mode-scoped learned state), then drain any host ack (its clamp is
|
||||
// authoritative), then feed the controller this window's congestion signals; a
|
||||
// decision becomes a SetBitrate on the control stream.
|
||||
let mg = pump_mode_gen.load(Ordering::Relaxed);
|
||||
if mg != seen_mode_gen {
|
||||
seen_mode_gen = mg;
|
||||
abr.on_mode_switch();
|
||||
}
|
||||
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
|
||||
abr.on_ack(acked);
|
||||
}
|
||||
@@ -356,6 +371,14 @@ impl DataPump {
|
||||
*acc = DecodeLatAcc::default();
|
||||
(count > 0).then(|| (sum / count as u64) as i64)
|
||||
};
|
||||
// Same drain for the host-encode window (0xCF `encode_us` via the datagram
|
||||
// task) — `None` on an old host that doesn't send stage timings.
|
||||
let encode_mean_us = {
|
||||
let mut acc = pump_encode_lat.lock().unwrap();
|
||||
let (sum, count) = (acc.sum_us, acc.count);
|
||||
*acc = Default::default();
|
||||
(count > 0).then(|| (sum / count as u64) as i64)
|
||||
};
|
||||
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
|
||||
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
|
||||
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
|
||||
@@ -369,17 +392,19 @@ impl DataPump {
|
||||
loss_ppm,
|
||||
owd_mean_us,
|
||||
decode_mean_us,
|
||||
encode_mean_us,
|
||||
actual_kbps,
|
||||
flush_in_window,
|
||||
) {
|
||||
// Log the window's signals alongside the decision so an on-glass session can
|
||||
// tell a decode-driven re-target (the new signal — decode_mean_us elevated with
|
||||
// tell a decode-/encode-driven re-target (the new signals — elevated with
|
||||
// loss/OWD flat) from a network-driven one.
|
||||
tracing::info!(
|
||||
kbps,
|
||||
loss_ppm,
|
||||
owd_mean_us = owd_mean_us.unwrap_or(-1),
|
||||
decode_mean_us = decode_mean_us.unwrap_or(-1),
|
||||
encode_mean_us = encode_mean_us.unwrap_or(-1),
|
||||
actual_kbps,
|
||||
flushed = flush_in_window,
|
||||
"adaptive bitrate: requesting encoder re-target"
|
||||
|
||||
@@ -14,6 +14,9 @@ pub(super) async fn run(
|
||||
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
|
||||
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
|
||||
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
|
||||
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
|
||||
// `host_timing_tx`: that channel is the overlay's, lossy and embedder-drained.
|
||||
encode_lat: Arc<Mutex<super::super::frame_channel::EncodeLatAcc>>,
|
||||
cursor_state_tx: std::sync::mpsc::SyncSender<crate::quic::CursorState>,
|
||||
) {
|
||||
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
|
||||
@@ -74,6 +77,11 @@ pub(super) async fn run(
|
||||
}
|
||||
Some(&crate::quic::HOST_TIMING_MAGIC) => {
|
||||
if let Some(t) = crate::quic::decode_host_timing_datagram(&d) {
|
||||
if let Some(s) = &t.stages {
|
||||
let mut acc = encode_lat.lock().unwrap();
|
||||
acc.sum_us += s.encode_us as u64;
|
||||
acc.count += 1;
|
||||
}
|
||||
let _ = host_timing_tx.try_send(t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +150,13 @@ pub(crate) struct StreamInfo {
|
||||
pub(crate) struct LocalSummary {
|
||||
/// Host version (mirrors `/health`).
|
||||
version: String,
|
||||
/// True while the video stream thread is running.
|
||||
/// True while video is streaming on EITHER plane: the GameStream media pipeline, or a live
|
||||
/// native (punktfunk/1) session — the default plane, invisible in the GameStream flag alone.
|
||||
video_streaming: bool,
|
||||
/// True while the audio stream thread is running.
|
||||
/// True while audio is streaming on either plane (same rule as `video_streaming`).
|
||||
audio_streaming: bool,
|
||||
/// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop).
|
||||
/// The active session: GameStream's launch (Moonlight `/launch`) when present, else the first
|
||||
/// live native session. `null` when nothing is streaming.
|
||||
session: Option<SessionInfo>,
|
||||
/// Number of pinned (paired) GameStream client certificates.
|
||||
paired_clients: u32,
|
||||
@@ -391,22 +393,23 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
|
||||
// Native punktfunk/1 plane (the DEFAULT plane; GameStream is opt-in) — read ONCE and used for
|
||||
// both the session card and the streaming flags below.
|
||||
let native = crate::session_status::snapshot();
|
||||
// GameStream launch, else the first live native session — so the tray reflects a native session
|
||||
// too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`).
|
||||
let session = st
|
||||
.app
|
||||
.launch
|
||||
.lock()
|
||||
.unwrap()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.map(|l| SessionInfo {
|
||||
width: l.width,
|
||||
height: l.height,
|
||||
fps: l.fps,
|
||||
})
|
||||
.or_else(|| {
|
||||
crate::session_status::snapshot()
|
||||
.first()
|
||||
.map(|s| SessionInfo {
|
||||
native.first().map(|s| SessionInfo {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
fps: s.fps,
|
||||
@@ -419,8 +422,10 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
|
||||
.unwrap_or((0, 0));
|
||||
Json(LocalSummary {
|
||||
version: env!("PUNKTFUNK_VERSION").into(),
|
||||
video_streaming: st.app.streaming.load(Ordering::SeqCst),
|
||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
|
||||
// Either plane counts, like `/status`: reading only the GameStream flags made the tray say
|
||||
// "idle" (and wear the idle icon) through an entire native session.
|
||||
video_streaming: st.app.streaming.load(Ordering::SeqCst) || !native.is_empty(),
|
||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst) || !native.is_empty(),
|
||||
session,
|
||||
paired_clients: st
|
||||
.app
|
||||
|
||||
@@ -286,11 +286,74 @@ async fn health_is_open_and_versioned() {
|
||||
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
|
||||
}
|
||||
|
||||
/// Serializes the tests that read (or write) the process-global live-session registry
|
||||
/// ([`crate::session_status`]): a session registered by one test would otherwise make a
|
||||
/// concurrently running one see a stream it never started.
|
||||
static SESSION_REGISTRY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// A `/local/summary` request from a loopback peer (the tray's own).
|
||||
fn summary_req() -> axum::http::Request<Body> {
|
||||
let mut req = get_req("/api/v1/local/summary");
|
||||
req.extensions_mut()
|
||||
.insert(PeerAddr("127.0.0.1:40000".parse().unwrap()));
|
||||
req
|
||||
}
|
||||
|
||||
/// Registers a stand-in live native session; the returned guard removes it on drop.
|
||||
fn fake_native_session(
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
) -> crate::session_status::LiveSessionGuard {
|
||||
let packed = ((width as u64) << 32) | ((height as u64) << 16) | fps as u64;
|
||||
crate::session_status::register(
|
||||
Arc::new(std::sync::atomic::AtomicU64::new(packed)),
|
||||
Arc::new(std::sync::atomic::AtomicU32::new(20_000)),
|
||||
Codec::H265,
|
||||
Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
"test-client".into(),
|
||||
false,
|
||||
Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||
Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A native (punktfunk/1) session — the DEFAULT plane — must read as streaming in the tray's
|
||||
/// summary. The GameStream `streaming` flag stays false throughout such a session, and reading it
|
||||
/// alone left the tray showing "idle" (with the idle icon) for the whole stream: exactly the blind
|
||||
/// spot `/status` was fixed for in [`crate::session_status`], which `/local/summary` still had.
|
||||
#[tokio::test]
|
||||
async fn local_summary_reports_a_native_session_as_streaming() {
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
let (status, body) = send(&app, summary_req()).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["video_streaming"], false);
|
||||
assert_eq!(body["session"], serde_json::Value::Null);
|
||||
|
||||
let session = fake_native_session(3840, 2160, 120);
|
||||
let (_, body) = send(&app, summary_req()).await;
|
||||
assert_eq!(body["video_streaming"], true, "native session: {body}");
|
||||
assert_eq!(body["audio_streaming"], true, "native session: {body}");
|
||||
assert_eq!(body["session"]["width"], 3840);
|
||||
assert_eq!(body["session"]["height"], 2160);
|
||||
assert_eq!(body["session"]["fps"], 120);
|
||||
|
||||
// Session over → back to idle.
|
||||
drop(session);
|
||||
let (_, body) = send(&app, summary_req()).await;
|
||||
assert_eq!(body["video_streaming"], false);
|
||||
assert_eq!(body["session"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
|
||||
/// rejected even though the route needs no bearer token, and the body never carries secret
|
||||
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
|
||||
#[tokio::test]
|
||||
async fn local_summary_is_loopback_only_and_non_sensitive() {
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(
|
||||
Some(std::env::temp_dir().join(format!("pf-mgmt-summary-{}.json", std::process::id()))),
|
||||
@@ -600,6 +663,7 @@ async fn compositors_lists_all_backends_with_flags() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_reflects_runtime_state() {
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
|
||||
@@ -756,6 +820,8 @@ async fn stop_session_clears_runtime_state() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn idr_requires_an_active_stream() {
|
||||
// A live native session (registered by a sibling test) is an active stream to this route.
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
let state = test_state();
|
||||
let app = test_app(state.clone(), None);
|
||||
let post = || {
|
||||
|
||||
@@ -985,6 +985,18 @@ async fn serve_session(
|
||||
// full IDR when the encoder supports it (native-AMF LTR / Windows NVENC).
|
||||
let (rfi_tx, rfi_rx) = std::sync::mpsc::channel::<(u32, u32)>();
|
||||
let (bitrate_tx, bitrate_rx) = std::sync::mpsc::channel::<u32>();
|
||||
// Encoder-truth bridge, data plane → control task (§ABR overdrive). The encode loop publishes
|
||||
// here; the control task reads at `SetBitrate`-resolve time, so the ack the client's
|
||||
// controller climbs from tracks what the encoder ACTUALLY does, not what was asked:
|
||||
// - `live_bitrate`: the encoder's applied rate (kbps) — also the send pacer's/console's view.
|
||||
// - `encoder_ceiling_kbps`: the discovered codec-level ceiling (0 = none discovered yet);
|
||||
// resolves land at min(policy clamp, ceiling), so overshoots stop costing rebuilds.
|
||||
// - `cadence_degraded`: encode can't hold the frame cadence — a climb is refused (acked at
|
||||
// the current rate); the network isn't the bottleneck, more bits are anti-medicine.
|
||||
// Plain atomics, not a channel: only the freshest value matters, and only at resolve time.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(welcome.bitrate_kbps));
|
||||
let encoder_ceiling_kbps = Arc::new(AtomicU32::new(0));
|
||||
let cadence_degraded = Arc::new(AtomicBool::new(false));
|
||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
|
||||
@@ -1036,6 +1048,9 @@ async fn serve_session(
|
||||
live_reconfig_ok,
|
||||
adaptive_fec,
|
||||
session_bitrate_kbps,
|
||||
live_bitrate.clone(),
|
||||
encoder_ceiling_kbps.clone(),
|
||||
cadence_degraded.clone(),
|
||||
fec_target_ctl,
|
||||
reconfig_tx,
|
||||
keyframe_tx,
|
||||
@@ -1429,6 +1444,9 @@ async fn serve_session(
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
chroma,
|
||||
|
||||
@@ -22,6 +22,13 @@ pub(super) async fn run(
|
||||
live_reconfig_ok: bool,
|
||||
adaptive_fec: bool,
|
||||
session_bitrate_kbps: u32,
|
||||
// Encoder-truth bridge (data plane → here, §ABR overdrive): the encoder's live applied rate,
|
||||
// its discovered codec-level ceiling (0 = unknown), and the "encode can't hold cadence"
|
||||
// flag. Read at `SetBitrate`-resolve time so the ack — the base the client's controller
|
||||
// climbs from — never promises a rate the encoder won't run at.
|
||||
live_bitrate: Arc<AtomicU32>,
|
||||
encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
cadence_degraded: Arc<AtomicBool>,
|
||||
fec_target_ctl: Arc<AtomicU8>,
|
||||
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
|
||||
keyframe_tx: std::sync::mpsc::Sender<()>,
|
||||
@@ -161,7 +168,31 @@ pub(super) async fn run(
|
||||
);
|
||||
session_bitrate_kbps
|
||||
} else {
|
||||
resolve_bitrate_kbps(req.bitrate_kbps)
|
||||
let mut r = resolve_bitrate_kbps(req.bitrate_kbps);
|
||||
// Encoder truth (§ABR overdrive): the ack below is the base the
|
||||
// client's controller climbs from, so it must not promise past the
|
||||
// encoder's discovered codec-level ceiling — the pre-fix path acked
|
||||
// 1.01 Gbps while the ASIC ran 794 Mbps, and the controller climbed
|
||||
// from the phantom number forever (a ~0.6 s rebuild + IDR per step).
|
||||
let ceiling = encoder_ceiling_kbps.load(Ordering::Relaxed);
|
||||
if ceiling != 0 && r > ceiling {
|
||||
r = ceiling;
|
||||
}
|
||||
// Climb refusal while encode can't hold cadence: on a fat LAN no
|
||||
// network signal ever stops the climb, and past the compute knee more
|
||||
// bits only deepen the miss. Resolve a CLIMB to the current applied
|
||||
// rate (descents pass — they're the cure); the short ack teaches the
|
||||
// client controller its ceiling.
|
||||
let live = live_bitrate.load(Ordering::Relaxed);
|
||||
if cadence_degraded.load(Ordering::Relaxed) && live != 0 && r > live {
|
||||
tracing::info!(
|
||||
requested_kbps = req.bitrate_kbps,
|
||||
held_kbps = live,
|
||||
"bitrate climb refused — encode is behind cadence"
|
||||
);
|
||||
r = live;
|
||||
}
|
||||
r
|
||||
};
|
||||
tracing::debug!(
|
||||
requested_kbps = req.bitrate_kbps,
|
||||
|
||||
@@ -909,6 +909,20 @@ pub(super) struct SessionContext {
|
||||
pub(super) compositor: crate::vdisplay::Compositor,
|
||||
/// Negotiated encoder bitrate (kbps).
|
||||
pub(super) bitrate_kbps: u32,
|
||||
/// The encoder's live APPLIED rate (kbps) — shared with the send pacer, the web console, the
|
||||
/// mgmt registry AND the control task (which acks climbs against it). The encode loop stores
|
||||
/// `Encoder::applied_bitrate_bps` here after every apply, so everything downstream tracks
|
||||
/// what the ASIC really targets, not what was requested (§ABR overdrive).
|
||||
pub(super) live_bitrate: Arc<AtomicU32>,
|
||||
/// The encoder's discovered codec-level bitrate ceiling (kbps; 0 = none discovered): written
|
||||
/// when an apply comes back short, read by this loop (pre-clamp incoming requests — a
|
||||
/// request already AT the ceiling then costs nothing) and by the control task (truthful
|
||||
/// acks from the first post-discovery request).
|
||||
pub(super) encoder_ceiling_kbps: Arc<AtomicU32>,
|
||||
/// "Encode can't hold the frame cadence" (the escalation leaky bucket is elevated, or the
|
||||
/// session escalated): while set, the control task refuses bitrate CLIMBS — the network
|
||||
/// isn't the bottleneck, feeding the encoder more bits deepens the miss.
|
||||
pub(super) cadence_degraded: Arc<AtomicBool>,
|
||||
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
|
||||
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
|
||||
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
|
||||
@@ -1035,6 +1049,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bitrate_rx,
|
||||
compositor,
|
||||
mut bitrate_kbps,
|
||||
live_bitrate,
|
||||
encoder_ceiling_kbps,
|
||||
cadence_degraded,
|
||||
bitrate_auto,
|
||||
bit_depth,
|
||||
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
|
||||
@@ -1254,9 +1271,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// The bounded channel applies backpressure (the encode thread blocks if the send falls behind,
|
||||
// so frames slow down rather than a dropped frame freezing the infinite-GOP stream).
|
||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<SendMsg>(3);
|
||||
// Live encoder bitrate, shared with the send thread's stats sample: a mid-stream adaptive
|
||||
// bitrate change (bitrate_rx below) updates it so the console shows the actual target.
|
||||
let live_bitrate = Arc::new(AtomicU32::new(bitrate_kbps));
|
||||
// `live_bitrate` (SessionContext) is shared with the send thread's stats sample AND the
|
||||
// control task: a mid-stream adaptive bitrate change (bitrate_rx below) stores the
|
||||
// encoder-APPLIED rate, so the console, pacer and climb-refusal acks all see the truth.
|
||||
// Live session mode, same pattern (H3): a mid-stream mode switch (reconfig below) updates it so
|
||||
// a stats capture armed after a resize registers the real mode. Seeded with the refresh the
|
||||
// initial build actually achieved (`interval_hz`), not the request — KWin may cap a virtual
|
||||
@@ -1436,6 +1453,26 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
const DEPTH_ESCALATE: u32 = 20;
|
||||
const DEPTH_BEHIND_CAP: u32 = 60;
|
||||
const DEPTH_WARMUP_FRAMES: u64 = 60;
|
||||
// Half the escalate threshold: ~10 net behind-frames is already solid "the encoder, not the
|
||||
// network, is the bottleneck" evidence — enough to flag `cadence_degraded` (the control task
|
||||
// then refuses bitrate CLIMBS) well before the session pays a latency escalation for it.
|
||||
const DEPTH_DEGRADE: u32 = 10;
|
||||
// De-escalation (the escalate-and-hold v1's missing half): a sustained clean run at the
|
||||
// escalated setting (~5 s at 120 fps, every frame on cadence) earns ONE attempt at winding
|
||||
// back — reverse order of the escalation, pipelined retrieve first (its rebuild restores
|
||||
// sub-frame streaming and the IO-stream binding), then capture depth back to 1. Each
|
||||
// attempt costs the wind-back rebuild's IDR, so attempts are paced by an exponential
|
||||
// backoff (1 → 5 → 25 min, capped) — a workload that genuinely needs the escalation
|
||||
// converges to keeping it, but NEVER a permanent latch: a latch plus the ABR sawtooth
|
||||
// pinned sessions at the floor with the escalation stuck.
|
||||
const DEESCALATE_CLEAN_FRAMES: u32 = 600;
|
||||
const DEESCALATE_BACKOFF_START: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
const DEESCALATE_BACKOFF_MAX: std::time::Duration = std::time::Duration::from_secs(25 * 60);
|
||||
let mut pipelined_active = false;
|
||||
let mut deescalating = false;
|
||||
let mut ahead_run: u32 = 0;
|
||||
let mut deescalate_not_before: Option<std::time::Instant> = None;
|
||||
let mut deescalate_backoff = DEESCALATE_BACKOFF_START;
|
||||
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
||||
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
|
||||
// place — a different compositor at the SAME client mode — keeping the Session + send thread
|
||||
@@ -1674,15 +1711,49 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
while let Ok(k) = bitrate_rx.try_recv() {
|
||||
want_kbps = Some(k);
|
||||
}
|
||||
// Known-ceiling pre-clamp (§ABR overdrive): once the encoder's codec-level ceiling is
|
||||
// known, resolve an over-asking request HERE — a request that clamps to the rate we're
|
||||
// already at then skips the whole apply, where the pre-fix path bounced every overshoot
|
||||
// off the driver into a full rebuild + IDR (~0.6 s each, four in one logged minute).
|
||||
// (The control task clamps its acks from the same atomic; this covers requests already
|
||||
// in flight when the ceiling was discovered.)
|
||||
if let Some(k) = want_kbps.as_mut() {
|
||||
let ceiling = encoder_ceiling_kbps.load(Ordering::Relaxed);
|
||||
if ceiling != 0 && *k > ceiling {
|
||||
tracing::info!(
|
||||
requested_kbps = *k,
|
||||
ceiling_kbps = ceiling,
|
||||
"bitrate request clamped to the known encoder ceiling"
|
||||
);
|
||||
*k = ceiling;
|
||||
}
|
||||
}
|
||||
if let Some(new_kbps) = want_kbps.filter(|&k| k != bitrate_kbps) {
|
||||
if enc.reconfigure_bitrate(new_kbps as u64 * 1000) {
|
||||
// Adopt the encoder's post-clamp truth, not the request: it feeds the send
|
||||
// pacer, the console/mgmt view and the control task's acks, and a short apply
|
||||
// teaches the ceiling used above.
|
||||
let applied_kbps = enc
|
||||
.applied_bitrate_bps()
|
||||
.map(|b| (b / 1000) as u32)
|
||||
.filter(|&k| k > 0)
|
||||
.unwrap_or(new_kbps);
|
||||
tracing::info!(
|
||||
from_kbps = bitrate_kbps,
|
||||
to_kbps = new_kbps,
|
||||
to_kbps = applied_kbps,
|
||||
requested_kbps = new_kbps,
|
||||
"encoder bitrate reconfigured in place (adaptive bitrate — no IDR)"
|
||||
);
|
||||
bitrate_kbps = new_kbps;
|
||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
||||
if applied_kbps < new_kbps {
|
||||
encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
|
||||
}
|
||||
if applied_kbps < bitrate_kbps {
|
||||
// Down-step: the behind-cadence backlog was scored against the old,
|
||||
// heavier rate — clean slate so it can't feed a false escalation.
|
||||
behind_score = 0;
|
||||
}
|
||||
bitrate_kbps = applied_kbps;
|
||||
live_bitrate.store(applied_kbps, Ordering::Relaxed);
|
||||
// Same encoder, same stream: the in-flight AUs and the wire-index prediction
|
||||
// stay valid — no inflight forfeit, no IDR-cooldown anchor.
|
||||
} else {
|
||||
@@ -1702,9 +1773,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan.cursor_blend,
|
||||
) {
|
||||
Ok(mut new_enc) => {
|
||||
// The fresh encoder may have clamped to its codec-level ceiling —
|
||||
// adopt (and record) ITS rate, not the request; see the in-place arm.
|
||||
let applied_kbps = new_enc
|
||||
.applied_bitrate_bps()
|
||||
.map(|b| (b / 1000) as u32)
|
||||
.filter(|&k| k > 0)
|
||||
.unwrap_or(new_kbps);
|
||||
tracing::info!(
|
||||
from_kbps = bitrate_kbps,
|
||||
to_kbps = new_kbps,
|
||||
to_kbps = applied_kbps,
|
||||
requested_kbps = new_kbps,
|
||||
"encoder rebuilt at new bitrate (adaptive bitrate)"
|
||||
);
|
||||
if let Some(c) = plan.wire_chunk {
|
||||
@@ -1714,8 +1793,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// directly so an ABR rebuild re-establishes the bound immediately.)
|
||||
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
enc = new_enc;
|
||||
bitrate_kbps = new_kbps;
|
||||
live_bitrate.store(new_kbps, Ordering::Relaxed);
|
||||
if applied_kbps < new_kbps {
|
||||
encoder_ceiling_kbps.store(applied_kbps, Ordering::Relaxed);
|
||||
}
|
||||
bitrate_kbps = applied_kbps;
|
||||
live_bitrate.store(applied_kbps, Ordering::Relaxed);
|
||||
// The owed AUs died with the old encoder — same bookkeeping as a
|
||||
// mode-switch rebuild; the fresh encoder opens on an IDR, so anchor the
|
||||
// IDR cooldown too.
|
||||
@@ -1723,6 +1805,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
last_au_at = std::time::Instant::now();
|
||||
encoder_resets = 0;
|
||||
last_forced_idr = Some(std::time::Instant::now());
|
||||
// The rebuild stall itself (~0.6 s ≈ 70 missed deadlines at 120 fps,
|
||||
// 3.5× the escalate threshold) must not feed the contention
|
||||
// escalation — clean slate + re-run the warmup before judging again.
|
||||
behind_score = 0;
|
||||
depth_frames = 0;
|
||||
ahead_run = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), to_kbps = new_kbps,
|
||||
@@ -2547,7 +2635,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// two-thread lock moves the encode wait off this loop so capture/submit keep cadence,
|
||||
// at ~one tick of AU latency. `enc.set_pipelined` may decline (unsupported backend or
|
||||
// an explicit PUNKTFUNK_NVENC_ASYNC=0); either way it is asked exactly once.
|
||||
if idd_adaptive_enabled() && (cur_depth < max_depth || !pipeline_asked) {
|
||||
if idd_adaptive_enabled() {
|
||||
depth_frames += 1;
|
||||
if depth_frames > DEPTH_WARMUP_FRAMES {
|
||||
let behind = std::time::Instant::now() >= next;
|
||||
@@ -2556,28 +2644,89 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
} else {
|
||||
behind_score.saturating_sub(1)
|
||||
};
|
||||
if behind_score >= DEPTH_ESCALATE {
|
||||
let escalated = cur_depth > 1 || pipelined_active || deescalating;
|
||||
// Export "encode can't hold cadence" for the control task's climb refusal.
|
||||
// An escalated session stays flagged even with the bucket drained: its climb
|
||||
// headroom is spent, and letting climbs resume would saw against the
|
||||
// escalation and starve the de-escalation clean run below.
|
||||
cadence_degraded.store(
|
||||
escalated || behind_score >= DEPTH_DEGRADE,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
if deescalating {
|
||||
// A requested wind-back completes at the encoder's drained safe point —
|
||||
// poll it (the call is a cheap latch check until then).
|
||||
if !enc.set_pipelined(false) {
|
||||
deescalating = false;
|
||||
pipelined_active = false;
|
||||
// Re-arm the ask: a future sustained overrun may escalate again (the
|
||||
// backoff below paces how soon another wind-back may follow it).
|
||||
pipeline_asked = false;
|
||||
tracing::info!(
|
||||
"encoder pipelined retrieve de-escalated — sync retrieve (and \
|
||||
sub-frame streaming, where armed) restored; re-monitoring cadence"
|
||||
);
|
||||
// The wind-back rebuild's own stall must not re-escalate on the spot.
|
||||
behind_score = 0;
|
||||
depth_frames = 0;
|
||||
ahead_run = 0;
|
||||
}
|
||||
} else if behind_score >= DEPTH_ESCALATE
|
||||
&& (cur_depth < max_depth || !pipeline_asked)
|
||||
{
|
||||
if cur_depth < max_depth {
|
||||
cur_depth = max_depth;
|
||||
tracing::info!(
|
||||
depth = cur_depth,
|
||||
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
|
||||
(GPU contention); pipelining for the rest of the session (latency \
|
||||
(GPU contention); pipelining until cadence holds clean (latency \
|
||||
trade for throughput)"
|
||||
);
|
||||
} else {
|
||||
pipeline_asked = true;
|
||||
if enc.set_pipelined(true) {
|
||||
pipelined_active = enc.set_pipelined(true);
|
||||
if pipelined_active {
|
||||
tracing::info!(
|
||||
"encoder pipelined retrieve escalated — encode can't hold \
|
||||
cadence and the capturer has no depth to give; the encode wait \
|
||||
moves off the loop for the rest of the session (latency trade \
|
||||
moves off the loop until cadence holds clean (latency trade \
|
||||
for throughput)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Give the action time to take effect before judging again.
|
||||
behind_score = 0;
|
||||
ahead_run = 0;
|
||||
} else if escalated {
|
||||
// De-escalation: a sustained every-frame-on-cadence run at the escalated
|
||||
// setting is the evidence the contention passed (a lower ABR rate, the
|
||||
// game scene lightened) — wind back in reverse order, paced by the
|
||||
// exponential backoff (see the consts above).
|
||||
ahead_run = if behind { 0 } else { ahead_run + 1 };
|
||||
if ahead_run >= DEESCALATE_CLEAN_FRAMES
|
||||
&& deescalate_not_before.is_none_or(|t| std::time::Instant::now() >= t)
|
||||
{
|
||||
ahead_run = 0;
|
||||
deescalate_not_before =
|
||||
Some(std::time::Instant::now() + deescalate_backoff);
|
||||
deescalate_backoff = (deescalate_backoff * 5).min(DEESCALATE_BACKOFF_MAX);
|
||||
if pipelined_active {
|
||||
tracing::info!(
|
||||
"cadence held clean while escalated — winding the pipelined \
|
||||
retrieve back (latency recovery; costs one IDR)"
|
||||
);
|
||||
deescalating = true;
|
||||
} else if cur_depth > 1 {
|
||||
cur_depth = 1;
|
||||
tracing::info!(
|
||||
depth = cur_depth,
|
||||
"IDD pipeline depth de-escalated — cadence held clean at the \
|
||||
escalated depth (latency recovery)"
|
||||
);
|
||||
behind_score = 0;
|
||||
depth_frames = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,16 +431,69 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
) {
|
||||
eprintln!("warning: could not add the firewall rule for TCP 47992");
|
||||
}
|
||||
// 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise)
|
||||
for _ in 0..30 {
|
||||
if token_path.exists() {
|
||||
break;
|
||||
// 5. Wait for EVERY file the launcher needs, then start the task and VERIFY it came up.
|
||||
//
|
||||
// `web-run.cmd` refuses to serve without the mgmt token AND the host identity cert, and the
|
||||
// host writes them in that order: the token during argument parsing (`main::parse_serve`,
|
||||
// milliseconds after `serve` starts), the cert only once `serve` reaches
|
||||
// `gamestream::cert::ServerIdentity::load_or_create` - behind a pure-Rust RSA-2048 keygen
|
||||
// whose prime search has a multi-second tail. Gating on the token alone therefore fired
|
||||
// `schtasks /run` while that keygen was still running: the launcher exited 1, and since the
|
||||
// task carries no trigger other than boot (and Task Scheduler's restart-on-failure does not
|
||||
// reliably fire for a plain non-zero exit code), a freshly installed console stayed down
|
||||
// until the next reboot. Gate on `cert.pem` - written LAST, after `key.pem` - so all three
|
||||
// files are on disk before the first start.
|
||||
let cert_path = data_dir.join("cert.pem");
|
||||
if !wait_for_files(&[token_path.as_path(), cert_path.as_path()], 90) {
|
||||
eprintln!(
|
||||
"warning: the host service has not written {} + {} yet - not starting the console now; \
|
||||
it will start at the next boot (or run: schtasks /run /tn {WEB_TASK})",
|
||||
token_path.display(),
|
||||
cert_path.display()
|
||||
);
|
||||
println!("web console set up (https://<host-ip>:47992)");
|
||||
return Ok(());
|
||||
}
|
||||
if start_web_task() {
|
||||
println!("web console set up + started (https://<host-ip>:47992)");
|
||||
} else {
|
||||
// Never claim "started" when it isn't: the launcher's own diagnostics go to a task with no
|
||||
// console, so this warning is the only trace an operator gets at install time.
|
||||
eprintln!(
|
||||
"warning: the {WEB_TASK} task did not bring up a listener on :47992 - check its Last Run \
|
||||
Result in Task Scheduler; the console will be retried at the next boot"
|
||||
);
|
||||
println!("web console set up (https://<host-ip>:47992)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until every path exists, or `secs` elapse. Returns whether they all showed up.
|
||||
fn wait_for_files(paths: &[&Path], secs: u64) -> bool {
|
||||
for _ in 0..secs {
|
||||
if paths.iter().all(|p| p.exists()) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
paths.iter().all(|p| p.exists())
|
||||
}
|
||||
|
||||
/// `schtasks /run` + verify: the command reports only that a start was *attempted*, so poll for the
|
||||
/// console's own listener and retry a couple of times before giving up. Returns whether :47992 ended
|
||||
/// up listening.
|
||||
fn start_web_task() -> bool {
|
||||
for _ in 0..3 {
|
||||
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
||||
println!("web console set up + started (https://<host-ip>:47992)");
|
||||
Ok(())
|
||||
// Bun binds the port a second or two after launch on a warm box; allow for a cold one.
|
||||
for _ in 0..10 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
if !web_listener_pids().is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback.
|
||||
@@ -505,30 +558,57 @@ fn random_password() -> String {
|
||||
/// ("LISTENING"/"ABHOEREN"/...) is never parsed.
|
||||
fn stop_web_console() {
|
||||
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
|
||||
for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() {
|
||||
let toks: Vec<&str> = line.split_whitespace().collect();
|
||||
if toks.len() >= 5
|
||||
&& toks[0].eq_ignore_ascii_case("tcp")
|
||||
&& toks[1].ends_with(":47992")
|
||||
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0")
|
||||
{
|
||||
let pid = toks[toks.len() - 1];
|
||||
if !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()) {
|
||||
run_quiet("taskkill", &["/PID", pid, "/F"]);
|
||||
}
|
||||
}
|
||||
for pid in web_listener_pids() {
|
||||
run_quiet("taskkill", &["/PID", &pid, "/F"]);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// Register the boot/SYSTEM/restart-on-failure task via a generated Task Scheduler XML (`schtasks /xml`,
|
||||
/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
|
||||
/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
|
||||
fn web_listener_pids() -> Vec<String> {
|
||||
run_capture("netstat", &["-ano", "-p", "tcp"])
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let toks: Vec<&str> = line.split_whitespace().collect();
|
||||
let listening = toks.len() >= 5
|
||||
&& toks[0].eq_ignore_ascii_case("tcp")
|
||||
&& toks[1].ends_with(":47992")
|
||||
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0");
|
||||
let pid = *toks.last()?;
|
||||
(listening && !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
|
||||
.then(|| pid.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Register the boot+logon/SYSTEM/restart-on-failure task via a generated Task Scheduler XML
|
||||
/// (`schtasks /xml`, no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
|
||||
///
|
||||
/// Two triggers, because boot alone left too many holes: a console that lost the install-time start
|
||||
/// (or a box where the host service was started only later) had to wait for a full reboot. Logon is
|
||||
/// free insurance - `MultipleInstancesPolicy=IgnoreNew` makes a redundant start a no-op. If a
|
||||
/// Task Scheduler build rejects the two-trigger XML we fall back to the boot-only form rather than
|
||||
/// fail registration outright (no task at all is strictly worse than a boot-only task).
|
||||
fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
let cmd_xml = xml_escape(&cmd.to_string_lossy());
|
||||
let boot = "<BootTrigger><Enabled>true</Enabled></BootTrigger>";
|
||||
let boot_and_logon = "<BootTrigger><Enabled>true</Enabled></BootTrigger>\
|
||||
<LogonTrigger><Enabled>true</Enabled></LogonTrigger>";
|
||||
if try_register_web_task(&cmd_xml, boot_and_logon) || try_register_web_task(&cmd_xml, boot) {
|
||||
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("schtasks /create {WEB_TASK} failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// One `schtasks /create /xml` attempt with the given `<Triggers>` body. Returns whether it took.
|
||||
fn try_register_web_task(cmd_xml: &str, triggers: &str) -> bool {
|
||||
let xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
|
||||
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
|
||||
<RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :47992)</Description></RegistrationInfo>\n\
|
||||
<Triggers><BootTrigger><Enabled>true</Enabled></BootTrigger></Triggers>\n\
|
||||
<Triggers>{triggers}</Triggers>\n\
|
||||
<Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\
|
||||
<Settings>\n\
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
|
||||
@@ -538,12 +618,13 @@ fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
|
||||
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
|
||||
</Settings>\n\
|
||||
<Actions Context=\"Author\"><Exec><Command>{}</Command></Exec></Actions>\n\
|
||||
</Task>",
|
||||
xml_escape(&cmd.to_string_lossy())
|
||||
<Actions Context=\"Author\"><Exec><Command>{cmd_xml}</Command></Exec></Actions>\n\
|
||||
</Task>"
|
||||
);
|
||||
let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml");
|
||||
write_utf16le_bom(&xml_path, &xml)?;
|
||||
if write_utf16le_bom(&xml_path, &xml).is_err() {
|
||||
return false;
|
||||
}
|
||||
let ok = run_quiet(
|
||||
"schtasks",
|
||||
&[
|
||||
@@ -556,12 +637,7 @@ fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
],
|
||||
);
|
||||
let _ = std::fs::remove_file(&xml_path);
|
||||
if ok {
|
||||
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("schtasks /create {WEB_TASK} failed")
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> {
|
||||
|
||||
@@ -15,8 +15,8 @@ use crate::status::{self, Poller, TrayStatus};
|
||||
struct HostTray {
|
||||
status: TrayStatus,
|
||||
web_port: u16,
|
||||
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
||||
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
||||
/// The console answered the poller's live loopback probe — labels the (always present) "Open
|
||||
/// web console" entry; it never hides it.
|
||||
web_console: bool,
|
||||
/// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions
|
||||
/// force an immediate re-poll instead of waiting out the cadence.
|
||||
@@ -33,8 +33,10 @@ impl HostTray {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_console(&self) {
|
||||
let url = format!("https://127.0.0.1:{}", self.web_port);
|
||||
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page
|
||||
/// the menu entry promised — the pairing queue, the virtual displays.
|
||||
fn open_console(&self, path: &str) {
|
||||
let url = format!("https://127.0.0.1:{}/{path}", self.web_port);
|
||||
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
}
|
||||
@@ -107,17 +109,33 @@ impl ksni::Tray for HostTray {
|
||||
}
|
||||
.into(),
|
||||
MenuItem::Separator,
|
||||
// Always present — it is the reason most people open this menu. When the loopback
|
||||
// probe says the console isn't answering, the label says so rather than the entry
|
||||
// disappearing (a menu missing its main action reads as a broken tray).
|
||||
StandardItem {
|
||||
label: "Open web console".into(),
|
||||
visible: self.web_console,
|
||||
activate: Box::new(|t: &mut Self| t.open_console()),
|
||||
label: if self.web_console {
|
||||
"Open web console".to_string()
|
||||
} else {
|
||||
"Open web console (not responding)".to_string()
|
||||
},
|
||||
activate: Box::new(|t: &mut Self| t.open_console("")),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: "Approve pairing request…".into(),
|
||||
visible: self.web_console && self.status.pairing_attention(),
|
||||
activate: Box::new(|t: &mut Self| t.open_console()),
|
||||
visible: self.status.pairing_attention(),
|
||||
activate: Box::new(|t: &mut Self| t.open_console("pairing")),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: match self.status.kept_displays() {
|
||||
1 => "Release kept display…".to_string(),
|
||||
n => format!("Release {n} kept displays…"),
|
||||
},
|
||||
visible: self.status.kept_displays() > 0,
|
||||
activate: Box::new(|t: &mut Self| t.open_console("displays")),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
|
||||
@@ -75,7 +75,7 @@ impl TrayStatus {
|
||||
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
|
||||
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
|
||||
TrayStatus::Running(s) => {
|
||||
let base = match (&s.session, s.video_streaming) {
|
||||
let base = match (&s.session, self.is_streaming()) {
|
||||
(Some(sess), true) => format!(
|
||||
"punktfunk host {} — streaming {}×{}@{}",
|
||||
s.version, sess.width, sess.height, sess.fps
|
||||
@@ -113,8 +113,23 @@ impl TrayStatus {
|
||||
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
|
||||
}
|
||||
|
||||
/// A client is streaming: the host's flag, OR a live session in the summary.
|
||||
///
|
||||
/// The session is checked too because a host from before the `get_local_summary` fix raised
|
||||
/// `video_streaming` from the GameStream plane only — through a whole session on the native
|
||||
/// (default) plane it said false while still reporting that session's mode, which is what left
|
||||
/// the tray sitting at "idle" mid-stream. This keeps a newer tray honest against such a host.
|
||||
pub fn is_streaming(&self) -> bool {
|
||||
matches!(self, TrayStatus::Running(s) if s.video_streaming)
|
||||
matches!(self, TrayStatus::Running(s) if s.video_streaming || s.session.is_some())
|
||||
}
|
||||
|
||||
/// Virtual displays held with no live session (lingering/pinned) — offered as a one-click
|
||||
/// release in the menu, since holding one can also be keeping physical monitors dark.
|
||||
pub fn kept_displays(&self) -> u32 {
|
||||
match self {
|
||||
TrayStatus::Running(s) => s.kept_displays,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A pairing attempt is waiting on the operator (shown as an extra menu entry).
|
||||
@@ -156,9 +171,11 @@ struct Shared {
|
||||
|
||||
impl Poller {
|
||||
/// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever
|
||||
/// either changes. `console_up` is a live loopback probe of the web console on `web_port` —
|
||||
/// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run
|
||||
/// from a repo checkout, and shows a dead entry while an installed console is still starting).
|
||||
/// either changes. `console_up` is a live loopback probe of the web console on `web_port`. It
|
||||
/// annotates the "Open web console" entry ("not responding") rather than hiding it: the entry
|
||||
/// is the tray's most-wanted action, and a menu that silently drops it — because the console
|
||||
/// was still starting, or the probe timed out — is indistinguishable from a tray that never
|
||||
/// had one.
|
||||
pub fn spawn(
|
||||
mgmt_addr: String,
|
||||
mgmt_port: u16,
|
||||
@@ -203,6 +220,10 @@ fn poll_loop(
|
||||
// When the summary became unreachable while the service was running (grace anchor).
|
||||
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
|
||||
let mut unreachable_since: Option<Instant> = None;
|
||||
// Consecutive failed console probes. One miss is not "down": the console is a bun/Nitro SSR
|
||||
// whose cold first render can outrun this agent's 2 s timeout, and a menu entry that changes
|
||||
// its label every few seconds reads as broken.
|
||||
let mut console_misses = 0u32;
|
||||
loop {
|
||||
let svc = probe_service();
|
||||
let summary = if svc == ServiceState::Running {
|
||||
@@ -219,7 +240,13 @@ fn poll_loop(
|
||||
};
|
||||
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
|
||||
let status = map_status(&svc, summary, grace_expired);
|
||||
let console_up = probe_console(&agent, &console_url);
|
||||
let console_up = if probe_console(&agent, &console_url) {
|
||||
console_misses = 0;
|
||||
true
|
||||
} else {
|
||||
console_misses += 1;
|
||||
console_misses < 2
|
||||
};
|
||||
if last.as_ref() != Some(&(status.clone(), console_up)) {
|
||||
on_change(status.clone(), console_up);
|
||||
last = Some((status, console_up));
|
||||
@@ -478,6 +505,32 @@ mod tests {
|
||||
.contains("status unavailable"));
|
||||
}
|
||||
|
||||
/// A live session means streaming even if the host's flag says otherwise — a host from before
|
||||
/// the `get_local_summary` fix only raised `video_streaming` for the GameStream plane, so a
|
||||
/// native session showed as "idle" with its own mode printed next to it.
|
||||
#[test]
|
||||
fn a_live_session_reads_as_streaming_without_the_flag() {
|
||||
let mut s = summary(true);
|
||||
s.video_streaming = false; // pre-fix host, native session
|
||||
let st = TrayStatus::Running(s);
|
||||
assert!(st.is_streaming());
|
||||
assert_eq!(
|
||||
st.headline(),
|
||||
"punktfunk host 0.5.1 — streaming 2560×1440@120"
|
||||
);
|
||||
// No session and no flag is still idle.
|
||||
assert!(!TrayStatus::Running(summary(false)).is_streaming());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_displays_are_reported_for_the_release_action() {
|
||||
assert_eq!(TrayStatus::Running(summary(false)).kept_displays(), 0);
|
||||
let mut s = summary(false);
|
||||
s.kept_displays = 2;
|
||||
assert_eq!(TrayStatus::Running(s).kept_displays(), 2);
|
||||
assert_eq!(TrayStatus::Degraded.kept_displays(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_attention_flags() {
|
||||
let mut s = summary(false);
|
||||
|
||||
@@ -51,6 +51,7 @@ const IDM_RESTART: usize = 0x0104;
|
||||
const IDM_LOGS: usize = 0x0105;
|
||||
const IDM_EXIT: usize = 0x0106;
|
||||
const IDM_PAIRING: usize = 0x0107;
|
||||
const IDM_DISPLAYS: usize = 0x0108;
|
||||
|
||||
/// Icon resource ordinals (embedded by build.rs).
|
||||
fn icon_ordinal(status: &TrayStatus) -> u16 {
|
||||
@@ -73,8 +74,9 @@ struct App {
|
||||
taskbar_created: u32,
|
||||
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
|
||||
host_exe: Option<std::path::PathBuf>,
|
||||
/// The console answered the poller's live loopback probe — the "Open web console" entry is
|
||||
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
|
||||
/// The console answered the poller's live loopback probe. Drives the label of the (always
|
||||
/// present) "Open web console" entry, and whether a left-click on the icon opens the console
|
||||
/// or falls back to showing the menu.
|
||||
web_console: AtomicBool,
|
||||
web_port: u16,
|
||||
}
|
||||
@@ -305,14 +307,24 @@ fn show_menu(hwnd: HWND) {
|
||||
};
|
||||
add(IDM_HEADER, &status.headline(), true);
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
// The console entry is ALWAYS here — it is the reason most people open this menu, and
|
||||
// left-clicking the icon is not a discoverable substitute. When the loopback probe says
|
||||
// the console isn't answering the label says so, rather than the entry vanishing.
|
||||
if app().web_console.load(Ordering::SeqCst) {
|
||||
add(IDM_OPEN_WEB, "Open web console", false);
|
||||
} else {
|
||||
add(IDM_OPEN_WEB, "Open web console (not responding)", false);
|
||||
}
|
||||
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
||||
if status.pairing_attention() {
|
||||
add(IDM_PAIRING, "Approve pairing request…", false);
|
||||
}
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
match status.kept_displays() {
|
||||
0 => {}
|
||||
1 => add(IDM_DISPLAYS, "Release kept display…", false),
|
||||
n => add(IDM_DISPLAYS, &format!("Release {n} kept displays…"), false),
|
||||
}
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
if can_control {
|
||||
if startable {
|
||||
add(IDM_START, "Start host", false);
|
||||
@@ -385,8 +397,13 @@ fn elevate_service(hwnd: HWND, verb: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_web_console(hwnd: HWND) {
|
||||
shell_open(hwnd, &format!("https://localhost:{}", app().web_port));
|
||||
/// Open the web console at `path` ("" = dashboard). Deep links land the operator on the page the
|
||||
/// menu entry promised — the pairing queue, the virtual displays — instead of the dashboard.
|
||||
fn open_web_console(hwnd: HWND, path: &str) {
|
||||
shell_open(
|
||||
hwnd,
|
||||
&format!("https://localhost:{}/{path}", app().web_port),
|
||||
);
|
||||
}
|
||||
|
||||
fn open_logs(hwnd: HWND) {
|
||||
@@ -416,7 +433,7 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
||||
WM_CONTEXTMENU => show_menu(hwnd),
|
||||
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
|
||||
if app.web_console.load(Ordering::SeqCst) {
|
||||
open_web_console(hwnd);
|
||||
open_web_console(hwnd, "");
|
||||
} else {
|
||||
show_menu(hwnd);
|
||||
}
|
||||
@@ -427,8 +444,9 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
||||
}
|
||||
WM_COMMAND => {
|
||||
match (wparam.0) & 0xffff {
|
||||
IDM_OPEN_WEB => open_web_console(hwnd),
|
||||
IDM_PAIRING => open_web_console(hwnd),
|
||||
IDM_OPEN_WEB => open_web_console(hwnd, ""),
|
||||
IDM_PAIRING => open_web_console(hwnd, "pairing"),
|
||||
IDM_DISPLAYS => open_web_console(hwnd, "displays"),
|
||||
IDM_START => elevate_service(hwnd, "start"),
|
||||
IDM_STOP => elevate_service(hwnd, "stop"),
|
||||
IDM_RESTART => elevate_service(hwnd, "restart"),
|
||||
|
||||
@@ -40,8 +40,16 @@ depends on the display manager driving the autologin:
|
||||
|
||||
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
|
||||
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager
|
||||
itself for the length of the stream and restart it afterwards, which needs privilege. Allow it
|
||||
with a polkit rule (adjust the unit and user names to your box):
|
||||
itself for the length of the stream and restart it afterwards, which needs privilege. The
|
||||
packages ship that privilege: a root helper (`/usr/libexec/punktfunk/pf-dm-helper`) behind its
|
||||
own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically when the plain
|
||||
`systemctl` verbs are denied — no setup. The helper only stops/restores the unit the
|
||||
`display-manager.service` symlink points at, the same class of local-seat operation these
|
||||
distros already authorize for their own session switcher (Nobara's `os-session-select`).
|
||||
|
||||
Installed from a tarball, or prefer not to ship the `allow_any` action? Remove the `.policy`
|
||||
file and use a polkit rule scoped to your user instead (adjust the unit and user names to your
|
||||
box) — the host tries the plain verbs first, so the rule takes precedence:
|
||||
|
||||
```js
|
||||
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules
|
||||
@@ -54,15 +62,16 @@ depends on the display manager driving the autologin:
|
||||
});
|
||||
```
|
||||
|
||||
Without the rule the host degrades safely: it **attaches** to the live Gaming Mode session
|
||||
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the
|
||||
display manager. If the display-manager restart ever loses its privilege mid-restore,
|
||||
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the
|
||||
fallback.
|
||||
With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode
|
||||
session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to
|
||||
the client — if your monitor stays on and the stream runs at the desktop's resolution, this is
|
||||
what happened; check the host log for "managed takeover unavailable"). If the display-manager
|
||||
restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
|
||||
[Configuration](/docs/configuration)) is fired as the fallback.
|
||||
|
||||
With the rule in place the **in-stream session switch round-trips** in managed mode: Steam's
|
||||
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the
|
||||
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
||||
With the takeover authorized the **in-stream session switch round-trips** in managed mode:
|
||||
Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
|
||||
and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
||||
|
||||
## Session following
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ the Windows specifics follow.
|
||||
|
||||
The installer also sets up the **web management console** (status, paired devices, the PIN pairing
|
||||
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
|
||||
**`https://<this-PC>:47992`**, starting at boot.
|
||||
**`https://<this-PC>:47992`**, starting at boot and at sign-in.
|
||||
|
||||
#### Console login password
|
||||
|
||||
|
||||
@@ -129,6 +129,14 @@ package_punktfunk-host() {
|
||||
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
|
||||
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID)
|
||||
install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
|
||||
# stop/restore the display manager for the stream. Arch has no /usr/libexec — install under
|
||||
# /usr/lib/punktfunk and rewrite the policy's exec.path annotation to match (the host probes both).
|
||||
install -Dm0755 "$R/scripts/pf-dm-helper" "$pkgdir/usr/lib/punktfunk/pf-dm-helper"
|
||||
install -Dm0644 "$R/scripts/io.unom.punktfunk.dm-helper.policy" \
|
||||
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \
|
||||
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads)
|
||||
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
|
||||
# 32 MB UDP socket buffers (send-side headroom at high bitrate)
|
||||
|
||||
@@ -52,6 +52,11 @@ SHAREDIR="$STAGE/usr/share/$PKG"
|
||||
# --- file layout (matches the RPM %install) ----------------------------------
|
||||
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
|
||||
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
|
||||
# stop/restore the display manager for the stream (the helper derives the DM unit itself).
|
||||
install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper"
|
||||
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy \
|
||||
"$STAGE/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
|
||||
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
|
||||
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
|
||||
|
||||
@@ -249,6 +249,12 @@ install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-h
|
||||
# udev rule — /dev/uinput access for virtual gamepads (input group).
|
||||
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
|
||||
|
||||
# Managed gamescope takeover on DM-autologin boxes (Nobara's plasmalogin): a root helper + polkit
|
||||
# action let the host stop/restore the display manager for the stream without a hand-installed
|
||||
# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units.
|
||||
install -Dm0755 scripts/pf-dm-helper %{buildroot}%{_libexecdir}/punktfunk/pf-dm-helper
|
||||
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy %{buildroot}%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
|
||||
|
||||
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
|
||||
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
|
||||
install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf
|
||||
@@ -383,6 +389,9 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
|
||||
%{_bindir}/punktfunk-host
|
||||
%{_bindir}/punktfunk-tray
|
||||
%{_udevrulesdir}/60-punktfunk.rules
|
||||
%dir %{_libexecdir}/punktfunk
|
||||
%{_libexecdir}/punktfunk/pf-dm-helper
|
||||
%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
|
||||
%{_prefix}/lib/modules-load.d/punktfunk.conf
|
||||
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>Punktfunk</vendor>
|
||||
<vendor_url>https://punktfunk.unom.io</vendor_url>
|
||||
|
||||
<!-- Stop/restore the box's display manager for a managed gamescope takeover. The helper derives
|
||||
the unit from the display-manager.service symlink itself (no caller-chosen units), so this
|
||||
grant is scoped to the box's own local-seat session lifecycle — the same class of operation
|
||||
these distros already authorize for their session switcher (e.g. Nobara's
|
||||
os-session-select, allow_any). allow_any because the host commonly runs sessionless (a
|
||||
lingering user unit, no polkit agent), where interactive auth can never be answered. -->
|
||||
<action id="io.unom.punktfunk.dm-helper">
|
||||
<description>Stop or restore the display manager for a Punktfunk stream</description>
|
||||
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
|
||||
<defaults>
|
||||
<allow_any>yes</allow_any>
|
||||
<allow_inactive>yes</allow_inactive>
|
||||
<allow_active>yes</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/libexec/punktfunk/pf-dm-helper</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/sh
|
||||
# Privileged display-manager verbs for the punktfunk managed gamescope takeover.
|
||||
#
|
||||
# On DM-autologin boxes whose display manager does not survive a masked session unit (Nobara's
|
||||
# plasmalogin, unknown DMs), taking the Gaming Mode session over means stopping the display
|
||||
# manager for the length of the stream and restarting it afterwards — root-only operations. The
|
||||
# host invokes this helper via pkexec under its own polkit action
|
||||
# (io.unom.punktfunk.dm-helper.policy, installed by the packages), the same mechanism these
|
||||
# distros use for their own session switching (Nobara's os-session-select).
|
||||
#
|
||||
# The unit is NEVER caller-controlled: it is derived here from the display-manager.service alias
|
||||
# symlink, so the polkit grant's blast radius is exactly "the box's own display manager" — a
|
||||
# local-seat operation, not arbitrary unit management.
|
||||
set -eu
|
||||
|
||||
dm_unit() {
|
||||
target=$(readlink /etc/systemd/system/display-manager.service) || {
|
||||
echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2
|
||||
exit 1
|
||||
}
|
||||
basename "$target"
|
||||
}
|
||||
|
||||
case "${1-}" in
|
||||
stop)
|
||||
exec systemctl stop "$(dm_unit)"
|
||||
;;
|
||||
restore)
|
||||
dm=$(dm_unit)
|
||||
# reset-failed first: a relogin loop may have tripped the unit's start limit, and a plain
|
||||
# restart would be refused until the accounting is cleared.
|
||||
systemctl reset-failed "$dm" 2>/dev/null || :
|
||||
exec systemctl restart "$dm"
|
||||
;;
|
||||
*)
|
||||
echo "usage: pf-dm-helper stop|restore" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -12,6 +12,11 @@ Description=punktfunk management web console
|
||||
# web-init generates the login password; the host writes the mgmt token. Order after both.
|
||||
After=punktfunk-web-init.service punktfunk-host.service
|
||||
Wants=punktfunk-web-init.service
|
||||
# Retry indefinitely while the host is still writing the mgmt token + identity cert. Without this,
|
||||
# systemd's default rate limit (5 starts / 10 s) plus RestartSec=2 gives up permanently after ~10 s
|
||||
# - so a console enabled before the host's first run stayed dead until someone restarted it by hand
|
||||
# (the same defect the Windows PunktfunkWeb task had).
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -16,17 +16,26 @@ set "CERTFILE=%PFDATA%\cert.pem"
|
||||
set "KEYFILE=%PFDATA%\key.pem"
|
||||
|
||||
rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist
|
||||
rem we have no credential and no TLS material, so fail and let the task's restart-on-failure retry
|
||||
rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than
|
||||
rem silently downgrading to plain HTTP.
|
||||
if not exist "%TOKENFILE%" (
|
||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%CERTFILE%" (
|
||||
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
|
||||
rem we have no credential and no TLS material, so WAIT rather than silently downgrading to plain HTTP.
|
||||
rem
|
||||
rem Wait in-process instead of exiting 1 and hoping the task's restart-on-failure retries: Task
|
||||
rem Scheduler does not reliably restart on a plain non-zero exit code, so a console that started
|
||||
rem before the host finished writing those files (the token lands at argument parse, the cert only
|
||||
rem after the RSA-2048 keygen) used to stay down until the next reboot. ~5 min at 2 s, then give up
|
||||
rem so a genuinely broken install still surfaces as a failed task rather than one that runs forever.
|
||||
rem `timeout` needs a console this task does not have, so `ping -n 3` is the 2-second sleep.
|
||||
set /a PFWAITS=0
|
||||
:pfwait
|
||||
if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
|
||||
if %PFWAITS% GEQ 150 (
|
||||
echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host service running?
|
||||
exit /b 1
|
||||
)
|
||||
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host service to write the mgmt token + identity cert...
|
||||
set /a PFWAITS+=1
|
||||
ping -n 3 127.0.0.1 >nul 2>&1
|
||||
goto pfwait
|
||||
:pfready
|
||||
|
||||
rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
|
||||
rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment.
|
||||
|
||||
@@ -54,7 +54,7 @@ export const LaunchSpec = Schema.Struct({ "kind": Schema.String.annotate({ "desc
|
||||
export type LayoutMode = "auto-row" | "manual"
|
||||
export const LayoutMode = Schema.Literals(["auto-row", "manual"]).annotate({ "description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage." })
|
||||
export type LocalSummary = { readonly "audio_streaming": boolean, readonly "conflicts"?: ReadonlyArray<string>, readonly "kept_displays": number, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pending_approvals": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "version": string, readonly "video_streaming": boolean }
|
||||
export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source." })
|
||||
export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." }), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source." })
|
||||
export type LogEntry = { readonly "level": string, readonly "msg": string, readonly "seq": number, readonly "target": string, readonly "ts_ms": number }
|
||||
export const LogEntry = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`." }), "msg": Schema.String.annotate({ "description": "The formatted message, structured fields appended as `key=value`." }), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target": Schema.String.annotate({ "description": "The emitting module path (tracing target)." }), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One captured log event." })
|
||||
export type ModeConflict = "separate" | "steal" | "join" | "reject"
|
||||
|
||||
+13
-8
@@ -18,16 +18,21 @@ set "CERTFILE=%PFDATA%\cert.pem"
|
||||
set "KEYFILE=%PFDATA%\key.pem"
|
||||
|
||||
rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy
|
||||
rem has no credential and no TLS material, so fail and let restart-on-failure retry (mirrors the
|
||||
rem installed launcher / Linux unit) rather than silently serving plain HTTP.
|
||||
if not exist "%TOKENFILE%" (
|
||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%CERTFILE%" (
|
||||
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
|
||||
rem has no credential and no TLS material, so WAIT for them (mirrors the installed launcher) rather
|
||||
rem than silently serving plain HTTP - see scripts\windows\web-run.cmd for why waiting here beats
|
||||
rem exiting 1 and relying on the task's restart-on-failure. ~5 min at 2 s, then give up.
|
||||
set /a PFWAITS=0
|
||||
:pfwait
|
||||
if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
|
||||
if %PFWAITS% GEQ 150 (
|
||||
echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host running?
|
||||
exit /b 1
|
||||
)
|
||||
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host to write the mgmt token + identity cert...
|
||||
set /a PFWAITS+=1
|
||||
ping -n 3 127.0.0.1 >nul 2>&1
|
||||
goto pfwait
|
||||
:pfready
|
||||
|
||||
rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... .
|
||||
rem Split on the first '=' and import each into the environment.
|
||||
|
||||
Reference in New Issue
Block a user