Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12f39e1967 | ||
|
|
2a1c968a0e | ||
|
|
eb8c943572 | ||
|
|
102f550bba | ||
|
|
818531a26e | ||
|
|
608baf63be | ||
|
|
8f9c72877e | ||
|
|
8f32976349 | ||
|
|
32cc8dd529 | ||
|
|
fba22c6c64 | ||
|
|
f242b2d2fc | ||
|
|
975fef2048 |
@@ -320,6 +320,46 @@ jobs:
|
||||
run: |
|
||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||
|
||||
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
||||
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
||||
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
||||
#
|
||||
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
- name: Build the patched gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
|
||||
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
|
||||
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
|
||||
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
|
||||
# a half-built result impossible to ship.
|
||||
run: |
|
||||
set -x
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
|
||||
apt-get build-dep -y gamescope || true
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
else
|
||||
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
- name: Build punktfunk-gamescope .deb
|
||||
# Picked up by the publish loop below, which globs dist/*.deb.
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
else
|
||||
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
|
||||
fi
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
@@ -213,6 +213,40 @@ jobs:
|
||||
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
# The same binary, as an ordinary RPM. The sysext below is the Atomic/Bazzite delivery; this
|
||||
# is the one a traditional Fedora-family box (Nobara, plain Fedora) can actually install —
|
||||
# until it existed those users had no packaged route to the patched build at all, and a stock
|
||||
# gamescope tells every game its display is 60 Hz whatever the client negotiated.
|
||||
#
|
||||
# Same best-effort rule as the build above: no binary, no package, and the host stays on its
|
||||
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
|
||||
- name: Package punktfunk-gamescope as an RPM
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/gamescope/build-gamescope-rpm.sh \
|
||||
--binary gs-cache/punktfunk-gamescope \
|
||||
--release "$PF_RELEASE"
|
||||
else
|
||||
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
|
||||
fi
|
||||
|
||||
- name: Publish punktfunk-gamescope to the Gitea RPM registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for rpm in dist/punktfunk-gamescope-*.rpm; do
|
||||
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
|
||||
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
|
||||
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
|
||||
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
|
||||
echo "uploading $rpm"
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||
done
|
||||
|
||||
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||
|
||||
@@ -547,7 +547,8 @@ pub struct IddPushCapturer {
|
||||
_keepalive: Box<dyn Send>,
|
||||
}
|
||||
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
|
||||
// COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is
|
||||
// COM interfaces; the frame/cursor delivery closures own `Arc` clones of the control device and are
|
||||
// `Send + Sync` on their own). It is
|
||||
// created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
|
||||
// `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
|
||||
// only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
|
||||
|
||||
@@ -260,6 +260,18 @@ pub struct HostConfig {
|
||||
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
|
||||
/// `None` = leave gamescope's own default.
|
||||
pub gamescope_sdr_nits: Option<u32>,
|
||||
/// `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` — extra refresh rates (Hz, comma-separated) a gamescope
|
||||
/// session offers its clients on top of the one it runs at, e.g. `60,90,120`.
|
||||
///
|
||||
/// A headless gamescope has no EDID, so it cannot work out what else its display could run at:
|
||||
/// on a stock build it advertises exactly ONE rate and Steam's in-session display settings show
|
||||
/// a single entry. Our `+pfhdr3` build takes this list (`--custom-refresh-rates`) and publishes
|
||||
/// it, which is what puts real choices in that menu. The session's own rate is always included
|
||||
/// whatever is set here, so this can only ever ADD options.
|
||||
///
|
||||
/// Empty (the default) = advertise only the negotiated rate. Ignored on a stock gamescope,
|
||||
/// which has no flag to take it.
|
||||
pub gamescope_refresh_rates: Vec<u32>,
|
||||
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
|
||||
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
|
||||
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
|
||||
@@ -379,6 +391,12 @@ impl HostConfig {
|
||||
gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS")
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
.filter(|n| (1..=10_000).contains(n)),
|
||||
// Unparseable entries are DROPPED rather than failing the host: this only ever widens a
|
||||
// menu, and the session's own rate is added back unconditionally, so the worst a typo
|
||||
// can cost is the extra option the operator wanted — never the session.
|
||||
gamescope_refresh_rates: parse_refresh_rates(
|
||||
val("PUNKTFUNK_GAMESCOPE_REFRESH_RATES").as_deref(),
|
||||
),
|
||||
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||
.filter(|s| !s.trim().is_empty()),
|
||||
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||
@@ -397,6 +415,20 @@ impl HostConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// `"60, 90,120"` → `[60, 90, 120]`, sorted and deduped. Junk entries and out-of-range rates are
|
||||
/// skipped rather than rejected wholesale — see the call site for why. Pure + unit-tested.
|
||||
fn parse_refresh_rates(raw: Option<&str>) -> Vec<u32> {
|
||||
let mut out: Vec<u32> = raw
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<u32>().ok())
|
||||
.filter(|&hz| (1..=1000).contains(&hz))
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
impl HostConfig {
|
||||
/// The rate to hand the compositor as the GAME's refresh: the session's rate, capped by
|
||||
/// [`Self::max_fps`]. Only the compositor's game-facing rate goes through here — the session's
|
||||
@@ -446,6 +478,24 @@ mod tests {
|
||||
assert_eq!(c.game_fps(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_rate_list_parses_and_tolerates_junk() {
|
||||
assert_eq!(parse_refresh_rates(Some("60,90,120")), vec![60, 90, 120]);
|
||||
// Spaces, unsorted input and duplicates all normalise.
|
||||
assert_eq!(
|
||||
parse_refresh_rates(Some(" 120, 60 ,90, 60")),
|
||||
vec![60, 90, 120]
|
||||
);
|
||||
// Unset and empty are the default: advertise only the session's own rate.
|
||||
assert!(parse_refresh_rates(None).is_empty());
|
||||
assert!(parse_refresh_rates(Some("")).is_empty());
|
||||
assert!(parse_refresh_rates(Some(" ")).is_empty());
|
||||
// A typo costs its own entry, never the whole list — the knob only widens a menu.
|
||||
assert_eq!(parse_refresh_rates(Some("60,abc,120")), vec![60, 120]);
|
||||
// Out of range in both directions (0 is not a refresh rate; 1920 is a width).
|
||||
assert_eq!(parse_refresh_rates(Some("0,60,1920")), vec![60]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_output_mode_parses_its_spellings() {
|
||||
for (s, want) in [
|
||||
|
||||
@@ -27,6 +27,7 @@ mod heads;
|
||||
mod splash;
|
||||
use discovery::{
|
||||
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_bin,
|
||||
gamescope_can_composite_external_overlay, gamescope_can_offer_refresh_rates,
|
||||
gamescope_node_present, poll_managed_node, wait_for_node,
|
||||
};
|
||||
pub(crate) use discovery::{
|
||||
@@ -1153,17 +1154,9 @@ fn gamescope_argvs() -> Vec<Vec<String>> {
|
||||
/// also the final filter that separates a compositor from anything else [`gamescope_argvs`] let by.
|
||||
fn current_gamescope_output_size() -> Option<(u32, u32)> {
|
||||
gamescope_argvs().into_iter().find_map(|args| {
|
||||
let flag = |names: &[&str]| -> Option<u32> {
|
||||
args.iter().enumerate().find_map(|(i, a)| {
|
||||
names
|
||||
.contains(&a.as_str())
|
||||
.then(|| args.get(i + 1).and_then(|v| v.parse().ok()))
|
||||
.flatten()
|
||||
})
|
||||
};
|
||||
match (
|
||||
flag(&["-W", "--output-width"]),
|
||||
flag(&["-H", "--output-height"]),
|
||||
argv_u32(&args, &["-W", "--output-width"]),
|
||||
argv_u32(&args, &["-H", "--output-height"]),
|
||||
) {
|
||||
(Some(w), Some(h)) => Some((w, h)),
|
||||
_ => None,
|
||||
@@ -1171,6 +1164,104 @@ fn current_gamescope_output_size() -> Option<(u32, u32)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The numeric value following the first of `names` present in `argv`. Pure + unit-tested — it is
|
||||
/// the shared reader behind both the output-size probe above and the mode verification below.
|
||||
fn argv_u32(argv: &[String], names: &[&str]) -> Option<u32> {
|
||||
argv.iter().enumerate().find_map(|(i, a)| {
|
||||
names
|
||||
.contains(&a.as_str())
|
||||
.then(|| argv.get(i + 1).and_then(|v| v.parse().ok()))
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// Did the MODE we asked an indirectly-spawned session for actually reach its gamescope?
|
||||
///
|
||||
/// [`verify_managed_spawn_flags`] answers the same question for the capability flags and REFUSES
|
||||
/// the session when they are missing, because the retry then resolves a different (correct) plan.
|
||||
/// The mode has no such recovery: relaunching would hand the session the exact same environment and
|
||||
/// lose it the same way, so refusing would only loop. It is not silent either, though — and it used
|
||||
/// to be, in the way that costs the most:
|
||||
///
|
||||
/// `--nested-refresh` is the ONLY refresh a headless gamescope has. `CHeadlessBackend::Init`
|
||||
/// assigns `g_nOutputRefresh = g_nNestedRefresh`, defaulting to **60 Hz** when the flag is absent,
|
||||
/// and that one number is what the session composites at, what `vblankmanager` paces to, and what
|
||||
/// Steam and every game are told the display runs at. It reaches a `gamescope-session-plus` only
|
||||
/// through the `GAMESCOPE_BIN` wrapper — which the session script is free to lose (a `sessions.d`
|
||||
/// file sourced with `set -a` can reassign `GAMESCOPE_BIN`; one that sets `GAMESCOPECMD` outright
|
||||
/// skips the whole builder). When that happened the stream still ran, still looked right, and still
|
||||
/// showed the client's own fps counter at the negotiated rate — because the encode loop repeats the
|
||||
/// held frame — while the game underneath was capped to 60. Field report 2026-08-08.
|
||||
///
|
||||
/// So: warn, name the numbers, and carry on. Same "any running gamescope carrying it" rule as the
|
||||
/// flag check, and the same silence when `/proc` cannot be read.
|
||||
fn warn_if_mode_lost(mode: Mode, want_hz: u32) {
|
||||
let argvs = gamescope_argvs();
|
||||
let lost = mode_mismatch(mode.width, mode.height, want_hz, &argvs);
|
||||
if lost.is_empty() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
lost = %lost.join(", "),
|
||||
"gamescope: the session did not start at the mode we asked for — the session script \
|
||||
dropped GAMESCOPE_BIN / SCREEN_WIDTH / SCREEN_HEIGHT. A headless gamescope reports \
|
||||
`--nested-refresh` as its ONE refresh rate (60 Hz when the flag never arrives), so games \
|
||||
and Steam will believe the display runs at that rate however fast the stream is. Install \
|
||||
punktfunk-gamescope, or check /etc/gamescope-session-plus/sessions.d/ for a file that \
|
||||
overrides GAMESCOPE_BIN or sets GAMESCOPECMD"
|
||||
);
|
||||
}
|
||||
|
||||
/// Which parts of the requested mode no running gamescope was started with, as human-readable
|
||||
/// `asked=…, got=…` fragments. Empty when it matches — or when there is nothing to compare against,
|
||||
/// which is the same fail-open rule [`missing_flags`] has and for the same reason. Pure +
|
||||
/// unit-tested.
|
||||
fn mode_mismatch(want_w: u32, want_h: u32, want_hz: u32, argvs: &[Vec<String>]) -> Vec<String> {
|
||||
if argvs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut lost = Vec::new();
|
||||
let sizes: Vec<(u32, u32)> = argvs
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
Some((
|
||||
argv_u32(a, &["-W", "--output-width"])?,
|
||||
argv_u32(a, &["-H", "--output-height"])?,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
// No gamescope carries an output size at all → we cannot tell ours apart from a nested one;
|
||||
// stay quiet rather than warn on every box that runs a second gamescope.
|
||||
if !sizes.is_empty() && !sizes.contains(&(want_w, want_h)) {
|
||||
lost.push(format!(
|
||||
"resolution asked={want_w}x{want_h}, got={}",
|
||||
sizes
|
||||
.iter()
|
||||
.map(|(w, h)| format!("{w}x{h}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
));
|
||||
}
|
||||
let rates: Vec<u32> = argvs
|
||||
.iter()
|
||||
.filter_map(|a| argv_u32(a, &["-r", "--nested-refresh"]))
|
||||
.collect();
|
||||
if !rates.contains(&want_hz) {
|
||||
lost.push(match rates.as_slice() {
|
||||
// The flag is absent everywhere — the exact shape that silently yields 60 Hz.
|
||||
[] => format!(
|
||||
"refresh asked={want_hz}Hz, got=no --nested-refresh at all (gamescope defaults to \
|
||||
60Hz headless)"
|
||||
),
|
||||
got => format!(
|
||||
"refresh asked={want_hz}Hz, got={}Hz",
|
||||
got.iter().map(u32::to_string).collect::<Vec<_>>().join("/")
|
||||
),
|
||||
});
|
||||
}
|
||||
lost
|
||||
}
|
||||
|
||||
/// Did the flags we passed an INDIRECTLY-spawned session actually reach its gamescope?
|
||||
///
|
||||
/// The bare spawn builds argv itself and cannot lose them. The two managed modes can: a
|
||||
@@ -2337,12 +2428,29 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
||||
let wrapper = write_gamescope_bin_wrapper()?;
|
||||
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
|
||||
let hz = mode.refresh_hz.max(1);
|
||||
// The two rates are deliberately different when the frame limiter is set. CUSTOM_REFRESH_RATES
|
||||
// generates the mode the session ADVERTISES, which must stay the client's — that is what makes
|
||||
// games see the real refresh instead of the box's EDID. PF_HZ becomes `--nested-refresh`, the
|
||||
// rate the game is clamped to, and is the only one the limiter touches. Identical when it's
|
||||
// unset, which is the default.
|
||||
// ONE rate reaches gamescope, and it is `--nested-refresh` (via the wrapper's `PF_HZ`). On the
|
||||
// headless backend that flag IS the output refresh — `CHeadlessBackend::Init` assigns
|
||||
// `g_nOutputRefresh = g_nNestedRefresh` — so it is simultaneously the rate the session
|
||||
// composites at, the rate `vblankmanager` paces to, and the rate Steam and every game are told
|
||||
// the display runs at. When the frame limiter (`PUNKTFUNK_MAX_FPS`) is set they all drop
|
||||
// together; that is the trade the knob is, and it is off by default.
|
||||
//
|
||||
// `CUSTOM_REFRESH_RATES` below does NOT do this, whatever its name suggests: it is the *set* of
|
||||
// rates the session may offer, and `gamescope-session-plus` gates it on the binary having
|
||||
// `--custom-refresh-rates`, which no upstream gamescope has ever had. On a stock gamescope it
|
||||
// is inert (it was a silent no-op for years); on our `+pfhdr3` build it is what puts more than
|
||||
// one entry in Steam's refresh menu. Either way it cannot fix a wrong `--nested-refresh`.
|
||||
let game = game_hz(mode.refresh_hz);
|
||||
// The advertised SET, which always contains the rate we actually run at.
|
||||
let offered = {
|
||||
let mut r = pf_host_config::config().gamescope_refresh_rates.clone();
|
||||
if !r.contains(&hz) {
|
||||
r.push(hz);
|
||||
}
|
||||
r.sort_unstable();
|
||||
r.dedup();
|
||||
r.iter().map(u32::to_string).collect::<Vec<_>>().join(",")
|
||||
};
|
||||
let start_unit = || -> Result<()> {
|
||||
let status = Command::new("systemd-run")
|
||||
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
||||
@@ -2366,7 +2474,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
||||
))
|
||||
.arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display()))
|
||||
.arg("--setenv=DRM_MODE=cvt")
|
||||
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}"))
|
||||
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={offered}"))
|
||||
.arg("--")
|
||||
.arg(SESSION_PLUS_BIN)
|
||||
.arg(client)
|
||||
@@ -2394,6 +2502,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
||||
stop_session(unit_name);
|
||||
return Err(e);
|
||||
}
|
||||
// Loud, but not fatal — see [`warn_if_mode_lost`] for why this one warns where the
|
||||
// capability flags above refuse.
|
||||
warn_if_mode_lost(mode, game);
|
||||
return Ok(id);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
@@ -2526,7 +2637,15 @@ fn add_bare_gamescope_args(
|
||||
if grab_cursor {
|
||||
command.arg("--force-grab-cursor");
|
||||
}
|
||||
for arg in hdr_args(hdr).into_iter().chain(cursor_args()) {
|
||||
// `-r` above is what this headless session will REPORT as its refresh (the headless backend
|
||||
// assigns `g_nOutputRefresh = g_nNestedRefresh`), so it is already correct here. This adds the
|
||||
// rest of the SET the in-session UI may offer — the bare spawn passes it directly, with none of
|
||||
// the session-script indirection the managed path has to route it through.
|
||||
for arg in hdr_args(hdr)
|
||||
.into_iter()
|
||||
.chain(cursor_args())
|
||||
.chain(refresh_rate_args(hz))
|
||||
{
|
||||
command.arg(arg);
|
||||
}
|
||||
command.args(["--xwayland-count", "1", "--"]);
|
||||
@@ -2571,11 +2690,48 @@ fn hdr_args(hdr: bool) -> Vec<String> {
|
||||
/// host-side (it costs the host a full-frame pass, and on the zero-CSC encode source it cannot be
|
||||
/// done at all). Empty on a stock gamescope, which is exactly the old behaviour.
|
||||
fn cursor_args() -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
if gamescope_can_composite_cursor() {
|
||||
vec!["--pipewire-composite-cursor".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
args.push("--pipewire-composite-cursor".to_string());
|
||||
}
|
||||
// The external overlay (mangoapp — the Deck UI's fps/frametime readout, patch level 4+). Unlike
|
||||
// the cursor there is no host-side fallback: the host cannot reconstruct another process's
|
||||
// overlay window, so without this the layer is simply absent from every gamescope stream.
|
||||
if gamescope_can_composite_external_overlay() {
|
||||
args.push("--pipewire-composite-external-overlay".to_string());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
/// `--custom-refresh-rates <list>` when the resolved gamescope has it (patch level 3+): the rates a
|
||||
/// HEADLESS session may offer its clients.
|
||||
///
|
||||
/// Without it a headless connector advertises exactly one rate, so Steam's in-session display
|
||||
/// settings show a single entry and a game reads the display as that one number. `session_hz` is
|
||||
/// always in the list — it is the mode the session actually runs at, and an advertised set that
|
||||
/// excluded it would be a lie in the other direction.
|
||||
///
|
||||
/// The operator can widen the set (`PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120`) so the in-session
|
||||
/// UI offers real choices; unset, we advertise the one rate we run at, which is what the client
|
||||
/// asked for.
|
||||
fn refresh_rate_args(session_hz: u32) -> Vec<String> {
|
||||
if !gamescope_can_offer_refresh_rates() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut rates = pf_host_config::config().gamescope_refresh_rates.clone();
|
||||
if !rates.contains(&session_hz) {
|
||||
rates.push(session_hz);
|
||||
}
|
||||
rates.sort_unstable();
|
||||
rates.dedup();
|
||||
vec![
|
||||
"--custom-refresh-rates".to_string(),
|
||||
rates
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
]
|
||||
}
|
||||
|
||||
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
|
||||
@@ -2717,7 +2873,7 @@ mod tests {
|
||||
use super::{
|
||||
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
|
||||
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args,
|
||||
is_steam_launch, missing_flags, nested_wrapper_script, sentinel_advanced,
|
||||
is_steam_launch, missing_flags, mode_mismatch, nested_wrapper_script, sentinel_advanced,
|
||||
shape_dedicated_command,
|
||||
};
|
||||
|
||||
@@ -2949,6 +3105,63 @@ mod tests {
|
||||
assert!(!cgroup_is_punktfunk_owned(""));
|
||||
}
|
||||
|
||||
/// The silent-60Hz guard. A headless gamescope reports `--nested-refresh` as its ONE refresh
|
||||
/// rate and falls back to 60 Hz when the flag never arrives, so a session that lost the
|
||||
/// `GAMESCOPE_BIN` wrapper streams at the client's rate while telling every game it is 60 —
|
||||
/// the exact shape of the 2026-08-08 field report, and invisible without this.
|
||||
#[test]
|
||||
fn mode_mismatch_names_what_the_session_actually_got() {
|
||||
let argv = |s: &str| -> Vec<String> { s.split(' ').map(str::to_string).collect() };
|
||||
|
||||
// The good case: our own managed spawn, carrying everything we asked for.
|
||||
let ok = vec![argv(
|
||||
"/usr/bin/gamescope --backend headless -W 1920 -H 1080 --nested-refresh 120 --steam",
|
||||
)];
|
||||
assert!(mode_mismatch(1920, 1080, 120, &ok).is_empty());
|
||||
|
||||
// THE field case: the wrapper was dropped, so there is no `--nested-refresh` anywhere and
|
||||
// gamescope silently ran its 60 Hz default. Size still landed (SCREEN_WIDTH survived).
|
||||
let lost = vec![argv(
|
||||
"/usr/bin/gamescope --backend headless -W 1920 -H 1080 --steam",
|
||||
)];
|
||||
let got = mode_mismatch(1920, 1080, 120, &lost);
|
||||
assert_eq!(got.len(), 1, "only the refresh is wrong: {got:?}");
|
||||
assert!(got[0].contains("asked=120Hz"), "{got:?}");
|
||||
assert!(got[0].contains("no --nested-refresh at all"), "{got:?}");
|
||||
|
||||
// A wrong rate is reported with the number it actually got, not just "missing".
|
||||
let wrong = vec![argv("gamescope -W 1920 -H 1080 --nested-refresh 60")];
|
||||
let got = mode_mismatch(1920, 1080, 120, &wrong);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert!(got[0].contains("got=60Hz"), "{got:?}");
|
||||
|
||||
// Resolution lost too (SCREEN_WIDTH/HEIGHT dropped as well) — both are named.
|
||||
let both = vec![argv("gamescope -W 1280 -H 720")];
|
||||
assert_eq!(mode_mismatch(1920, 1080, 120, &both).len(), 2);
|
||||
|
||||
// Fail OPEN, exactly like `missing_flags`: nothing to compare against says nothing. A box
|
||||
// with a second gamescope that carries no output size must not produce a false alarm.
|
||||
assert!(mode_mismatch(1920, 1080, 120, &[]).is_empty());
|
||||
|
||||
// ANY running gamescope carrying the mode satisfies it — a Deck commonly runs a nested one
|
||||
// beside the session, and demanding that every gamescope match would reject a good session.
|
||||
let two = vec![
|
||||
argv("gamescope -W 1280 -H 800 --nested-refresh 60"),
|
||||
argv("gamescope -W 1920 -H 1080 --nested-refresh 120"),
|
||||
];
|
||||
assert!(mode_mismatch(1920, 1080, 120, &two).is_empty());
|
||||
|
||||
// The long spellings are read too.
|
||||
let long = vec![argv(
|
||||
"gamescope --output-width 1920 --output-height 1080 --nested-refresh 120",
|
||||
)];
|
||||
assert!(mode_mismatch(1920, 1080, 120, &long).is_empty());
|
||||
|
||||
// A flag with no value after it must not panic or read past the end.
|
||||
let truncated = vec![argv("gamescope -W 1920 -H 1080 --nested-refresh")];
|
||||
assert_eq!(mode_mismatch(1920, 1080, 120, &truncated).len(), 1);
|
||||
}
|
||||
|
||||
/// The silent-cursor guard: a managed session that ignored `GAMESCOPE_BIN` / the PATH shim runs
|
||||
/// a stock gamescope, and the host — already told the compositor would paint the pointer —
|
||||
/// paints none either. Only a compositor we can SEE, missing a flag we can NAME, may fail.
|
||||
|
||||
@@ -449,6 +449,34 @@ pub(crate) fn gamescope_can_composite_cursor() -> bool {
|
||||
gamescope_patch_level() >= 2 && !flags_lost()
|
||||
}
|
||||
|
||||
/// Does the resolved gamescope let us hand a headless session the list of refresh rates it may
|
||||
/// offer (`--custom-refresh-rates`)?
|
||||
///
|
||||
/// Below this level a headless gamescope advertises **one** rate — whatever `--nested-refresh`
|
||||
/// resolved to, or its own 60 Hz default — and no resolution list at all, because its connector
|
||||
/// returns empty spans from `GetModes()`/`GetValidDynamicRefreshRates()` and reports an INTERNAL
|
||||
/// screen (which makes `update_mode_atoms` delete the mode-list atom outright). So on a stock
|
||||
/// gamescope, Steam's in-session display settings show exactly one refresh rate and no
|
||||
/// resolutions, and games read the display as 60 Hz whatever the client negotiated.
|
||||
///
|
||||
/// `gamescope-session-plus` has probed for this flag for years (`CUSTOM_REFRESH_RATES` is gated on
|
||||
/// `gamescope --help` mentioning it) — upstream simply never had it, so the env var it plumbs was
|
||||
/// a no-op everywhere.
|
||||
pub(crate) fn gamescope_can_offer_refresh_rates() -> bool {
|
||||
gamescope_patch_level() >= 3 && !flags_lost()
|
||||
}
|
||||
|
||||
/// Can the resolved gamescope paint the EXTERNAL OVERLAY — mangoapp, the Deck-UI fps/frametime
|
||||
/// readout — into its PipeWire node (`--pipewire-composite-external-overlay`)?
|
||||
///
|
||||
/// `paint_pipewire` has never referenced that layer on any upstream version, so a client whose
|
||||
/// only view of the session is the node sees the overlay it just enabled simply not appear.
|
||||
/// Unlike the cursor there is no host-side substitute: the host cannot reconstruct someone else's
|
||||
/// overlay window.
|
||||
pub(crate) fn gamescope_can_composite_external_overlay() -> bool {
|
||||
gamescope_patch_level() >= 4 && !flags_lost()
|
||||
}
|
||||
|
||||
/// Has a spawn been observed where our flags did NOT reach the gamescope process?
|
||||
///
|
||||
/// The binary probe above answers "can it", which is all the bare spawn needs — there we build
|
||||
|
||||
@@ -299,16 +299,21 @@ struct Pinger {
|
||||
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
|
||||
/// cached handle (every IOCTL fails with a gone-class code forever), so such a failure RETIRES it and
|
||||
/// the next [`VirtualDisplayManager::ensure_device`] reopens the (new) device interface, re-running
|
||||
/// the version handshake. Retired handles are deliberately kept alive — never closed — for the
|
||||
/// process lifetime: the pinger/linger threads and every capturer's `ChannelBroker` hold BARE
|
||||
/// `HANDLE` copies whose soundness contract is "never closed"; a retired handle only ever FAILS
|
||||
/// IOCTLs, which every holder already tolerates. Reopens are rare (a driver restart), so the retained
|
||||
/// list is bounded in practice.
|
||||
/// the version handshake.
|
||||
///
|
||||
/// Ownership is `Arc` all the way out: every consumer — `acquire`'s IOCTL runs, the pinger/linger
|
||||
/// threads, the capture layer's delivery closures — holds its OWN clone across its use, so retiring
|
||||
/// here merely drops the manager's reference and the handle CLOSES when the last in-flight user
|
||||
/// drains. That close is load-bearing, not housekeeping: an open control handle is exactly what
|
||||
/// vetoes the PnP disable/restart the wake-from-sleep recovery leans on (field 2026-08-08 — every
|
||||
/// reload REFUSED `Generic failure`; `reset-pf-vdisplay.ps1` stops the whole host service precisely
|
||||
/// to get its handles closed, and Arc ownership buys the same release without dying). The previous
|
||||
/// contract kept retired handles open for the process lifetime because bare `HANDLE` copies were
|
||||
/// smuggled into threads and closures; those copies are gone, and nothing may rely on a dead
|
||||
/// handle staying open again.
|
||||
#[derive(Default)]
|
||||
struct DeviceSlot {
|
||||
current: Option<Arc<OwnedHandle>>,
|
||||
/// Never dropped — see the type doc (bare-`HANDLE` holders rely on no-close).
|
||||
retired: Vec<Arc<OwnedHandle>>,
|
||||
/// `CLEAR_ALL` (crashed-host orphan reap) runs only on the FIRST open of the process; a reopen
|
||||
/// races sessions this process still considers live and must not raze them.
|
||||
opened_once: bool,
|
||||
@@ -397,11 +402,6 @@ pub fn vdm() -> &'static VirtualDisplayManager {
|
||||
.expect("VirtualDisplayManager used before a backend initialised it")
|
||||
}
|
||||
|
||||
/// The live pf-vdisplay control-device handle, for the IDD-push capturer's sealed-channel delivery
|
||||
/// (`IOCTL_SET_FRAME_CHANNEL`). Safe to hand out as a bare `HANDLE`: cached handles are never closed
|
||||
/// for the process lifetime — a dead one is RETIRED (kept alive, see [`DeviceSlot`]), so a stale copy
|
||||
/// can only fail IOCTLs, never dangle. `None` before the first backend open — impossible for a
|
||||
/// capturer, which only exists on a monitor the manager created.
|
||||
/// Can this host's pf-vdisplay driver run the v5 hardware-cursor channel? Reads the
|
||||
/// handshake-latched protocol version, opening the control device once if no session has
|
||||
/// opened it yet this service run (the same open every session performs anyway) — so the
|
||||
@@ -421,7 +421,13 @@ pub fn hw_cursor_capable() -> bool {
|
||||
m.driver_proto.load(Ordering::Relaxed) >= 5
|
||||
}
|
||||
|
||||
pub fn control_device_handle() -> Option<HANDLE> {
|
||||
/// The live pf-vdisplay control device, for the IDD-push capturer's sealed-channel delivery
|
||||
/// (`IOCTL_SET_FRAME_CHANNEL`) — an `Arc` clone the caller (and every closure it builds) holds for
|
||||
/// as long as it may issue IOCTLs: the handle stays open while any holder lives and closes when the
|
||||
/// last drains, which is what lets the wake-from-sleep recovery's PnP disable proceed once the
|
||||
/// manager retires it (see [`DeviceSlot`]). `None` before the first backend open — impossible for a
|
||||
/// capturer, which only exists on a monitor the manager created.
|
||||
pub fn control_device_handle() -> Option<Arc<OwnedHandle>> {
|
||||
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||
}
|
||||
|
||||
@@ -497,17 +503,28 @@ fn is_device_gone(e: &anyhow::Error) -> bool {
|
||||
GONE.contains(&w.code().0)
|
||||
}
|
||||
|
||||
/// The transient raw `HANDLE` view of an Arc-held control device, for the backend IOCTL surface.
|
||||
/// Sound only while the `Arc` it borrows from is held — which the borrow makes structural: every
|
||||
/// use site necessarily has the owning clone alive across the call, so a concurrent retire (which
|
||||
/// now really closes the handle once its users drain — see [`DeviceSlot`]) can never close it
|
||||
/// mid-IOCTL.
|
||||
fn dev_raw(dev: &OwnedHandle) -> HANDLE {
|
||||
HANDLE(dev.as_raw_handle())
|
||||
}
|
||||
|
||||
impl VirtualDisplayManager {
|
||||
pub(crate) fn backend_name(&self) -> &'static str {
|
||||
self.driver.name()
|
||||
}
|
||||
|
||||
/// Open + cache the control device; REOPEN when a gone-classified failure retired the cached one
|
||||
/// (driver upgrade / WUDFHost restart). The `device` mutex serializes racing opens.
|
||||
fn ensure_device(&self) -> Result<HANDLE> {
|
||||
/// (driver upgrade / WUDFHost restart). The `device` mutex serializes racing opens. Returns an
|
||||
/// `Arc` clone the caller holds across every IOCTL it derives from it — a concurrent retire then
|
||||
/// drops only the manager's reference and closes nothing under the caller (see [`DeviceSlot`]).
|
||||
fn ensure_device(&self) -> Result<Arc<OwnedHandle>> {
|
||||
let mut slot = self.device.lock().unwrap();
|
||||
if let Some(d) = &slot.current {
|
||||
return Ok(HANDLE(d.as_raw_handle()));
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let reap = !slot.opened_once;
|
||||
claim_instance()?;
|
||||
@@ -519,35 +536,33 @@ impl VirtualDisplayManager {
|
||||
slot.opened_once = true;
|
||||
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
||||
self.driver_proto.store(driver_proto, Ordering::Relaxed);
|
||||
let raw = HANDLE(handle.as_raw_handle());
|
||||
slot.current = Some(Arc::new(handle));
|
||||
let dev = Arc::new(handle);
|
||||
slot.current = Some(dev.clone());
|
||||
if !reap {
|
||||
tracing::info!("virtual-display control device reopened (retired handle replaced)");
|
||||
}
|
||||
Ok(raw)
|
||||
Ok(dev)
|
||||
}
|
||||
|
||||
/// The live control handle for the pinger/linger threads. `None` before the first acquire opened
|
||||
/// it, or between a retire and the next reopen.
|
||||
fn device_handle(&self) -> Option<HANDLE> {
|
||||
self.device
|
||||
.lock()
|
||||
.unwrap()
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|d| HANDLE(d.as_raw_handle()))
|
||||
/// The live control device for the pinger/linger threads — an `Arc` clone the caller holds
|
||||
/// across its IOCTLs. `None` before the first acquire opened it, or between a retire and the
|
||||
/// next reopen.
|
||||
fn device_handle(&self) -> Option<Arc<OwnedHandle>> {
|
||||
self.device.lock().unwrap().current.clone()
|
||||
}
|
||||
|
||||
/// Retire the cached control handle after a gone-classified IOCTL failure. The handle is retained
|
||||
/// un-closed (see [`DeviceSlot`]); the next [`ensure_device`](Self::ensure_device) reopens the
|
||||
/// (new) device interface and re-runs the version handshake.
|
||||
/// Retire the cached control handle after a gone-classified IOCTL failure: drop the manager's
|
||||
/// reference, so the handle CLOSES once the last in-flight user drains (see [`DeviceSlot`]) —
|
||||
/// the release the wake-from-sleep recovery needs before it can cycle the adapter devnode. The
|
||||
/// next [`ensure_device`](Self::ensure_device) reopens the (new) device interface and re-runs
|
||||
/// the version handshake.
|
||||
fn invalidate_device(&self, why: &anyhow::Error) {
|
||||
let mut slot = self.device.lock().unwrap();
|
||||
if let Some(cur) = slot.current.take() {
|
||||
if slot.current.take().is_some() {
|
||||
tracing::warn!(
|
||||
"virtual-display control device retired — reopening on next use (cause: {why:#})"
|
||||
"virtual-display control device retired — closes when its last user drains, \
|
||||
reopening on next use (cause: {why:#})"
|
||||
);
|
||||
slot.retired.push(cur);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,11 +635,11 @@ impl VirtualDisplayManager {
|
||||
old_target,
|
||||
"IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; `dev` is the
|
||||
// value `ensure_device()` returned above (cached handles are never closed — a dead one
|
||||
// is retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
|
||||
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; the `dev`
|
||||
// Arc `ensure_device()` returned above is held across this call, so the handle stays
|
||||
// open even against a concurrent retire. `mon` was just removed from the map, so it
|
||||
// is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
|
||||
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
|
||||
// Verified-state wait, ceiling = the old fixed 400 ms settle (latency plan P0.3).
|
||||
@@ -657,11 +672,11 @@ impl VirtualDisplayManager {
|
||||
wudf_pid = mon.wudf_pid,
|
||||
"virtual monitor's WUDFHost is gone — preempting the dead monitor, recreating"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; `dev` is the value
|
||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is
|
||||
// retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; the `dev` Arc
|
||||
// `ensure_device()` returned above is held across this call, so the handle stays
|
||||
// open even against a concurrent retire. `mon` was just removed from the map, so it
|
||||
// is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
// Same async-departure settle as the reconnect preempt above (verified wait, P0.3).
|
||||
let _ = wait_target_departed(old_target, Duration::from_millis(400));
|
||||
}
|
||||
@@ -693,9 +708,10 @@ impl VirtualDisplayManager {
|
||||
else {
|
||||
unreachable!("just matched Active");
|
||||
};
|
||||
// SAFETY: `dev` is the handle `ensure_device()` returned above; the CCD
|
||||
// waits inside run under the held `state` lock (this fn's discipline).
|
||||
match unsafe { self.resize_in_place(dev, mon, mode) } {
|
||||
// SAFETY: the `dev` Arc `ensure_device()` returned above is held across
|
||||
// this call (so the handle stays open); the CCD waits inside run under
|
||||
// the held `state` lock (this fn's discipline).
|
||||
match unsafe { self.resize_in_place(dev_raw(&dev), mon, mode) } {
|
||||
Ok(()) => {
|
||||
// Same join semantics as the re-arrival: +1 ref for the new
|
||||
// (build-then-drop overlap) lease; `gen` untouched, so the old
|
||||
@@ -734,10 +750,11 @@ impl VirtualDisplayManager {
|
||||
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
|
||||
unreachable!("just matched Active");
|
||||
};
|
||||
// SAFETY: `dev` is the handle `ensure_device()` returned above; `re_add` touches the
|
||||
// live topology under the held `state` lock. `mon` is owned here (removed from the map).
|
||||
// SAFETY: the `dev` Arc `ensure_device()` returned above is held across this call
|
||||
// (so the handle stays open); `re_add` touches the live topology under the held
|
||||
// `state` lock. `mon` is owned here (removed from the map).
|
||||
let new_mon = match unsafe {
|
||||
self.re_add(dev, &mut inner, slot, &mon, mode, client_hdr)
|
||||
self.re_add(dev_raw(&dev), &mut inner, slot, &mon, mode, client_hdr)
|
||||
} {
|
||||
ReAdd::Arrived(m) => *m,
|
||||
ReAdd::RolledBack {
|
||||
@@ -815,11 +832,11 @@ impl VirtualDisplayManager {
|
||||
}
|
||||
|
||||
// The slot is empty: create a fresh monitor for it.
|
||||
// SAFETY: `create_monitor` requires `dev` to be a valid control handle; `dev` is the handle
|
||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is retired,
|
||||
// kept alive; see `DeviceSlot`), and we hold the `state` lock.
|
||||
// SAFETY: `create_monitor` requires `dev` to be a valid control handle; the `dev` Arc
|
||||
// `ensure_device()` returned above is held across this call (so the handle stays open even
|
||||
// against a concurrent retire), and we hold the `state` lock.
|
||||
let mon = match unsafe {
|
||||
self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)
|
||||
self.create_monitor(dev_raw(&dev), mode, slot, client_hdr, hw_cursor, &mut inner)
|
||||
} {
|
||||
// The cached device died under us (driver upgrade / WUDFHost restart, detected only
|
||||
// now — e.g. the host sat idle past the pinger-less window). Retire it, reopen, and
|
||||
@@ -831,9 +848,18 @@ impl VirtualDisplayManager {
|
||||
tracing::info!(
|
||||
"virtual-display control device reopened — retrying the monitor create"
|
||||
);
|
||||
// SAFETY: as above — `dev` is the handle the reopening `ensure_device` just
|
||||
// returned, and the `state` lock is still held.
|
||||
unsafe { self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)? }
|
||||
// SAFETY: as above — the `dev` Arc the reopening `ensure_device` just returned is
|
||||
// held across this call, and the `state` lock is still held.
|
||||
unsafe {
|
||||
self.create_monitor(
|
||||
dev_raw(&dev),
|
||||
mode,
|
||||
slot,
|
||||
client_hdr,
|
||||
hw_cursor,
|
||||
&mut inner,
|
||||
)?
|
||||
}
|
||||
}
|
||||
r => r?,
|
||||
};
|
||||
@@ -887,13 +913,12 @@ impl VirtualDisplayManager {
|
||||
let mut warned = false;
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
if let Some(h) = vdm().device_handle() {
|
||||
// SAFETY: `ping` requires `dev` to be a valid control handle. `h` is from
|
||||
// `device_handle()` (the `Some` branch) — cached handles are NEVER closed for the
|
||||
// process lifetime (a dead one is retired, kept alive; see `DeviceSlot`), so the
|
||||
// handle stays valid for this call even if it was retired concurrently — at worst
|
||||
// the IOCTL fails. The pinger thread only spins while the `&'static` manager
|
||||
// singleton lives.
|
||||
match unsafe { vdm().driver.ping(h) } {
|
||||
// SAFETY: `ping` requires `dev` to be a valid control handle. The `h` Arc from
|
||||
// `device_handle()` is held across this call, so the handle stays open even if
|
||||
// it is retired concurrently — at worst the IOCTL fails (the retire drops only
|
||||
// the manager's reference; see `DeviceSlot`). The pinger thread only spins
|
||||
// while the `&'static` manager singleton lives.
|
||||
match unsafe { vdm().driver.ping(dev_raw(&h)) } {
|
||||
Ok(()) => warned = false,
|
||||
Err(e) if is_device_gone(&e) => {
|
||||
// The device itself is gone (driver upgrade / WUDFHost restart) — pings
|
||||
@@ -1897,12 +1922,11 @@ impl VirtualDisplayManager {
|
||||
slot,
|
||||
"virtual-display: last session left (deliberate quit) — tearing down now, linger skipped"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle; `dev`
|
||||
// is the cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some`
|
||||
// checked above; cached handles are never closed — a dead one is retired, kept
|
||||
// alive). `mon` was moved out of the map under the `state` lock, so it is
|
||||
// exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle; the
|
||||
// `dev` Arc from `device_handle()` (the `Some` checked above) is held across
|
||||
// this call, so the handle stays open. `mon` was moved out of the map under the
|
||||
// `state` lock, so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
}
|
||||
None => {
|
||||
inner.slots.insert(
|
||||
@@ -1980,10 +2004,10 @@ impl VirtualDisplayManager {
|
||||
"IDD-push setup: force-preempting the stuck-Active prior monitor (its IddCx swap-chain is dead)"
|
||||
);
|
||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle;
|
||||
// `dev` is the cached process-lifetime `OwnedHandle` from `device_handle()`
|
||||
// (the `Some` checked above). `mon` was moved out of the map under the
|
||||
// `state` lock, so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
// the `dev` Arc from `device_handle()` (the `Some` checked above) is held
|
||||
// across this call, so the handle stays open. `mon` was moved out of the
|
||||
// map under the `state` lock, so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
// Let the OS finish the ASYNC departure before the next ADD (mirrors the
|
||||
// acquire() Lingering-preempt settle).
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
@@ -2051,11 +2075,12 @@ impl VirtualDisplayManager {
|
||||
// its session. Lock order stays state → device (teardown's invalidate
|
||||
// path), same as every other holder; the pinger takes only the device
|
||||
// lock — no inversion.
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; `dev` is
|
||||
// from `self.device_handle()` (cached handles are never closed — a dead
|
||||
// one is retired, kept alive; see `DeviceSlot`). `mon` was moved out of
|
||||
// the map under the lock, so it is exclusively owned here.
|
||||
unsafe { self.teardown_removed(dev, &mut g, mon) };
|
||||
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
|
||||
// Arc from `self.device_handle()` is held across this call, so the
|
||||
// handle stays open (a concurrent retire drops only the manager's
|
||||
// reference; see `DeviceSlot`). `mon` was moved out of the map under
|
||||
// the lock, so it is exclusively owned here.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2218,11 +2243,11 @@ impl VirtualDisplayManager {
|
||||
if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) =
|
||||
inner.slots.remove(&k)
|
||||
{
|
||||
// SAFETY: `teardown_removed` needs a live control handle; `dev` is from
|
||||
// `device_handle()` (cached handles are never closed — a dead one is retired, kept
|
||||
// alive; see `DeviceSlot`). `mon` was moved out of the map under the `state` lock,
|
||||
// so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
||||
// SAFETY: `teardown_removed` needs a live control handle; the `dev` Arc from
|
||||
// `device_handle()` is held across this call, so the handle stays open (see
|
||||
// `DeviceSlot`). `mon` was moved out of the map under the `state` lock, so it is
|
||||
// exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||
released += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,14 +100,27 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result
|
||||
/// `reset-pf-vdisplay.ps1` step 2 (proven on-box). Best-effort + idempotent: only NOT-present nodes
|
||||
/// (`Status != OK`) are removed, so the LIVE session's monitor (`Status OK`) is never touched; any
|
||||
/// failure is logged and swallowed. Returns the number removed.
|
||||
///
|
||||
/// The outcome is logged UNCONDITIONALLY, as found + removed: the old script counted only removals
|
||||
/// and the host spoke only when that count was positive, so a reap whose pnputil never launched and
|
||||
/// a box with no ghosts produced byte-identical logs (silence) — the same vacuous-signal family as
|
||||
/// the `status=OK` trap [`reload_vdisplay_adapter`] answers — while ghosts ratcheted toward the
|
||||
/// wedge with every sleep cycle.
|
||||
fn reap_ghost_monitors() -> u32 {
|
||||
// Mirrors reset-pf-vdisplay.ps1 step 2. powershell is always present for the SYSTEM service; the
|
||||
// matched tokens ('OK', 'punktfunk', the InstanceId) are locale-invariant, so this is safe on a
|
||||
// non-English box (unlike a .ps1 *file* read in the machine codepage).
|
||||
//
|
||||
// pnputil is resolved by full path and `$LASTEXITCODE` pre-seeded to failure before every
|
||||
// launch, exactly like the reload path below: a LocalSystem service's PATH need not include
|
||||
// System32 (and a SYSTEM process must not trust PATH anyway — a planted `pnputil.exe` would run
|
||||
// elevated), and the old bare-name call failed INVISIBLY there — `SilentlyContinue` swallowed
|
||||
// the miss, no exit code was written, and the ghosts stayed to wedge `IOCTL_ADD` at 0x80070490.
|
||||
const REAP_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$g = Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }; \
|
||||
$n = 0; foreach ($d in $g) { pnputil /remove-device $d.InstanceId *> $null; if ($LASTEXITCODE -eq 0) { $n++ } }; \
|
||||
Write-Output $n";
|
||||
$g = @(Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }); \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); \
|
||||
$n = 0; foreach ($d in $g) { $LASTEXITCODE = 1; if (Test-Path $pnp) { & $pnp /remove-device $d.InstanceId *> $null }; if ($LASTEXITCODE -eq 0) { $n++ } }; \
|
||||
Write-Output ($g.Count.ToString() + ' ' + $n)";
|
||||
// Resolve powershell by full path — the LocalSystem service's PATH is not guaranteed to include
|
||||
// System32 — with a bare-name fallback.
|
||||
let ps = std::env::var("SystemRoot")
|
||||
@@ -125,17 +138,29 @@ fn reap_ghost_monitors() -> u32 {
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let n = String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
if n > 0 {
|
||||
let raw = String::from_utf8_lossy(&o.stdout);
|
||||
let Some((found, removed)) = parse_reap_output(&raw) else {
|
||||
tracing::warn!(
|
||||
reaped = n,
|
||||
output = %raw.trim(),
|
||||
"pf-vdisplay: ghost-monitor reap died before reporting — ghost nodes (if any) still pin IddCx monitor slots"
|
||||
);
|
||||
return 0;
|
||||
};
|
||||
if found == 0 {
|
||||
tracing::info!("pf-vdisplay: no ghost (not-present) virtual-monitor nodes to reap");
|
||||
} else if removed < found {
|
||||
tracing::warn!(
|
||||
found,
|
||||
removed,
|
||||
"pf-vdisplay: ghost-monitor reap could NOT remove every ghost node — the leftovers keep pinning IddCx monitor slots toward the 0x80070490 wedge"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
reaped = removed,
|
||||
"pf-vdisplay: reaped ghost (not-present) virtual-monitor nodes — IddCx slot-exhaustion prevention"
|
||||
);
|
||||
}
|
||||
n
|
||||
removed
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "pf-vdisplay: ghost-monitor reap could not spawn powershell");
|
||||
@@ -144,6 +169,18 @@ fn reap_ghost_monitors() -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse [`reap_ghost_monitors`]'s script output — `"<found> <removed>"`. Split out to be testable
|
||||
/// without a box, like [`classify_reload_output`]: the field failure this answers was a reap whose
|
||||
/// outcome could not be decoded from the log at all, so the decoding is worth pinning down. `None`
|
||||
/// = the script died before reporting (callers treat that as "removed nothing", loudly).
|
||||
fn parse_reap_output(out: &str) -> Option<(u32, u32)> {
|
||||
let mut it = out.split_whitespace().map(str::parse::<u32>);
|
||||
match (it.next(), it.next()) {
|
||||
(Some(Ok(found)), Some(Ok(removed))) => Some((found, removed)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
|
||||
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
|
||||
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
|
||||
@@ -178,6 +215,14 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
|
||||
// reported tokens are ours, so parsing them is locale-invariant too.
|
||||
//
|
||||
// The selector prefers LIVE devnodes: `Get-PnpDevice` also lists not-present PHANTOMS (an
|
||||
// upgrade/reinstall leftover), and the old `Select-Object -First 1` could hand every recovery
|
||||
// attempt a phantom — whose disable AND restart both fail — while a live node sat unexamined.
|
||||
// A phantom-only state gets its own truthful refusal: no reload lever can revive a devnode
|
||||
// record whose device is GONE; only re-creating the node (reinstall) can. `Present` is the
|
||||
// authoritative bit, with `Status -ne 'Unknown'` as the fallback should it read null; live
|
||||
// `OK` nodes sort ahead of problem-state ones.
|
||||
//
|
||||
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
|
||||
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
|
||||
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
|
||||
@@ -188,10 +233,19 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
|
||||
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
|
||||
// System32.
|
||||
//
|
||||
// The REFUSED line carries the evidence a field log needs to tell the failure modes apart
|
||||
// (2026-08-08: a woken box logged only `REFUSED Generic failure` — the WMI catch-all — leaving
|
||||
// handle-veto vs phantom vs problem-state undecidable): how many devnodes matched and how many
|
||||
// are live, the chosen node's PnP Status + ConfigManager problem code, and the pnputil
|
||||
// /restart-device exit code the old script threw away (3010 = needs a reboot, which is its own
|
||||
// diagnosis).
|
||||
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
|
||||
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
|
||||
$id = $ad.InstanceId; $err = ''; \
|
||||
$all = @(Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' }); \
|
||||
if ($all.Count -eq 0) { Write-Output 'ABSENT'; exit }; \
|
||||
$live = @($all | Where-Object { $_.Present -or $_.Status -ne 'Unknown' } | Sort-Object { $_.Status -ne 'OK' }); \
|
||||
if ($live.Count -eq 0) { Write-Output ('REFUSED only phantom (not-present) adapter devnodes remain (' + $all.Count + ') - the device node itself is gone and no reload can revive it; reinstalling the host re-creates it'); exit }; \
|
||||
$ad = $live[0]; $id = $ad.InstanceId; $err = ''; \
|
||||
try { \
|
||||
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
|
||||
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
|
||||
@@ -201,9 +255,11 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
||||
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
|
||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
|
||||
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
|
||||
if ($LASTEXITCODE -eq 0) { Start-Sleep -Seconds 2; \
|
||||
$rx = $LASTEXITCODE; \
|
||||
if ($rx -eq 0) { Start-Sleep -Seconds 2; \
|
||||
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
|
||||
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; Write-Output ('REFUSED ' + $err) }";
|
||||
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; \
|
||||
Write-Output ('REFUSED devnodes=' + $all.Count + ' live=' + $live.Count + ' status=' + $ad.Status + ' problem=' + $ad.ConfigManagerErrorCode + ' restart_exit=' + $rx + ' ' + $err) }";
|
||||
let ps = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
@@ -1050,10 +1106,12 @@ const BRIEF_RETRY: Duration = Duration::from_secs(3);
|
||||
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
|
||||
/// on. The second caller through typically finds the interface already up and returns at once.
|
||||
///
|
||||
/// Taken ONLY by [`ensure_available`], which holds no manager lock, and released before the retire
|
||||
/// hook below takes the manager's `device` mutex. That is what keeps the lock order one-way:
|
||||
/// [`VdisplayDriver::open`] runs *inside* that same `device` mutex, so if it could also take this
|
||||
/// lock the two orders would invert and deadlock. It cannot — it never reloads.
|
||||
/// Taken ONLY by [`ensure_available`], which holds no manager lock. The lock order is one-way —
|
||||
/// `RECOVERY` → `device`: the recovery's handle-release hooks (`invalidate_cached_device`, which
|
||||
/// drops the manager's reference so the control handle can CLOSE before the PnP cycle) take the
|
||||
/// `device` mutex while this is held. It must stay one-way: [`VdisplayDriver::open`] runs *inside*
|
||||
/// that same `device` mutex, so if it could also take this lock the two orders would invert and
|
||||
/// deadlock. It cannot — it never reloads.
|
||||
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
|
||||
@@ -1069,10 +1127,11 @@ pub fn ensure_available() -> Result<()> {
|
||||
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
wait_for_interface(NOT_READY_GRACE, true)
|
||||
};
|
||||
// OUTSIDE the recovery lock, by the ordering contract on `RECOVERY`. A reload tore the driver
|
||||
// stack down and back up, so any control handle a previous session cached is dead by
|
||||
// construction — retire it while we know that for certain, rather than leaving the next session
|
||||
// to discover it by having an IOCTL fail. No-op before any backend opened the device.
|
||||
// A reload tore the driver stack down and back up, so any control handle cached MEANWHILE (a
|
||||
// racing open during the arrival window) is dead by construction — retire it while we know
|
||||
// that for certain, rather than leaving the next session to discover it by having an IOCTL
|
||||
// fail. Usually a no-op now: the recovery path already released the manager's reference
|
||||
// before the reload (the handle-drain that lets the PnP cycle proceed at all).
|
||||
if reloaded {
|
||||
super::manager::invalidate_cached_device(
|
||||
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
|
||||
@@ -1119,12 +1178,33 @@ fn wait_for_interface(not_ready_grace: Duration, reload: bool) -> (Result<OwnedH
|
||||
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
|
||||
// between absent and not-ready is treated as the transition it is.
|
||||
if probe.is_absent() {
|
||||
if absent_since.is_none() && reload {
|
||||
// First absent sighting on the recovery path: drop the manager's reference to the
|
||||
// (dead) control device NOW, so the ABSENT_SETTLE below doubles as the drain window
|
||||
// for every outstanding `Arc` clone — the handle then actually CLOSES before the
|
||||
// reload runs. An open control handle is exactly what vetoes the PnP disable (and
|
||||
// can wedge the pnputil restart) that the reload leans on; reset-pf-vdisplay.ps1
|
||||
// stops the whole host service to get the same release (field 2026-08-08: every
|
||||
// reload on a woken box came back REFUSED `Generic failure`). Gated on `reload`:
|
||||
// the BRIEF_RETRY caller runs inside the manager's `device` mutex, where taking it
|
||||
// again would deadlock — and that caller never reloads anyway.
|
||||
super::manager::invalidate_cached_device(
|
||||
"control interface absent — releasing the host's own device handle ahead of a \
|
||||
possible adapter reload",
|
||||
);
|
||||
}
|
||||
absent_since.get_or_insert_with(Instant::now);
|
||||
} else {
|
||||
absent_since = None;
|
||||
}
|
||||
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
|
||||
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
|
||||
// The not-ready path reaches here without the absent-sighting release above — drop the
|
||||
// manager's reference now for the same reason (idempotent: a second call is a no-op).
|
||||
super::manager::invalidate_cached_device(
|
||||
"adapter reload imminent — releasing the host's own device handle (open handles \
|
||||
veto the PnP cycle)",
|
||||
);
|
||||
match reload_vdisplay_adapter() {
|
||||
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
|
||||
// burning the arrival window on a box that simply does not have it installed.
|
||||
@@ -1195,6 +1275,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A refusal must carry evidence, not just a verdict. The 2026-08-08 field log showed only
|
||||
/// `REFUSED Generic failure` — the WMI catch-all — leaving handle-veto vs phantom vs
|
||||
/// problem-state undecidable from the log. The enriched line's tokens (devnode counts, PnP
|
||||
/// status, problem code, the pnputil restart exit code the old script discarded) must survive
|
||||
/// decoding verbatim, and the phantom-only state must decode as a refusal too — a reload
|
||||
/// cannot revive a devnode record whose device is gone.
|
||||
#[test]
|
||||
fn a_refusal_keeps_its_evidence() {
|
||||
let why = match classify_reload_output(
|
||||
"REFUSED devnodes=2 live=1 status=OK problem=0 restart_exit=3010 Generic failure",
|
||||
) {
|
||||
AdapterCycle::Refused(why) => why,
|
||||
other => panic!("expected Refused, got {}", variant(&other)),
|
||||
};
|
||||
for token in ["devnodes=2", "live=1", "status=OK", "restart_exit=3010"] {
|
||||
assert!(why.contains(token), "{token} must survive: {why:?}");
|
||||
}
|
||||
assert!(matches!(
|
||||
classify_reload_output(
|
||||
"REFUSED only phantom (not-present) adapter devnodes remain (2) - the device node \
|
||||
itself is gone and no reload can revive it; reinstalling the host re-creates it"
|
||||
),
|
||||
AdapterCycle::Refused(why) if why.contains("phantom")
|
||||
));
|
||||
}
|
||||
|
||||
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
|
||||
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
|
||||
/// disable was refused and something still holds the device open).
|
||||
@@ -1226,6 +1332,29 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// The reap's outcome must decode losslessly — the field ratchet (0.23→0.25) was a reap whose
|
||||
/// bare-named pnputil never launched under the LocalSystem PATH while the host stayed silent:
|
||||
/// "no ghosts" and "removed nothing" were byte-identical. Found and removed now travel
|
||||
/// separately so a leftover ghost is loud, and the old single-number output (or a powershell
|
||||
/// that died before reporting) must not decode as anything.
|
||||
#[test]
|
||||
fn reap_output_decodes_found_and_removed() {
|
||||
assert_eq!(parse_reap_output("3 3\r\n"), Some((3, 3)));
|
||||
assert_eq!(
|
||||
parse_reap_output("4 0"),
|
||||
Some((4, 0)),
|
||||
"pnputil unlaunchable"
|
||||
);
|
||||
assert_eq!(parse_reap_output("0 0"), Some((0, 0)), "clean box");
|
||||
for dead in ["5", "", " ", "garbage", "OK"] {
|
||||
assert_eq!(
|
||||
parse_reap_output(dead),
|
||||
None,
|
||||
"{dead:?} is not a reap report"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
|
||||
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
|
||||
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
|
||||
|
||||
@@ -194,27 +194,29 @@ pub fn capture_virtual_output(
|
||||
crate::inject::set_stream_target(Some(target.target_id));
|
||||
let pref = vout.preferred_mode;
|
||||
let keep = vout.keepalive;
|
||||
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE (it is
|
||||
// process-global — a dead one is retired, kept alive — so the raw value is stable for the
|
||||
// process) and wrap `send_frame_channel` in a `Send + Sync` closure the IDD-push capturer calls
|
||||
// at ring attach. This is the ONE reach into `crate::vdisplay` the capturer would otherwise make;
|
||||
// building it here keeps the capture→vdisplay dependency out of pf-capture (plan §W6).
|
||||
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE and wrap
|
||||
// `send_frame_channel` in a `Send + Sync` closure the IDD-push capturer calls at ring attach.
|
||||
// This is the ONE reach into `crate::vdisplay` the capturer would otherwise make; building it
|
||||
// here keeps the capture→vdisplay dependency out of pf-capture (plan §W6).
|
||||
let control = crate::vdisplay::manager::control_device_handle().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"pf-vdisplay control device not open (monitor not created via the manager?)"
|
||||
)
|
||||
})?;
|
||||
// `HANDLE` is not `Send`; capture the raw value and rebuild it inside the closure (the control
|
||||
// device is never closed for the process lifetime, so the value stays valid).
|
||||
let control_raw = control.0 as isize;
|
||||
// Each closure keeps its own `Arc<OwnedHandle>` clone (`Send + Sync`), so the handle is open
|
||||
// for exactly as long as any delivery closure lives — and CLOSES once the manager retires it
|
||||
// and the last session drops, which is what lets the wake-from-sleep recovery's PnP device
|
||||
// cycle proceed (an open control handle vetoes it).
|
||||
let control_frame = control.clone();
|
||||
let sender: pf_capture::FrameChannelSender = std::sync::Arc::new(
|
||||
move |req: &pf_driver_proto::control::SetFrameChannelRequest| {
|
||||
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is never
|
||||
// closed for the process lifetime, so reconstructing the `HANDLE` and issuing the
|
||||
// `IOCTL_SET_FRAME_CHANNEL` is sound (`send_frame_channel`'s precondition).
|
||||
// SAFETY: the captured `control_frame` Arc keeps the control handle open across this
|
||||
// call — `send_frame_channel`'s precondition.
|
||||
unsafe {
|
||||
crate::vdisplay::driver::send_frame_channel(
|
||||
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
||||
windows::Win32::Foundation::HANDLE(
|
||||
std::os::windows::io::AsRawHandle::as_raw_handle(&*control_frame),
|
||||
),
|
||||
req,
|
||||
)
|
||||
}
|
||||
@@ -231,14 +233,17 @@ pub fn capture_virtual_output(
|
||||
// Cursor-forward sessions (M2c): hand the capturer the v5 cursor-channel delivery closure —
|
||||
// its presence opts the session in (the capturer creates + delivers the CursorShm section,
|
||||
// the driver declares the IddCx hardware cursor). Built exactly like `sender` above.
|
||||
let control_cursor = control.clone();
|
||||
let cursor_sender: Option<pf_capture::CursorChannelSender> = want.hw_cursor.then(|| {
|
||||
std::sync::Arc::new(
|
||||
move |req: &pf_driver_proto::control::SetCursorChannelRequest| {
|
||||
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is
|
||||
// never closed for the process lifetime (`send_cursor_channel`'s precondition).
|
||||
// SAFETY: the captured `control_cursor` Arc keeps the control handle open across
|
||||
// this call (`send_cursor_channel`'s precondition).
|
||||
unsafe {
|
||||
crate::vdisplay::driver::send_cursor_channel(
|
||||
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
||||
windows::Win32::Foundation::HANDLE(
|
||||
std::os::windows::io::AsRawHandle::as_raw_handle(&*control_cursor),
|
||||
),
|
||||
req,
|
||||
)
|
||||
}
|
||||
@@ -261,11 +266,13 @@ pub fn capture_virtual_output(
|
||||
target_id,
|
||||
enable: enable as u32,
|
||||
};
|
||||
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is
|
||||
// never closed for the process lifetime (`send_cursor_forward`'s precondition).
|
||||
// SAFETY: the captured `control` Arc keeps the control handle open across this call
|
||||
// (`send_cursor_forward`'s precondition).
|
||||
unsafe {
|
||||
crate::vdisplay::driver::send_cursor_forward(
|
||||
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
||||
windows::Win32::Foundation::HANDLE(
|
||||
std::os::windows::io::AsRawHandle::as_raw_handle(&*control),
|
||||
),
|
||||
&req,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
|
||||
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
|
||||
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length in frames (unset = about ten minutes' worth, `fps × 600`; set `0` for encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
|
||||
| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. |
|
||||
| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. ⚠️ On gamescope that one number is also the refresh the session **reports**: Steam's in-session display settings and every game will read the display as `N` Hz, and a game that paces itself to the display will hold itself there. If you want a quieter box without games believing the panel changed, cap the client's requested refresh instead. |
|
||||
| `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` | e.g. `60,90,120` *(default: just the session's own rate)* | Extra refresh rates a gamescope session **offers** in its in-session display settings. A headless gamescope has no EDID, so it cannot work out what else the display could run at — without this it advertises exactly one rate and Steam's refresh menu has a single entry. The rate the session actually runs at is always included, so this can only add options. Needs the `punktfunk-gamescope` build (`+pfhdr3`); ignored on a stock gamescope, which has no flag to take it. |
|
||||
| `PUNKTFUNK_VDISPLAY_HZ_MULT` | `1`–`4` *(default `1` = off)* | Run the **virtual display** at a multiple of the session's frame rate without sending a single extra frame. A compositor paints on its own vblank, so a frame finished just after the capture sampled waits nearly a whole interval to be picked up — the jittery part of the latency budget. At `2` that worst case halves. Costs the compositor and GPU the extra composites, so it's opt-in. If the backend won't give the multiplied rate it reports what it achieved and the stream paces to that. |
|
||||
|
||||
## Gamepads
|
||||
|
||||
@@ -161,6 +161,9 @@ To stream real HDR you need `punktfunk-gamescope`: gamescope plus a small patch
|
||||
own name and does **not** replace your system gamescope — your Gaming Mode keeps using that one.
|
||||
|
||||
- **Bazzite / Fedora Atomic** — included in the Punktfunk sysext; `punktfunk-sysext update` gets it.
|
||||
- **Fedora, Nobara and other RPM boxes** — `sudo dnf install punktfunk-gamescope` from the same
|
||||
Punktfunk repo the host comes from.
|
||||
- **Debian / Ubuntu** — `sudo apt install punktfunk-gamescope` from the Punktfunk apt repo.
|
||||
- **Arch** — the `punktfunk-gamescope` package.
|
||||
- **SteamOS (Steam Deck installer)** — built and wired automatically by
|
||||
`scripts/steamdeck/install.sh` / `update.sh`.
|
||||
@@ -200,6 +203,22 @@ These apply to the **Gaming Mode (gamescope)** path only; the desktop path is un
|
||||
capture node, so the overlay is missing from an otherwise perfect picture. Either case is logged
|
||||
at startup with the version found. Bazzite's and SteamOS's current gamescope is past both; this
|
||||
only bites if you've pinned an old one.
|
||||
- **On a stock gamescope, Gaming Mode reports the wrong refresh rate — and offers no resolutions.**
|
||||
A headless gamescope has no EDID, and upstream's headless connector advertises no display modes
|
||||
and no refresh rates at all. Steam's in-session display settings then show a single refresh entry
|
||||
and an empty resolution list, and that one entry is whatever the session was launched with — or
|
||||
**60 Hz** if the launch flag went missing. Games that pace themselves to the display will hold
|
||||
themselves there, even though the stream is running at your client's full rate (the client's own
|
||||
fps counter keeps reading correctly, because the encoder repeats held frames — so the counter is
|
||||
not the thing to trust here; an in-game fps readout is). `punktfunk-gamescope` publishes the real
|
||||
mode and rate, and `PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120` puts more than one entry in that
|
||||
menu. If the host log says *"the session did not start at the mode we asked for"*, a file in
|
||||
`/etc/gamescope-session-plus/sessions.d/` is overriding `GAMESCOPE_BIN` or setting `GAMESCOPECMD`.
|
||||
- **The performance overlay (fps / frametime / stats) needs the patched build.** It is mangoapp,
|
||||
which gamescope draws as an *external overlay* — a layer upstream's capture composite has never
|
||||
included on any version, so on a stock gamescope you can turn the overlay on and it simply will
|
||||
not appear in the stream. There is no host-side substitute: the host cannot reconstruct another
|
||||
process's overlay window. `punktfunk-gamescope` paints it into the capture stream.
|
||||
- **The cursor comes from the compositor when it can, and from the host otherwise.** A stock
|
||||
gamescope leaves the pointer out of its captured image, so the host reads it separately and draws
|
||||
it into every frame — a full pass over the picture, and the fastest encode source cannot blend at
|
||||
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package an already-built punktfunk-gamescope binary as a .deb, for the Gitea apt registry.
|
||||
#
|
||||
# Counterpart to ../gamescope/build-gamescope-rpm.sh, and the same argument: the binary is a
|
||||
# ~10-minute meson build of an unrelated tree that CI does once and caches, so this repacks rather
|
||||
# than rebuilds. The Arch package (../gamescope/PKGBUILD) is the one recipe that builds from source,
|
||||
# because that is what makepkg is for.
|
||||
#
|
||||
# Installed as /usr/bin/punktfunk-gamescope — it does NOT replace the distro's gamescope, and does
|
||||
# not Provide/Conflict with it. Only the sessions punktfunk-host starts itself resolve this binary
|
||||
# (PUNKTFUNK_GAMESCOPE_BIN > punktfunk-gamescope > gamescope).
|
||||
#
|
||||
# Usage:
|
||||
# VERSION=3.16.25.pfhdr4~ci42.gdeadbee bash packaging/debian/build-gamescope-deb.sh \
|
||||
# --binary gs-cache/punktfunk-gamescope [--arch amd64]
|
||||
# Output: dist/punktfunk-gamescope_<version>_<arch>.deb
|
||||
set -euo pipefail
|
||||
|
||||
BINARY=""
|
||||
DEB_ARCH=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
|
||||
--arch) DEB_ARCH="${2:?--arch needs a value}"; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
|
||||
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
|
||||
|
||||
PKG="punktfunk-gamescope"
|
||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$ROOTDIR"
|
||||
|
||||
DEB_ARCH="${DEB_ARCH:-$(dpkg --print-architecture)}"
|
||||
|
||||
# The marker is the host's whole capability probe: a binary that lost the patches installs fine and
|
||||
# then silently streams SDR, cursorless, at a 60 Hz-advertising session. Refuse to package it.
|
||||
BANNER="$("$BINARY" --version 2>&1 | head -1)"
|
||||
case "$BANNER" in
|
||||
*'+pfhdr'*) ;;
|
||||
*) echo "ERROR: $BINARY has no +pfhdr marker — it is not a punktfunk gamescope build" >&2
|
||||
echo " banner: $BANNER" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
# Derive the version from the binary when the caller did not pass one — it is the only source that
|
||||
# cannot drift from what is actually in the package.
|
||||
if [ -z "${VERSION:-}" ]; then
|
||||
UPSTREAM="$(printf '%s\n' "$BANNER" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1)"
|
||||
PFHDR="$(printf '%s\n' "$BANNER" | grep -o '+pfhdr[0-9]\+' | head -1 | tr -d '+')"
|
||||
[ -n "$UPSTREAM" ] || { echo "ERROR: no X.Y.Z version in banner: $BANNER" >&2; exit 1; }
|
||||
VERSION="${UPSTREAM}.${PFHDR}"
|
||||
fi
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
# mktemp gives 0700; the package root has to be world-readable or `dpkg-deb -c` shows the tree as
|
||||
# root-only and some tooling refuses it.
|
||||
chmod 0755 "$STAGE"
|
||||
install -Dm0755 "$BINARY" "$STAGE/usr/bin/punktfunk-gamescope"
|
||||
mkdir -p "$STAGE/DEBIAN"
|
||||
|
||||
# Shared-library dependencies straight from the binary's own ELF NEEDED entries. That is what makes
|
||||
# the package honest about the Ubuntu release it was compiled on: gamescope links a broad set
|
||||
# (wlroots, SDL, libliftoff, vulkan, xwayland's libs), and hand-listing them would rot.
|
||||
DEPS=""
|
||||
if command -v dpkg-shlibdeps >/dev/null 2>&1; then
|
||||
# dpkg-shlibdeps insists on running from a package root with a debian/ dir.
|
||||
mkdir -p "$STAGE/debian"
|
||||
: > "$STAGE/debian/control"
|
||||
( cd "$STAGE" && dpkg-shlibdeps -O --ignore-missing-info usr/bin/punktfunk-gamescope 2>/dev/null ) \
|
||||
> "$STAGE/.shlibdeps" || true
|
||||
DEPS="$(sed -n 's/^shlibs:Depends=//p' "$STAGE/.shlibdeps" | head -1)"
|
||||
rm -rf "$STAGE/debian" "$STAGE/.shlibdeps"
|
||||
fi
|
||||
[ -n "$DEPS" ] || echo "WARNING: dpkg-shlibdeps produced no Depends — packaging without them" >&2
|
||||
|
||||
{
|
||||
echo "Package: $PKG"
|
||||
echo "Version: $VERSION"
|
||||
echo "Architecture: $DEB_ARCH"
|
||||
echo "Maintainer: unom <packages@unom.io>"
|
||||
echo "Section: utils"
|
||||
echo "Priority: optional"
|
||||
[ -n "$DEPS" ] && echo "Depends: $DEPS"
|
||||
# Not a hard dependency in either direction: the host works without this binary (SDR,
|
||||
# host-composited cursor), and someone may want the binary for their own capture consumer.
|
||||
echo "Recommends: punktfunk-host"
|
||||
echo "Homepage: https://git.unom.io/unom/punktfunk"
|
||||
echo "Description: gamescope with punktfunk's PipeWire capture patches"
|
||||
echo " gamescope built from the upstream revision punktfunk pins, plus the patches in"
|
||||
echo " packaging/gamescope/patches:"
|
||||
echo " ."
|
||||
echo " * 10-bit BT.2020/PQ capture formats, so an HDR game reaches a capture consumer as HDR"
|
||||
echo " instead of pre-tonemapped SDR."
|
||||
echo " * --pipewire-composite-cursor: the pointer is painted into the capture stream, so a"
|
||||
echo " consumer with no cursor of its own gets one and the host stops blending one in."
|
||||
echo " * A headless session advertises its real mode and refresh rates (and"
|
||||
echo " --custom-refresh-rates), so Steam and games see the resolution and refresh the stream"
|
||||
echo " actually runs at instead of an unnamed 60 Hz panel."
|
||||
echo " * --pipewire-composite-external-overlay: the mangoapp performance overlay is painted"
|
||||
echo " into the capture stream, so the fps/stats readout is visible remotely."
|
||||
echo " ."
|
||||
echo " Installed as /usr/bin/punktfunk-gamescope; your system gamescope is untouched."
|
||||
} > "$STAGE/DEBIAN/control"
|
||||
|
||||
mkdir -p dist
|
||||
OUT="dist/${PKG}_${VERSION}_${DEB_ARCH}.deb"
|
||||
dpkg-deb --build --root-owner-group "$STAGE" "$OUT"
|
||||
echo "==> wrote $OUT"
|
||||
echo " banner: $BANNER"
|
||||
@@ -19,7 +19,7 @@ pkgname=punktfunk-gamescope
|
||||
# bump it with the marker so pacman sees a new version when only our patches moved.
|
||||
_gsver=3.16.25
|
||||
_gsrev=8c676c399c761e4540587f61004c957993d12fea
|
||||
pkgver="${_gsver}.pfhdr2"
|
||||
pkgver="${_gsver}.pfhdr4"
|
||||
pkgrel=1
|
||||
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
|
||||
arch=('x86_64' 'aarch64')
|
||||
|
||||
@@ -13,7 +13,21 @@ The patches here add the missing half, and nothing else. See
|
||||
|---|---|---|
|
||||
| `0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch` | Offer SPA `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 props, map them to `DRM_FORMAT_XRGB2101010`/`XBGR2101010`, and composite them with `g_ScreenshotColorMgmtLutsHDR` + `EOTF_PQ` | **Yes** — offered against [gamescope#2126](https://github.com/ValveSoftware/gamescope/issues/2126) |
|
||||
| `0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch` | `--pipewire-composite-cursor` (off by default): paint the pointer into the capture stream, using the same `MouseCursor::paint` call the scanout composite uses | **Yes** — independently useful to any consumer with no cursor of its own |
|
||||
| `0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No** — ours only, retired when the two above land upstream |
|
||||
| `0003-headless-advertise-the-virtual-display-s-mode-and-re.patch` | Give `CHeadlessConnector` a real `GetModes()` + `GetValidDynamicRefreshRates()` from the resolved `-W`/`-H`/`-r`, report `GAMESCOPE_SCREEN_TYPE_EXTERNAL` so `update_mode_atoms` publishes the list, and add `--custom-refresh-rates` | **Yes** — a headless session that cannot report its own mode is a plain bug |
|
||||
| `0004-pipewire-optionally-composite-the-external-overlay-i.patch` | `--pipewire-composite-external-overlay` (off by default): paint the external overlay layer (mangoapp — the fps/stats readout) into the capture stream | **Yes** — same shape as the cursor patch, same argument |
|
||||
| `0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No** — ours only, retired when the functional patches above land upstream |
|
||||
|
||||
### Why the headless patch matters
|
||||
|
||||
A headless gamescope is how a streaming host gives a game a display: the caller passes the
|
||||
client's exact mode and expects the session to run at it. It *does* — but it never told anyone.
|
||||
`CHeadlessConnector` returned an empty span from both `GetModes()` and
|
||||
`GetValidDynamicRefreshRates()` and reported `GAMESCOPE_SCREEN_TYPE_INTERNAL`, so
|
||||
`update_mode_atoms()` **deleted** `GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL` (no resolution list) and
|
||||
`wlserver_send_gamescope_control()` fell through to a **one-entry** refresh list built from
|
||||
`g_nOutputRefresh` (no refresh list). With `-r` absent that entry is `Init()`'s 60 Hz default, so a
|
||||
client on a 120 Hz panel was told its display was 60 Hz — and games capped themselves to it. Field
|
||||
report 2026-08-08: "gamescope only shows 60hz and there's no other option".
|
||||
|
||||
### Why the cursor patch matters more than it looks
|
||||
|
||||
@@ -40,6 +54,8 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
|
||||
|---|---|
|
||||
| `+pfhdr1` | 10-bit BT.2020/PQ capture formats |
|
||||
| `+pfhdr2` | …and `--pipewire-composite-cursor` |
|
||||
| `+pfhdr3` | …and the headless connector advertises its mode + `--custom-refresh-rates` |
|
||||
| `+pfhdr4` | …and `--pipewire-composite-external-overlay` |
|
||||
|
||||
Bump it whenever a patch adds or changes something the host must know about before it spawns.
|
||||
|
||||
@@ -142,7 +158,7 @@ Note what is NOT in that table: the `.deb`. Debian/Ubuntu boxes build it by hand
|
||||
## Verifying the patch on a box (P0 exit)
|
||||
|
||||
```sh
|
||||
punktfunk-gamescope --version # must contain +pfhdr2
|
||||
punktfunk-gamescope --version # must contain +pfhdr4
|
||||
punktfunk-gamescope --backend headless -W 1920 -H 1080 -r 60 \
|
||||
--hdr-enabled --hdr-debug-force-support --pipewire-composite-cursor -- vkcube &
|
||||
pw-dump | grep -A40 '"gamescope"' # node offers xRGB_210LE / xBGR_210LE
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package an already-built punktfunk-gamescope binary as an RPM, for the Gitea RPM registry.
|
||||
#
|
||||
# WHY this exists: before it, the only ways to get punktfunk-gamescope were the Bazzite/Atomic
|
||||
# sysext, the Arch package, the SteamOS installer, a NixOS option — or building gamescope from
|
||||
# source yourself. A traditional Fedora-family box (Nobara, plain Fedora, Nobara-derived HTPCs)
|
||||
# had no packaged route at all, which is how a field report ended up on a stock gamescope streaming
|
||||
# a session that told every game the display was 60 Hz.
|
||||
#
|
||||
# The binary is NOT built here; CI builds it once per Fedora major and caches it
|
||||
# (.gitea/workflows/rpm.yml). See punktfunk-gamescope.spec's header for why repacking beats
|
||||
# rebuilding.
|
||||
#
|
||||
# Usage:
|
||||
# bash packaging/gamescope/build-gamescope-rpm.sh \
|
||||
# --binary gs-cache/punktfunk-gamescope \
|
||||
# [--version 3.16.25] [--release 1] [--outdir dist]
|
||||
#
|
||||
# Output: <outdir>/punktfunk-gamescope-<version>-<release>.<arch>.rpm
|
||||
set -euo pipefail
|
||||
|
||||
BINARY=""
|
||||
# Default the version to the upstream gamescope the pinned revision describes as, suffixed with the
|
||||
# patch-set revision — same shape as the Arch package's `pkgver`, so the two channels read alike.
|
||||
VERSION=""
|
||||
RELEASE="1"
|
||||
OUTDIR="dist"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
|
||||
--version) VERSION="${2:?--version needs a value}"; shift 2 ;;
|
||||
--release) RELEASE="${2:?--release needs a value}"; shift 2 ;;
|
||||
--outdir) OUTDIR="${2:?--outdir needs a value}"; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
|
||||
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
|
||||
|
||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$ROOTDIR"
|
||||
|
||||
# Derive the version from the binary itself when not told: it is the only source that cannot drift
|
||||
# from what is actually being packaged. `gamescope version 3.16.25-1-g8c676c3+pfhdr4 (gcc …)` →
|
||||
# `3.16.25` + the marker. RPM versions may not contain `-`, hence the trailing `.pfhdrN` form.
|
||||
BANNER="$("$BINARY" --version 2>&1 | head -1)"
|
||||
case "$BANNER" in
|
||||
*'+pfhdr'*) ;;
|
||||
*) echo "ERROR: $BINARY has no +pfhdr marker — it is not a punktfunk gamescope build" >&2
|
||||
echo " banner: $BANNER" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
PFHDR="$(printf '%s\n' "$BANNER" | grep -o '+pfhdr[0-9]\+' | head -1 | tr -d '+')"
|
||||
if [ -z "$VERSION" ]; then
|
||||
UPSTREAM="$(printf '%s\n' "$BANNER" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1)"
|
||||
[ -n "$UPSTREAM" ] || { echo "ERROR: no X.Y.Z version in banner: $BANNER" >&2; exit 1; }
|
||||
VERSION="${UPSTREAM}.${PFHDR}"
|
||||
fi
|
||||
|
||||
echo "==> packaging $BINARY as punktfunk-gamescope-${VERSION}-${RELEASE}"
|
||||
echo " banner: $BANNER"
|
||||
|
||||
TOP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TOP"' EXIT
|
||||
mkdir -p "$TOP"/{SOURCES,SPECS,BUILD,BUILDROOT,RPMS,SRPMS}
|
||||
install -m0755 "$BINARY" "$TOP/SOURCES/punktfunk-gamescope"
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
rpmbuild \
|
||||
--define "_topdir $TOP" \
|
||||
--define "pf_version $VERSION" \
|
||||
--define "pf_release $RELEASE" \
|
||||
-bb packaging/gamescope/punktfunk-gamescope.spec
|
||||
|
||||
find "$TOP/RPMS" -name '*.rpm' -exec cp -v {} "$OUTDIR/" \;
|
||||
echo "==> wrote $(find "$OUTDIR" -name 'punktfunk-gamescope-*.rpm' -newer "$TOP" -print -quit 2>/dev/null || echo "$OUTDIR"/punktfunk-gamescope-*.rpm)"
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
|
||||
Date: Sat, 8 Aug 2026 13:13:09 +0000
|
||||
Subject: [PATCH] headless: advertise the virtual display's mode and refresh
|
||||
rates
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
A headless gamescope is how a remote-desktop/streaming host gives a game a
|
||||
display: the caller passes the client's exact mode with -W/-H/-r and expects
|
||||
the session to run at it. It does composite at that rate — but it never told
|
||||
anyone. CHeadlessConnector returned an empty span from both GetModes() and
|
||||
GetValidDynamicRefreshRates(), and reported GAMESCOPE_SCREEN_TYPE_INTERNAL.
|
||||
|
||||
Both halves of that are visible to clients:
|
||||
|
||||
* update_mode_atoms() takes the INTERNAL branch, which DELETES
|
||||
GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL — so there is no resolution list.
|
||||
* wlserver_send_gamescope_control() finds no valid dynamic refresh rates and
|
||||
falls through to a one-entry list built from g_nOutputRefresh — so there is
|
||||
no refresh list either, just whatever the session happens to run at. With
|
||||
-r absent that is the 60 Hz default from Init(), and a client on a 120 Hz
|
||||
panel is told its display is 60 Hz and caps itself accordingly.
|
||||
|
||||
Populate both from the mode Init() has already resolved, and report EXTERNAL:
|
||||
a virtual display is not a built-in panel, and INTERNAL is what suppressed the
|
||||
mode list in the first place. GetConnector() follows so a lookup by type cannot
|
||||
contradict the connector's own answer.
|
||||
|
||||
--custom-refresh-rates lists the rates the display may switch between, for a
|
||||
backend that has no EDID to derive them from (gamescope-session-plus already
|
||||
passes this env through, gated on the flag existing). The running rate is always
|
||||
included, so the advertised set can never exclude the mode in use.
|
||||
|
||||
Only the headless backend changes; every other backend derives its modes from a
|
||||
real connector and is untouched.
|
||||
---
|
||||
src/Backends/HeadlessBackend.cpp | 66 ++++++++++++++++++++++++++++++--
|
||||
src/main.cpp | 41 ++++++++++++++++++++
|
||||
2 files changed, 103 insertions(+), 4 deletions(-)
|
||||
|
||||
diff --git a/src/Backends/HeadlessBackend.cpp b/src/Backends/HeadlessBackend.cpp
|
||||
index 8e45400..3168c88 100644
|
||||
--- a/src/Backends/HeadlessBackend.cpp
|
||||
+++ b/src/Backends/HeadlessBackend.cpp
|
||||
@@ -3,8 +3,13 @@
|
||||
#include "wlserver.hpp"
|
||||
#include "refresh_rate.h"
|
||||
|
||||
+#include <algorithm>
|
||||
+#include <vector>
|
||||
+
|
||||
extern int g_nPreferredOutputWidth;
|
||||
extern int g_nPreferredOutputHeight;
|
||||
+// `--custom-refresh-rates` (main.cpp): the rates this virtual display may switch between.
|
||||
+extern std::vector<uint32_t> g_customRefreshRates;
|
||||
|
||||
namespace gamescope
|
||||
{
|
||||
@@ -18,9 +23,16 @@ namespace gamescope
|
||||
{
|
||||
}
|
||||
|
||||
+ // A headless gamescope IS a virtual display: its mode is whatever the caller asked
|
||||
+ // for (`-W`/`-H`/`-r`), never a fixed built-in panel. Reporting INTERNAL made
|
||||
+ // `update_mode_atoms` DELETE GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL and set the
|
||||
+ // GAMESCOPE_CONTROL_DISPLAY_FLAG_INTERNAL_DISPLAY flag, so a client driving this
|
||||
+ // session was offered no resolutions at all — and, with the empty rate list below,
|
||||
+ // no refresh rates either. EXTERNAL is both the honest answer and the one that lets
|
||||
+ // the mode list reach Steam.
|
||||
virtual gamescope::GamescopeScreenType GetScreenType() const override
|
||||
{
|
||||
- return GAMESCOPE_SCREEN_TYPE_INTERNAL;
|
||||
+ return GAMESCOPE_SCREEN_TYPE_EXTERNAL;
|
||||
}
|
||||
virtual GamescopePanelOrientation GetCurrentOrientation() const override
|
||||
{
|
||||
@@ -44,7 +56,7 @@ namespace gamescope
|
||||
}
|
||||
virtual std::span<const BackendMode> GetModes() const override
|
||||
{
|
||||
- return std::span<const BackendMode>{};
|
||||
+ return m_Modes;
|
||||
}
|
||||
|
||||
virtual bool SupportsVRR() const override
|
||||
@@ -58,7 +70,7 @@ namespace gamescope
|
||||
}
|
||||
virtual std::span<const uint32_t> GetValidDynamicRefreshRates() const override
|
||||
{
|
||||
- return std::span<const uint32_t>{};
|
||||
+ return m_ValidDynamicRefreshRates;
|
||||
}
|
||||
|
||||
virtual void GetNativeColorimetry(
|
||||
@@ -90,8 +102,42 @@ namespace gamescope
|
||||
return 0;
|
||||
}
|
||||
|
||||
+ // Publish the mode this session was actually started with, plus every refresh rate it
|
||||
+ // is allowed to switch between. Called once from CHeadlessBackend::Init(), after the
|
||||
+ // -W/-H/-r defaults are resolved, because that is the first point at which the answer
|
||||
+ // exists — and it has to exist before the first `gamescope_control` bind, which is
|
||||
+ // what hands Steam the list.
|
||||
+ void SetVirtualMode( uint32_t uWidth, uint32_t uHeight, uint32_t uRefreshHz,
|
||||
+ std::span<const uint32_t> uOfferRatesHz )
|
||||
+ {
|
||||
+ m_ValidDynamicRefreshRates.clear();
|
||||
+ m_Modes.clear();
|
||||
+
|
||||
+ auto AddRate = [ this ]( uint32_t uRate )
|
||||
+ {
|
||||
+ if ( !uRate )
|
||||
+ return;
|
||||
+ if ( std::find( m_ValidDynamicRefreshRates.begin(), m_ValidDynamicRefreshRates.end(), uRate )
|
||||
+ == m_ValidDynamicRefreshRates.end() )
|
||||
+ m_ValidDynamicRefreshRates.push_back( uRate );
|
||||
+ };
|
||||
+
|
||||
+ for ( uint32_t uRate : uOfferRatesHz )
|
||||
+ AddRate( uRate );
|
||||
+ // The rate we are running at is always offerable, whatever the caller listed —
|
||||
+ // otherwise Steam is handed a set that excludes the mode it is looking at.
|
||||
+ AddRate( uRefreshHz );
|
||||
+
|
||||
+ std::sort( m_ValidDynamicRefreshRates.begin(), m_ValidDynamicRefreshRates.end() );
|
||||
+
|
||||
+ for ( uint32_t uRate : m_ValidDynamicRefreshRates )
|
||||
+ m_Modes.push_back( BackendMode{ uWidth, uHeight, uRate } );
|
||||
+ }
|
||||
+
|
||||
private:
|
||||
BackendConnectorHDRInfo m_HDRInfo{};
|
||||
+ std::vector<BackendMode> m_Modes;
|
||||
+ std::vector<uint32_t> m_ValidDynamicRefreshRates;
|
||||
};
|
||||
|
||||
class CHeadlessBackend final : public CBaseBackend
|
||||
@@ -125,6 +171,16 @@ namespace gamescope
|
||||
if ( g_nOutputRefresh == 0 )
|
||||
g_nOutputRefresh = ConvertHztomHz( 60 );
|
||||
|
||||
+ // Hand the connector the resolved mode. Until this existed the headless connector
|
||||
+ // advertised NOTHING — no modes, no dynamic refresh rates — so `wlserver`'s
|
||||
+ // `active_display_info` fell through to a one-entry list built from g_nOutputRefresh
|
||||
+ // and every client concluded the display was a 60 Hz panel it could not change.
|
||||
+ m_Connector.SetVirtualMode(
|
||||
+ uint32_t( g_nOutputWidth ),
|
||||
+ uint32_t( g_nOutputHeight ),
|
||||
+ ConvertmHzToHz( uint32_t( g_nOutputRefresh ) ),
|
||||
+ g_customRefreshRates );
|
||||
+
|
||||
if ( !vulkan_init( vulkan_get_instance(), VK_NULL_HANDLE ) )
|
||||
{
|
||||
return false;
|
||||
@@ -200,7 +256,9 @@ namespace gamescope
|
||||
}
|
||||
virtual IBackendConnector *GetConnector( GamescopeScreenType eScreenType ) override
|
||||
{
|
||||
- if ( eScreenType == GAMESCOPE_SCREEN_TYPE_INTERNAL )
|
||||
+ // Must agree with CHeadlessConnector::GetScreenType() — a lookup by type that
|
||||
+ // contradicted the connector's own answer would hand callers the wrong screen.
|
||||
+ if ( eScreenType == GAMESCOPE_SCREEN_TYPE_EXTERNAL )
|
||||
return &m_Connector;
|
||||
|
||||
return nullptr;
|
||||
diff --git a/src/main.cpp b/src/main.cpp
|
||||
index 1eb35b3..2c4fb50 100644
|
||||
--- a/src/main.cpp
|
||||
+++ b/src/main.cpp
|
||||
@@ -92,6 +92,7 @@ const struct option *gamescope_options = (struct option[]){
|
||||
{ "prefer-output", required_argument, nullptr, 'O' },
|
||||
{ "default-touch-mode", required_argument, nullptr, 0 },
|
||||
{ "generate-drm-mode", required_argument, nullptr, 0 },
|
||||
+ { "custom-refresh-rates", required_argument, nullptr, 0 },
|
||||
{ "immediate-flips", no_argument, nullptr, 0 },
|
||||
{ "framerate-limit", required_argument, nullptr, 0 },
|
||||
|
||||
@@ -232,6 +233,7 @@ const char usage[] =
|
||||
" -O, --prefer-output list of connectors in order of preference (ex: DP-1,DP-2,DP-3,HDMI-A-1)\n"
|
||||
" --default-touch-mode 0: hover, 1: left, 2: right, 3: middle, 4: passthrough\n"
|
||||
" --generate-drm-mode DRM mode generation algorithm (cvt, fixed)\n"
|
||||
+ " --custom-refresh-rates comma-separated refresh rates (Hz) this display may switch between, eg. 60,90,120 (headless only)\n"
|
||||
" --immediate-flips Enable immediate flips, may result in tearing\n"
|
||||
"\n"
|
||||
#if HAVE_OPENVR
|
||||
@@ -297,6 +299,10 @@ int g_nNestedHeight = 0;
|
||||
int g_nNestedRefresh = 0;
|
||||
int g_nNestedUnfocusedRefresh = 0;
|
||||
int g_nNestedDisplayIndex = 0;
|
||||
+// `--custom-refresh-rates`, in Hz. Consumed by the headless backend, which has no EDID to
|
||||
+// derive a mode list from and so cannot answer "what else could this display run at" on its
|
||||
+// own. Empty = offer only the rate the session was started at.
|
||||
+std::vector<uint32_t> g_customRefreshRates;
|
||||
|
||||
uint32_t g_nOutputWidth = 0;
|
||||
uint32_t g_nOutputHeight = 0;
|
||||
@@ -447,6 +453,39 @@ static enum gamescope::GamescopeBackend parse_backend_name(const char *str)
|
||||
}
|
||||
}
|
||||
|
||||
+// `--custom-refresh-rates 60,90,120` -> { 60, 90, 120 }. Anything unparseable is a hard error,
|
||||
+// exactly like every other option value: silently dropping a rate would leave a display
|
||||
+// advertising a list the caller never asked for, which is worse than not starting.
|
||||
+static std::vector<uint32_t> parse_refresh_rate_list(const char *str)
|
||||
+{
|
||||
+ std::vector<uint32_t> rates;
|
||||
+ std::string_view svRest{ str };
|
||||
+ while ( !svRest.empty() )
|
||||
+ {
|
||||
+ const size_t nComma = svRest.find( ',' );
|
||||
+ std::string_view svTok = svRest.substr( 0, nComma );
|
||||
+ svRest = nComma == std::string_view::npos ? std::string_view{} : svRest.substr( nComma + 1 );
|
||||
+
|
||||
+ // `Parse` is `std::from_chars`, which rejects leading blanks outright — trim so that a
|
||||
+ // perfectly ordinary "60, 90, 120" is not an error.
|
||||
+ while ( !svTok.empty() && svTok.front() == ' ' )
|
||||
+ svTok.remove_prefix( 1 );
|
||||
+ while ( !svTok.empty() && svTok.back() == ' ' )
|
||||
+ svTok.remove_suffix( 1 );
|
||||
+
|
||||
+ std::optional<uint32_t> oRate = gamescope::Parse<uint32_t>( svTok );
|
||||
+ // 1000 Hz is not a limit anyone will meet; it is there so a typo'd "1920" cannot become a
|
||||
+ // refresh rate that every consumer then has to sanity-check for us.
|
||||
+ if ( !oRate || *oRate == 0 || *oRate > 1000 )
|
||||
+ {
|
||||
+ fprintf( stderr, "gamescope: invalid value for --custom-refresh-rates: %s\n", str );
|
||||
+ exit( 1 );
|
||||
+ }
|
||||
+ rates.push_back( *oRate );
|
||||
+ }
|
||||
+ return rates;
|
||||
+}
|
||||
+
|
||||
static int parse_integer(const char *str, const char *optionName)
|
||||
{
|
||||
auto result = gamescope::Parse<int>(str);
|
||||
@@ -800,6 +839,8 @@ int main(int argc, char **argv)
|
||||
gamescope::cv_touch_click_mode = (gamescope::TouchClickMode) parse_integer( optarg, opt_name );
|
||||
} else if (strcmp(opt_name, "generate-drm-mode") == 0) {
|
||||
g_eGamescopeModeGeneration = parse_gamescope_mode_generation( optarg );
|
||||
+ } else if (strcmp(opt_name, "custom-refresh-rates") == 0) {
|
||||
+ g_customRefreshRates = parse_refresh_rate_list( optarg );
|
||||
} else if (strcmp(opt_name, "force-orientation") == 0) {
|
||||
g_DesiredInternalOrientation = force_orientation( optarg );
|
||||
} else if (strcmp(opt_name, "sharpness") == 0 ||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
|
||||
Date: Sat, 8 Aug 2026 13:17:14 +0000
|
||||
Subject: [PATCH] pipewire: optionally composite the external overlay into the
|
||||
capture stream
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
paint_pipewire() is a separate, reduced composite from the scanout one. It
|
||||
paints the focus window, the override window and — since 3.16.23 — the Steam
|
||||
overlay, but it has never referenced externalOverlayWindow. That layer is
|
||||
mangoapp: the fps / frametime / stats readout the Deck UI turns on.
|
||||
|
||||
On a real handheld the omission is invisible, because the person who enabled
|
||||
the overlay is looking at the panel it is drawn on. For a consumer whose ONLY
|
||||
view of the session is this node — a remote-desktop or streaming host — the
|
||||
overlay simply does not exist: the user turns it on, sees nothing, and there is
|
||||
nothing they can configure to change that.
|
||||
|
||||
Add --pipewire-composite-external-overlay, off by default for the same reason
|
||||
--pipewire-composite-cursor is: the node has never carried this layer, and a
|
||||
consumer showing the stream to the same person already looking at the screen
|
||||
would get two of them.
|
||||
|
||||
Two details worth naming:
|
||||
|
||||
- The overlay's commit id joins the repaint test. Its numbers change every
|
||||
frame precisely while the picture behind them is static, which is exactly
|
||||
the case the existing focus/override-only test skips — without this the
|
||||
stream would show a frozen overlay.
|
||||
- It is painted WITHOUT NoScale, unlike paint_all. There the overlay is
|
||||
already sized to the output; here currentOutputWidth/Height are the capture
|
||||
size, so a stream captured at another resolution needs it scaled to match.
|
||||
|
||||
Notifications are deliberately left out: unlike a performance overlay, they are
|
||||
not something the viewer asked to see on that screen.
|
||||
---
|
||||
src/main.cpp | 3 +++
|
||||
src/steamcompmgr.cpp | 39 +++++++++++++++++++++++++++++++++++++++
|
||||
2 files changed, 42 insertions(+)
|
||||
|
||||
diff --git a/src/main.cpp b/src/main.cpp
|
||||
index 2c4fb50..b406caf 100644
|
||||
--- a/src/main.cpp
|
||||
+++ b/src/main.cpp
|
||||
@@ -143,6 +143,7 @@ const struct option *gamescope_options = (struct option[]){
|
||||
{ "disable-color-management", no_argument, nullptr, 0 },
|
||||
{ "sdr-gamut-wideness", required_argument, nullptr, 0 },
|
||||
{ "pipewire-composite-cursor", no_argument, nullptr, 0 },
|
||||
+ { "pipewire-composite-external-overlay", no_argument, nullptr, 0 },
|
||||
{ "hdr-enabled", no_argument, nullptr, 0 },
|
||||
{ "hdr-sdr-content-nits", required_argument, nullptr, 0 },
|
||||
{ "hdr-itm-enabled", no_argument, nullptr, 0 },
|
||||
@@ -208,6 +209,8 @@ const char usage[] =
|
||||
" --cursor-scale-height if specified, sets a base output height to linearly scale the cursor against.\n"
|
||||
" --virtual-connector-strategy Specifies how we should make virtual connectors.\n"
|
||||
" --pipewire-composite-cursor composite the cursor into the PipeWire capture stream (off by default: the node has never carried it, and a consumer that draws its own would get two)\n"
|
||||
+ " --pipewire-composite-external-overlay\n"
|
||||
+ " composite the external overlay layer (mangoapp) into the PipeWire capture stream (off by default, like the cursor)\n"
|
||||
" --hdr-enabled enable HDR output (needs Gamescope WSI layer enabled for support from clients)\n"
|
||||
" If this is not set, and there is a HDR client, it will be tonemapped SDR.\n"
|
||||
" --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n"
|
||||
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
|
||||
index 5c65420..0d293c6 100644
|
||||
--- a/src/steamcompmgr.cpp
|
||||
+++ b/src/steamcompmgr.cpp
|
||||
@@ -2323,6 +2323,12 @@ gamescope::ConVar<bool> cv_pipewire_composite_cursor{ "pipewire_composite_cursor
|
||||
"default: the node has never carried the pointer, and a consumer that draws its own would get "
|
||||
"two." };
|
||||
|
||||
+gamescope::ConVar<bool> cv_pipewire_composite_external_overlay{ "pipewire_composite_external_overlay", false,
|
||||
+ "Composite the external overlay layer (mangoapp — the performance overlay) into the PipeWire "
|
||||
+ "capture stream (--pipewire-composite-external-overlay). Off by default, like the cursor: the "
|
||||
+ "node has never carried it, and a consumer showing the stream to the same person already "
|
||||
+ "looking at the screen would get two." };
|
||||
+
|
||||
static void paint_pipewire()
|
||||
{
|
||||
static struct pipewire_buffer *s_pPipewireBuffer = nullptr;
|
||||
@@ -2440,14 +2446,31 @@ static void paint_pipewire()
|
||||
}
|
||||
}
|
||||
|
||||
+ // The external overlay — mangoapp, i.e. the fps/frametime/stats readout. `paint_all` draws
|
||||
+ // it on the scanout composite; this reduced one never referenced it, so a consumer whose
|
||||
+ // ONLY view of the session is this node could not see the overlay the user had turned on.
|
||||
+ // Gated on the focus-appid for the same reason the Steam overlay above is: a consumer that
|
||||
+ // asked for one specific app is asking for that app, not for the session's chrome.
|
||||
+ static uint64_t s_ulLastExternalOverlayCommitId = 0;
|
||||
+ steamcompmgr_win_t *pExternalOverlay = nullptr;
|
||||
+ if ( cv_pipewire_composite_external_overlay && !ulFocusAppId &&
|
||||
+ pFocus->externalOverlayWindow && pFocus->externalOverlayWindow->opacity )
|
||||
+ pExternalOverlay = pFocus->externalOverlayWindow;
|
||||
+ // Its commit id has to join the repaint test below, or the overlay would freeze at whatever
|
||||
+ // it read when the game last presented — the numbers on it change every frame precisely
|
||||
+ // WHILE the picture behind them is static, which is the case the test would otherwise skip.
|
||||
+ const uint64_t ulExternalOverlayCommitId = window_last_done_commit_id( pExternalOverlay );
|
||||
+
|
||||
if ( ulFocusCommitId == s_ulLastFocusCommitId &&
|
||||
ulOverrideCommitId == s_ulLastOverrideCommitId &&
|
||||
+ ulExternalOverlayCommitId == s_ulLastExternalOverlayCommitId &&
|
||||
bDrawCursor == s_bLastCursorDrawn &&
|
||||
nCursorX == s_nLastCursorX && nCursorY == s_nLastCursorY )
|
||||
return;
|
||||
|
||||
s_ulLastFocusCommitId = ulFocusCommitId;
|
||||
s_ulLastOverrideCommitId = ulOverrideCommitId;
|
||||
+ s_ulLastExternalOverlayCommitId = ulExternalOverlayCommitId;
|
||||
s_bLastCursorDrawn = bDrawCursor;
|
||||
s_nLastCursorX = nCursorX;
|
||||
s_nLastCursorY = nCursorY;
|
||||
@@ -2475,6 +2498,16 @@ static void paint_pipewire()
|
||||
( cv_overlay_unmultiplied_alpha ? PaintWindowFlag::CoverageMode : 0 ) );
|
||||
}
|
||||
|
||||
+ // Deliberately WITHOUT `NoScale`, which is what the scanout composite uses: there the
|
||||
+ // overlay is already sized to the output, while here `currentOutputWidth/Height` are the
|
||||
+ // capture size, and a stream captured at anything other than the session's own resolution
|
||||
+ // would otherwise get the overlay at the wrong size in the corner.
|
||||
+ if ( pExternalOverlay )
|
||||
+ {
|
||||
+ paint_window( pExternalOverlay, pExternalOverlay, &frameInfo, nullptr, PaintWindowFlag::NoFilter |
|
||||
+ ( cv_overlay_unmultiplied_alpha ? PaintWindowFlag::CoverageMode : 0 ) );
|
||||
+ }
|
||||
+
|
||||
// The cursor, when this stream was asked for it. gamescope keeps the pointer OUT of the
|
||||
// PipeWire node by default — it lives on a hardware plane for scanout, and a remote-play
|
||||
// consumer that draws its own would end up with two — so a consumer that has no cursor of
|
||||
@@ -8457,6 +8490,12 @@ steamcompmgr_main(int argc, char **argv)
|
||||
cv_pipewire_composite_cursor = true;
|
||||
#else
|
||||
fprintf( stderr, "gamescope: --pipewire-composite-cursor ignored (built without PipeWire)\n" );
|
||||
+#endif
|
||||
+ } else if (strcmp(opt_name, "pipewire-composite-external-overlay") == 0) {
|
||||
+#if HAVE_PIPEWIRE
|
||||
+ cv_pipewire_composite_external_overlay = true;
|
||||
+#else
|
||||
+ fprintf( stderr, "gamescope: --pipewire-composite-external-overlay ignored (built without PipeWire)\n" );
|
||||
#endif
|
||||
} else if (strcmp(opt_name, "hdr-enabled") == 0 || strcmp(opt_name, "hdr-enable") == 0) {
|
||||
cv_hdr_enabled = true;
|
||||
+14
-10
@@ -1,6 +1,6 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
|
||||
Date: Tue, 28 Jul 2026 15:42:01 +0200
|
||||
Date: Sat, 8 Aug 2026 13:17:37 +0000
|
||||
Subject: [PATCH] punktfunk: stamp the version banner with +pfhdrN
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
@@ -8,25 +8,27 @@ Content-Transfer-Encoding: 8bit
|
||||
|
||||
punktfunk decides a session's shape before the virtual display exists — the
|
||||
bit depth in the Welcome (irrevocable; a PQ stream on an 8-bit encoder is a
|
||||
hard error), and whether it must composite the cursor host-side before the
|
||||
encoder is even opened. Both answers therefore have to be static properties of
|
||||
the resolved binary rather than something negotiated later.
|
||||
hard error), whether it must composite the cursor host-side before the encoder
|
||||
is even opened, and which flags the spawn has to carry. All of those have to be
|
||||
static properties of the resolved binary rather than something negotiated later.
|
||||
|
||||
The number is a monotonic patch-set revision, so one probe answers both:
|
||||
The number is a monotonic patch-set revision, so one probe answers all of them:
|
||||
+pfhdr1 10-bit BT.2020/PQ capture formats
|
||||
+pfhdr2 …and --pipewire-composite-cursor
|
||||
+pfhdr3 …and the headless connector advertises its mode + --custom-refresh-rates
|
||||
+pfhdr4 …and --pipewire-composite-external-overlay
|
||||
|
||||
NOT for upstream: drop this once the functional patches land there and plain
|
||||
version floors answer the same questions.
|
||||
---
|
||||
src/meson.build | 7 ++++++-
|
||||
1 file changed, 6 insertions(+), 1 deletion(-)
|
||||
src/meson.build | 9 ++++++++-
|
||||
1 file changed, 8 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/src/meson.build b/src/meson.build
|
||||
index 662f752..af48d01 100644
|
||||
index 662f752..12fd38a 100644
|
||||
--- a/src/meson.build
|
||||
+++ b/src/meson.build
|
||||
@@ -177,7 +177,12 @@ compiler_version = cc.version()
|
||||
@@ -177,7 +177,14 @@ compiler_version = cc.version()
|
||||
|
||||
vcs_tag_cmd = ['git', 'describe', '--always', '--tags', '--dirty=+']
|
||||
vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
|
||||
@@ -36,7 +38,9 @@ index 662f752..af48d01 100644
|
||||
+# punktfunk/1 Welcome is irrevocable). The number is a monotonic PATCH-SET revision:
|
||||
+# +pfhdr1 — 10-bit BT.2020/PQ capture formats on the PipeWire node
|
||||
+# +pfhdr2 — …and `--pipewire-composite-cursor`
|
||||
+version_tag = vcs_tag + '+pfhdr2' + ' (' + compiler_name + ' ' + compiler_version + ')'
|
||||
+# +pfhdr3 — …and the headless connector advertises its mode + `--custom-refresh-rates`
|
||||
+# +pfhdr4 — …and `--pipewire-composite-external-overlay`
|
||||
+version_tag = vcs_tag + '+pfhdr4' + ' (' + compiler_name + ' ' + compiler_version + ')'
|
||||
|
||||
gamescope_version_conf = configuration_data()
|
||||
gamescope_version_conf.set('VCS_TAG', version_tag)
|
||||
@@ -0,0 +1,78 @@
|
||||
# punktfunk-gamescope — gamescope carrying punktfunk's capture patches, installed under its own
|
||||
# name so it sits BESIDE the distro's gamescope (Game Mode keeps using that one).
|
||||
#
|
||||
# This spec REPACKS a binary that was already built, rather than building gamescope itself: the
|
||||
# build is a ~10-minute meson compile of an unrelated tree that CI already does once per Fedora
|
||||
# major and caches (.gitea/workflows/rpm.yml). Rebuilding it inside rpmbuild would double that for
|
||||
# no gain, and would need network access in the build root to fetch the upstream tree.
|
||||
#
|
||||
# The counterpart for Arch is packaging/gamescope/PKGBUILD, which DOES build from source, because
|
||||
# makepkg fetches sources by design and the AUR-style recipe is what an Arch user expects.
|
||||
#
|
||||
# Usage: bash packaging/gamescope/build-gamescope-rpm.sh --binary <path-to-punktfunk-gamescope>
|
||||
Name: punktfunk-gamescope
|
||||
Version: %{pf_version}
|
||||
Release: %{pf_release}%{?dist}
|
||||
Summary: gamescope with punktfunk's PipeWire capture patches (HDR, cursor, overlay, virtual-display modes)
|
||||
|
||||
# gamescope is BSD-2-Clause; our patches are contributed under the same terms.
|
||||
License: BSD-2-Clause
|
||||
URL: https://git.unom.io/unom/punktfunk
|
||||
Source0: punktfunk-gamescope
|
||||
|
||||
# Not `Provides: gamescope` and not `Conflicts:` either — this ships a differently-named binary and
|
||||
# is designed to coexist. A box's Game Mode session keeps running the distro's gamescope; only the
|
||||
# sessions punktfunk-host starts itself resolve this one (PUNKTFUNK_GAMESCOPE_BIN >
|
||||
# punktfunk-gamescope > gamescope).
|
||||
#
|
||||
# The runtime library Requires are auto-generated by rpmbuild from the binary's ELF NEEDED entries,
|
||||
# which is exactly right here: this binary is soname-coupled to the Fedora major it was compiled
|
||||
# on, and the generated Requires are what stop it installing on the wrong one.
|
||||
Recommends: punktfunk-host
|
||||
|
||||
# Nothing is compiled here, so there is no debuginfo to extract; without this rpmbuild fails
|
||||
# looking for sources it was never given.
|
||||
%global debug_package %{nil}
|
||||
|
||||
%description
|
||||
gamescope built from the upstream revision punktfunk pins, plus the patches in
|
||||
packaging/gamescope/patches:
|
||||
|
||||
* 10-bit BT.2020/PQ capture formats on the PipeWire node, so an HDR game reaches a capture
|
||||
consumer as HDR instead of pre-tonemapped SDR.
|
||||
* --pipewire-composite-cursor: paint the pointer into the capture stream, so a consumer with no
|
||||
cursor of its own gets one — and the host stops blending one in, which frees the encoder's
|
||||
fastest zero-copy source.
|
||||
* A headless session advertises its real mode and refresh rates (and --custom-refresh-rates), so
|
||||
Steam and games see the resolution and refresh the stream actually runs at instead of an
|
||||
unnamed 60 Hz panel.
|
||||
* --pipewire-composite-external-overlay: paint the mangoapp performance overlay into the capture
|
||||
stream, so the fps/stats readout is visible to someone watching remotely.
|
||||
|
||||
Installed as /usr/bin/punktfunk-gamescope. Your system gamescope is untouched.
|
||||
|
||||
%prep
|
||||
# Nothing to unpack: Source0 IS the binary.
|
||||
|
||||
%build
|
||||
# Nothing to build — see the header.
|
||||
|
||||
%install
|
||||
install -Dm0755 %{SOURCE0} %{buildroot}%{_bindir}/punktfunk-gamescope
|
||||
|
||||
%check
|
||||
# The marker is the host's entire capability probe (`gamescope_patch_level()`): a binary that lost
|
||||
# the patches would install fine and then silently stream SDR with no cursor. Refuse to package it.
|
||||
#
|
||||
# Executed in the build root, which is the same container the binary was compiled in — if that ever
|
||||
# stops being true this check is the thing that notices.
|
||||
%{buildroot}%{_bindir}/punktfunk-gamescope --version 2>&1 | grep -q '+pfhdr' || {
|
||||
echo "punktfunk-gamescope: the +pfhdr marker is missing — the patches did not take" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
%files
|
||||
%{_bindir}/punktfunk-gamescope
|
||||
|
||||
%changelog
|
||||
# Generated per build; see the git history for the patch set's own changes.
|
||||
@@ -63,7 +63,7 @@ unwrapped.overrideAttrs (old: {
|
||||
);
|
||||
|
||||
# nixpkgs builds from a `fetchFromGitHub` src, so there is no `.git` for `git describe` and the
|
||||
# banner would read `+pfhdr2 (gcc …)` with no version at all — which the host's diagnostic
|
||||
# banner would read `+pfhdrN (gcc …)` with no version at all — which the host's diagnostic
|
||||
# version gate then misreads (it takes the first X.Y.Z triple it finds, i.e. the compiler's).
|
||||
# Substituting the real version in keeps `--version` honest AND keeps our marker.
|
||||
postPatch = (old.postPatch or "") + ''
|
||||
|
||||
@@ -147,7 +147,10 @@ if [ "$WITH_WEB" = 1 ]; then
|
||||
distrobox enter "$BOX" -- bash -lc "
|
||||
set -e
|
||||
export PATH=\$HOME/.bun/bin:\$PATH
|
||||
cd '$SRC/web' && bun install --frozen-lockfile && bun run build
|
||||
# --ignore-scripts + explicit codegen: keep in step with scripts/steamdeck/update.sh, which
|
||||
# explains why (web's `postinstall` writes the COMMITTED web/bun.nix; its `prepare`/codegen is
|
||||
# required because src/api/gen, src/paraglide and src/routeTree.gen.ts are gitignored).
|
||||
cd '$SRC/web' && bun install --frozen-lockfile --ignore-scripts && bun run codegen && bun run build
|
||||
"
|
||||
[ -f "$SRC/web/.output/server/index.mjs" ] || die "web build did not produce web/.output/server/index.mjs"
|
||||
ok "web console built"
|
||||
|
||||
@@ -20,7 +20,28 @@ TARGET_DIR="$SRC/target-steamos"
|
||||
WEB=0; [ -f "$HOME/.config/systemd/user/punktfunk-web.service" ] && WEB=1
|
||||
|
||||
if [ "${1:-}" = "--pull" ]; then
|
||||
if [ -d "$SRC/.git" ]; then log "git pull"; git -C "$SRC" pull --ff-only; ok "pulled"; else die "$SRC is not a git checkout — rsync new source then run without --pull"; fi
|
||||
[ -d "$SRC/.git" ] || die "$SRC is not a git checkout — rsync new source then run without --pull"
|
||||
# web/bun.nix and sdk/bun.nix are GENERATED (bun2nix, a pure function of the matching bun.lock —
|
||||
# packaging/nix/README.md) yet COMMITTED, because the Nix build fetches node_modules only from
|
||||
# them. Until the --ignore-scripts fix below, web's `bun install` here ran its `postinstall`
|
||||
# (`bun2nix -o bun.nix`) and rewrote that tracked file on every single update. That is invisible
|
||||
# while the committed file is in sync — but main carried a STALE web/bun.nix from 1db8f763 to
|
||||
# b79d90b4, so any Deck updated in that window had the file rewritten to the *correct* content
|
||||
# and has been sitting dirty ever since. The next `git pull --ff-only` that touches it then dies
|
||||
# with "Your local changes to the following files would be overwritten by merge", and the update
|
||||
# stops before a single service is restarted.
|
||||
#
|
||||
# Restore ONLY these two derived paths. Not a blanket `git reset --hard`: $SRC is the operator's
|
||||
# own checkout (they may have patched a source file, or be carrying a cherry-pick), and silently
|
||||
# deleting that to save an update is a far worse trade than one legible error. Discarding these
|
||||
# two is provably lossless — regenerating them from the lockfiles is exactly what bun2nix does.
|
||||
git -C "$SRC" checkout -- web/bun.nix sdk/bun.nix 2>/dev/null || true
|
||||
log "git pull"
|
||||
git -C "$SRC" pull --ff-only \
|
||||
|| die "git pull --ff-only failed in $SRC. If it named locally-modified files, this checkout
|
||||
has local changes: review them with 'git -C $SRC status', then commit or stash them (or discard
|
||||
one with 'git -C $SRC checkout -- <file>') and re-run. Nothing was rebuilt or restarted."
|
||||
ok "pulled"
|
||||
fi
|
||||
|
||||
log "Rebuilding host (release)"
|
||||
@@ -29,7 +50,14 @@ distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH
|
||||
ok "host rebuilt"
|
||||
if [ "$WEB" = 1 ]; then
|
||||
log "Rebuilding web console"
|
||||
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.bun/bin:\$PATH; cd '$SRC/web' && bun install --frozen-lockfile && bun run build"
|
||||
# --ignore-scripts, then `bun run codegen` explicitly: web has TWO install lifecycle scripts and
|
||||
# we want exactly one of them. `prepare` (= codegen: orval + paraglide + the i18n check) is
|
||||
# REQUIRED — src/api/gen, src/paraglide and src/routeTree.gen.ts are gitignored, and `prebuild`
|
||||
# only re-runs orval, so dropping codegen leaves the build without its i18n messages. But
|
||||
# `postinstall` (`bun2nix -o bun.nix`) writes a COMMITTED file, and an updater must never dirty
|
||||
# the tree it just pulled into — that is what broke `--pull` above. The SDK install below has
|
||||
# always passed --ignore-scripts, which is why only web/bun.nix ever went dirty.
|
||||
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.bun/bin:\$PATH; cd '$SRC/web' && bun install --frozen-lockfile --ignore-scripts && bun run codegen && bun run build"
|
||||
ok "web rebuilt"
|
||||
fi
|
||||
|
||||
|
||||
+12
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/host",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -13,7 +13,13 @@
|
||||
"bugs": {
|
||||
"url": "https://git.unom.io/unom/punktfunk/issues"
|
||||
},
|
||||
"keywords": ["punktfunk", "game-streaming", "automation", "sdk", "effect"],
|
||||
"keywords": [
|
||||
"punktfunk",
|
||||
"game-streaming",
|
||||
"automation",
|
||||
"sdk",
|
||||
"effect"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
@@ -29,7 +35,10 @@
|
||||
"bin": {
|
||||
"punktfunk-scripting": "./dist/runner-cli.js"
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { configDir } from "./config.js";
|
||||
import { SDK_VERSION } from "./version.js";
|
||||
|
||||
/** The `@punktfunk` package registry (Gitea's npm registry for the `unom` org). */
|
||||
export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
|
||||
@@ -187,6 +188,109 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
|
||||
}
|
||||
};
|
||||
|
||||
/** The SDK version installed in a plugins tree, or undefined if it isn't installed at all. */
|
||||
export const installedSdkVersion = (
|
||||
dir = pluginsDirDefault(),
|
||||
): string | undefined => {
|
||||
try {
|
||||
const manifest = path.join(
|
||||
dir,
|
||||
"node_modules",
|
||||
"@punktfunk",
|
||||
"host",
|
||||
"package.json",
|
||||
);
|
||||
const v = (
|
||||
JSON.parse(fs.readFileSync(manifest, "utf8")) as { version?: string }
|
||||
).version;
|
||||
return typeof v === "string" ? v : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Bring the plugins tree's `@punktfunk/host` up to the version THIS runner was built from.
|
||||
*
|
||||
* **Why this exists.** The SDK is the seam every plugin registers through, but each plugin resolves
|
||||
* it from the plugins tree, and `bun.lock` pins it to an exact version with an integrity hash. No
|
||||
* user-facing flow re-resolves that pin: installing a plugin, reinstalling it, even updating it to a
|
||||
* newer release all leave the SDK where it is, because the plugin's `^0.1.x` range is already
|
||||
* satisfied. Measured on 2026-08-08 — publishing `@punktfunk/host@0.1.3` (the release that lets a
|
||||
* library scanner register `category`, so it stays out of the console nav) reached **no existing
|
||||
* install**, and the only thing that moved it was deleting the lockfile by hand over ssh. Shipping a
|
||||
* fix that needs an ssh session is not shipping a fix.
|
||||
*
|
||||
* The runner is the right owner: it is bundled from this same `sdk/` at the host's release commit
|
||||
* (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the punktfunk-scripting package), so
|
||||
* `SDK_VERSION` is by construction the SDK that matches the host now on disk. A host upgrade then
|
||||
* carries the SDK with it and nobody touches a runner.
|
||||
*
|
||||
* **Why the whole lockfile.** A targeted `bun add @punktfunk/host@<v>` at the root does NOT work
|
||||
* while plugins still declare the SDK in their own `dependencies` (they do, though none import it):
|
||||
* bun honours their locked resolution and gives each plugin a private nested copy, which then
|
||||
* SHADOWS the root — measured, 5 nested copies. A lockless resolve hoists one copy for everyone,
|
||||
* also measured. Once the plugins drop that spurious dependency this can become the targeted form.
|
||||
*
|
||||
* Safety: the plugins' own versions are pinned exactly in the root `package.json`, so a re-resolve
|
||||
* cannot move them; only shared transitive deps float within their declared ranges. The lockfile is
|
||||
* backed up first and restored if the install fails, and any failure is logged and swallowed — a
|
||||
* dependency refresh must never stop the plugins that are already working from loading.
|
||||
*/
|
||||
export const reconcileSharedSdk = (
|
||||
dir = pluginsDirDefault(),
|
||||
log: (line: string) => void = (l) => console.log(l),
|
||||
): void => {
|
||||
const have = installedSdkVersion(dir);
|
||||
// Nothing installed = no plugins yet; the first `bun add` resolves the current SDK on its own.
|
||||
if (have === undefined || have === SDK_VERSION) return;
|
||||
|
||||
const lock = path.join(dir, "bun.lock");
|
||||
const backup = `${lock}.pf-bak`;
|
||||
log(
|
||||
`[plugins] @punktfunk/host ${have} installed, this host ships ${SDK_VERSION} — refreshing`,
|
||||
);
|
||||
let restore = false;
|
||||
try {
|
||||
if (fs.existsSync(lock)) {
|
||||
fs.copyFileSync(lock, backup);
|
||||
fs.rmSync(lock);
|
||||
restore = true;
|
||||
}
|
||||
const res = Bun.spawnSync([process.execPath, "install", "--ignore-scripts"], {
|
||||
cwd: dir,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (!res.success) {
|
||||
throw new Error(`bun install exited ${res.exitCode ?? "?"}`);
|
||||
}
|
||||
const now = installedSdkVersion(dir);
|
||||
if (now !== SDK_VERSION) {
|
||||
// The install "succeeded" and still did not deliver the version — better to sit on the
|
||||
// known-good tree than to keep a half-resolved one.
|
||||
throw new Error(`still ${now ?? "absent"} after install`);
|
||||
}
|
||||
restore = false;
|
||||
if (fs.existsSync(backup)) fs.rmSync(backup);
|
||||
log(`[plugins] @punktfunk/host is now ${SDK_VERSION}`);
|
||||
} catch (e) {
|
||||
log(
|
||||
`[plugins] WARNING: could not refresh @punktfunk/host (${
|
||||
e instanceof Error ? e.message : e
|
||||
}) — plugins keep running against ${have}`,
|
||||
);
|
||||
if (restore && fs.existsSync(backup)) {
|
||||
try {
|
||||
fs.copyFileSync(backup, lock);
|
||||
fs.rmSync(backup);
|
||||
} catch {
|
||||
// The backup is still on disk under its own name; say so rather than pretend.
|
||||
log(`[plugins] the previous lockfile is at ${backup}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Install one or more plugins by friendly name or package. */
|
||||
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => {
|
||||
const pkgs = names.map((n) => resolvePackage(n, opts));
|
||||
|
||||
+13
-1
@@ -23,7 +23,12 @@
|
||||
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
|
||||
import { Effect, Fiber } from "effect";
|
||||
import { installLogShipper } from "./log-ship.js";
|
||||
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
|
||||
import {
|
||||
addPlugins,
|
||||
listInstalled,
|
||||
reconcileSharedSdk,
|
||||
removePlugins,
|
||||
} from "./plugins.js";
|
||||
import { discoverUnits, runner } from "./runner.js";
|
||||
|
||||
const arg = (flag: string): string | undefined => {
|
||||
@@ -166,6 +171,13 @@ const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
|
||||
// a plugin failing to load are the first ones out.
|
||||
const shipper = installLogShipper();
|
||||
|
||||
// Before any plugin loads: make the tree's shared SDK the one this runner was built from. A host
|
||||
// upgrade is the only moment that can deliver an SDK fix to already-installed plugins, and this is
|
||||
// that moment — see `reconcileSharedSdk`. Deliberately AFTER the log shipper so the operator can
|
||||
// read what it did from the console's Logs page, and BEFORE `runner()` so plugins import the
|
||||
// refreshed copy rather than the one they were started with.
|
||||
reconcileSharedSdk(options.pluginsDir);
|
||||
|
||||
const fiber = Effect.runFork(runner(options));
|
||||
let stopping = false;
|
||||
const shutdown = (signal: string) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* The version of this SDK, as a value the bundled runner can read about ITSELF.
|
||||
*
|
||||
* A constant rather than an import of `package.json`: `tsconfig.build.json` sets `rootDir: "src"`,
|
||||
* so reaching one directory up breaks the npm build, and the runner ships as a single bundled
|
||||
* `runner-cli.js` with no `package.json` beside it (`/usr/share/punktfunk-scripting/`), so there is
|
||||
* nothing to read at runtime either. Inlining it at build time is the only form that survives both.
|
||||
*
|
||||
* `version.test.ts` fails if this and `package.json` disagree, so the duplication cannot rot.
|
||||
*/
|
||||
export const SDK_VERSION = "0.1.4";
|
||||
@@ -0,0 +1,90 @@
|
||||
// `reconcileSharedSdk` runs on EVERY runner start, so its no-op path is the safety-critical one:
|
||||
// a false positive deletes a working lockfile and re-resolves the whole tree on a box that was
|
||||
// fine. These tests pin the decision, not the install (which needs a registry).
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { installedSdkVersion, reconcileSharedSdk } from "../src/plugins.js";
|
||||
import { SDK_VERSION } from "../src/version.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
/** A plugins tree whose installed `@punktfunk/host` is `version` (omit for "not installed"). */
|
||||
const tree = (version?: string): string => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-reconcile-"));
|
||||
dirs.push(dir);
|
||||
fs.writeFileSync(path.join(dir, "package.json"), '{"private":true}\n');
|
||||
fs.writeFileSync(path.join(dir, "bun.lock"), "ORIGINAL-LOCK\n");
|
||||
if (version !== undefined) {
|
||||
const host = path.join(dir, "node_modules", "@punktfunk", "host");
|
||||
fs.mkdirSync(host, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(host, "package.json"),
|
||||
JSON.stringify({ name: "@punktfunk/host", version }),
|
||||
);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("installedSdkVersion", () => {
|
||||
test("reads the installed version, and is undefined when absent", () => {
|
||||
expect(installedSdkVersion(tree("0.1.2"))).toBe("0.1.2");
|
||||
expect(installedSdkVersion(tree())).toBeUndefined();
|
||||
});
|
||||
|
||||
test("is undefined rather than throwing on a corrupt manifest", () => {
|
||||
const dir = tree("0.1.2");
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "node_modules", "@punktfunk", "host", "package.json"),
|
||||
"{ not json",
|
||||
);
|
||||
expect(installedSdkVersion(dir)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileSharedSdk", () => {
|
||||
// The common case, every start, on every healthy box: touch nothing.
|
||||
test("is a silent no-op when the installed SDK already matches", () => {
|
||||
const dir = tree(SDK_VERSION);
|
||||
const lines: string[] = [];
|
||||
reconcileSharedSdk(dir, (l) => lines.push(l));
|
||||
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
|
||||
"ORIGINAL-LOCK\n",
|
||||
);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
// A tree with no SDK has no plugins yet — the first `bun add` resolves the current one, so
|
||||
// there is nothing to refresh and nothing to log about.
|
||||
test("is a silent no-op when no SDK is installed at all", () => {
|
||||
const dir = tree();
|
||||
const lines: string[] = [];
|
||||
reconcileSharedSdk(dir, (l) => lines.push(l));
|
||||
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
|
||||
"ORIGINAL-LOCK\n",
|
||||
);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
// The failure path matters as much as the happy one: this runs unattended at boot, and the
|
||||
// tree it just took the lockfile away from is the one the operator's plugins load from. The
|
||||
// install cannot succeed here (the fake package.json resolves nothing), so this exercises the
|
||||
// real rollback.
|
||||
test("restores the lockfile and keeps going when the refresh fails", () => {
|
||||
const dir = tree("0.0.1-not-a-real-version");
|
||||
const lines: string[] = [];
|
||||
expect(() => reconcileSharedSdk(dir, (l) => lines.push(l))).not.toThrow();
|
||||
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
|
||||
"ORIGINAL-LOCK\n",
|
||||
);
|
||||
expect(lines.join("\n")).toContain("WARNING");
|
||||
// And it names both versions, so the log says what it was trying to do.
|
||||
expect(lines.join("\n")).toContain("0.0.1-not-a-real-version");
|
||||
expect(fs.existsSync(path.join(dir, "bun.lock.pf-bak"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// The one thing that keeps `SDK_VERSION` honest. The runner compares it against the SDK actually
|
||||
// installed in the plugins tree and reinstalls on a mismatch, so a stale constant would either
|
||||
// reinstall forever (constant behind) or never deliver a fix (constant ahead of a release).
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SDK_VERSION } from "../src/version.js";
|
||||
|
||||
describe("SDK_VERSION", () => {
|
||||
test("matches package.json — bump both or neither", () => {
|
||||
// Read rather than import: `tsconfig.build.json` pins `rootDir: "src"`, so a JSON import of
|
||||
// the manifest would not compile for the npm build even though bun would run it fine.
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
||||
) as { version: string };
|
||||
expect(SDK_VERSION).toBe(pkg.version);
|
||||
});
|
||||
|
||||
test("is a plain semver triple", () => {
|
||||
// The runner compares it to an installed version string, so anything with a range operator
|
||||
// (`^0.1.3`) would never compare equal and would reinstall on every start.
|
||||
expect(SDK_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user