Compare commits

...
Author SHA1 Message Date
enricobuehler d8b7a86366 ci(plugin-kit): repair the file: dependency bun 1.3 links to itself
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m23s
apple / swift (push) Successful in 5m39s
ci / bench (push) Successful in 5m53s
ci / rust-arm64 (push) Successful in 10m18s
ci / rust (push) Failing after 13m3s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
android / android (push) Successful in 16m17s
arch / build-publish (push) Successful in 16m23s
deb / build-publish (push) Successful in 9m14s
deb / build-publish-host (push) Successful in 10m6s
deb / build-publish-client-arm64 (push) Successful in 7m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m52s
apple / screenshots (push) Successful in 25m45s
docker / build-push-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 26s
The publish workflow has been failing at Typecheck with "cannot find module
'@punktfunk/host'" — every import of the SDK, in a job whose previous step built
that very SDK successfully.

bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking
each top-level FILE to itself: `node_modules/@punktfunk/host/package.json ->
package.json`, a dangling self-reference. So `dist/index.d.ts` arrives intact
while the manifest that points at it does not, and resolution dies at the first
step it takes. The types were never missing; nothing could find the front door.

Replacing that tree with a real copy is the whole fix, and the step no-ops
itself the moment bun links `file:` deps correctly again.

Not a regression from anything in the kit — it has been broken since bun
updated, and went unnoticed because nothing has been published since 0.1.4.
Verified from a clean node_modules: install → repair → typecheck → 20 tests →
build, all green.
2026-07-26 18:58:49 +02:00
enricobuehler f0e71e928a fix(gamestream): the resolved launch command is what the backend nests
plugin-kit-publish / publish (push) Failing after 34s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m2s
apple / swift (push) Successful in 5m39s
ci / bench (push) Successful in 6m18s
deb / build-publish (push) Successful in 8m56s
decky / build-publish (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
android / android (push) Successful in 12m2s
deb / build-publish-client-arm64 (push) Successful in 7m35s
ci / rust-arm64 (push) Successful in 17m58s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
windows-host / package (push) Successful in 19m41s
deb / build-publish-host (push) Successful in 18m45s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8m49s
arch / build-publish (push) Successful in 22m17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9m4s
ci / rust (push) Failing after 23m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12m7s
docker / deploy-docs (push) Successful in 28s
docker / build-push-arm64cross (push) Successful in 4m20s
apple / screenshots (push) Successful in 24m30s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m18s
A Windows/macOS build compiled the resolved-launch parameter out entirely and
warned about it, because the two uses left were both Linux-gated. Both platforms
want the same value: off Linux `set_launch_command` is a backend no-op and a
library title resolves to no command at all (the interactive-session spawner
launches it by id), so the cfg split was carrying an older distinction that no
longer exists.

Also covers the Windows half of `process_name` with a test — the pure name match
plus a live scan for this test process, since `find`'s early return would
otherwise skip the signal entirely and the test would pass vacuously.

Gates: .133 check + clippy --all-targets clean with no warnings, 5/5 procscan
(incl. live); .21 299 tests + fmt.
2026-07-26 18:28:13 +02:00
enricobuehler ee722c2160 docs: what happens when a game ends, and when a session does
The two behaviors are opt-in-shaped in different directions — one on by
default, one off — so the page says which is which, what `always` costs, and
that the keep-alive above it is a different clock governing a different thing.
The precedence rule (a display kept forever stays up regardless) is spelled out
rather than left to be discovered.

The dedicated-game-session blurb claimed game-exit-ends-the-session as its own
feature; that is now true everywhere, so it moves to where it belongs.

Automation gains the `game.running` / `game.exited` rows and a short section on
reacting to a game rather than a stream — the two are usually the same moment
but not always, and anyone who has been polling the host to find out when a game
finished can stop.

docs-site: build + tsc clean.
2026-07-26 18:28:13 +02:00
enricobuehler 6a2c127ce9 feat(mgmt/web): show the running game, and who decides when it ends
The host knows what it launched and, after a disconnect, that it is counting
down to closing it. None of that was visible: the console showed a stream with
no game, and a game on its way to being closed was something you found out about
afterwards.

The Dashboard gains a running-game card above the session card — box art matched
against the catalog it already fetches, so no new endpoint and no request on the
2 s status poll. "End now" means the two different things the row's state
implies: a live game ends by stopping its session (what then happens to the game
follows the policy — stopping a session is not licence to close a game), while
one already waiting out its reconnect window has no session left to stop and is
ended directly.

The settings card sits next to the display keep-alive policy because that is the
same question one step out: keep-alive decides how long a *display* outlives a
disconnect, this decides whether the *game* does. The copy says plainly what
`always` costs, that a drop is not someone pressing Stop, and that a display kept
forever is unaffected either way — the precedence rule that would otherwise
surprise someone. Where a build enforces nothing (macOS, no launch path) the
controls are shown disabled rather than hidden: "does nothing here" is
information.

Also fixes the story fixtures, which had gone stale against the `games[]` field
Phase 1 added, and adds a story for the state the card exists for — a game whose
client walked away.

web: build + tsc clean, biome-formatted; en/de messages complete.
2026-07-26 18:28:13 +02:00
enricobuehler 110eabf281 feat(library/providers): let a provider say how to recognize its games
A plugin's titles launch through the provider's own client, which hands off and
exits — so the host had nothing left to watch, and both lifetime behaviors went
quiet for exactly the entries a provider contributes. A `ProviderEntry` (and a
manual custom entry) may now carry an optional `detect` hint: install dir, exe,
or process name.

It is deliberately a subset of what the host tracks internally. A Steam appid or
a launcher's environment marker are things the host discovers for itself and
would be meaningless — or dangerous — to take on someone's word; where a title
is installed is something only the provider knows. The host's own findings win
where both exist, so a stale export can never redirect the matcher, and a blank
field is treated as absent rather than as "match everything" — an empty install
dir would otherwise prefix-match every process on the box, and this feature can
end processes.

`process_name` is the weakest of the three and the only one typed by hand, so it
is matched case-insensitively against the image's file name and nothing else:
`retroarch` finds RetroArch, not a helper whose name merely starts the same way,
and not a script that happens to live in a `retroarch/` directory. The
never-adopt-a-pre-existing-process rule still bounds it.

Also: the tray summary gains the running-game row (with the closing-in countdown
for a game whose client is gone — visible at the machine without opening the
console), the SDK mirrors the `game.*` events, and its generated client catches
up with the endpoints Phase 1 added.

Gates on .21: check + clippy --all-targets clean, 299 tests, fmt CI-parity,
openapi regenerated (GameEntry still carries no `detect` outbound); SDK tsc +
54 tests green.
2026-07-26 18:28:13 +02:00
enricobuehler 98121ccd53 feat(session/gamestream): the compat plane learns a game's lifetime too
Moonlight sessions had neither direction of the session⇄game binding: a game
exiting left the player looking at a desktop, and a session ending never touched
the game. The machinery for both landed with the native plane; this wires it up.

The compat plane had no way to say "this is over" — RTSP carries no close code —
so `/cancel`, the management stop and a game exiting all looked exactly like a
client vanishing. They now set a session quit flag, which is what lets the
virtual display skip its keep-alive linger for a real stop and what the
end-game-on-session-end policy reads to tell a decision from a network blip. A
drop still lingers, and still gets its reconnect window.

Moonlight can't resume a session, so a client coming back for a game it left
behind does it by launching the title again — that reclaims the waiting game
before anything starts, rather than letting the old window close on the copy the
player is now holding.

Both planes now resolve a launch through one lookup (`resolve_launch`), so a
GameStream title arrives with the same identity and the same detect signals a
native one does; `resolve_session_launch`/`launch_command` were the older,
identity-less half of that and are gone. A Moonlight game also has no
live-session entry to hang off, so the status surface gains a slot for it —
without it the Dashboard would show a stream with no game while one was running.

Gates on .21: check + clippy --all-targets clean, 296 tests (293 + 3), fmt
CI-parity.
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Fable 5 ed4e3d3ab6 feat(session): end-game/end-session lifetime on Windows
Phase 2. The lease, the settings, the events and the status surface were already
platform-neutral; what Windows lacked was a way to see processes and a way to ask
one to close.

`procscan` splits per-OS behind one contract. The Windows matcher is a Toolhelp
snapshot plus each process's full image path (`QueryFullProcessImageNameW`), with
creation times from `GetProcessTimes` enforcing the two rules the module exists
for: never adopt a process that predates the launch, never trust a bare pid. Both
matter more here than on Linux — the host is SYSTEM, so it can see and signal
everything, and Windows recycles pids briskly.

Path matching is case-insensitive and separator-aware, and normalizes the `\\?\`
prefix `canonicalize` adds, without which a store-derived path would never compare
equal to a live image path.

There is no reaper argv and no readable environment on Windows, so a spec carrying
only a Steam appid or an env marker matches nothing rather than falling through to
an empty predicate. Steam's appid instead feeds a **veto**: its per-app `Running`
flag in the user's hive can't be trusted to say a game IS running (Steam sets it
around updates too, and leaves it stale after a crash), but it is exactly right for
refusing to declare one gone. If the matcher can't see a game whose exe sits
outside its manifest's install dir, ending the session would be a false positive
the player feels immediately; honoring the veto only ever leaves a stream up.

Termination asks before it insists, which on Windows needs a detail that fails
silently if missed: `EnumWindows` only sees the calling thread's desktop, and the
host's is session 0, which holds none of the user's windows. So the terminating
thread binds to the input desktop first (the pattern `pf-inject`'s `sendinput.rs`
uses), posts `WM_CLOSE` to the game's visible top-level windows, waits, and only
then calls `TerminateProcess` on freshly re-verified pids. Without the desktop
bind the polite pass finds nothing and every game dies unsaved.

Job Objects, planned as WP2.3, are deferred with the reasoning in the design doc:
every Windows launch either hands off through a launcher or the shell — where a job
would wrap a shim that exits immediately — or is a direct exe whose path we already
know, and wiring one in would touch the `CreateProcessAsUserW` token path the UAC
and secure-desktop work depends on.

Also: the watcher's steady-state poll now re-verifies known pids and only re-scans
the whole table once they all vanish (which still catches a game that re-execs).
On Windows a full scan is an `OpenProcess` per process on the box, so this is the
difference between a negligible and a noticeable poll.

Gates: Linux .21 check/clippy/fmt/293 tests green; Windows .133 check + clippy
--all-targets clean, and the 4 procscan tests pass — including the live one that
finds the test process in the real process table and rejects a wrong creation time.
On-glass Windows (Steam/Epic/GOG/Xbox titles, the unsaved-progress check) still
owed; it needs a box with games on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Fable 5 10902f57d0 test(session/gamelease): prove it against real processes, not just fixtures
The fixture tests establish that the parsing is right; they cannot establish that
it describes this kernel. Two tests close that gap.

procscan now scans the real /proc for a process it just started, which is the
only version that would catch wrong `stat` field ordering, a broken uid check, or
a mis-read uptime clock. Writing it found something worth keeping: a wrapper
script that `exec`s a binary outside the install dir leaves no trace of that
directory in either the image path or the command line, so the install-dir recipe
cannot see it. A real game's binary lives under its install dir, so the fixture
now models that instead — and the note is in the test for whoever wonders.

The gamelease test drives a real child from Running through a host-requested end
to Exited, and asserts the session-ending action does NOT fire for it — the
difference between "the player quit" (end the session) and "we closed it" (do
not). It needs ~12s to outlive the shim window and the exit confirmation, so it
is #[ignore]d; run it with `-- --ignored gamelease`. Verified on .21.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Fable 5 a17aa61c5a fix(session/gamelease): four things the first pass got wrong
Found reviewing the riskiest paths of the previous commit.

- An install dir of `/games/x` matched a process running out of `/games/xyz`.
  The image-path check was already component-wise and fine; the command-line
  check compared raw bytes, so it needed the separator. Two library folders
  where one name prefixes the other is not a contrived case.
- Ending a nested game force-releases kept displays, which is not per-display —
  so with another client streaming it could retire a display that was never
  ours. Skip it while anyone else has a live session: the failure mode becomes a
  game that keeps running, which is the default everywhere else anyway.
- A lease nothing polls (no detect signals, or a platform without a matcher yet)
  sat at "launching" forever in the console. Report it as running: the host did
  just launch it, and with no watcher it is claiming nothing about the exit.
- A game ended after its session was already gone reported no `game.exited` at
  all — its watcher had stopped with the session, so nothing was left to emit
  it. That is every grace expiry and every `POST /game/end`, i.e. exactly the
  ones an operator most wants to see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Fable 5 64c0ff96bc feat(session): bind a session's life to its game's, in both directions (Linux)
Two of the most-asked-for behaviors, and until now the host could only manage a
sliver of one of them: end the streaming session when the launched game exits,
and — when the operator asks — end the game when the session ends.

Ending the session on game exit already worked, but only for a Steam title under
a bare-spawn gamescope. Everywhere else the host spawned a launch and forgot it:
the pid was logged and dropped, so on KWin, Mutter, wlroots or gamescope-attach a
finished game left the client staring at a desktop. Ending a game was not
possible at all — there was no primitive for it.

The missing piece was never plumbing, it was knowledge: a launch says what to
run, not what the game looks like once a launcher has handed off. So each store
now also reports how to recognize its game (DetectSpec, previous commit's
subject matter, folded in here), procscan turns that into live pids on Linux, and
gamelease turns pids into a lifetime — with four kinds covering how the game got
started:

  nested    gamescope owns it; its display teardown ends the game
  child     the host spawned it, in its own process group
  matched   a launcher owns it; recognized by its store's signals
  untracked nothing identifies it — both behaviors stay off, and the host
            says so once rather than guessing

A child that exits successfully within 5s was a launcher handing off, not the
game: the lease re-resolves to matched (or to untracked) instead of reporting an
exit. That single rule is what keeps steam://, epic:// and playnite:// launches
from ending a session the moment they start.

Ending a game is destructive, so three rules bound it. It is opt-in
(game_on_session_end defaults to keep). A drop is not a decision — `always`
waits out a reconnect window (5 min) that a returning client cancels by
reclaiming its own game, matched on fingerprint and title. And only ever this
session's game: a pid is adopted only if it started after the launch, so a copy
the player already had open is never touched, and every pid is re-verified
against its start time immediately before being signalled, so a recycled pid
never is. Termination asks first (SIGTERM to the group, 10s) and only then
insists.

Also here, because they are the same decision seen from other angles:

- A management stop is now a deliberate stop. `DELETE /session` sets quit before
  stop, so it behaves like a client pressing Stop — the display skips its
  keep-alive linger instead of lingering for a session nobody is coming back to.
- `/status` reports what is running (games[]), including a game whose session has
  gone and which is waiting out its window, so the console can show it and offer
  `POST /game/end`.
- New game.running / game.exited events, filterable like every other kind, which
  is the whole polling loop of the community plugin this replaces.
- session_status::register takes a named struct: eleven positional arguments,
  half of them same-typed Arc<Atomic…>, is a transposition waiting to happen.

Gates on Linux (.21): check, clippy --all-targets, fmt, and 291 tests green.
Windows (Job Objects, Toolhelp matching) and the GameStream plane are phases 2
and 3; both are inert here, as is macOS, which has no launch path at all.

Design + plan: punktfunk-planning/design/session-game-lifetime{,-implementation-plan}.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Opus 5 0d9d78398c fix(drivers/windows): name each virtual pad for what it is — one shared description read as "the setting did nothing"
deb / build-publish (push) Successful in 9m31s
windows-drivers / probe-and-proto (push) Successful in 42s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m9s
windows-drivers / driver-build (push) Successful in 2m1s
ci / bench (push) Successful in 7m35s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
deb / build-publish-host (push) Successful in 10m4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
android / android (push) Successful in 12m30s
ci / rust-arm64 (push) Successful in 13m55s
apple / swift (push) Successful in 5m28s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
deb / build-publish-client-arm64 (push) Successful in 11m7s
windows-host / package (push) Successful in 16m55s
arch / build-publish (push) Successful in 20m3s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8m46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9m7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10m54s
docker / deploy-docs (push) Successful in 34s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m4s
flatpak / build-publish (push) Failing after 8m23s
docker / build-push-arm64cross (push) Successful in 3m59s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m28s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
ci / rust (push) Successful in 32m19s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m53s
release / apple (push) Successful in 29m51s
apple / screenshots (push) Successful in 24m26s
`pf_dualsense.inx` gave all four hardware ids a single %DeviceDesc%, so Device
Manager labelled an emulated DualShock 4, DualSense Edge and Steam Deck pad
"punktfunk Virtual DualSense". The HID layer was always per-type — device_type
picks the PID (09CC for DS4), the report descriptor and the product string — but
the one place a user goes to check said DualSense for every choice, which reads
exactly like the controller-type setting being ignored.

Split into four model lines over the same install section, one description each.
No binding, service or descriptor change; stampinf's 9.9.MMdd.HHmm DriverVer
increments on every build, so pnputil takes the update. InfVerif on the WDK
runner: INF is VALID.

Also correct the Slot.pref comment from the previous commit: emulating a
DualShock 4 gives up adaptive triggers by construction. HidOutput::Trigger is
emitted only by dualsense_proto, and a DS4 has no trigger-effect reports — the
host never generates any to send. Rumble and the lightbar remain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:56:44 +02:00
enricobuehlerandClaude Opus 5 6edd700910 fix(client/gamepad): honor an explicit controller type — the per-pad arrival was re-declaring the physical pad
The "Controller type" setting reached the Hello and stopped there. Every pad then
opened with a GamepadArrival carrying its DETECTED kind, and the host builds each
virtual device from that arrival — the session default is only the fallback for a
pad that never declares one. So "emulate my DualSense as a DualShock 4" put a
DualSense on the host the instant the controller connected, and no amount of
reconnecting helped. Apple and the native clients both; Android already applied
the setting per pad.

The setting now rides the declaration too: explicit wins for every slot, Automatic
keeps per-pad detection so a mixed session stays honest. The physical kind still
drives the LOCAL feedback paths — a DualSense emulated as a DualShock 4 keeps its
lightbar and adaptive triggers, and a Deck keeps its rumble keep-alive.

Regressed in 97c67b26 / 76be4c3e (multi-controller support); ships in 0.19.x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:56:44 +02:00
enricobuehlerandClaude Fable 5 8362d57001 fix(vdisplay/windows): exclusive topology gets re-asserted — a verified isolate is not durable
ci / docs-site (push) Successful in 1m8s
ci / web (push) Successful in 1m40s
apple / swift (push) Successful in 5m9s
ci / bench (push) Successful in 6m36s
ci / rust-arm64 (push) Successful in 10m12s
deb / build-publish (push) Successful in 9m32s
decky / build-publish (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
android / android (push) Canceled after 13m40s
apple / screenshots (push) Canceled after 8m25s
arch / build-publish (push) Canceled after 13m44s
ci / rust (push) Canceled after 13m45s
deb / build-publish-host (push) Canceled after 12m5s
deb / build-publish-client-arm64 (push) Canceled after 7m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 2m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1s
windows-host / package (push) Canceled after 13m45s
The CCD isolate verifies "sole active desktop" at commit time and is never
checked again. On a hybrid Intel+NVIDIA box the internal panel is re-activated
moments later (the isolated topology is deliberately not saved to the CCD
database so teardown can restore the user's layout, and the post-isolate
resize/HDR churn — or the panel's own driver, or display-poller software —
re-resolves back to the stored layout). Proven on-glass: seconds after the
verify, CCD showed the panel ACTIVE beside the virtual display while the host
still believed it was exclusive — cursor and windows can land off-stream, and
the lock screen can land on the physical panel.

A group-scoped watchdog now re-queries every PUNKTFUNK_EXCLUSIVE_REASSERT_MS
(default 2 s, 0 disables) while an EXCLUSIVE isolate is live and re-issues the
isolate when a non-managed display crept back, logging who-is-fighting
escalation (3×WARN → 1×ERROR → DEBUG). Gated on a new GroupState.ccd_exclusive
flag so a Primary group's deliberately-lit panels are never "fixed". Cycles
take the state lock via try_lock with a sliced sleep, so teardown's stop+join
under the state lock cannot deadlock and is bounded by ~250 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:53:19 +02:00
enricobuehlerandClaude Fable 5 6c2c8b6eab feat(mgmt/web): surface the host.env encoder pin so a conflicting GPU choice isn't invisible
listGpus gains encoder_pin (PUNKTFUNK_ENCODER when it actually pins something —
unset/auto stay null), and the console's GPU card shows it: a muted note when
the pin is compatible, an amber warning naming the pinned vendor and the next
session's GPU when it contradicts the selection (the host overrides such a pin
at session open — without this field the selection just looked broken). Docs
updated to say the adapter wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:53:19 +02:00
enricobuehlerandClaude Fable 5 751d1de506 fix(encode/windows): a stale PUNKTFUNK_ENCODER pin no longer wedges a conflicting GPU selection
On a hybrid box, picking a GPU in the web console whose vendor contradicts a
host.env PUNKTFUNK_ENCODER pin produced an unrecoverable session: the pin won
backend selection while the adapter followed the console, the wrong-vendor
encoder failed deterministically at submit, the reset ladder burned its 5
in-place rebuilds on it, and the client reconnected into the identical wall
forever (~10 s per cycle, no visible reason).

Three legs:
- windows_resolved_backend() now reconciles: a hardware pin whose vendor
  contradicts the selected GPU is overridden by the adapter-derived backend
  (capture + encode share one adapter, so honoring the pin can only fail);
  open_video warns loudly when a pin loses. The reconciliation is a pure,
  unit-tested table (resolve_windows_backend).
- the QSV wrong-adapter bind is typed TerminalEncoderError, and the stream
  loop's reset ladder ends the session immediately on it instead of feeding a
  deterministic config error 5 futile rebuilds.
- the un-pinned path was already correct (verified on-glass: NVIDIA preference
  + no pin = stable AV1 10-bit HDR on NVENC), so the override simply takes the
  conflict case onto that path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:53:19 +02:00
enricobuehlerandClaude Opus 5 36e566052f fix(apple/cursor): a pointer whose bitmap has not landed must not vanish
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m5s
ci / bench (push) Successful in 7m30s
apple / swift (push) Successful in 6m42s
ci / rust-arm64 (push) Successful in 10m2s
decky / build-publish (push) Successful in 27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
deb / build-publish-host (push) Successful in 10m7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
android / android (push) Successful in 12m14s
deb / build-publish (push) Successful in 12m47s
windows-host / package (push) Successful in 16m3s
docker / build-push-arm64cross (push) Successful in 24s
docker / deploy-docs (push) Successful in 50s
arch / build-publish (push) Successful in 18m4s
deb / build-publish-client-arm64 (push) Successful in 10m45s
flatpak / build-publish (push) Successful in 7m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m25s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m17s
ci / rust (push) Successful in 28m53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
release / apple (push) Successful in 33m5s
apple / screenshots (push) Successful in 24m18s
`resetCursorRects` wore the host shape only when `hostCursors[st.serial]` hit,
and fell through to `invisibleCursor` on a miss. But a miss is the ROUTINE case,
not a degenerate one, and it is not a reason to hide the pointer:

  * State (0xD0) is a per-frame datagram and announces the new serial the moment
    the host QUEUES the bitmap on the reliable control stream, so on every single
    shape change the client knows a serial before it holds the pixels.
  * The shape ring drops the NEWEST under burst (CURSOR_SHAPE_QUEUE = 8) and the
    host never re-sends it — it only sends on a serial CHANGE — so a dropped
    serial stays un-backed until the pointer next changes shape. Crossing a
    toolbar flips arrow/I-beam/hand/resize several times a second, and each flip
    mints a fresh serial and a fresh bitmap, so bursts are ordinary.

Either way the pointer BLINKED OUT rather than lagging, and in the dropped case
it stayed gone for as long as the pointer held that shape — reported on glass as
"the I-beam never appears over text fields, every other cursor is fine".

Hold the last worn shape across the gap: only `st.visible == false` (the host
says the pointer is hidden) may hide it now, never a missing bitmap. Worst case
is a briefly stale pointer. This also makes two comments that already claimed
this behaviour true — the shape-rejected warning's "keeping the previous cursor"
and CURSOR_SHAPE_QUEUE's healing claim, both of which were fiction.

Host side is exonerated: on .221 the poller sees the I-beam handle (0x10005,
CURSOR_SHOWING set), and the monochrome AND-over-XOR conversion renders a correct
glyph — black beam, white outline — so the bitmap that goes on the wire is good.

swift build PunktfunkKit + cargo check punktfunk-core green; on-glass owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:13:08 +02:00
enricobuehlerandClaude Opus 5 6a9b3b0f28 fix(windows/cursor): a re-rendered pointer keeps its handle — re-probe the extent
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m9s
ci / bench (push) Successful in 7m23s
apple / swift (push) Successful in 6m15s
deb / build-publish (push) Successful in 9m17s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
deb / build-publish-host (push) Successful in 9m54s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
android / android (push) Successful in 12m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 14m7s
deb / build-publish-client-arm64 (push) Successful in 10m39s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6m38s
arch / build-publish (push) Successful in 19m9s
windows-host / package (push) Successful in 19m10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m11s
docker / deploy-docs (push) Successful in 31s
docker / build-push-arm64cross (push) Successful in 8m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m15s
ci / rust (push) Successful in 31m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m38s
apple / screenshots (push) Successful in 25m36s
The GDI shape poller rasterises only when the HCURSOR VALUE changes. But Windows
rebuilds the system cursors at a new size whenever the display scale under the
pointer changes, and it does that behind a handle that never moves — the shared
arrow is 0x10003 for the whole session. So the cache latched whatever size the
pointer happened to have when the poller started and never let go.

That window is not rare, it is the norm: a fresh virtual display is created at
Windows' RECOMMENDED scale and only picks up the client's saved
PerMonitorSettings override a beat later, while the poller starts within a
second of the monitor appearing. Sample inside it and the session forwarded —
and composited — a 96 px pointer over a 100 % desktop for its entire life, which
reads on the client as a pointer 3x too large while every other thing on the
streamed desktop is correctly sized. Scaling on the client cannot undo it: the
bitmap is proportional to the video, it is just proportional to the WRONG scale.

Re-read the bitmap's extent on a slow cadence whenever the handle is unchanged —
dimensions only, no pixel copy, 4 Hz — and drop the cache when it moved, so the
next tick re-rasterises and publishes a new serial. Observing the extent itself
rather than a DPI proxy also covers the accessibility pointer-size slider and any
other cause of a same-handle re-render.

Windows-side clippy -D warnings green via scripts/wincheck.sh; on-glass owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:22:53 +02:00
enricobuehlerandClaude Opus 5 1421e235f2 fix(host): hdr-p010-selftest silently ignored its size argument
apple / swift (push) Successful in 5m41s
ci / web (push) Successful in 1m24s
ci / docs-site (push) Successful in 2m15s
windows-host / package (push) Successful in 11m2s
arch / build-publish (push) Successful in 12m41s
ci / rust-arm64 (push) Successful in 9m56s
ci / bench (push) Successful in 7m52s
android / android (push) Successful in 17m58s
decky / build-publish (push) Successful in 28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish (push) Successful in 9m2s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 11s
ci / rust (push) Successful in 21m47s
deb / build-publish-host (push) Successful in 9m27s
deb / build-publish-client-arm64 (push) Successful in 8m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m31s
apple / screenshots (push) Successful in 29m0s
`args` starts AT the subcommand (main builds it with `env::args().skip(1)`), so a subcommand's own
optional arguments begin at index 1 — cf. `args.get(1)` in the `service` arm. This arm read
`skip(2)`, swallowing the first optional argument.

The documented invocation `hdr-p010-selftest 1920x1080 nvidia` therefore picked up the vendor (it is
in the second slot) and ran at the 64x64 DEFAULT. A size-only `hdr-p010-selftest 1920x1080` parsed
nothing whatsoever and still printed PASS. Nothing warned, because an unrecognised token is the only
thing that errors and no token was ever inspected.

The size is the entire point of the flag: the arm's own comment says heights like 1080 are not
16-aligned and exercise a different driver path. So the self-test has only ever validated the one
geometry that exercises the least, and the sweep's W7 gate — 1920x1080 and 5120x2880 on the RTX box
— could not actually run as written.

Found by running that gate: both sizes printed "HDR P010 self-test (64x64 ...)" with byte-identical
plane layouts (row_pitch=128, expected_total=12288 — those are 64x64's).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:12:20 +02:00
enricobuehlerandClaude Opus 5 28bbaa5250 tools: check and lint Windows-only Rust from a Linux dev box (scripts/wincheck.sh)
ci / web (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 9m50s
ci / bench (push) Successful in 7m15s
apple / swift (push) Successful in 5m41s
android / android (push) Successful in 12m23s
arch / build-publish (push) Successful in 17m26s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
windows-host / package (push) Successful in 18m55s
deb / build-publish (push) Successful in 9m9s
deb / build-publish-client-arm64 (push) Successful in 7m29s
ci / rust (push) Successful in 22m14s
deb / build-publish-host (push) Successful in 11m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m15s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 23s
apple / screenshots (push) Successful in 26m13s
Linux CI never compiles `#[cfg(target_os = "windows")]` code, so a Windows-side edit was
unverifiable until the Windows CI job ran or someone tested on a real box. The pf-capture sweep's
Phases 3–6 used an ad-hoc version of this harness in a scratch directory; committing it means the
next Windows change does not have to rediscover the recipe.

The obvious in-tree command fails, and not because of the Windows code:

    cargo check --target x86_64-pc-windows-msvc -p pf-capture

dies in build scripts that compile C for the target — audiopus_sys (cmake wants a VS generator),
then ring (needs an MSVC C compiler plus the Windows SDK headers). Both enter through
punktfunk-core's `quic` feature.

So the script generates a workspace whose members are Cargo.toml copies with `src` SYMLINKED at the
real crate directories — nothing to keep in sync, no copies to go stale — plus a ~30-line stub
punktfunk-core carrying only `Mode` and `quic::HdrMeta`, which is provably all the Windows capture
stack uses. rustls, ring and opus never enter the graph. Every path dep that is not a generated
member points at the real crate.

Covers pf-frame, pf-win-display and pf-capture with --all-targets, so the Windows `#[cfg(test)]`
modules are type-checked too. ~10 s cold, ~1 s warm, into target/wincheck (gitignored).

Verified non-vacuous: a deliberate type error planted in a windows-only file
(idd_push/stall.rs) is caught and fails the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:07:22 +02:00
enricobuehlerandClaude Opus 5 0d69ebf60b ci(windows): run pf-capture's tests instead of only type-checking them
Phase 0 of the sweep added `clippy -p pf-capture --all-targets` to this workflow, which type-checks
all 19 Windows `#[test]`s and executes none of them. They were decorative.

The workflow's own comment explains why pf-encode's tests cannot run here — nvenc link-imports
NvEncodeAPICreateInstance, which resolves only against the driver's import lib. That reasoning does
not extend to pf-capture: it has no encoder dependency at all (`cargo tree -p pf-capture` lists no
nvidia/ffmpeg/libvpl/pyrowave), so its test binary links against nothing the runner lacks.

Confirmed empirically rather than reasoned: `cargo test --release -p pf-capture` was run on a
Windows dev box against a workspace checkout — it linked, executed and passed, building in ~51 s off
an existing release target dir. --release keeps it in the one C:\t\release tree, so it does not
trip the C1069 disk exhaustion a second debug dep tree causes.

18 of the 19 run here (StallWatch + ring-generation masking, the cursor shape→wire truth table, and
`f32_to_f16`'s rounding-carry/saturation edges); all are pure — no Win32, no device, no desktop. The
19th, `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel adapter.

This is Windows-only code no other job can execute, so it was the cheapest coverage still on the
table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:07:22 +02:00
enricobuehlerandClaude Opus 5 1423b63333 fix(gamestream): the HDR SDR-downgrade latch gated the capture offer but not the negotiation (X6)
`pf_capture::hdr_capture_failed()` had exactly one consumer: `open_portal_monitor`, which uses it to
drop the HDR offer (`want_hdr && !hdr_capture_failed()`). The RTSP negotiation consulted only
`gnome_hdr_monitor_active()`. So once the latch was set — an HDR negotiation timed out, the monitor
left HDR mode between probe and negotiation, NVIDIA EGL not listing LINEAR for XR30, a pre-50 Mutter
— the host kept telling the client HDR while capturing and encoding SDR: `cfg.hdr` flowed to
`open_portal_monitor`, which then silently opened the SDR offer.

The client renders an SDR stream as PQ. Washed out, wrong gamut, and no error anywhere. The latch is
sticky until host restart, so every reconnect repeated it.

Consulted at RTSP honor time rather than folded into `host_hdr_capable()` for the same reason as the
colour-mode probe next to it: that fn is the STATIC serverinfo capability (it decides whether to
advertise SCM_HEVC_MAIN10 at all), and this is a live per-session fact.

The native plane needs no equivalent — `capturer_supports_hdr()` is hard-false on Linux, and the
latch is a fact about the portal capturer, which that plane never uses. Noted in handshake.rs so it
isn't re-derived.

Listed in the sweep's confirmed-defect register as cross-cutting/medium, then absent from every
phase of its implementation plan. Verification is on-glass (force the latch, reconnect a client that
requests HDR, confirm the Welcome/SDP reports SDR).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:07:04 +02:00
enricobuehlerandClaude Opus 5 59e33ad11d Merge origin/main into the pf-capture sweep
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m10s
ci / bench (push) Successful in 5m43s
windows-host / package (push) Successful in 10m48s
ci / rust-arm64 (push) Successful in 13m38s
decky / build-publish (push) Successful in 34s
deb / build-publish (push) Successful in 12m32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 40s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
android / android (push) Successful in 17m7s
deb / build-publish-host (push) Successful in 9m49s
arch / build-publish (push) Successful in 17m59s
ci / rust (push) Successful in 21m32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m51s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-client-arm64 (push) Successful in 9m55s
apple / swift (push) Successful in 6m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m24s
apple / screenshots (push) Successful in 24m29s
Upstream landed `fc335b39` (negotiate the cursor around what the encoder can blend) on the same
files this sweep restructured. Merged rather than rebased: the sweep MOVED ~2,000 lines out of
`linux/mod.rs` into `pipewire.rs`/`portal.rs`/`pw_*.rs`, so a rebase would have re-fought the same
conflict in up to nine commits; merging resolves it once with both sides in view. The repo already
carries merge commits (`land/sweep-all`).

Two conflicts, both resolved by keeping BOTH changes:

* `linux/mod.rs` — `PortalCapturer::open` gains upstream's `want_metadata_cursor` AND keeps the
  sweep's `PortalSession` teardown, so the portal threads now take `(setup_tx, quit_rx,
  want_metadata_cursor)`. The second conflict was upstream editing inside the region Phase 5.1/5.3
  moved out; the split wins and upstream's change is ported to where the code now lives:
  `choose_cursor_mode`'s new `want_metadata` ladder (4 arms — prefer Embedded when the encoder can't
  blend, and warn-then-take Metadata when Embedded isn't advertised) is now in `portal.rs`, taken
  verbatim.
* `gamestream/stream.rs` — the pooled-capturer slot keeps upstream's third reuse key
  (`metadata_cursor`, beside HDR-ness) AND the sweep's `result.is_ok() && capturer.is_alive()`
  re-pool gate. Both guard the same slot against different failures: theirs against reusing a
  session negotiated for the wrong cursor mode, the sweep's (L2) against reusing a dead one.

Everything else auto-merged, including the `want_metadata_cursor` thread through
`host/capture.rs`'s facade and `native/stream.rs`'s `session_plan::cursor_blend_for`.

Verified after the merge: workspace `cargo test` green, `clippy --workspace --all-targets` clean on
Linux AND on x86_64-pc-windows-msvc, `cargo fmt --check --all` clean, pf-capture 38/38.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:44:22 +02:00
enricobuehlerandClaude Opus 5 14914bc72f test(pf-capture): the suite the splits enable — 38 Linux + 19 Windows tests (Phase 6)
The plan's priority order was "every producer- or OS-controlled parser, blend, geometry guard or
negotiation decision", none of which had a test before this sweep (X3). Phase 5's splits are what
made most of them reachable without a compositor or a driver.

**6.1 `bitmap_extent`** — extracted from `update_cursor_meta`, which is the guard whose own SAFETY
proof says a missing bound "SIGSEGVs inside the PipeWire `.process` callback (a segfault
`catch_unwind` cannot catch)". Every input except the region size is producer-written. 5 tests:
a bitmap that fits (with and without header/pixel offsets); the last-row rule (`stride·(bh−1) + row`,
so a bitmap ending flush against the region is accepted rather than rejected by padding that is
never read — and one byte short is rejected); anything past the region; degenerate geometry; and four
distinct overflow vectors including the near-`i32::MAX` stride the SAFETY comment calls out. One
assertion I first wrote was wrong, not the code — with a single row `stride·0 == 0`, so even a
`usize::MAX`-wide row is arithmetically in range; the test now exercises the ≥2-row case that really
overflows and says why the other is fine (the caller has already capped `bw` at 1024).

**6.2 the composite blits** — 8 tests over `composite_cursor` / `composite_cursor_rgb10`: every
packed `PixelFormat` arm lands the colour in its OWN channels (so a byte-order slip cannot pass);
clipping off all four edges plus six fully-outside positions; zero-alpha, `visible: false` and
no-bitmap-yet all drawing nothing; the integer alpha blend; NV12/Yuv444 declined rather than
mis-blitted; and for the 10-bit path a bit-exact round trip of an untouched pixel (including the two
alpha bits the repack must preserve), the R-at-bit-20 vs R-at-bit-0 distinction between `X2Rgb10` and
`X2Bgr10`, and the same clipping. Plus `decode_bitmap_pixel`'s four byte orders.

**6.4 the pod builders** — 4 tests. The `dataType` bitmask is pinned per Buffers pod, because each
bit is load-bearing in a different direction: the mappable pod MUST include DmaBuf (or gamescope's
modifier-bearing format pod wins and the buffer intersection is empty — a link silently stuck in
"negotiating"), the SHM-only pod MUST exclude it (or Mutter hands dmabufs and the race-free download
path is not), and the dmabuf pod MUST exclude the mappable types (or an HDR session can be handed a
MemFd buffer, which Mutter paints 8-bit ARGB32 regardless of the negotiated 10-bit format). Also:
every pod parses back through `Pod::from_bytes`; the HDR pods carry MANDATORY PQ + BT.2020 +
LINEAR-modifier; and only the NV12 offer pins the colour matrix/range. The `dataType` reader parses
the SPA property layout literally (`key, flags, size, type, value`) — a "find the first
plausible-looking int" heuristic read the `size` word, which is also 4, and reported the wrong mask.

**6.5 `FrameToken` generation masking (W14)** — 2 tests, with `IDD_GENERATION` parked two below the
24-bit boundary so the WRAP is what gets exercised: every minted generation is non-zero, fits the
token's field, and survives the pack/unpack round trip `try_consume` performs; and the cleared-
`latest` 0 sentinel never matches a live generation.

**6.6 the cursor conversion (Windows)** — `convert`'s pixel logic extracted from its GDI plumbing
into `mono_planes_to_rgba` / `apply_and_mask_alpha` / `alpha_is_empty`, which is what makes it
testable at all (the caller needs a live `HCURSOR` and a screen DC). 6 tests: the four-state AND/XOR
truth table in one row; transparency surviving without an invert neighbour; the invert outline
covering all eight neighbours, overwriting only TRANSPARENT ones, and clipping at the edges; the
alpha-less colour cursor taking alpha from the AND mask; and a short mask not panicking.

**6.3 / 6.7** landed with the fixes they guard (`negotiation_plan` in 2.3, `f32_to_f16` in 3.5).

38 Linux tests, up from 6 at the start of the sweep — ci.yml already runs them. 19 `#[test]`s on the
Windows side; Phase 0.1's `--all-targets` lint type-checks them all, but note it does not RUN them
(the workflow lints only), so the Windows ones execute on a Windows box. clippy --all-targets clean
on both targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:31:34 +02:00
enricobuehlerandClaude Opus 5 306f4a514d refactor(pf-capture): structural splits + collapse the restated signal set (Phase 5)
Refactors LAST, after every defect fix, so no behaviour change hides in a move diff — and so the
newly-exposed review surface was already clean when it landed (the WP7 discipline). 28f8fc71's
recorded trap (an inline `use super::X` inside a moved body silently changing meaning) was audited
first: every `use super::` in the moved code is either at module level — where `super` still means
`linux` — or inside a `mod tests` that moved with its parent.

5.1 `mod pipewire` → `linux/pipewire.rs`. It was 1,900 of that file's lines. `mod.rs` is a directory
module, so a plain `mod pipewire;` resolves with no `#[path]`.

5.2 out of that, `linux/pw_pods.rs` (the 7 param-pod builders + `serialize_pod` + the PQ constant
and its test) and `linux/pw_cursor.rs` (`CursorState`, `decode_bitmap_pixel`, `update_cursor_meta`,
`dst_offsets`, both `composite_cursor*`). The pods are the crate's WIRE surface — a missing property
is not a compile error but a link that stalls in `negotiating`; the cursor half is producer-driven
and bounds-critical. Both are now pure enough to unit-test without a compositor, which is what
Phase 6 needs.

5.3 `linux/portal.rs`: the ScreenCast/RemoteDesktop handshake, the cursor-mode choice and GNOME's
BT.2100 probe — the async/tokio/zbus control plane, separated from the realtime half. Zero
re-exports changed: `mod.rs` re-exports `gnome_hdr_monitor_active` at its old path.

5.4 `idd_push/open.rs` (the whole one-time construction path + `SharedObjectSa` + `AttachTexFail`)
and `idd_push/compose_kick.rs`. Read `open.rs` when a session will not START and the parent when one
stops flowing; the steady state stays with the parent by decision. Also moved the ~65-line stall
REPORTING block out of `try_consume` (the hot loop) into `StallWatch::report`, taking its two
correlation counters with it — they were capturer fields nothing else touched.

5.5 `windows/dxgi/selftest.rs`: `p010_reference`, both self-tests, `hdr_p010_convert_bars_on_luid`,
`f32_to_f16` and the two test modules — the validation path, none of which runs in a session. The
two `pub` entry points are re-exported, so `main.rs`'s subcommand and pf-encode's live e2e keep
their paths. (An explicit `#[path]`, like `idd_push`'s children: this file is itself reached through
a `#[path]`, so a bare `mod` would resolve to `windows/selftest.rs`.)

5.6 `CaptureSignals`. The seven shared flags/slots were created in `spawn_pipewire`, `_cb`-cloned one
by one, passed as seven of `pipewire_thread`'s parameters, and then re-declared as fields on
`PwHandles`, `PortalCapturer` AND `UserData` — the same list written four times, each with its own
drifting copy of the doc comment. One `#[derive(Clone)]` struct instead (a refcount bump per field),
which also makes it structurally obvious that producer and consumer share ONE set. And `CaptureOpts`
for the four trailing `bool`s: four adjacent same-typed positional arguments are a
silent-transposition footgun — swap `want_444` and `want_hdr` and it compiles, negotiates the wrong
pod family, and surfaces as a black screen ten seconds later.

5.7 trait shape, targeted: `set_active(&self)` → `&mut self` (it took `&self` only because the flag
happened to be an `Arc<AtomicBool>` — an implementation detail leaking into the contract);
`capture_target_id` ⇒ `resize_output`'s pairing rule stated on both methods and in a cluster note;
one-line cluster headers over the 13 methods.

NOT done from 5.7: taking the gamescope targets as an `open_*` option instead of the trait method.
It would put a Linux-only parameter on the cross-platform `open_virtual_output` and on the host's
`capture_virtual_output` facade — a `#[cfg]`-shaped wart in two shared signatures to delete one
already-`cfg`'d defaulted method whose lifecycle rule 2.5c had already made harmless. Wrong trade
under 5.7's own "no churn for no defect fixed" principle.

File sizes: `linux/mod.rs` 2,778 → 770 (target ≤900 ✓), `windows/dxgi.rs` 1,374 → 954, and
`windows/idd_push.rs` 2,694 → 1,922. The last two miss the plan's ≤850/≤1,300 targets, which were
computed against the ORIGINAL line counts — Phases 1–4 added several hundred lines of SAFETY proofs
and defect rationale to exactly these files before the split. The moves themselves are the ones the
plan specifies; closing the rest would mean inventing new splits it does not call for.

Zero behaviour delta. pf-capture 20/20; workspace clippy --all-targets clean on Linux and on
windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:25:39 +02:00
enricobuehlerandClaude Opus 5 530909a154 perf(pf-capture): take three per-frame costs off the Windows HDR hot path (Phase 4)
**4.1 (W10) — the HDR convert allocated two views and mapped a constant buffer per frame, inside
the ring slot's keyed-mutex hold.** So the driver's publisher sat blocked on that slot while the
host created `CreateRenderTargetView`s and Mapped/Unmapped a 16-byte buffer whose contents never
change. Both are lifetime-of-mode facts, not per-frame ones:
  - the two P010 plane RTVs now belong to the out-ring SLOT, built once in `ensure_out_ring`
    (`out_ring` becomes `Vec<OutSlot>`). A driver that rejects planar RTVs — the one hard
    requirement of this path — therefore fails at ring build, where such a failure belongs, instead
    of on the first frame.
  - `inv_src` becomes an IMMUTABLE constant buffer filled in `HdrP010Converter::new(device, w, h)`.
    The converter is already dropped by `recreate_ring` on every mode change, so it cannot go
    stale. This also deletes the `Map`'s silently-ignored failure: it could leave the chroma pass
    sampling at a garbage texel size with no error anywhere.

**4.2 (W15) — `sdr_white_level_scale` ran inside the keyed-mutex hold too.** It is a CCD query that
contends the display-config lock — precisely the contention `DescriptorPoller`'s doc says must stay
off the frame path — and `prepare_blend_scratch` called it while holding the slot. Moved to
`refresh_sdr_white_scale`, called at open and after each ring recreate (where the scratch is
invalidated anyway). "Only on scratch rebuilds" is not the same as harmless when the thing being
blocked is the producer.

**4.3 (W11) — `VideoProcessorSetStreamAutoProcessingMode(vp, 0, false)`.** The comment claimed "no
interpolation/auto-processing" while only setting the frame format; auto-processing's documented
default is ENABLED, so vendor denoise/edge-enhancement was free to run inside every SDR
`VideoProcessorBlt` — altering the pixels we encode and spending video-engine time on the
desktop-capture hot path. Now actually disabled, with each call's purpose stated separately.

**4.4 (the Linux CPU-path map cache) is deliberately NOT done.** The plan gates it on measurement
("measure first, and only if the CPU path still matters") and this box cannot measure a live capture
session; it also needs a frame-buffer return path through `FramePayload::Cpu`, which is a larger
change than the rest of this phase. Left for Phase 7's measured pass.

Compile-verified for windows-msvc locally; pf-capture 20/20 on Linux; workspace clippy
--all-targets clean on both targets. The GPU-capture check and the measured `cap_us` delta are owed
to Phase 7.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:11:46 +02:00
enricobuehlerandClaude Opus 5 bc45287dc6 fix(pf-capture): Windows IDD-push defects — HDR pin, cursor, recreate, handles, f16 (Phase 3)
Compile-verified for x86_64-pc-windows-msvc locally (a scratch workspace symlinking the real
sources against a stub punktfunk-core — the `quic` feature's ring/opus C builds are what blocks an
in-tree cross-check). Behaviour is owed the on-glass validation in sweep Phase 7.3.

**3.1 (W1/F3) — the HDR pin asserted a flip it never verified.** `poll_display_hdr` discarded
`set_advanced_color`'s `bool` and then wrote `now.hdr = self.client_10bit` — the DESIRED state in
place of the observed one. On a display that cannot be flipped (the state this file already logs as
"Downgrade point D" at open) that broke in both directions: wanting HDR, the fabricated `true`
differed from `current`, so two poller samples drove `recreate_ring(true, …)` and rebuilt the ring
FP16 while the driver composed 8-bit BGRA — every publish dropped by the driver's format guard,
`recovering_since` expiring, `try_consume` bailing: a permanent 3-second reconnect loop. Wanting
SDR, the fabricated `false` MATCHED `current`, so no recreate ever fired and the ring stayed BGRA
against an FP16 composition — the same dropped-publish outcome, silently. Now it re-reads
`advanced_color_enabled` and follows what the display actually composes, with a one-shot error
naming want/observed/returned. A not-yet-settled read costs one debounce cycle, never a wrong ring,
which is why this does not block the frame path on a settle poll the way `open_on` does. Downgrade
point D's error now carries the same pair, so `Some(false)` (display says no) and `None` (the CCD
read failed) are distinguishable.

**3.2 (W2, W3, W6) — cursor correctness.**
  - W2: the poller's desktop rect was captured once at open and used forever, for BOTH the
    desktop→frame offset and the `in_rect` visibility test — while both mid-session mode-change
    paths (`resize_output`, `poll_display_hdr` → `recreate_ring`) keep the same poller. After an
    in-place resize the pointer was clipped to the old rect and offset by a stale origin. It is now
    a SEED: the poll thread re-queries on its existing 250 ms reattach cadence, keeping the last
    good value on `None` (a transient CCD failure must not park the rect at zero and report every
    position invisible), which keeps the CCD call off the encode thread as `DescriptorPoller`
    demands.
  - W3: `composite_forced` tested `cursor_sender.is_none()`, but §8.6's rationale is "no cursor
    CHANNEL" — and the delivery just above it is explicitly allowed to fail non-fatally, which is
    precisely the state needing the rescue. It was the one state that skipped it: a negotiated
    channel that failed to create or deliver left a cursor-excluded target with NO pointer at all.
    Now `cursor_shared.is_none()`, evaluated after that binding.
  - W6: `cursor()` degraded poller→shm correctly, but the BLEND path — the only consumer that
    matters in the composite model, since the Windows encode loop never attaches `frame.cursor` —
    read the poller directly with no `alive()` check and no fallback, so the documented fallback and
    the spawn-failure warning were both untrue for exactly those sessions (a dead poller meant
    pointer-less frames, not a degraded pointer). One `live_cursor()` now serves all three
    consumers and LATCHES the source, because the two keep independent serial namespaces and
    interleaving them poisons the client's shape cache.

**3.3 (W4, W5, W14) — recreate hardening.**
  - W4: `recreate_ring` committed `display_hdr`/`width`/`height` BEFORE the fallible
    `create_ring_slots` (VRAM pressure at a large new mode — exactly when resizes happen), leaving
    a failed recreate emitting frames stamped with the new geometry against the old ring, the old
    generation and an unchanged header. Slots are built first; nothing after the commit point fails.
  - W5: a recreate never cleared the driver's status words, and `wait_for_attach` — the only
    classifier of TEX_FAIL/BIND_FAIL and the only source of the LUID rebind — runs at open ONLY. A
    stale `OPENED` therefore made a failed re-attach look healthy while the recover-or-drop bail
    reported nothing. Now cleared before the Release generation store (plus `status_logged`), and
    the 3 s bail prints the live `(driver_status, detail, render_luid)` the way `next_frame`'s 20 s
    bail already did. The four-field read is one `driver_diag()` helper instead of four copies of
    the same unsafe block.
  - W14: `IDD_GENERATION` is a full `u32` but the publish token carries 24 bits and `unpack` masks
    what it reads, so past 2²⁴ recreates `tok.generation != self.generation` would be permanently
    true — every frame rejected. Masked at the single mint point, and 0 skipped (it is also the
    cleared-`latest` sentinel).

**3.4 (W8, W9, W12) — handle hygiene.** `shared_object_sa`'s security descriptor is a `LocalAlloc`
nobody freed: leaked twice per open and once per ring recreate. It is now an RAII `SharedObjectSa`
whose `Drop` `LocalFree`s it and whose `as_ptr()` only lends a borrow — which also makes the
"descriptor must outlive the attributes" rule structural instead of a comment. The PyroWave fence's
shared NT handle, created per capturer and never closed, becomes an `OwnedHandle` (the encoder holds
its own duplicate, so closing ours is safe). `cursor_blend`'s `cbuf_scale` is cached only on a
successful `Map` — caching unconditionally wedged the HDR/SDR cursor scale for the session after one
transient failure.

**3.5 (W7) — `f32_to_f16` swallowed the rounding carry.** `sign | half_exp | (half_mant + round)`
ORs a mantissa carry into bit 10, so for every ODD biased exponent (bit 10 already set) the carry
vanished and the result came back ~2× low: `1.9998779 → 1.0`, `0.49996948 → 0.25`. Only values one
ULP below a power of two are affected — precisely what a gradient test pattern is full of — so this
made `hdr-p010-selftest` FAIL a correct shader. Composed additively, with 4 tests (18 asserted bit
patterns, a round-trip property over the self-test's scRGB values, saturation) — all verified
numerically against a standalone reference on this box, including a scan confirming old-vs-new
diverges ONLY on the carry cases. Phase 0.1's `--all-targets` lint is what lets these compile in CI
at all.

pf-capture 20/20 on Linux; workspace clippy --all-targets clean on Linux and windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:06:26 +02:00
enricobuehlerandClaude Opus 5 9a7b3a46d0 fix(pf-capture): gamescope cursor — auth without setenv, frame-space coords, live targets (2.5)
**2.5a (L4) — stop `set_var`ing `XAUTHORITY` from a live multithreaded host.** The old connect
swapped the process-global var around each `RustConnection::connect` under a mutex. That lock
serialises this source against itself and nothing else: `getenv` takes no lock, so every OTHER
thread's read raced it — and the concurrency is by construction, since `attach_gamescope_cursor`
runs while the PipeWire thread is starting up (libspa plugin load, EGL/CUDA init). The primary path
now parses the MIT-MAGIC-COOKIE-1 entry out of the given file itself and hands it to
`DefaultStream::connect` + `RustConnection::connect_to_stream_with_auth_info` — the same two steps
`RustConnection::connect` performs internally, minus its env-derived auth lookup — so nothing in
this process touches the environment. The env swap survives only as a fallback for a file we cannot
parse, and a wrong cookie pick cannot do damage: the server rejects it and the fallback (which does
libxcb's full family/address match) takes over. Sharing `pf_vdisplay`'s process-wide env lock was
not the fix — wrong layer, and it would still not fix `getenv`.

**2.5b (L6) — publish the pointer in FRAME coordinates.** `QueryPointer` answers in the nested
root's space, but `CursorOverlay::x/y`'s contract is frame pixels, and gamescope's `-w/-h` (nested
root) and `-W/-H` (output + PipeWire node) are independent knobs — at `-W 1280 -H 720 -w 640 -h 360`
they differ by 2×, so the pointer drew at a fraction of its real position. The root size comes free
off the setup reply we already parse; the negotiated frame size arrives from the PipeWire thread's
`param_changed` through a new `Arc<AtomicU64>` (`0` = not negotiated ⇒ pass through, as before).
Scaling is computed in `i64` — a 5K coordinate times a 5K width overflows `i32`. Position only: the
bitmap stays at root scale, warned once so a mismatched session is visible.

**2.5c (L7) — the targets are a PROVIDER, not a snapshot.** The list was discovered once, before
the game launched. gamescope creates the game's Xwayland at launch and advertises only the FIRST in
any child's environ (verified on this box: `--xwayland-count 2` makes `:2` and `:3`, only `:2` is
advertised), so the game's display was invisible — and when the connected Big Picture display then
reported "gamescope is not drawing the pointer here", the source blanked the cursor for the whole
game session, which is the exact regression the module doc says it fixed. The worker now re-runs the
provider every 2 s: it adopts new Xwaylands, reconnects dead ones, and `spawn` no longer returns
`None` on an empty list — a stream that starts before the game converges instead of staying
cursorless. The provider is a host-facade closure, same one-way-edge shape as `FrameChannelSender`.

**2.5d (L8) — bound the teardown join.** `Drop` joined the worker unbounded while the worker blocks
in `RustConnection` replies with NO read timeout, so a peer that stops answering but keeps its
socket open hung capturer teardown — on the session path. Now: a completion channel,
`recv_timeout(250 ms)`, then detach with a warning (the thread only touches its own X connections
and an `Arc`'d slot).

7 new tests, all host-runnable: the cookie reader against a hostile `.Xauthority` (wrong protocol,
wrong display, wildcard entry, truncation mid-length-prefix, an over-long length prefix, a missing
file), display-string parsing, and the root→frame mapping incl. the 5K overflow. pf-capture 20/20;
workspace clippy --all-targets clean on Linux and windows-msvc. Phase 7.2 owes the on-glass
nested-gamescope validation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:56:05 +02:00
enricobuehlerandClaude Fable 5 3f4ad08869 test(encode/ffmpeg_win): extract the decision logic and pin it with unit tests
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish (push) Successful in 9m9s
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 24s
deb / build-publish-host (push) Successful in 10m47s
android / android (push) Successful in 14m53s
deb / build-publish-client-arm64 (push) Successful in 10m38s
windows-host / package (push) Successful in 17m40s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m50s
arch / build-publish (push) Successful in 11m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m11s
apple / swift (push) Successful in 6m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m15s
ci / bench (push) Successful in 5m47s
ci / rust-arm64 (push) Successful in 9m11s
ci / rust (push) Successful in 21m50s
apple / screenshots (push) Successful in 25m14s
The QSV open-failure fallback (1,400 lines, 23 unsafe, 0 tests) follows the
vaapi.rs treatment: the device-free decisions now live in named functions
with their contracts pinned — the per-vendor zero-copy default matrix (AMF
on-glass-validated on, QSV opt-in), the PUNKTFUNK_FFWIN_POLL_MS
clamp-before-µs-conversion (the 27.7-hour-spin class), the readback routing
table with its mid-stream depth-change guard, the swscale source map, the
QSV display-remoting latency contract (async_depth=1/low_power=1/
look_ahead=0/forced_idr=1/scenario) and the AMF no-B-frames contract, and
the per-vendor zero-copy pool bind flags. A probe smoke rides along
#[ignore]d for the runner. No FFI plumbing chased; no behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:50:33 +02:00
enricobuehlerandClaude Fable 5 fc335b39e9 fix(host/encode): negotiate the cursor around what the encoder can blend
EncoderCaps::blends_cursor's contract said the HOST must fall back to
capturer-side compositing when a cursor-as-metadata session lands on an
encoder that can't composite — but that host half was never built: open_video
warned and the session streamed WITHOUT a pointer (confirmed on the VAAPI
dmabuf and libav-NVENC CUDA paths; latent on vulkan RGB-direct/native-NV12).
The negotiation is now caps-aware, ahead of capture, on both planes:

* pf-encode grows cursor_blend_capable() — the pre-open dispatch mirror
  (sibling of linux_native_nv12_ok) answering whether the resolved backend
  composites frame.cursor; its pure core is test-pinned arm by arm.
* Native plane: handshake::cursor_forward grants the cursor channel only
  where the resolved backend can blend (the capture-mouse flip makes the host
  draw the pointer on demand); denied sessions keep the pre-channel path —
  the compositor EMBEDS the pointer, never cursorless, never doubled. The
  Welcome's HOST_CAP_CURSOR bit is computed once and read back at both
  session-wiring sites instead of recomputed. SessionPlan::output_format
  additionally keeps every cursor-blend session off producer-native NV12 (the
  arm with no CSC to fold a cursor into), and vulkan RGB-direct now yields to
  a cursor-blend session even when pinned (EFC cannot composite; the open
  logs the override). Windows plans cursor_blend=false via the new shared
  cursor_blend_for() rule — the IDD capturer composites the pointer itself,
  and asking the encoder anyway fired the blends-cursor warn spuriously on
  every cursor-channel session.
* GameStream plane: the hardcoded cursor_blend=true is gone. The portal
  source asks for cursor-as-metadata only when the resolved backend blends,
  otherwise negotiates an Embedded pointer (choose_cursor_mode's new ladder);
  the capturer pool now also keys on that mode. The virtual-output source
  passes false — its capture embeds the pointer where it can.

The per-arm warns in vulkan_video (RGB-direct, native-NV12) are now
structurally unreachable and removed. open_video's post-open check stays as
the single backstop for what planning cannot see: a Vulkan-open falling back
to VAAPI mid-session, and the gamescope residual (no embedded mode exists
there, so a never-blending backend — H.264-on-AMD VAAPI, software — still
streams cursorless; fixing that needs a compositing stage, deliberately not
built in this pass). Zero-copy is preserved throughout — every fallback is a
capture-negotiation change, never a readback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:50:33 +02:00
enricobuehlerandClaude Opus 5 759fdf767c fix(pf-capture): buffer-geometry guards + a genuine drop-oldest hand-off (sweep 2.4, 2.6)
**2.4 — buffer geometry correctness on the Linux CPU path.**

L5: the self-mmap de-pad mapped the fd from offset 0 and then indexed by `chunk.offset()` ALONE,
ignoring `spa_data.mapoffset` — where that spa_data's region actually begins inside the fd. For a
pooled-memfd producer (every buffer a slice of one fd) it therefore read the WRONG buffer, and the
`needed > avail` guard could not catch it: `avail` came from the whole-fd mapping, so it was ample
for any single buffer's span. PipeWire's own MAP_BUFFERS slice (the fallback) was always correct
because it already starts at `mapoffset` — only the hand-rolled "fix" path was wrong, so the two
paths now compute their base separately (checked add; both halves are producer-controlled).

L15: when `fstat` failed, the mmap length was invented as `offset + needed` — producer-controlled
geometry. That defeats the entire point of the fstat (the "buffer smaller than the frame span"
guard then compares against the number the producer just supplied) and can map, and read, past the
end of the object — a SIGBUS, not an `Err`. Without a real length we now decline to self-map and
let PipeWire's own slice serve, which is bounded by construction.

L12: `bytes_per_pixel()`'s catch-all answers 4 for NV12, so a producer-native NV12 buffer computed
`row = 4w` against a stride of ~w and ALWAYS tripped the stride guard — reporting "chunk stride <
row", i.e. blaming the producer for a host limitation. NV12 cannot be de-padded on this path at all
(plane 1 is not even in `datas[0]`'s span), so reject it with a message that says which side is at
fault and under what condition the NV12 offer is valid.

L14: `spa_meta_bitmap` was read field-by-field through an aligned `*const` at a
PRODUCER-controlled offset, with a SAFETY comment asserting an alignment the code never
established. One `read_unaligned` of the `Copy` POD instead.

**2.6 — hand-off semantics (L9, L10).** The frame channel was `sync_channel(8)` + `try_send`, i.e.
drop-NEWEST, while the trait documented drop-oldest. Neither remedy the plan offered works: a
`SyncSender` cannot pop, so "drain one and retry" is not expressible, and shrinking the bound makes
staleness WORSE, not better (with bound N the queue holds the first N frames of a stall and
everything after is discarded, so a larger N actually yields a fresher frame — the defect is the
discarding, not the depth). Replaced with a one-deep OVERWRITING mailbox plus a depth-1 wakeup
channel: publishing overwrites, so a stalled consumer loses the intermediate frames and is always
handed the freshest one. `next_frame` used to return the OLDEST of eight stale frames.

Falls out of it:
  - `wait_arrival` now honours its documented "must NOT consume" contract by peeking the slot. The
    `pending` field existed only to stash a frame the un-peekable channel forced it to consume;
    it is gone.
  - a queued `CapturedFrame` can own a dup'd dmabuf fd or a CUDA buffer, so the old 8-deep backlog
    could pin eight compositor buffers. One-deep by construction now.
  - L10: `set_active(false)` flushes the mailbox, so a reused capturer no longer opens the next
    stream with the previous session's last frame (wrong content, and a `pts_ns` from the old
    clock). Two lines, where flushing a queue + a `pending` stash was fiddlier.
  - the producer-liveness signal moves to the wakeup channel's `Disconnected`, and a final frame
    left in the slot as the thread exits is still served before the error.

pf-capture 13/13; workspace clippy --all-targets clean on Linux and windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:48:18 +02:00
enricobuehlerandClaude Fable 5 f495b201e1 fix(encode): delete the write-only EncoderCaps::supports_hdr_metadata
A caps field nothing reads is a contract nobody honors — and this one shipped
write-only: its single reader anywhere in the workspace was a hardware-gated
assertion inside pf-encode's own AMF smoke test. Both planes send the static
HDR grade out-of-band unconditionally (the native 0xCE datagram per keyframe,
the GameStream 0x010e control message), every first-party client reads
exclusively that path, and none parse in-band SEI — so the host decision the
field was reserved for (suppress out-of-band when the encoder embeds) can
never validly exist. The field's doc contract had also rotted in two
directions: it claimed set_hdr_meta no-ops when false (native AMF and QSV
consume it regardless) and that only Windows direct-NVENC attaches in-band
metadata (AMF and QSV do too). The in-band SEI/OBU emission itself is
untouched — it stays a bonus for stock decoders, documented at the emit
sites; the trait docs now describe the real routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:48:07 +02:00
enricobuehlerandClaude Opus 5 17eb8100a3 fix(pf-capture): portal teardown, observable death, one negotiation resolver (sweep 2.1–2.3)
**2.1 (L1) — the portal thread leaked a live screencast session.** It parked on
`future::pending()` and `Drop` stopped only the PipeWire thread, so every dropped portal capturer
permanently leaked a thread + a 2-worker tokio runtime + a zbus connection — and because dropping
that connection is what ends the compositor's cast (ashpd's `Session` has no `Drop`), the
COMPOSITOR-side session leaked too. GameStream drops the pooled capturer on every HDR↔SDR
reconnect flip and opens a fresh one, so the sessions accumulated: exactly the "second conflicting
screencast" the pooling exists to prevent. Both portal threads now park on a `oneshot::Receiver`
and `PortalSession::drop` fires it, then waits — BOUNDED (750 ms, then detach with a warning) —
for a completion signal the thread sends after its runtime has been dropped. Bounded because
teardown runs on the session path and the thread may be inside a D-Bus round-trip: an unbounded
`join()` there would hang the host rather than leak. Every early-return path in `open` drops the
session too, so a failed PipeWire spawn no longer strands a fresh cast.

**2.2 (L2) — a dead capturer was pooled forever.** All three of the portal backend's terminal
states are sticky and observable only by consuming a frame, and `Capturer` exposed no health
predicate, so GameStream re-pooled unconditionally — after `result` was already an `Err`. One
zerocopy-worker death or one compositor restart therefore wedged GameStream portal video at 10 s
per reconnect attempt, permanently (that path has no rebuild closure). Adds
`Capturer::is_alive()` (default `true`), implemented for `PortalCapturer` as
`!broken && streaming && !thread.is_finished()` — the third term catches a dead PipeWire thread,
which is otherwise indistinguishable from an idle desktop. A static desktop stays `Streaming`, so
an idle-but-healthy capture is never reported dead. The host re-pools only on
`result.is_ok() && capturer.is_alive()` and logs which of the two retired it. Both halves ship
together: either alone leaves the wedge reachable.

**2.3 (L3/F1) — a "VAAPI" downgrade latch was really a global zero-copy kill switch.**
`spawn_pipewire` hand-mirrored the thread's `vaapi_passthrough` decision and the copy omitted
`raw_dmabuf_import_disabled`, so once that latch fired the capturer still believed it had made the
passthrough offer while the thread had already fallen back — and its timeout branch then latched a
downgrade for an offer nobody made. That downgrade was `note_vaapi_dmabuf_failed`, which fed
`pf_zerocopy::enabled()`, so ONE failed negotiation dropped every later session on the host — the
NVENC EGL→CUDA path included — to CPU capture until restart. And since the raw-passthrough offer
is also the PyroWave one on ANY vendor, a single PyroWave negotiation timeout was enough to do it
on an NVIDIA box.

Two fixes, both structural:
  - `negotiation_plan(NegotiationInputs) -> NegotiationPlan` is now the ONE resolver, consumed by
    the thread AND by `spawn_pipewire` — the mirror is deleted, not re-synced (WP7.6's shape), so
    the drift class is unrepresentable. It is pure (every env/latch read is an input), which is
    what makes it testable; the runtime-dependent half stays a method (`want_dmabuf` needs the
    modifier list the importer's construction actually yielded). The redundant `importer.is_none()`
    term goes: `build_importer` already excludes the passthrough, and that term is what made the
    decision look impure enough to "need" a mirror. `PUNKTFUNK_FORCE_SHM` was ALSO read in both
    places; now once.
  - the capture-side downgrade becomes `pf_zerocopy::note_raw_dmabuf_negotiation_failed`, which
    latches `RAW_DMABUF_DISABLED` — gating only the raw-passthrough decision. `enabled()` is now
    the env var alone, and `VAAPI_DMABUF_FAILED` is deleted.

Also (L13, needed by 2.3's plan inputs): `PUNKTFUNK_PIPEWIRE_NV12` honoured only the exact string
`"0"`, so `"0 "` or `"false"` read as force-ON — the bug class of ed525c4c. pf-host-config's
explicit-off grammar is now exported as `env_on()` and the four in-crate copies (`PUNKTFUNK_
ZEROCOPY`/`_10BIT`/`_444`/`_CHACHA20`) plus this new caller share it.

7 new tests pin the four invariants that were prose-only comments, plus the latch property that
made the mirror wrong. pf-capture 13/13; workspace clippy --all-targets clean on Linux and on
x86_64-pc-windows-msvc. On-glass validation of 2.1/2.2/2.3 is owed (sweep Phase 7.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:41:53 +02:00
enricobuehlerandClaude Fable 5 232b6d6be2 test(encode/vaapi): extract the open-time decision logic and pin it with unit tests
The fallback backend under Vulkan Video — and the only AMD/Intel H.264 and
10-bit/HDR encoder — had 1,300 lines, 26 unsafe blocks, and zero tests. The
device-free decisions now live in named functions with their contracts pinned:
the entrypoint ladder + LP_MODE latch round-trip (the cross-GPU session-killer
and the 8-bit-pins-10-bit under-advertisement are both key'd tests now), the
PUNKTFUNK_VAAPI_LOW_POWER / _ASYNC_DEPTH grammars, the VUI ↔ scale_vaapi
colour agreement (the Mesa-BT.601 hue-shift pin), the honest-downgrade depth
table, the HEVC-Main10-only explicit profile, and the 10-bit probe gate.
Probe + CPU-path encode round-trip ride along as #[ignore]d hardware smokes
in the house style. No FFI plumbing was chased; no behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:29:31 +02:00
enricobuehlerandClaude Opus 5 54a37aeb46 docs(pf-capture): truth pass over comments, docs and log strings (sweep Phase 1)
Every item here is prose that actively misdirects a maintainer or an operator. Landing it before
the defect fixes means those diffs get reviewed against accurate comments.

1.1 (X5) — split two merged doc blocks that documented the item ABOVE them: `hdr_meta`'s whole
contract was glued onto `fn cursor()` in lib.rs (leaving `hdr_meta` undocumented), and
`hdr_p010_selftest_at`'s was glued onto `hdr_p010_convert_bars_on_luid` in dxgi.rs. rustdoc for
the trait's central HDR contract was simply wrong.

1.2 (L9 doc half) — the `Capturer` trait doc advertised a "bounded drop-oldest channel". The
Linux portal's `sync_channel(8)` + `try_send` is drop-NEWEST: under a consumer stall the arriving
frame is discarded and the encoder gets the stalest queued one. Say so. (Phase 2.6 changes the
behaviour; this commit only stops the doc from lying about today's code.)

1.3 — delete the DONT_FIXATE claim from the dmabuf offer comment. `build_dmabuf_format` emits a
plain MANDATORY `ChoiceEnum::Enum` and `param_changed` re-emits nothing, so there is no two-step
DMA-BUF handshake here; libspa 0.9's `ChoiceFlags` cannot even express DONT_FIXATE. Recorded as a
real follow-up instead of an implemented thing.

1.4 — `mainloop.run()` no longer "blocks until process exit": the quit channel attached above it
stops the loop from `PortalCapturer::drop`.

1.5 (L11) — the 6-arm negotiation log ladder told a fully zero-copy PyroWave session "VAAPI
encode with the CPU capture path — zero-copy was disabled", one line after logging that it had
advertised the PyroWave device's dmabuf modifiers: the passthrough arm was gated on
`pyrowave_modifiers.is_empty()`, so the pyrowave case fell through to the CPU warning. Ungate it;
the CPU-path arm is now reachable only when no dmabuf is advertised at all, which is what it
claims. Its parenthetical also gains the third real cause (the session asked for CPU frames).
Control-flow-neutral for every other combination.

1.6 (W13) — `kick_dwm_compose` claimed a "sub-millisecond" round trip while its
cursor-on-a-sibling-display branch sleeps 35 ms on the CALLER's thread. State the cost on the
function and at both call sites, including which one is on the live frame path.

1.7 (W16) — DDA-era residue, DDA having been removed:
  - retarget the win32u hook's rationale + all four log strings: its job is no longer "keep DDA on
    one adapter" but "keep the virtual display on the adapter SET_RENDER_ADAPTER pinned" (a DXGI
    reparent surfaces as the driver's TEX_FAIL render-adapter mismatch). The hook stays installed.
  - the per-monitor-v2 DPI awareness rationale likewise: not DuplicateOutput1's E_ACCESSDENIED any
    more, but keeping cursor/window coordinates in the PHYSICAL pixels the host's CCD geometry
    (`source_desktop_rect`, `desktop_bounds`) is already in.
  - `HYBRID_HOOK_HITS` was write-only. Surface it as `hybrid_hook_hits()` on the IDD-push open
    line, which is the first point where DXGI has actually been exercised — the patch-readback
    check proves the bytes landed, only this proves DXGI reaches the export.
  - delete `hdr_p010_selftest()`: an unreachable 64×64 wrapper (main.rs calls
    `hdr_p010_selftest_at`); its description moves onto the function that survives.
  - `VideoConverter`'s docs promised a P010/BT.2020 output and a live scRGB input path. It pins
    `YCBCR_STUDIO_G22_LEFT_P709` unconditionally and its only caller always passes
    `scrgb_input: false`.

1.8 (X7) — the native handshake's 4:4:4 gate comment stated the OPPOSITE of what
`pf_capture::capturer_supports_444` returns on Windows ("delivers subsampled NV12/P010 today, so
it returns false there" — it forwards `resolved_backend_ingests_rgb_444()` and the IDD ring passes
BGRA through for an SDR 4:4:4 session).

No `.rs` behaviour delta: comments, doc comments and log strings, plus 1.5's control-flow-neutral
ladder and 1.7's counter accessor. Linux tests 6/6; clippy --all-targets clean on both targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:27:06 +02:00
enricobuehlerandClaude Opus 5 cc848479c4 test(pf-encode): re-point the cpu_img size-change smoke at the CSC guard's contract
arch / build-publish (push) Failing after 58s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 53s
android / android (push) Failing after 7m59s
ci / bench (push) Failing after 4m9s
ci / rust-arm64 (push) Failing after 6m44s
ci / rust (push) Failing after 6m45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 24s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
apple / swift (push) Successful in 5m28s
deb / build-publish (push) Successful in 9m25s
windows-host / package (push) Successful in 10m44s
docker / deploy-docs (push) Successful in 31s
docker / build-push-arm64cross (push) Successful in 5m33s
deb / build-publish-client-arm64 (push) Successful in 8m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m4s
deb / build-publish-host (push) Successful in 12m7s
apple / screenshots (push) Successful in 25m11s
vulkan_cpu_img_survives_a_source_size_change drove MISMATCHED source
sizes through the then-lenient CSC arm as its vehicle for the staging
cache hazard (format-only-keyed cpu_img → OOB copy while submit said
Ok). e3354b6d's guard — correctly the equality check every sibling arm
always had, against the MODE, not the coded extent, so the padded
render-vs-coded tolerance in the direct arms is untouched — makes that
scenario unrepresentable through submit and broke the test on main
(.25 layers baseline read 13/13 instead of 14/12).

Replaced by vulkan_csc_refuses_a_mismatched_source: refusal pinned in
BOTH directions, plus the property that actually needs proving — a
refused submit does not WEDGE the session (the bail lands after step
1's frame-type bookkeeping; the next well-sized frame must still
encode, and an AU must come out). Verified on the 780M under
validation layers: 8/8 vulkan tests, full-suite baseline restored to
14/12.

The WP4.2 size-keyed staging stays as belt-and-braces; the hazard it
fixed is now structurally unreachable through submit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:20:37 +02:00
enricobuehlerandClaude Opus 5 6a2a153c0b chore(pf-capture): make the crate verifiable — Windows CI lint + two denies (sweep Phase 0)
Gate for the rest of design/pf-capture-sweep.md: today half this crate is compiled by no CI job
at all, so the Windows-side findings can't even be type-checked.

0.1 (X1) — windows-host.yml lints `-p pf-capture --all-targets`. The host lint above it builds
pf-capture only as a DEPENDENCY, so its `#[cfg(test)]` modules were never compiled anywhere: the
5 StallWatch tests, the DXGI HDR self-tests and the cursor-conversion tables were dead code in
CI. The workflow's own comment already diagnosed this blind spot and fixed it for pf-encode;
pf-capture never got the same treatment. No features on this crate, so no feature juggling and
no extra dep tree.

0.2 (X2) — `#![deny(unsafe_op_in_unsafe_fn)]` beside the existing
`deny(clippy::undocumented_unsafe_blocks)`. The crate declares "every unsafe block carries a
SAFETY proof; enforce it" in ten file headers, but in edition 2021 that lint has nothing to fire
on inside an `unsafe fn` — so the hardest FFI in the crate was exempt from its own program: the
ring/slot construction, the fence signal, the blend scratch, and every D3D converter ctor/convert.
119 operations across 16 functions now carry a proof. `shared_object_sa` loses its `unsafe`
marker instead (it states no precondition — only its RESULT is a raw pointer, which is the
caller's business).

0.3 (X4) — drop the crate-wide `#![allow(dead_code)]`. Verified: zero warnings on either target
without it, so it was hiding nothing the lint can see (W16's dead items are `pub`/written-once,
so they need Phase 1.7's deletion instead).

No behaviour change: only lint attributes, `unsafe { }` scoping and comments.

Linux `cargo test -p pf-capture` 6/6, clippy --all-targets clean on both targets (Windows
verified by cross-checking x86_64-pc-windows-msvc locally).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:18:48 +02:00
97 changed files with 15087 additions and 5370 deletions
+19
View File
@@ -39,6 +39,25 @@ jobs:
working-directory: plugin-kit
run: bun install --frozen-lockfile --ignore-scripts
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
# dangling self-reference. `dist/` therefore arrives intact while the manifest that points at
# it does not, so module resolution dies at the first step and every `@punktfunk/host` import
# reads as "cannot find module". Replacing the tree with a real copy is the whole fix; drop
# this step once bun links `file:` deps correctly again.
- name: Repair the file: dependency (bun 1.3 self-symlink)
working-directory: plugin-kit
run: |
# -f follows the link, so this is true only when the manifest actually resolves.
if test -f node_modules/@punktfunk/host/package.json; then
echo "bun linked it correctly — this step can go"
else
rm -rf node_modules/@punktfunk/host
cp -R ../sdk node_modules/@punktfunk/host
fi
test -f node_modules/@punktfunk/host/package.json
test -f node_modules/@punktfunk/host/dist/index.d.ts
- name: Typecheck
working-directory: plugin-kit
run: bun run typecheck
+46 -13
View File
@@ -172,24 +172,57 @@ jobs:
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
# pf-vkhdr-layer's clippy below runs --release.
#
# pf-encode is linted SEPARATELY with --all-targets so its Windows `#[cfg(test)]` modules
# are type-checked — the AMF C-ABI layout assertions (`variant_layout_matches_c` and
# friends, which are the only guard on a hand-mirrored vtable ABI), the QSV tests, and the
# PyroWave-Windows smoke test. The host lint above cannot cover them: `-p punktfunk-host`
# only builds pf-encode as a dependency, so its test targets are never compiled, and that
# blind spot is what let the Linux twin's tests rot to the wrong arity unnoticed.
# NOTE: clippy (a check, no link step) is deliberately the vehicle here — `cargo test`
# with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk link-imports
# NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve only against
# the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can and does
# run the tests there.) Running them here would need an `--features amf-qsv,qsv` build
# without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 — not
# worth it while ci.yml executes the same tests.
# pf-encode and pf-capture are linted SEPARATELY with --all-targets so their Windows
# `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
# (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored
# vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
# tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
# cannot cover them: `-p punktfunk-host` only builds those crates as dependencies, so their
# test targets are never compiled anywhere, and that blind spot is what let the Linux twin's
# tests rot to the wrong arity unnoticed. pf-capture has no cargo features, so it needs no
# feature juggling and pulls in no extra dep tree.
# NOTE: for the HOST and pf-encode, clippy (a check, no link step) is deliberately the
# vehicle — `cargo test` with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk
# link-imports NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve
# only against the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can
# and does run the tests there.) Running them here would need an `--features amf-qsv,qsv`
# build without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 —
# not worth it while ci.yml executes the same tests.
#
# That reasoning does NOT extend to pf-capture: it has no encoder dependency at all
# (`cargo tree -p pf-capture` lists no nvidia/ffmpeg/libvpl/pyrowave), so its test binary
# links against nothing this runner lacks, and it reuses the release artifacts the steps
# above already built. Its Windows `#[test]`s — StallWatch, the f16 conversions, the cursor
# truth table, the IDD generation masking — are Windows-only code that NO other job can
# execute, so linting them was leaving real coverage on the table. See the run step below.
run: |
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
cargo clippy --release -p pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" }
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Test (pf-capture, Windows)
shell: pwsh
# The only Rust tests that RUN on Windows CI. pf-capture's `#[cfg(target_os = "windows")]`
# test modules cover code no Linux job compiles, let alone executes: 19 declared, of which
# 18 execute here — the IDD-push StallWatch state machine and ring-generation masking
# (idd_push.rs), the cursor shape→wire truth table (idd_push/cursor_poll.rs), and
# `f32_to_f16` including the rounding-carry / saturation edges the HDR P010 path depends on
# (dxgi/selftest.rs). All 18 are pure — no Win32, no device, no desktop. The 19th,
# `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel
# adapter; it stays a manual `-- --ignored` run on the validation boxes. Until this step
# the whole set was type-checked by the clippy line above and nothing more.
#
# --release for the same reason as the clippy step: it reuses C:\t\release instead of
# spawning a second debug dep tree (the C1069 disk-exhaustion trigger). If this step ever
# starts tripping C1069 anyway, record THAT here rather than quietly dropping the step.
#
# The link question this step turns on was settled empirically before it was added: the same
# command was run on a Windows dev box against a workspace checkout and linked + executed
# cleanly, building in ~51 s off an existing release target dir.
run: |
cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
+442 -3
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.18.0"
"version": "0.19.2"
},
"paths": {
"/api/v1/clients": {
@@ -665,6 +665,58 @@
}
}
},
"/api/v1/game/end": {
"post": {
"tags": [
"session"
],
"summary": "End a launched game",
"description": "Ends a game whose session has already gone and which is waiting out its reconnect window — the\nconsole's \"End now\" for a game the host is about to close anyway. `app_id` picks one title; omit it\nto end every waiting game.\n\nThis does **not** touch a game whose session is still live: ending that is session management\n(`DELETE /session`), and how the game is treated then follows the operator's policy.",
"operationId": "endGame",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EndGameRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "How many waiting games were ended",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EndGameResult"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "No game is waiting to be ended",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/gpus": {
"get": {
"tags": [
@@ -2226,7 +2278,7 @@
"session"
],
"summary": "Stop the active session",
"description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.",
"description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.\n\nCounts as a **deliberate** stop, exactly like a client pressing Stop: the display skips its\nkeep-alive linger, and the end-game-on-session-end policy (if the operator enabled one) applies.",
"operationId": "stopSession",
"responses": {
"204": {
@@ -2280,6 +2332,98 @@
}
}
},
"/api/v1/session/settings": {
"get": {
"tags": [
"session"
],
"summary": "Session⇄game lifetime settings",
"description": "Whether a launched game's exit ends the streaming session, and whether a session ending ends the\ngame (with the reconnect window that protects a dropped client's unsaved progress). See\n`design/session-game-lifetime.md`.",
"operationId": "getSessionSettings",
"responses": {
"200": {
"description": "Stored settings + which axes this build enforces",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionSettingsState"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"put": {
"tags": [
"session"
],
"summary": "Set the session⇄game lifetime settings",
"description": "Persists the settings (clamped) and applies them from the next decision — including to a session\nthat is already streaming, since the policy is read when a session ends rather than when it starts.",
"operationId": "setSessionSettings",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionSettings"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Settings stored; the new state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionSettingsState"
}
}
}
},
"400": {
"description": "Malformed settings body",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Settings could not be persisted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/capture/live": {
"get": {
"tags": [
@@ -3251,6 +3395,67 @@
},
"components": {
"schemas": {
"ActiveGame": {
"type": "object",
"description": "One launched game, for the console's running-game card.",
"required": [
"client",
"title",
"plane",
"state"
],
"properties": {
"app_id": {
"type": [
"string",
"null"
],
"description": "Store-qualified library id (`steam:570`) — the key the console matches against `GET /library`\nto show box art. Absent for an operator-typed GameStream command."
},
"client": {
"type": "string",
"description": "Client-supplied device name of the session that launched it; may be empty."
},
"grace_remaining_s": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Seconds until this game is ended — only present on a `grace` row.",
"minimum": 0
},
"plane": {
"$ref": "#/components/schemas/Plane",
"description": "`native` or `gamestream`."
},
"session_id": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "The session streaming it; `null` for a game waiting out its reconnect window.",
"minimum": 0
},
"state": {
"type": "string",
"description": "`launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is\ngone and it will be ended when the reconnect window closes).",
"example": "running"
},
"store": {
"type": [
"string",
"null"
],
"description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known."
},
"title": {
"type": "string",
"description": "Display title."
}
}
},
"ApiActiveGpu": {
"type": "object",
"description": "The GPU live sessions are encoding on right now.",
@@ -3817,6 +4022,10 @@
"art": {
"$ref": "#/components/schemas/Artwork"
},
"detect": {
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns."
},
"external_id": {
"type": [
"string",
@@ -3867,6 +4076,10 @@
"art": {
"$ref": "#/components/schemas/Artwork"
},
"detect": {
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
},
"launch": {
"oneOf": [
{
@@ -3935,6 +4148,33 @@
}
}
},
"DetectHint": {
"type": "object",
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
"properties": {
"exe": {
"type": [
"string",
"null"
],
"description": "The game's own executable, as an absolute path."
},
"install_dir": {
"type": [
"string",
"null"
],
"description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one."
},
"process_name": {
"type": [
"string",
"null"
],
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
}
}
},
"DeviceRef": {
"type": "object",
"description": "A device in the pairing flow.",
@@ -4126,6 +4366,33 @@
}
}
},
"EndGameRequest": {
"type": "object",
"description": "Request body for `endGame`.",
"properties": {
"app_id": {
"type": [
"string",
"null"
],
"description": "Store-qualified library id (`steam:570`) to end; omit to end every waiting game."
}
}
},
"EndGameResult": {
"type": "object",
"description": "Result of an `endGame`.",
"required": [
"ended"
],
"properties": {
"ended": {
"type": "integer",
"description": "How many waiting games were ended.",
"minimum": 0
}
}
},
"EventKind": {
"oneOf": [
{
@@ -4240,6 +4507,48 @@
}
}
},
{
"type": "object",
"description": "A launched game was confirmed running — fires once per launch, after the host has actually\nseen the game's process (not merely spawned its launcher).",
"required": [
"game",
"kind"
],
"properties": {
"game": {
"$ref": "#/components/schemas/GameRefPayload"
},
"kind": {
"type": "string",
"enum": [
"game.running"
]
}
}
},
{
"type": "object",
"description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy.",
"required": [
"game",
"reason",
"kind"
],
"properties": {
"game": {
"$ref": "#/components/schemas/GameRefPayload"
},
"kind": {
"type": "string",
"enum": [
"game.exited"
]
},
"reason": {
"$ref": "#/components/schemas/GameEndReason"
}
}
},
{
"type": "object",
"required": [
@@ -4432,6 +4741,14 @@
],
"description": "The event catalog (RFC §4). Serialized internally tagged as `\"kind\": \"<domain>.<verb>\"`,\nflattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`]."
},
"GameEndReason": {
"type": "string",
"description": "Why a launched game is no longer running.",
"enum": [
"exited",
"terminated"
]
},
"GameEntry": {
"type": "object",
"description": "One title in the unified library, regardless of which store it came from.",
@@ -4478,6 +4795,51 @@
}
}
},
"GameOnSessionEnd": {
"type": "string",
"description": "What to do with the launched game when its session ends.",
"enum": [
"keep",
"on_quit",
"always"
]
},
"GameRefPayload": {
"type": "object",
"description": "A launched game, as the `game.*` events see it.",
"required": [
"title",
"client",
"plane"
],
"properties": {
"app": {
"type": [
"string",
"null"
],
"description": "Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream\n`apps.json` command, which has no library entry behind it."
},
"client": {
"type": "string",
"description": "Client-supplied device name of the session that launched it; may be empty."
},
"plane": {
"$ref": "#/components/schemas/Plane"
},
"store": {
"type": [
"string",
"null"
],
"description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known."
},
"title": {
"type": "string",
"description": "Display title."
}
}
},
"GameSession": {
"type": "string",
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
@@ -4506,6 +4868,13 @@
}
]
},
"encoder_pin": {
"type": [
"string",
"null"
],
"description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken."
},
"env_override": {
"type": [
"string",
@@ -5115,6 +5484,13 @@
},
"description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails."
},
"games": {
"type": "array",
"items": {
"type": "string"
},
"description": "Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).\n\nThe countdown form is the one that matters: it means the game's client is gone and the host\nwill end the game when the window closes — something a user at the machine should be able to\nsee (and stop) without opening the console. Empty when nothing was launched."
},
"kept_displays": {
"type": "integer",
"format": "int32",
@@ -5637,6 +6013,10 @@
"art": {
"$ref": "#/components/schemas/Artwork"
},
"detect": {
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits."
},
"external_id": {
"type": "string",
"description": "The provider's stable id for this title (the reconcile diff key)."
@@ -5725,7 +6105,8 @@
"audio_streaming",
"pin_pending",
"paired_clients",
"active_sessions"
"active_sessions",
"games"
],
"properties": {
"active_sessions": {
@@ -5738,6 +6119,13 @@
"type": "boolean",
"description": "True while the audio stream thread is running."
},
"games": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ActiveGame"
},
"description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game."
},
"paired_clients": {
"type": "integer",
"format": "int32",
@@ -5907,6 +6295,57 @@
}
}
},
"SessionSettings": {
"type": "object",
"description": "The persisted settings.",
"properties": {
"disconnect_grace_seconds": {
"type": "integer",
"format": "int32",
"description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.",
"minimum": 0
},
"game_on_session_end": {
"$ref": "#/components/schemas/GameOnSessionEnd",
"description": "End the launched game when the session ends. See [`GameOnSessionEnd`]."
},
"session_on_game_exit": {
"type": "boolean",
"description": "End the streaming session when the launched game exits."
},
"version": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
},
"SessionSettingsState": {
"type": "object",
"description": "The session⇄game lifetime settings, plus which axes this build acts on.",
"required": [
"settings",
"configured",
"enforced"
],
"properties": {
"configured": {
"type": "boolean",
"description": "Whether an operator has ever saved these settings (`false` ⇒ `settings` are the defaults)."
},
"enforced": {
"type": "array",
"items": {
"type": "string"
},
"description": "Which fields this build actually enforces. Empty on a platform with no launch path (macOS),\nso the console can say so instead of offering a switch that does nothing."
},
"settings": {
"$ref": "#/components/schemas/SessionSettings",
"description": "The stored settings (or the built-in defaults when this host has never been configured)."
}
}
},
"SetGpuPreference": {
"type": "object",
"description": "Request body for `setGpuPreference`.",
@@ -990,7 +990,9 @@ struct ContentView: View {
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
let g = PunktfunkConnection.GamepadType(name: name) {
pad = g
// Back through resolveType so the lever is adopted as the session's setting: the
// per-pad arrivals declare it too, which is what the host actually builds from.
pad = GamepadManager.shared.resolveType(setting: g)
}
var bitrate = UInt32(clamping: bitrateKbps)
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
@@ -55,7 +55,13 @@ public final class GamepadCapture {
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
/// event this controller sends the low byte of `flags`.
let pad: UInt32
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
/// The controller KIND declared to the host (GamepadArrival) when the slot opened the
/// user's explicit "Controller type" setting when they picked one, else the detected
/// kind (`GamepadManager.declaredKind(for:)`). NOT the physical pad's kind: local feedback
/// keys off the live `GCController` subclass instead, so whatever the host DOES send is
/// applied natively to the pad in the user's hands. What the host sends is bounded by the
/// emulated type, though a virtual DualShock 4 has no adaptive-trigger reports in its
/// protocol, so emulating one gives those up by construction (rumble + lightbar remain).
let pref: PunktfunkConnection.GamepadType
var buttons: UInt32 = 0
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
@@ -166,7 +172,7 @@ public final class GamepadCapture {
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
let c = dc.controller
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
let slot = Slot(controller: c, pad: UInt32(pad), pref: manager.declaredKind(for: dc))
slots.append(slot)
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
@@ -192,9 +198,12 @@ public final class GamepadCapture {
}
}
// Declare this pad's controller KIND before any of its input, so the host builds a
// matching virtual device (mixed types pad 0 a DualSense, pad 1 an Xbox pad). The core
// re-sends it a few times against datagram loss; an older host ignores it and uses the
// session-default kind. Then wake the host pad (pads are created lazily from the first
// matching virtual device the user's chosen type when they picked one, else per-pad
// detection (mixed types pad 0 a DualSense, pad 1 an Xbox pad). This declaration is
// what the host actually builds from, so it MUST carry an explicit setting; the
// handshake's session default is only the fallback for a pad that never declares. The
// core re-sends it a few times against datagram loss; an older host ignores it and uses
// the session-default kind. Then wake the host pad (pads are created lazily from the first
// event; a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
@@ -131,13 +131,40 @@ public final class GamepadManager: ObservableObject {
GCController.stopWirelessControllerDiscovery()
}
/// The user's controller-type choice AS CHOSEN (not resolved) for the session being dialed
/// adopted by `resolveType` and read back by `declaredKind(for:)`. `.auto` = detect per pad.
public private(set) var typeSetting: PunktfunkConnection.GamepadType = .auto
/// The kind to DECLARE to the host for one forwarded controller (its `GamepadArrival`).
/// An explicit setting wins for every pad the handshake's session default alone does NOT
/// stick, because a current host honors the per-pad arrival over it (punktfunk-host's
/// `Pads::set_kind`), so a client that declared only the detected kind here would silently
/// undo the user's choice. `.auto` keeps per-pad detection, which is what makes a mixed
/// session (pad 0 a DualSense, pad 1 an Xbox pad) honest.
public func declaredKind(
for controller: DiscoveredController
) -> PunktfunkConnection.GamepadType {
Self.declaredKind(setting: typeSetting, detected: controller.kind)
}
/// The pure fold behind `declaredKind(for:)` (pf-client-core's `declared_kind`).
nonisolated static func declaredKind(
setting: PunktfunkConnection.GamepadType,
detected: PunktfunkConnection.GamepadType
) -> PunktfunkConnection.GamepadType {
setting == .auto ? detected : setting
}
/// Connect-time resolution of the user's controller-type setting: an explicit choice
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense
/// DualSense, DualShock 4 DualShock 4, an Xbox pad Xbox One, anything else Xbox
/// 360); no controller at all defers to the host.
/// 360); no controller at all defers to the host. Called once per dial with the RAW setting,
/// which it also adopts for `declaredKind(for:)` so the handshake default and every pad's
/// arrival can never disagree about an explicit choice.
public func resolveType(
setting: PunktfunkConnection.GamepadType
) -> PunktfunkConnection.GamepadType {
typeSetting = setting
guard setting == .auto else { return setting }
// Refresh from the LIVE controller list first. `active` is otherwise only populated by the
// async `.GCControllerDidConnect` notification, so at connect time it can still be nil even
@@ -236,6 +236,13 @@ public final class StreamLayerView: NSView {
let hotY: Int
}
private var hostCursors: [UInt32: HostCursorShape] = [:]
/// The last shape actually worn. State (`0xD0`, a per-frame datagram) announces a new serial the
/// moment the host QUEUES its bitmap on the reliable control stream, so the client routinely
/// knows a serial before it holds the pixels and the shape ring drops the NEWEST under burst
/// (`CURSOR_SHAPE_QUEUE`), which the host never re-sends because it only sends on a serial
/// CHANGE. Both leave `hostCursors[serial]` empty; wearing the previous pointer through that
/// gap degrades it to a briefly-stale shape instead of blinking the pointer out of existence.
private var lastWornShape: HostCursorShape?
private var cursorState: PunktfunkConnection.CursorStateEvent?
/// Last `CursorRenderMode.clientDraws` told to the host (the §8 mid-stream render flip);
/// nil = nothing sent yet. Edge-detected by [`reconcileCursorRender`] from the live mouse
@@ -509,10 +516,19 @@ public final class StreamLayerView: NSView {
override public func resetCursorRects() {
if captured && desktopMouse {
// Cursor channel active: wear the HOST's pointer shape (it is no longer in the
// video); hidden host pointer (or no shape yet) = invisible. Without the channel,
// M1 behavior: invisible local cursor, the composited host cursor is the visible one.
// video); a HIDDEN host pointer (or nothing seen yet at all) = invisible. Without the
// channel, M1 behavior: invisible local cursor, the composited host cursor is the
// visible one.
//
// A visible pointer whose announced serial has no bitmap yet falls back to the last
// worn shape (see `lastWornShape`) rather than to `invisibleCursor`. That case is
// routine, not degenerate state outruns its bitmap on every single shape change
// and treating it as "hide the pointer" made the pointer VANISH over anything whose
// shape arrived late or got dropped, with no recovery until the next change. Only
// `st.visible == false` may hide the pointer; a missing bitmap may not.
if cursorChannelActive, let st = cursorState, st.visible,
let shape = hostCursors[st.serial] {
let shape = hostCursors[st.serial] ?? lastWornShape {
lastWornShape = shape
addCursorRect(bounds, cursor: scaledCursor(shape))
} else {
addCursorRect(bounds, cursor: Self.invisibleCursor)
@@ -583,6 +599,8 @@ public final class StreamLayerView: NSView {
private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) {
guard let shape = Self.makeShape(ev) else {
// Truthful only because `resetCursorRects` falls back to `lastWornShape`: before that,
// a rejection here left the announced serial with no bitmap and HID the pointer.
streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor")
return
}
@@ -119,6 +119,25 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.maxPads, 16)
}
func testAnExplicitControllerTypeIsWhatEveryPadDeclares() {
// The regression this pins: the "Controller type" setting reached the Hello only, and each
// pad's arrival then re-declared the DETECTED kind which the host honors over the
// session default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
// (pf-client-core's `declared_kind` test is the same table.)
XCTAssertEqual(
GamepadManager.declaredKind(setting: .dualShock4, detected: .dualSense), .dualShock4)
// Every physical pad in a mixed session follows the one explicit choice.
for detected: PunktfunkConnection.GamepadType in [
.dualSense, .xbox360, .switchPro, .dualSenseEdge,
] {
XCTAssertEqual(
GamepadManager.declaredKind(setting: .xbox360, detected: detected), .xbox360)
}
// Automatic keeps per-pad detection otherwise a mixed session collapses to one type.
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .dualSense), .dualSense)
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .switchPro), .switchPro)
}
func testTouchpadConversionCorners() {
// GC ±1 with +y up wire 0...65535 with origin top-left, +y down.
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
+11 -3
View File
@@ -204,9 +204,17 @@ mod session_main {
mode,
compositor: CompositorPref::from_name(&settings.compositor)
.unwrap_or(CompositorPref::Auto),
gamepad: match GamepadPref::from_name(&settings.gamepad) {
Some(GamepadPref::Auto) | None => gamepad.auto_pref(),
Some(explicit) => explicit,
gamepad: {
// The setting AS CHOSEN goes to the pad service too, not just the Hello: the host
// builds each virtual pad from that pad's arrival and only falls back to this
// session default for a pad that never declares one, so an explicit choice that
// stopped here would be undone the moment a controller connected.
let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto);
gamepad.set_kind_override(chosen);
match chosen {
GamepadPref::Auto => gamepad.auto_pref(),
explicit => explicit,
}
},
bitrate_kbps: settings.bitrate_kbps,
audio_channels: settings.audio_channels,
+118 -26
View File
@@ -7,10 +7,12 @@
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
//! orchestrator).
// Scaffold: trait defaults + synthetic sources are defined ahead of the backends that use them.
#![allow(dead_code)]
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted the crate's hardest FFI
// (the ring/slot construction, the channel broker, every D3D converter ctor) from the deny above.
#![deny(unsafe_op_in_unsafe_fn)]
use anyhow::Result;
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
@@ -19,9 +21,16 @@ use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
#[cfg(target_os = "linux")]
use pf_frame::DmabufFrame;
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder
/// over a bounded drop-oldest channel (never block the compositor).
/// Produces frames from a captured output. Lives on its own thread, handing frames over without
/// ever blocking the compositor — the Linux portal publishes into a one-deep OVERWRITING slot
/// (drop-oldest), so a stalled consumer costs the intermediate frames and is still handed the
/// freshest one.
pub trait Capturer: Send {
// ---- Frames -----------------------------------------------------------------------------
// `next_frame` blocks for one; `try_latest` is the steady-state non-blocking read;
// `wait_arrival` + `supports_arrival_wait` are the frame-driven trigger that replaces a
// free-running tick.
fn next_frame(&mut self) -> Result<CapturedFrame>;
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
@@ -52,23 +61,47 @@ pub trait Capturer: Send {
/// Block until a FRESH frame is available via [`try_latest`](Self::try_latest) or
/// `deadline` passes — the encode loop's frame-driven wait (latency plan T1.1): waking on
/// the compositor's publish instead of sampling at a free-running tick deletes the
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame (the
/// loop's `try_latest` call does); backends buffer internally where the arrival channel
/// can't be peeked. Only called when [`supports_arrival_wait`](Self::supports_arrival_wait)
/// is `true`; errors surface at the following `try_latest`.
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame the
/// loop's `try_latest` call does that — so a backend implements this by waiting on a wakeup
/// and then PEEKING its hand-off slot. Only called when
/// [`supports_arrival_wait`](Self::supports_arrival_wait) is `true`; errors surface at the
/// following `try_latest`.
fn wait_arrival(&mut self, _deadline: std::time::Instant) {}
// ---- Lifecycle --------------------------------------------------------------------------
// Whether the capturer is being used right now, and whether it can still be used at all.
/// Gate expensive per-frame work so the capturer can be kept alive (reused) between
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive;
/// the default is a no-op (synthetic sources are produced on demand). Set `true` for the
/// duration of a stream, `false` when it ends.
fn set_active(&self, _active: bool) {}
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive and
/// flushes its frame mailbox on `false`; the default is a no-op (synthetic sources are produced
/// on demand). Set `true` for the duration of a stream, `false` when it ends.
///
/// `&mut self`: it mutates capturer state, and every caller owns the capturer. It took `&self`
/// only because the flag happened to be an `Arc<AtomicBool>` — an implementation detail leaking
/// into the contract, and one the mailbox flush this now also does would not have shared.
fn set_active(&mut self, _active: bool) {}
/// Whether this capturer can still produce frames — the gate a caller that POOLS capturers
/// across streams must consult before reusing one.
///
/// Some backends have TERMINAL states that are only observable by trying to consume a frame:
/// the Linux portal capturer's zero-copy poison flag, a dead PipeWire thread, and a source that
/// never returns to `Streaming` are all sticky, and each makes every subsequent
/// [`next_frame`](Self::next_frame) / [`try_latest`](Self::try_latest) fail — for that backend
/// an `Err` from either is terminal, never transient. A pool that re-admits such a capturer
/// wedges the next session permanently (it re-fails at the same point, every reconnect), which
/// is why this predicate exists rather than leaving callers to infer liveness from an error they
/// have often already discarded.
///
/// `true` (the default) for backends with no such state: the synthetic sources, and the Windows
/// IDD-push capturer, whose failures already end the session through its own rebuild path.
fn is_alive(&self) -> bool {
true
}
// ---- Cursor -----------------------------------------------------------------------------
// The out-of-band pointer: where it is, who draws it, and (Linux/gamescope) where to read it.
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
/// The capture source's LIVE cursor state, when it arrives out-of-band from the frames
/// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and
/// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves
@@ -90,13 +123,21 @@ pub trait Capturer: Send {
/// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no
/// `SPA_META_Cursor`, so [`cursor`](Self::cursor)'s slot stays empty — this hands the Linux
/// portal capturer gamescope's nested Xwayland `(DISPLAY, XAUTHORITY)` targets (it may run
/// several — one per `--xwayland-count`) so it reads the pointer shape/position over X11
/// (XFixes + QueryPointer), following whichever display is focused, and publishes it into that
/// same slot. Called once, after the capturer is built, only for gamescope sessions. Default
/// no-op: every non-gamescope capturer already has a cursor source.
fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option<String>)>) {}
/// portal capturer a way to reach gamescope's nested Xwaylands (it may run several — one per
/// `--xwayland-count`) so it reads the pointer shape/position over X11 (XFixes +
/// QueryPointer), following whichever display is focused, and publishes it into that same slot.
/// Called once, after the capturer is built, only for gamescope sessions. Default no-op: every
/// non-gamescope capturer already has a cursor source.
#[cfg(target_os = "linux")]
fn attach_gamescope_cursor(&mut self, _targets: GamescopeCursorTargets) {}
// ---- Stream properties ------------------------------------------------------------------
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
None
}
@@ -111,10 +152,18 @@ pub trait Capturer: Send {
1
}
// ---- Host-initiated resize --------------------------------------------------------------
// These two are ONE operation split in half and must be implemented together: a backend that
// returns `Some` from `capture_target_id` is promising `resize_output` works, and one that
// implements `resize_output` without the identity leaves the caller no way to check that the
// display it just reconfigured is still this capturer's. Both defaults decline.
/// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path
/// can verify the display it just reconfigured is STILL the one this capturer serves (an
/// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a
/// fresh capturer). `None` = the backend has no such identity (every non-IDD backend).
///
/// PAIRED with [`resize_output`](Self::resize_output) — see the cluster note above.
fn capture_target_id(&self) -> Option<u32> {
None
}
@@ -125,9 +174,25 @@ pub trait Capturer: Send {
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive;
/// only the encoder is swapped by the caller once the first new-size frame arrives. Returns
/// `true` when handled; `false` (the default) routes the caller to the full-rebuild path.
///
/// PAIRED with [`capture_target_id`](Self::capture_target_id) — see the cluster note above.
fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
false
}
/// Recreate the delivery ring at the CURRENT mode and re-run the driver attach handshake —
/// the recovery half of a swap-chain bounce the descriptor poller cannot see: an
/// exclusive-topology eviction (the vdisplay re-assert watchdog) is a real topology change,
/// so the OS drives COMMIT_MODES on the live virtual display too and the driver's swap-chain
/// is recreated while this capturer keeps waiting on the old ring attachment — frames stop
/// with an unchanged descriptor (same mode, same HDR), so the two-strike debounce never
/// trips. Arms the same recover-or-drop window as a real resize, so a driver that cannot
/// re-attach still fails the session cleanly. Returns `true` when handled; `false` (the
/// default) means the backend has no in-place ring recovery and the caller should treat the
/// pipeline as unrecoverable in place.
fn recreate_ring_in_place(&mut self) -> bool {
false
}
}
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
@@ -299,6 +364,23 @@ pub struct ZeroCopyPolicy {
pub pyrowave_modifiers: Vec<u64>,
}
/// Discovers gamescope's nested Xwayland cursor targets — `(DISPLAY, XAUTHORITY)`, one per
/// `--xwayland-count` — for [`Capturer::attach_gamescope_cursor`].
///
/// A CLOSURE, not the `Vec` it used to be, and re-run on a slow cadence by the cursor worker. The
/// snapshot was taken once, before the game launched: gamescope creates a second Xwayland for the
/// game but only advertises the FIRST in any child's environ, so the game's display was invisible to
/// discovery — and when the connected (Big Picture) display then reported "gamescope is not drawing
/// the pointer here", the source blanked the cursor for the whole game session, which is the exact
/// regression the module doc says it fixed. A provider also lets the worker retry a display that
/// died, and lets a stream that starts BEFORE the game converge instead of staying cursorless.
///
/// Built by the host facade (it wraps `pf_vdisplay::gamescope_xwayland_cursor_targets`), exactly
/// like [`FrameChannelSender`] — so the capture→host edge stays one-way.
#[cfg(target_os = "linux")]
pub type GamescopeCursorTargets =
std::sync::Arc<dyn Fn() -> Vec<(String, Option<String>)> + Send + Sync>;
#[cfg(target_os = "linux")]
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
true
@@ -447,15 +529,25 @@ pub mod synthetic_nv12;
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
/// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes
/// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade.
/// The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
/// `want_metadata_cursor` asks for cursor-as-metadata (`SPA_META_Cursor`) — pass it only when
/// the session's encode path composites `CapturedFrame::cursor` (the host consults
/// `pf-encode`'s `cursor_blend_capable`); otherwise the portal EMBEDS the pointer so it is
/// never silently lost. The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts
/// (the one-way edge).
#[cfg(target_os = "linux")]
pub fn open_portal_monitor(
anchored: bool,
want_hdr: bool,
want_metadata_cursor: bool,
policy: ZeroCopyPolicy,
) -> Result<Box<dyn Capturer>> {
linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), policy)
.map(|c| Box::new(c) as Box<dyn Capturer>)
linux::PortalCapturer::open(
anchored,
want_hdr && !hdr_capture_failed(),
want_metadata_cursor,
policy,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+357
View File
@@ -0,0 +1,357 @@
//! The portal CONTROL PLANE: the xdg ScreenCast / RemoteDesktop handshake (async, `ashpd` over
//! zbus, on its own tokio runtime), the cursor-mode choice, and GNOME's BT.2100 colour-mode probe.
//!
//! Split out of `linux/mod.rs` (sweep Phase 5.3) to separate the async control plane from the
//! realtime half: nothing here runs per frame — the handshake happens once, then the thread parks
//! until `PortalSession`'s `Drop` (in the parent) releases it, and DROPPING the zbus
//! connection is what ends the compositor's cast. The probe is likewise a one-shot D-Bus round-trip
//! for a control-plane caller.
use anyhow::{anyhow, Context, Result};
use std::os::fd::OwnedFd;
/// Whether any monitor of the live GNOME session is currently in BT.2100 (HDR) colour mode — the
/// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+;
/// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ).
/// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property
/// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48
/// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer.
/// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only.
pub fn gnome_hdr_monitor_active() -> bool {
use ashpd::zbus;
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
// (spec(ssss), modes a(siiddada{sv}), properties a{sv}) — "color-mode" lives in the monitor
// properties.
type Mode = (
String,
i32,
i32,
f64,
f64,
Vec<f64>,
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
);
type Monitor = (
(String, String, String, String),
Vec<Mode>,
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
);
type LogicalMonitor = (
i32,
i32,
f64,
u32,
bool,
Vec<(String, String, String, String)>,
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
);
type State = (
u32,
Vec<Monitor>,
Vec<LogicalMonitor>,
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
);
let probe = || -> Result<bool> {
// zbus is built async-only here (ashpd's tokio integration) — run the one round-trip on
// a throwaway current-thread runtime; this is a control-plane call, never per-frame.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build tokio runtime")?;
rt.block_on(async {
let conn = zbus::Connection::session().await.context("session bus")?;
let reply = conn
.call_method(
Some("org.gnome.Mutter.DisplayConfig"),
"/org/gnome/Mutter/DisplayConfig",
Some("org.gnome.Mutter.DisplayConfig"),
"GetCurrentState",
&(),
)
.await
.context("DisplayConfig.GetCurrentState")?;
let (_serial, monitors, _logical, _props): State = reply
.body()
.deserialize()
.context("parse GetCurrentState")?;
Ok(monitors.iter().any(|(_spec, _modes, props)| {
props
.get("color-mode")
.and_then(|v| u32::try_from(v).ok())
.is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100
}))
})
};
match probe() {
Ok(hdr) => hdr,
Err(e) => {
tracing::debug!(error = %format!("{e:#}"), "GNOME HDR colour-mode probe failed — SDR");
false
}
}
}
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`).
/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap
/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position +
/// an occasional bitmap), which the consumer composites itself — avoiding the producer burning the
/// cursor into every frame (`Embedded`), which on gamescope would defeat its HW cursor plane.
/// Without it — the session's encode path has no compositing stage for a metadata cursor
/// (`pf-encode`'s `cursor_blend_capable` said the resolved backend can't blend) — the ladder
/// prefers `Embedded`, so the pointer is in the pixels instead of in metadata nothing would draw.
/// Both ladders fall through to the other mode, then `Hidden`; a failed property query (an older
/// portal) keeps the prior `Embedded` behavior so the cursor is never silently lost.
async fn choose_cursor_mode(
proxy: &ashpd::desktop::screencast::Screencast,
want_metadata: bool,
) -> ashpd::desktop::screencast::CursorMode {
use ashpd::desktop::screencast::CursorMode;
match proxy.available_cursor_modes().await {
Ok(avail) if want_metadata && avail.contains(CursorMode::Metadata) => {
tracing::info!(
?avail,
"ScreenCast: requesting cursor-as-metadata (SPA_META_Cursor)"
);
CursorMode::Metadata
}
Ok(avail) if avail.contains(CursorMode::Embedded) => {
if want_metadata {
tracing::info!(
?avail,
"ScreenCast: cursor metadata unavailable — requesting Embedded cursor"
);
} else {
tracing::info!(
?avail,
"ScreenCast: requesting Embedded cursor (this session's encoder does not \
composite a metadata cursor)"
);
}
CursorMode::Embedded
}
Ok(avail) if avail.contains(CursorMode::Metadata) => {
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture
// path composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
tracing::warn!(
?avail,
"ScreenCast: Embedded cursor not advertised — requesting cursor-as-metadata \
(only CPU-path frames will composite it)"
);
CursorMode::Metadata
}
Ok(avail) => {
tracing::warn!(
?avail,
"ScreenCast: neither Metadata nor Embedded cursor advertised — cursor will be hidden"
);
CursorMode::Hidden
}
Err(e) => {
tracing::warn!(
error = %e,
"ScreenCast: AvailableCursorModes query failed — defaulting to Embedded cursor"
);
CursorMode::Embedded
}
}
}
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
/// PipeWire remote, hand the fd + node id back, then keep the session alive until `quit_rx`
/// resolves (the capturer's `Drop` — see [`PortalSession`]).
pub(super) fn portal_thread(
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
quit_rx: tokio::sync::oneshot::Receiver<()>,
want_metadata_cursor: bool,
) {
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
// Multi-thread runtime: the zbus connection's background reader must be pumped
// continuously across the create_session → select_sources → start handshake, or the
// portal reports "Invalid session". (A current-thread runtime starves it.)
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
return;
}
};
let err_tx = setup_tx.clone();
rt.block_on(async move {
let result: Result<()> = async {
let proxy = Screencast::new()
.await
.context("connect ScreenCast portal")?;
let session = proxy
.create_session(Default::default())
.await
.context("create_session")?;
let cursor_mode = choose_cursor_mode(&proxy, want_metadata_cursor).await;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode)
// Only MONITOR is offered by the wlroots backend
// (AvailableSourceTypes=1); requesting unsupported types
// invalidates the session.
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_sources")?
.response()
.context("select_sources rejected (unsupported source type / cursor mode?)")?;
let streams = proxy
.start(&session, None, Default::default())
.await
.context("start cast")?
.response()
.context("start response (chooser cancelled? portal misconfigured?)")?;
let stream = streams
.streams()
.first()
.context("portal returned no streams")?
.clone();
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.context("open_pipe_wire_remote")?;
setup_tx
.send(Ok((fd, node_id)))
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
// Keep `proxy` + `session` (and the underlying zbus connection) alive for the
// capture; the cast is torn down when the connection drops (ashpd's `Session`
// has no `Drop`) — which now happens when this park returns, not at process exit.
let _keep_alive = (&proxy, &session);
let _ = quit_rx.await;
Ok(())
}
.await;
if let Err(e) = result {
let _ = err_tx.send(Err(format!("{e:#}")));
}
});
// Drop the runtime HERE, before the caller signals completion: shutting the 2 workers down is
// what finishes releasing the zbus connection, so a `done` signal sent after this means the
// compositor-side session is really gone (see `PortalSession::drop`).
drop(rt);
}
/// Combined RemoteDesktop+ScreenCast portal setup (KWin/GNOME). ScreenCast sources are selected
/// on a session created via RemoteDesktop, so a single RemoteDesktop `start` grant —
/// pre-authorized headlessly via the `kde-authorized` permission, exactly like the libei input
/// path — also covers screen capture, with no separate ScreenCast dialog (which has no such
/// bypass). Yields the same PipeWire fd + node id as the standalone path; the consumer is
/// identical, as is the `quit_rx` teardown park (see [`PortalSession`]).
pub(super) fn portal_thread_remote_desktop(
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
quit_rx: tokio::sync::oneshot::Receiver<()>,
want_metadata_cursor: bool,
) {
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
return;
}
};
let err_tx = setup_tx.clone();
rt.block_on(async move {
let result: Result<()> = async {
let remote = RemoteDesktop::new()
.await
.context("connect RemoteDesktop portal")?;
let screencast = Screencast::new()
.await
.context("connect ScreenCast portal")?;
let session = remote
.create_session(Default::default())
.await
.context("create RemoteDesktop session")?;
// RemoteDesktop requires a device selection; we never connect_to_eis on this session
// (input injection runs its own), but selecting devices is what makes `start` the
// RemoteDesktop grant the kde-authorized bypass covers.
remote
.select_devices(
&session,
SelectDevicesOptions::default()
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_devices")?
.response()
.context("select_devices rejected")?;
let cursor_mode = choose_cursor_mode(&screencast, want_metadata_cursor).await;
screencast
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode)
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_sources")?
.response()
.context("select_sources rejected (unsupported source type?)")?;
let streams = remote
.start(&session, None, Default::default())
.await
.context("start RemoteDesktop+ScreenCast")?
.response()
.context("start response (grant not pre-authorized / headless dialog?)")?;
let stream = streams
.streams()
.first()
.context("portal returned no screencast streams")?
.clone();
let node_id = stream.pipe_wire_node_id();
let fd = screencast
.open_pipe_wire_remote(&session, Default::default())
.await
.context("open_pipe_wire_remote")?;
setup_tx
.send(Ok((fd, node_id)))
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
// Keep the proxies + session (and their zbus connection) alive for the capture, until
// the capturer's `Drop` fires the quit channel.
let _keep_alive = (&remote, &screencast, &session);
let _ = quit_rx.await;
Ok(())
}
.await;
if let Err(e) = result {
let _ = err_tx.send(Err(format!("{e:#}")));
}
});
// See `portal_thread`: drop the runtime before the caller's completion signal.
drop(rt);
}
+634
View File
@@ -0,0 +1,634 @@
//! Cursor-as-metadata: the `SPA_META_Cursor` parser and the CPU-path composite blits.
//!
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2). Both halves are producer-driven and
//! bounds-critical: [`update_cursor_meta`] reads a bitmap at offsets the COMPOSITOR chose (its own
//! SAFETY proof notes that a missing bound SIGSEGVs inside the PipeWire `.process` callback, where
//! `catch_unwind` cannot help), and the `composite_cursor*` blits clip a caller-positioned bitmap
//! into a frame buffer. Separating them from the stream machinery is what makes them testable
//! without a compositor.
use super::PixelFormat;
use pipewire as pw;
use pw::spa;
use std::sync::Arc;
/// Latest cursor state parsed from `SPA_META_Cursor` (cursor-as-metadata mode). Position is
/// refreshed every buffer that carries the meta (including Mutter's cursor-only "corrupted"
/// buffers we otherwise skip for their stale frame); the RGBA bitmap is cached and only
/// replaced when the compositor sends a fresh one (`bitmap_offset != 0`).
#[derive(Default)]
pub(super) struct CursorState {
/// True when the compositor reports a visible pointer (`spa_meta_cursor.id != 0`).
visible: bool,
/// Top-left where the bitmap is drawn = reported position hotspot.
x: i32,
y: i32,
/// Cached straight-alpha RGBA pixels (`bw*bh*4`, bytes R,G,B,A). `Arc` so the overlay handed
/// to each GPU frame is a refcount bump, not a copy. Empty until the first bitmap arrives.
rgba: Arc<Vec<u8>>,
bw: u32,
bh: u32,
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
/// so the GPU encoder re-uploads its cursor texture only on change.
serial: u64,
/// The compositor-reported hotspot — carried on the overlay for the cursor-forward
/// channel (the blend path uses the pre-adjusted `x`/`y` and never reads it).
hot_x: i32,
hot_y: i32,
}
impl CursorState {
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
/// client's relative-mode hint (M3) — which is a different fact from "no cursor yet".
/// The encode loop strips invisible overlays before any blend path sees the frame.
/// Cheap: clones an `Arc` + a few scalars.
pub(super) fn overlay(&self) -> Option<pf_frame::CursorOverlay> {
if self.rgba.is_empty() {
return None;
}
Some(pf_frame::CursorOverlay {
x: self.x,
y: self.y,
w: self.bw,
h: self.bh,
rgba: self.rgba.clone(),
serial: self.serial,
hot_x: self.hot_x.max(0) as u32,
hot_y: self.hot_y.max(0) as u32,
visible: self.visible,
})
}
}
/// Extract straight (R,G,B,A) from one 4-byte cursor-bitmap pixel, honoring the bitmap's SPA
/// video format (portals emit RGBA or BGRA; ARGB/ABGR handled for completeness). Unknown
/// 4-byte formats are read as RGBA.
pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
match vfmt {
x if x == spa::sys::SPA_VIDEO_FORMAT_RGBA => (s[0], s[1], s[2], s[3]),
x if x == spa::sys::SPA_VIDEO_FORMAT_BGRA => (s[2], s[1], s[0], s[3]),
x if x == spa::sys::SPA_VIDEO_FORMAT_ARGB => (s[1], s[2], s[3], s[0]),
x if x == spa::sys::SPA_VIDEO_FORMAT_ABGR => (s[3], s[2], s[1], s[0]),
_ => (s[0], s[1], s[2], s[3]),
}
}
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
// `spa_buffer_find_meta` returns the `spa_meta` (type + byte `size` + `data` pointer) for
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below
// are ALL producer-written, and without a bound against the actual region they drive
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
// catch). Every offset below is validated against `region_size` with checked arithmetic,
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) };
if meta.is_null() {
return;
}
// SAFETY: `meta` is non-null and points into the held buffer's metadata array.
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) };
if data.is_null() || region_size < std::mem::size_of::<spa::sys::spa_meta_cursor>() {
return;
}
let cur = data as *const spa::sys::spa_meta_cursor;
// SAFETY: `region_size >= size_of::<spa_meta_cursor>()` checked above, so every field is in bounds.
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
(
(*cur).id,
(*cur).position.x,
(*cur).position.y,
(*cur).hotspot.x,
(*cur).hotspot.y,
(*cur).bitmap_offset,
)
};
if id == 0 {
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
return;
}
cursor.visible = true;
cursor.x = pos_x - hot_x;
cursor.y = pos_y - hot_y;
cursor.hot_x = hot_x;
cursor.hot_y = hot_y;
if bmp_off == 0 {
// Position-only update — keep the cached bitmap.
return;
}
let bmp_off = bmp_off as usize;
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it —
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata.
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) {
Some(end) if end <= region_size => {}
_ => return,
}
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
// so the header is fully in bounds for a read of that many bytes. `read_unaligned` is
// REQUIRED, not defensive: `bmp_off` is producer-written and nothing in the SPA contract or
// in this function establishes that `data + bmp_off` meets `spa_meta_bitmap`'s alignment —
// the previous field reads through an aligned `*const` asserted an invariant the code never
// proved. The struct is `Copy` POD, so one unaligned read yields an owned, aligned local.
let bmp = unsafe { (data.add(bmp_off) as *const spa::sys::spa_meta_bitmap).read_unaligned() };
let (vfmt, bw, bh, stride, pix_off) = (
bmp.format,
bmp.size.width,
bmp.size.height,
bmp.stride.max(0) as usize,
bmp.offset as usize,
);
// Ignore empty or implausibly large bitmaps (the meta-size request covers <= 1024×1024;
// real cursors are ≤96px — the cursor channel downscales >120px for the wire anyway).
if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 {
return;
}
let row = bw as usize * 4;
let stride = if stride < row { row } else { stride };
let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size)
else {
return;
};
// SAFETY: `bitmap_extent` returned `Some`, which means (see its contract) the whole range
// `[bmp_off + pix_off, +len)` lies inside `region_size` and `len` is EXACTLY the extent the
// strided loop below reads. `data` is the producer's meta-region base, live for this callback.
let src = unsafe { std::slice::from_raw_parts(data.add(extent.start), extent.len()) };
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
for y in 0..bh as usize {
for x in 0..bw as usize {
let so = y * stride + x * 4;
let (r, g, b, a) = decode_bitmap_pixel(vfmt, &src[so..so + 4]);
let d = (y * bw as usize + x) * 4;
rgba[d] = r;
rgba[d + 1] = g;
rgba[d + 2] = b;
rgba[d + 3] = a;
}
}
cursor.rgba = Arc::new(rgba);
cursor.bw = bw;
cursor.bh = bh;
cursor.serial = cursor.serial.wrapping_add(1);
}
/// The byte range inside the producer's cursor-meta region that a `bh`-row, `row`-wide,
/// `stride`-strided bitmap at `bmp_off + pix_off` occupies — or `None` when it does not fit, or when
/// any of the arithmetic overflows.
///
/// THE bound on `update_cursor_meta`. Every input except `region_size` is producer-written, and
/// `region_size` is the real byte size of the meta region libspa handed us (which is why the caller
/// takes `spa_buffer_find_meta` rather than `find_meta_data`). Without this check the offsets drive
/// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read that
/// SIGSEGVs inside the PipeWire `.process` callback, where `catch_unwind` cannot help. A stride near
/// `i32::MAX` is enough to overflow the multiply on its own, so every step is checked.
///
/// `len()` of the returned range is EXACTLY `stride·(bh1) + row`: the last row contributes only its
/// `row` visible bytes, not a full stride, so a bitmap that ends flush against the region's end is
/// accepted rather than rejected by a padding byte that is never read.
///
/// Extracted (sweep Phase 6.1) purely so it can be tested — the caller is unreachable without a live
/// compositor.
fn bitmap_extent(
bmp_off: usize,
pix_off: usize,
stride: usize,
row: usize,
bh: usize,
region_size: usize,
) -> Option<std::ops::Range<usize>> {
if bh == 0 || row == 0 || stride < row {
return None;
}
let span = stride.checked_mul(bh - 1)?.checked_add(row)?;
let start = bmp_off.checked_add(pix_off)?;
let end = start.checked_add(span)?;
(end <= region_size).then_some(start..end)
}
/// Destination channel byte offsets (R,G,B) and bytes-per-pixel for a packed-RGB `PixelFormat`,
/// or `None` for a layout the CPU cursor blit doesn't handle (YUV/10-bit — those never reach
/// the CPU de-pad path anyway).
pub(super) fn dst_offsets(fmt: PixelFormat) -> Option<(usize, usize, usize, usize)> {
Some(match fmt {
PixelFormat::Bgrx | PixelFormat::Bgra => (2, 1, 0, 4),
PixelFormat::Rgbx | PixelFormat::Rgba => (0, 1, 2, 4),
PixelFormat::Rgb => (0, 1, 2, 3),
PixelFormat::Bgr => (2, 1, 0, 3),
_ => return None,
})
}
/// Alpha-blend the cached cursor bitmap into a packed 10-bit (`X2Rgb10`/`X2Bgr10`) CPU frame:
/// unpack each u32, blend the 8-bit cursor channels scaled to 10 bits (`v<<2 | v>>6`), repack.
/// The frame samples are PQ-encoded, so like the 8-bit gamma-space blend this is a display-
/// referred approximation — fine for a cursor. `r_shift` is the R channel's bit offset (20 for
/// x:R:G:B, 0 for x:B:G:R); G is always at 10 and B mirrors R.
pub(super) fn composite_cursor_rgb10(
tight: &mut [u8],
w: usize,
h: usize,
r_shift: u32,
cursor: &CursorState,
) {
let b_shift = 20 - r_shift; // 0 or 20 — the opposite end from R
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
for cy in 0..bh {
let dy = cursor.y + cy;
if dy < 0 || dy as usize >= h {
continue;
}
for cx in 0..bw {
let dx = cursor.x + cx;
if dx < 0 || dx as usize >= w {
continue;
}
let s = ((cy * bw + cx) as usize) * 4;
let a = cursor.rgba[s + 3] as u32;
if a == 0 {
continue;
}
// 8-bit cursor channel → 10-bit (replicate the top bits into the bottom).
let up10 = |v: u8| ((v as u32) << 2) | ((v as u32) >> 6);
let (sr, sg, sb) = (
up10(cursor.rgba[s]),
up10(cursor.rgba[s + 1]),
up10(cursor.rgba[s + 2]),
);
let di = (dy as usize * w + dx as usize) * 4;
let px = u32::from_le_bytes(tight[di..di + 4].try_into().unwrap());
let blend = |dst: u32, src: u32| (src * a + dst * (255 - a)) / 255;
let dr = blend((px >> r_shift) & 0x3ff, sr);
let dg = blend((px >> 10) & 0x3ff, sg);
let db = blend((px >> b_shift) & 0x3ff, sb);
let out = (px & 0xc000_0000) | (dr << r_shift) | (dg << 10) | (db << b_shift);
tight[di..di + 4].copy_from_slice(&out.to_le_bytes());
}
}
}
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
pub(super) fn composite_cursor(
tight: &mut [u8],
w: usize,
h: usize,
fmt: PixelFormat,
cursor: &CursorState,
) {
if !cursor.visible || cursor.rgba.is_empty() {
return;
}
// The packed 10-bit HDR layouts blend via bit unpack/repack, not byte offsets.
match fmt {
PixelFormat::X2Rgb10 => return composite_cursor_rgb10(tight, w, h, 20, cursor),
PixelFormat::X2Bgr10 => return composite_cursor_rgb10(tight, w, h, 0, cursor),
_ => {}
}
let Some((ri, gi, bi, bpp)) = dst_offsets(fmt) else {
return;
};
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
for cy in 0..bh {
let dy = cursor.y + cy;
if dy < 0 || dy as usize >= h {
continue;
}
for cx in 0..bw {
let dx = cursor.x + cx;
if dx < 0 || dx as usize >= w {
continue;
}
let s = ((cy * bw + cx) as usize) * 4;
let a = cursor.rgba[s + 3] as u32;
if a == 0 {
continue;
}
let (sr, sg, sb) = (
cursor.rgba[s] as u32,
cursor.rgba[s + 1] as u32,
cursor.rgba[s + 2] as u32,
);
let di = (dy as usize * w + dx as usize) * bpp;
let blend = |dst: u8, src: u32| ((src * a + dst as u32 * (255 - a)) / 255) as u8;
tight[di + ri] = blend(tight[di + ri], sr);
tight[di + gi] = blend(tight[di + gi], sg);
tight[di + bi] = blend(tight[di + bi], sb);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A solid-colour cursor state at `(x, y)`, `w`×`h`, alpha `a`.
fn cursor(x: i32, y: i32, w: u32, h: u32, rgb: (u8, u8, u8), a: u8) -> CursorState {
let mut px = Vec::with_capacity((w * h * 4) as usize);
for _ in 0..w * h {
px.extend_from_slice(&[rgb.0, rgb.1, rgb.2, a]);
}
CursorState {
visible: true,
x,
y,
rgba: Arc::new(px),
bw: w,
bh: h,
serial: 1,
hot_x: 0,
hot_y: 0,
}
}
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
#[test]
fn bitmap_extent_accepts_a_bitmap_that_fits() {
// 4×2 RGBA, tightly packed: 32 bytes at offset 0.
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 32), Some(0..32));
// …and the same bitmap behind a header + pixel offset.
assert_eq!(bitmap_extent(24, 8, 16, 16, 2, 64), Some(32..64));
}
#[test]
fn bitmap_extent_charges_the_last_row_only_its_visible_bytes() {
// stride 32, row 16, 3 rows ⇒ 32*2 + 16 = 80, NOT 96. A bitmap ending flush against the
// region must be accepted: the trailing stride padding is never read.
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 80), Some(0..80));
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 79), None, "one byte short");
}
#[test]
fn bitmap_extent_rejects_anything_past_the_region() {
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 31), None);
// An offset alone can push it out.
assert_eq!(bitmap_extent(1, 0, 16, 16, 2, 32), None);
assert_eq!(bitmap_extent(0, 1, 16, 16, 2, 32), None);
// A region of zero accepts nothing.
assert_eq!(bitmap_extent(0, 0, 16, 16, 1, 0), None);
}
/// The producer picks `stride` and both offsets, so each is an overflow vector on its own.
#[test]
fn bitmap_extent_survives_hostile_arithmetic() {
// stride × (bh-1) overflows.
assert_eq!(bitmap_extent(0, 0, usize::MAX, 16, 3, usize::MAX), None);
// span + row overflows. Needs ≥2 rows so `stride·(bh1)` is already at the ceiling: with a
// SINGLE row `stride·0 == 0`, and even a `usize::MAX`-wide row is then arithmetically in
// range — which is correct rather than a miss, since the caller has already capped `bw` at
// 1024 and `row` is therefore ≤ 4096.
assert_eq!(
bitmap_extent(0, 0, usize::MAX, usize::MAX, 2, usize::MAX),
None
);
// bmp_off + pix_off overflows.
assert_eq!(bitmap_extent(usize::MAX, 1, 16, 16, 1, usize::MAX), None);
// start + span overflows.
assert_eq!(
bitmap_extent(usize::MAX - 8, 0, 16, 16, 1, usize::MAX),
None
);
// A near-i32::MAX stride — the case the SAFETY comment calls out — must not wrap.
assert_eq!(bitmap_extent(0, 0, i32::MAX as usize, 16, 1024, 4096), None);
}
#[test]
fn bitmap_extent_rejects_degenerate_geometry() {
assert_eq!(bitmap_extent(0, 0, 16, 16, 0, 4096), None, "zero rows");
assert_eq!(bitmap_extent(0, 0, 16, 0, 2, 4096), None, "zero-width row");
assert_eq!(bitmap_extent(0, 0, 8, 16, 2, 4096), None, "stride < row");
}
// ---- composite_cursor: clipping, alpha, and every layout --------------------------------
/// Read pixel `(x, y)`'s (R, G, B) out of a packed frame, honouring the layout's byte order.
fn px_rgb(buf: &[u8], w: usize, x: usize, y: usize, fmt: PixelFormat) -> (u8, u8, u8) {
let (ri, gi, bi, bpp) = dst_offsets(fmt).expect("packed layout");
let i = (y * w + x) * bpp;
(buf[i + ri], buf[i + gi], buf[i + bi])
}
#[test]
fn every_packed_layout_lands_the_colour_in_its_own_channels() {
for fmt in [
PixelFormat::Bgrx,
PixelFormat::Bgra,
PixelFormat::Rgbx,
PixelFormat::Rgba,
PixelFormat::Rgb,
PixelFormat::Bgr,
] {
let bpp = dst_offsets(fmt).unwrap().3;
let (w, h) = (4usize, 4usize);
let mut buf = vec![0u8; w * h * bpp];
// Opaque pure red at (1, 1).
composite_cursor(&mut buf, w, h, fmt, &cursor(1, 1, 1, 1, (255, 0, 0), 255));
assert_eq!(px_rgb(&buf, w, 1, 1, fmt), (255, 0, 0), "{fmt:?}");
// Nothing else moved.
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (0, 0, 0), "{fmt:?}");
assert_eq!(px_rgb(&buf, w, 2, 1, fmt), (0, 0, 0), "{fmt:?}");
}
}
#[test]
fn a_cursor_hanging_off_every_edge_is_clipped_not_wrapped() {
let (w, h, fmt) = (4usize, 4usize, PixelFormat::Bgrx);
// Top-left: only the bottom-right quarter of a 2×2 lands, at (0, 0).
let mut buf = vec![0u8; w * h * 4];
composite_cursor(
&mut buf,
w,
h,
fmt,
&cursor(-1, -1, 2, 2, (10, 20, 30), 255),
);
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (10, 20, 30));
assert_eq!(px_rgb(&buf, w, 1, 0, fmt), (0, 0, 0));
assert_eq!(px_rgb(&buf, w, 0, 1, fmt), (0, 0, 0));
// Bottom-right: only the top-left quarter lands, at (3, 3).
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &cursor(3, 3, 2, 2, (10, 20, 30), 255));
assert_eq!(px_rgb(&buf, w, 3, 3, fmt), (10, 20, 30));
assert_eq!(px_rgb(&buf, w, 2, 3, fmt), (0, 0, 0));
// Fully outside in each direction: the frame is untouched.
for pos in [(-2, 0), (0, -2), (4, 0), (0, 4), (-9, -9), (99, 99)] {
let mut buf = vec![0u8; w * h * 4];
composite_cursor(
&mut buf,
w,
h,
fmt,
&cursor(pos.0, pos.1, 2, 2, (255, 255, 255), 255),
);
assert!(buf.iter().all(|&b| b == 0), "drew something at {pos:?}");
}
}
#[test]
fn transparent_and_hidden_cursors_draw_nothing() {
let (w, h, fmt) = (2usize, 2usize, PixelFormat::Bgrx);
// Alpha 0 — every pixel skipped.
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 2, 2, (255, 255, 255), 0));
assert!(buf.iter().all(|&b| b == 0));
// `visible: false` — the whole blit is skipped.
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
c.visible = false;
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &c);
assert!(buf.iter().all(|&b| b == 0));
// No bitmap yet — likewise.
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
c.rgba = Arc::new(Vec::new());
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &c);
assert!(buf.iter().all(|&b| b == 0));
}
#[test]
fn half_alpha_blends_toward_the_destination() {
let (w, h, fmt) = (1usize, 1usize, PixelFormat::Bgrx);
// dst = white, src = black at 50% ⇒ mid grey (integer blend: (0*128 + 255*127)/255 = 127).
let mut buf = vec![255u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 1, 1, (0, 0, 0), 128));
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (127, 127, 127));
}
/// A layout the CPU blit cannot address must be declined, not mis-blitted.
#[test]
fn unsupported_layouts_are_declined() {
assert!(dst_offsets(PixelFormat::Nv12).is_none());
assert!(dst_offsets(PixelFormat::Yuv444).is_none());
let (w, h) = (2usize, 2usize);
let mut buf = vec![0u8; w * h * 4];
composite_cursor(
&mut buf,
w,
h,
PixelFormat::Nv12,
&cursor(0, 0, 2, 2, (255, 255, 255), 255),
);
assert!(buf.iter().all(|&b| b == 0), "NV12 must not be blitted");
}
// ---- composite_cursor_rgb10: the 10-bit unpack/repack round trip -------------------------
/// Pack an `x:R:G:B` (`X2Rgb10`) pixel — R at bit 20, G at 10, B at 0 — the way a producer does.
fn pack_x2rgb10(r: u32, g: u32, b: u32) -> [u8; 4] {
(0xC000_0000 | (r << 20) | (g << 10) | b).to_le_bytes()
}
#[test]
fn the_10bit_path_round_trips_an_untouched_pixel() {
// Alpha 0 ⇒ the blend is skipped entirely, so the packed pixel must come back bit-identical
// (including the top two bits, which are alpha and must survive the repack).
for (r, g, b) in [(0, 0, 0), (1023, 1023, 1023), (940, 64, 512), (1, 2, 3)] {
let src = pack_x2rgb10(r, g, b);
let mut buf = src.to_vec();
composite_cursor(
&mut buf,
1,
1,
PixelFormat::X2Rgb10,
&cursor(0, 0, 1, 1, (255, 255, 255), 0),
);
assert_eq!(buf, src, "({r},{g},{b}) was modified by a zero-alpha blend");
}
}
#[test]
fn the_10bit_path_writes_the_right_channel_at_the_right_shift() {
// Opaque pure red, 8-bit 255 → 10-bit 1023 (the `v<<2 | v>>6` expansion).
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
composite_cursor(
&mut buf,
1,
1,
PixelFormat::X2Rgb10,
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
);
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
assert_eq!((v >> 20) & 0x3ff, 1023, "R");
assert_eq!((v >> 10) & 0x3ff, 0, "G");
assert_eq!(v & 0x3ff, 0, "B");
assert_eq!(v & 0xc000_0000, 0xc000_0000, "alpha bits preserved");
// X2Bgr10 puts R at bit 0 and B at 20 — the SAME cursor must land in the other end.
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
composite_cursor(
&mut buf,
1,
1,
PixelFormat::X2Bgr10,
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
);
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
assert_eq!(v & 0x3ff, 1023, "R at bit 0 for x:B:G:R");
assert_eq!((v >> 20) & 0x3ff, 0, "B untouched");
}
#[test]
fn the_10bit_path_clips_like_the_8bit_one() {
let (w, h) = (2usize, 2usize);
let mut buf: Vec<u8> = (0..w * h).flat_map(|_| pack_x2rgb10(0, 0, 0)).collect();
let before = buf.clone();
// Entirely off-frame.
composite_cursor(
&mut buf,
w,
h,
PixelFormat::X2Rgb10,
&cursor(-5, -5, 2, 2, (255, 255, 255), 255),
);
assert_eq!(buf, before);
// Straddling the top-left corner: only (0, 0) is written.
composite_cursor(
&mut buf,
w,
h,
PixelFormat::X2Rgb10,
&cursor(-1, -1, 2, 2, (255, 255, 255), 255),
);
let p0 = u32::from_le_bytes(buf[0..4].try_into().unwrap());
let p1 = u32::from_le_bytes(buf[4..8].try_into().unwrap());
assert_eq!((p0 >> 20) & 0x3ff, 1023);
assert_eq!((p1 >> 20) & 0x3ff, 0);
}
// ---- decode_bitmap_pixel: the producer's byte order ------------------------------------
#[test]
fn each_bitmap_format_is_decoded_to_straight_rgba() {
let s = [1u8, 2, 3, 4];
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_RGBA, &s),
(1, 2, 3, 4)
);
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_BGRA, &s),
(3, 2, 1, 4)
);
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ARGB, &s),
(2, 3, 4, 1)
);
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ABGR, &s),
(4, 3, 2, 1)
);
// An unknown 4-byte format reads as RGBA rather than being rejected — documented behaviour.
assert_eq!(decode_bitmap_pixel(0xdead_beef, &s), (1, 2, 3, 4));
}
}
+515
View File
@@ -0,0 +1,515 @@
//! The PipeWire `EnumFormat` / `Buffers` / `Meta` param pods the capture stream offers, and the
//! `Pod` serializer they all end in.
//!
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2) because it is the crate's WIRE surface: what
//! these builders put in a pod is what the compositor intersects against, and a missing property is
//! not a compile error but a link that silently stalls in `negotiating`. Nothing here touches the
//! stream, the buffers or the frames — every function is a pure `facts -> Vec<u8>`.
use anyhow::{Context, Result};
use pipewire as pw;
use pw::spa;
use spa::param::video::VideoFormat;
pub(super) fn serialize_pod(obj: pw::spa::pod::Object) -> Result<Vec<u8>> {
Ok(pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize pod")?
.0
.into_inner())
}
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
pub(super) fn build_dmabuf_format(
format: VideoFormat,
modifiers: &[u64],
preferred: Option<(u32, u32, u32)>,
) -> Result<Vec<u8>> {
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
let mut obj = pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
pw::spa::pod::property!(
FormatProperties::VideoSize,
Choice,
Range,
Rectangle,
pw::spa::utils::Rectangle {
width: dw,
height: dh
},
pw::spa::utils::Rectangle {
width: 1,
height: 1
},
pw::spa::utils::Rectangle {
width: 8192,
height: 8192
}
),
pw::spa::pod::property!(
FormatProperties::VideoFramerate,
Choice,
Range,
Fraction,
pw::spa::utils::Fraction { num: dhz, denom: 1 },
pw::spa::utils::Fraction { num: 0, denom: 1 },
pw::spa::utils::Fraction { num: 240, denom: 1 }
),
);
if format == VideoFormat::NV12 {
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
)),
});
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
)),
});
}
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Long(
pw::spa::utils::Choice(
pw::spa::utils::ChoiceFlags::empty(),
pw::spa::utils::ChoiceEnum::Enum {
default: modifiers[0] as i64,
alternatives: modifiers.iter().map(|&m| m as i64).collect(),
},
),
)),
});
serialize_pod(obj)
}
/// Build one GNOME 50+ HDR format pod: `format` (xRGB_210LE / xBGR_210LE) as a LINEAR-only
/// dmabuf with **MANDATORY** BT.2020 primaries + SMPTE ST.2084 (PQ) transfer-function props —
/// the exact colorimetry Mutter's monitor stream advertises while the mirrored monitor is in
/// HDR mode (its HDR pods carry the same props MANDATORY, so both sides must speak them for
/// the intersection to exist; an SDR or pre-50 producer can never match this pod).
///
/// LINEAR-only because every 10-bit consumer we have reads the buffer without a de-tile pass:
/// the CPU path mmaps it, and the VAAPI passthrough imports it into a VA surface. The tiled
/// EGL de-tile blit renders into an 8-bit `GL_RGBA8` texture — it would silently crush the
/// depth — so tiled modifiers are deliberately NOT advertised (a zero-copy 10-bit de-tile is
/// the follow-up). SHM is excluded entirely: Mutter's SHM record path paints 8-bit ARGB32
/// regardless of the negotiated format.
/// `SPA_VIDEO_TRANSFER_SMPTE2084` (PQ) — spelled out rather than taken from `pw::spa::sys`
/// because libspa only grew the constant with the BT2020_10/SMPTE2084/ARIB_STD_B67 block, and
/// the distro builders (Ubuntu 24.04 noble for the .deb) ship headers predating it — bindgen
/// then emits no such constant and the host fails to compile there, even though the code never
/// runs on those systems (the HDR path needs GNOME 50+).
///
/// 14 is the enum's position in `spa/param/video/color.h` and is wire ABI, not a private
/// detail: SPA mirrors GStreamer's `GstVideoTransferFunction`, where that block was added
/// together, so the value is identical on every libspa that has the symbol at all. On one that
/// doesn't, PipeWire simply fails to intersect this format offer and the session negotiates
/// SDR — the same outcome as not offering HDR.
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
pub(super) fn build_hdr_dmabuf_format(
format: VideoFormat,
preferred: Option<(u32, u32, u32)>,
) -> Result<Vec<u8>> {
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
let mut obj = pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
pw::spa::pod::property!(
FormatProperties::VideoSize,
Choice,
Range,
Rectangle,
pw::spa::utils::Rectangle {
width: dw,
height: dh
},
pw::spa::utils::Rectangle {
width: 1,
height: 1
},
pw::spa::utils::Rectangle {
width: 8192,
height: 8192
}
),
pw::spa::pod::property!(
FormatProperties::VideoFramerate,
Choice,
Range,
Fraction,
pw::spa::utils::Fraction { num: dhz, denom: 1 },
pw::spa::utils::Fraction { num: 0, denom: 1 },
pw::spa::utils::Fraction { num: 240, denom: 1 }
),
);
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Long(0), // DRM_FORMAT_MOD_LINEAR
});
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_transferFunction,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(SPA_VIDEO_TRANSFER_SMPTE2084)),
});
obj.properties.push(pw::spa::pod::Property {
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorPrimaries,
flags: pw::spa::pod::PropertyFlags::MANDATORY,
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
pw::spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020,
)),
});
serialize_pod(obj)
}
/// The default (shm/CPU-path) format offer: raw video in any encoder-mappable layout, any
/// size, any framerate (0/1 = variable allowed — gamescope fixates exactly that).
pub(super) fn build_default_format_obj(preferred: Option<(u32, u32, u32)>) -> pw::spa::pod::Object {
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::MediaType,
Id,
pw::spa::param::format::MediaType::Video
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::MediaSubtype,
Id,
pw::spa::param::format::MediaSubtype::Raw
),
// Offer the layouts the encoder can map to an NVENC input format. wlroots
// commonly fixates packed RGB (3 bpp); other compositors offer 4 bpp. Only
// these are requested, so negotiation fails loudly rather than handing us a
// format we'd misinterpret.
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoFormat,
Choice,
Enum,
Id,
VideoFormat::RGB,
VideoFormat::RGB,
VideoFormat::BGR,
VideoFormat::RGBx,
VideoFormat::BGRx,
VideoFormat::RGBA,
VideoFormat::BGRA,
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoSize,
Choice,
Range,
Rectangle,
pw::spa::utils::Rectangle {
width: dw,
height: dh
},
pw::spa::utils::Rectangle {
width: 1,
height: 1
},
pw::spa::utils::Rectangle {
width: 8192,
height: 8192
}
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoFramerate,
Choice,
Range,
Fraction,
pw::spa::utils::Fraction { num: dhz, denom: 1 },
pw::spa::utils::Fraction { num: 0, denom: 1 },
pw::spa::utils::Fraction { num: 240, denom: 1 }
),
)
}
/// Build a Buffers param for the CPU path accepting anything mappable: MemPtr, MemFd, and
/// DmaBuf. The DmaBuf bit matters for producers like gamescope whose format intersection
/// lands on their modifier-bearing (LINEAR) pod: they then offer *only* DmaBuf buffers, and
/// without this bit the buffer-type intersection is empty and the link silently stalls in
/// "negotiating". A LINEAR dmabuf is mmap-able by MAP_BUFFERS, so the CPU de-pad copy works.
pub(super) fn build_mappable_buffers() -> Result<Vec<u8>> {
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(),
properties: vec![pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(
(1i32 << pw::spa::sys::SPA_DATA_MemPtr)
| (1i32 << pw::spa::sys::SPA_DATA_MemFd)
| (1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
),
}],
})
}
/// Build a Buffers param for a TRUE SHM path: MemPtr + MemFd only, NO DmaBuf. Forces the
/// producer to download into mappable memory (Mutter's `glReadPixels`), which orders against its
/// render — so the frame is complete and current by construction. This is the only race-free
/// capture of Mutter's virtual monitor on NVIDIA: the compositor renders straight into the buffer
/// pool, NVIDIA attaches no implicit dmabuf fence (verified: `EXPORT_SYNC_FILE` waited=false) and
/// can't produce an explicit sync_fd, so any dmabuf read (zero-copy OR mmap) races the render and
/// flashes the buffer's previous frame. Excluding DmaBuf is what makes the difference vs.
/// `build_mappable_buffers` (which still let Mutter hand dmabufs).
pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(),
properties: vec![pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(
(1i32 << pw::spa::sys::SPA_DATA_MemPtr) | (1i32 << pw::spa::sys::SPA_DATA_MemFd),
),
}],
})
}
/// Build a Buffers param requesting dmabuf-only buffers.
pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(),
properties: vec![pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
}],
})
}
/// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as
/// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired
/// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't
/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor.
pub(super) fn build_cursor_meta_param() -> Result<Vec<u8>> {
fn meta_size(w: u32, h: u32) -> i32 {
(std::mem::size_of::<spa::sys::spa_meta_cursor>()
+ std::mem::size_of::<spa::sys::spa_meta_bitmap>()
+ (w as usize * h as usize * 4)) as i32
}
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamMeta.as_raw(),
id: pw::spa::param::ParamType::Meta.as_raw(),
properties: vec![
pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_META_type,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(spa::sys::SPA_META_Cursor)),
},
pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_META_size,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
pw::spa::utils::Choice(
pw::spa::utils::ChoiceFlags::empty(),
// The max must cover the producer's offer or the Meta param silently
// fails to negotiate and NO buffer ever carries the meta region:
// Mutter offers a FIXED `SPA_POD_Int(CURSOR_META_SIZE(384, 384))`
// (meta-screen-cast-stream-src.c, GNOME 50) — a 256² max made the
// intersection empty, which cost the whole Linux cursor channel
// on-glass. 1024² is headroom, not an allocation: the negotiated
// region follows the producer's value.
pw::spa::utils::ChoiceEnum::Range {
default: meta_size(64, 64),
min: meta_size(1, 1),
max: meta_size(1024, 1024),
},
),
)),
},
],
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The `SPA_PARAM_BUFFERS_dataType` bitmask a serialized Buffers pod carries.
///
/// A deliberately literal SPA reader rather than a heuristic scan: an object property is
/// `{ key: u32, flags: u32, value: spa_pod }` and a `spa_pod` is `{ size: u32, type: u32, body }`,
/// so the `i32` sits exactly 16 bytes past the key — and the intervening `size` word is itself
/// `4`, which is why "find the first plausible-looking int" reads the wrong field.
fn buffers_data_type(pod: &[u8]) -> i32 {
let key = spa::sys::SPA_PARAM_BUFFERS_dataType.to_ne_bytes();
let at = pod
.windows(4)
.position(|w| w == key)
.expect("dataType key present in the Buffers pod");
let word = |off: usize| u32::from_ne_bytes(pod[off..off + 4].try_into().unwrap());
assert_eq!(word(at + 8), 4, "dataType's value pod should be 4 bytes");
assert_eq!(
word(at + 12),
spa::sys::SPA_TYPE_Int,
"dataType's value pod should be an Int"
);
i32::from_ne_bytes(pod[at + 16..at + 20].try_into().unwrap())
}
const MEM_PTR: i32 = 1 << spa::sys::SPA_DATA_MemPtr;
const MEM_FD: i32 = 1 << spa::sys::SPA_DATA_MemFd;
const DMABUF: i32 = 1 << spa::sys::SPA_DATA_DmaBuf;
/// The three Buffers pods differ ONLY in this bitmask, and each bit is load-bearing:
/// `build_mappable_buffers` must include DmaBuf or gamescope's modifier-bearing pod wins the
/// format intersection and the BUFFER intersection is then empty (a link stuck in
/// "negotiating"); `build_shm_only_buffers` must EXCLUDE it or Mutter hands dmabufs and the
/// race-free download path is not race-free; `build_dmabuf_buffers` must exclude the mappable
/// types or an HDR session can be handed a MemFd buffer, which Mutter paints 8-bit ARGB32
/// regardless of the negotiated 10-bit format.
#[test]
fn each_buffers_pod_requests_exactly_its_own_data_types() {
assert_eq!(
buffers_data_type(&build_mappable_buffers().unwrap()),
MEM_PTR | MEM_FD | DMABUF,
"the CPU path must accept mappable dmabufs too"
);
assert_eq!(
buffers_data_type(&build_shm_only_buffers().unwrap()),
MEM_PTR | MEM_FD,
"PUNKTFUNK_FORCE_SHM must exclude DmaBuf"
);
assert_eq!(
buffers_data_type(&build_dmabuf_buffers().unwrap()),
DMABUF,
"the zero-copy/HDR path must exclude SHM"
);
}
/// Every pod builder must produce a pod libspa will accept back — a serializer that silently
/// emitted a malformed object would fail only at negotiation, on a live compositor.
#[test]
fn every_pod_round_trips_through_pod_from_bytes() {
let mut pods: Vec<(&str, Vec<u8>)> = vec![
("mappable buffers", build_mappable_buffers().unwrap()),
("shm-only buffers", build_shm_only_buffers().unwrap()),
("dmabuf buffers", build_dmabuf_buffers().unwrap()),
("cursor meta", build_cursor_meta_param().unwrap()),
(
"default format",
serialize_pod(build_default_format_obj(None)).unwrap(),
),
(
"dmabuf BGRx",
build_dmabuf_format(VideoFormat::BGRx, &[0, 1, 2], Some((1920, 1080, 60))).unwrap(),
),
(
"dmabuf NV12",
build_dmabuf_format(VideoFormat::NV12, &[0], Some((1280, 720, 60))).unwrap(),
),
(
"hdr xRGB",
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, None).unwrap(),
),
(
"hdr xBGR",
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, Some((3840, 2160, 120))).unwrap(),
),
];
for (name, bytes) in &mut pods {
assert!(!bytes.is_empty(), "{name} serialized to nothing");
assert_eq!(bytes.len() % 8, 0, "{name} is not 8-byte aligned/padded");
assert!(
spa::pod::Pod::from_bytes(bytes).is_some(),
"{name} did not parse back as a pod"
);
}
}
/// The HDR pods carry BOTH colorimetry properties MANDATORY — Mutter's HDR pods do the same, so
/// the intersection only exists if we speak them. Dropping either would negotiate an SDR-labelled
/// 10-bit stream (or nothing at all).
#[test]
fn the_hdr_pods_carry_mandatory_pq_and_bt2020() {
for fmt in [VideoFormat::xRGB_210LE, VideoFormat::xBGR_210LE] {
let pod = build_hdr_dmabuf_format(fmt, None).unwrap();
for (name, key) in [
(
"transferFunction",
spa::sys::SPA_FORMAT_VIDEO_transferFunction,
),
("colorPrimaries", spa::sys::SPA_FORMAT_VIDEO_colorPrimaries),
("modifier", spa::sys::SPA_FORMAT_VIDEO_modifier),
] {
assert!(
pod.windows(4).any(|w| w == key.to_ne_bytes()),
"{fmt:?} pod is missing {name}"
);
}
// The PQ id and BT.2020 id must both appear as values.
assert!(
pod.windows(4)
.any(|w| w == SPA_VIDEO_TRANSFER_SMPTE2084.to_ne_bytes()),
"{fmt:?} pod does not carry the PQ transfer id"
);
assert!(
pod.windows(4)
.any(|w| w == spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020.to_ne_bytes()),
"{fmt:?} pod does not carry BT.2020 primaries"
);
}
}
/// An NV12 offer pins BT.709 limited so gamescope's producer-side RGB→YUV shader matches OUR
/// bitstream colorimetry; the packed-RGB offer must NOT carry those (it is not YUV).
#[test]
fn only_the_nv12_offer_pins_the_colour_matrix() {
let nv12 = build_dmabuf_format(VideoFormat::NV12, &[0], None).unwrap();
let bgrx = build_dmabuf_format(VideoFormat::BGRx, &[0], None).unwrap();
for (name, key) in [
("colorMatrix", spa::sys::SPA_FORMAT_VIDEO_colorMatrix),
("colorRange", spa::sys::SPA_FORMAT_VIDEO_colorRange),
] {
assert!(
nv12.windows(4).any(|w| w == key.to_ne_bytes()),
"NV12 offer is missing {name}"
);
assert!(
!bgrx.windows(4).any(|w| w == key.to_ne_bytes()),
"packed-RGB offer should not pin {name}"
);
}
}
/// Pin our hand-written PQ transfer id against the real libspa binding. We can't take the
/// constant from `pw::spa::sys` directly (older distro headers don't export it — see
/// [`super::SPA_VIDEO_TRANSFER_SMPTE2084`]), so assert the two agree wherever the symbol
/// DOES exist. Any libspa that renumbers the enum fails this instead of silently tagging
/// the HDR offer with the wrong transfer function.
///
/// Only builds where tests are compiled — the .deb/.rpm builders run plain `cargo build`,
/// so this never reintroduces the compile failure it exists to prevent.
#[test]
fn pq_transfer_id_matches_libspa() {
assert_eq!(
super::SPA_VIDEO_TRANSFER_SMPTE2084,
super::pw::spa::sys::SPA_VIDEO_TRANSFER_SMPTE2084,
"libspa renumbered spa_video_transfer_function — update the hardcoded PQ id"
);
}
}
+457 -67
View File
@@ -37,7 +37,7 @@
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
use std::sync::{
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
};
use std::time::Duration;
@@ -51,12 +51,29 @@ use x11rb::protocol::xproto::{
Window,
};
use x11rb::protocol::Event;
use x11rb::rust_connection::RustConnection;
use x11rb::rust_connection::{DefaultStream, RustConnection};
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
/// ever contended if two gamescope sessions start at once — rare, and the swap is microseconds.
use crate::GamescopeCursorTargets;
/// Serializes the `XAUTHORITY` env swap of the LEGACY connect fallback (the var is process-global).
///
/// The fallback is a last resort now — see [`connect_conn`]. It serialises this source against
/// itself and nothing else: `getenv` needs no lock to be racy, so every OTHER thread's read (libspa
/// plugin load, EGL/CUDA init — concurrent by construction, since `attach_gamescope_cursor` runs
/// while the PipeWire thread is starting) could still observe the swapped value or a torn
/// environ. That is why the primary path parses the cookie itself and never touches the
/// environment.
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
/// The `MIT-MAGIC-COOKIE-1` auth-protocol name, as it appears in an `.Xauthority` entry.
const MIT_MAGIC_COOKIE_1: &[u8] = b"MIT-MAGIC-COOKIE-1";
/// Re-run the targets provider this often: adopt Xwaylands that appeared after the stream started
/// (gamescope spawns the game's on launch and advertises only the first in any child's environ) and
/// retry ones whose connection died. Two seconds is far below a human's tolerance for a missing
/// pointer and costs one cheap socket probe per known display.
const REDISCOVER: Duration = Duration::from_secs(2);
/// Position out-paces the stream fps (`POLL`); shape rides `CursorNotify` events drained each tick.
/// 4 ms ≈ 250 Hz matches the Windows GDI poller — the polled position IS the composited position
/// and must out-run a 240 fps session or the pointer stutters.
@@ -73,62 +90,64 @@ const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";
/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll.
const FEEDBACK_RESYNC: Duration = Duration::from_millis(250);
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
/// X connections so it lives exactly as long as the capturer that owns it.
/// A running XFixes cursor reader. Dropping it stops the worker thread and waits — bounded — for it
/// to release the X connections, so it lives (at most) as long as the capturer that owns it.
pub(super) struct XFixesCursorSource {
stop: Arc<AtomicBool>,
/// Signalled by the worker just before it returns — lets `Drop` bound its wait (see there).
done: std::sync::mpsc::Receiver<()>,
join: Option<std::thread::JoinHandle<()>>,
}
impl XFixesCursorSource {
/// Connect to every gamescope nested Xwayland in `targets` (`(DISPLAY, XAUTHORITY)`) and start
/// publishing cursor overlays into `slot`, following the focused display's pointer. Returns
/// `None` — and logs — if NONE can be used (no X connection / no XFixes), so the caller
/// degrades to no gamescope cursor (today's behaviour) instead of failing the session.
/// Start publishing cursor overlays into `slot` from whichever of gamescope's nested Xwaylands
/// it is drawing the pointer on. `targets` is re-run every [`REDISCOVER`] to adopt Xwaylands
/// that appear later and retry dead ones; `frame_size` is the negotiated capture size, packed
/// `(w << 32) | h`, `0` until the first negotiation (see [`scale_to_frame`]).
///
/// Returns `None` only if the thread cannot be spawned. An empty or entirely-unusable target
/// list is NOT a failure: the worker idles and keeps re-running the provider, so a stream that
/// starts before the game converges instead of being cursorless for the session.
pub(super) fn spawn(
targets: Vec<(String, Option<String>)>,
targets: GamescopeCursorTargets,
slot: Arc<Mutex<Option<CursorOverlay>>>,
frame_size: Arc<AtomicU64>,
) -> Option<Self> {
// Connect on the caller's thread so failures degrade cleanly and the displays are validated
// before we commit a thread.
// First pass on the caller's thread: the common case connects here, so the log line below
// reports the real state instead of "starting…".
let mut displays = Vec::new();
for (dpy, xauth) in targets {
match connect(&dpy, xauth.as_deref()) {
Ok((conn, root, feedback)) => {
displays.push(XDisplay::new(dpy, conn, root, feedback))
}
Err(e) => tracing::warn!(
dpy = %dpy,
error = %e,
"gamescope cursor: skipping a nested Xwayland we can't use"
),
}
}
if displays.is_empty() {
tracing::warn!(
"gamescope cursor: no usable nested Xwayland — no in-video pointer this session \
(falls back to today's cursorless gamescope stream)"
);
return None;
}
rediscover(&mut displays, &targets, true);
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
let feedback = displays.iter().any(|d| d.gs_visible.is_some());
tracing::info!(
displays = ?names,
cursor_feedback = feedback,
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \
pointer on (cursor_feedback=false this gamescope publishes no \
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
);
if displays.is_empty() {
tracing::warn!(
"gamescope cursor: no usable nested Xwayland yet — retrying every {}s (a game's \
Xwayland appears when it launches)",
REDISCOVER.as_secs()
);
} else {
tracing::info!(
displays = ?names,
cursor_feedback = feedback,
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \
pointer on (cursor_feedback=false this gamescope publishes no \
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
);
}
let stop = Arc::new(AtomicBool::new(false));
let stop_worker = Arc::clone(&stop);
let (done_tx, done_rx) = std::sync::mpsc::sync_channel::<()>(1);
let join = std::thread::Builder::new()
.name("pf-gs-cursor".into())
.spawn(move || run(displays, slot, stop_worker))
.spawn(move || {
run(displays, slot, stop_worker, targets, frame_size);
let _ = done_tx.send(());
})
.ok()?;
Some(XFixesCursorSource {
stop,
done: done_rx,
join: Some(join),
})
}
@@ -137,35 +156,69 @@ impl XFixesCursorSource {
impl Drop for XFixesCursorSource {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
// BOUNDED join. The worker blocks in `RustConnection` replies with no read timeout, so a
// peer that stops answering while keeping its socket open (a hung Xwayland) would hang
// capturer teardown — and teardown runs on the session path. On timeout we detach: the
// thread only ever touches its own X connections and an `Arc`'d slot, so leaving it to
// finish on its own is safe (it observes `stop` and exits the moment its reply lands).
let joinable = self.done.recv_timeout(Duration::from_millis(250)).is_ok();
if let Some(j) = self.join.take() {
let _ = j.join();
if joinable {
let _ = j.join(); // returns at once: `done` already fired
} else {
tracing::warn!(
"gamescope cursor: worker did not stop within 250ms (blocked on an X reply?) — \
detaching it"
);
}
}
}
}
/// Re-run the targets provider and reconcile `displays` with it: connect to any target we do not
/// track yet, and re-connect one whose display died. Existing healthy displays are left alone, so
/// their shape cache and last position survive.
fn rediscover(displays: &mut Vec<XDisplay>, targets: &GamescopeCursorTargets, first: bool) {
for (dpy, xauth) in targets() {
let existing = displays.iter().position(|d| d.name == dpy);
if existing.is_some_and(|i| !displays[i].dead) {
continue;
}
match connect(&dpy, xauth.as_deref()) {
Ok((conn, root, root_size, feedback)) => {
let d = XDisplay::new(dpy.clone(), conn, root, root_size, feedback);
match existing {
Some(i) => {
tracing::info!(dpy = %dpy, "gamescope cursor: reconnected a nested Xwayland");
displays[i] = d;
}
None => {
if !first {
tracing::info!(dpy = %dpy, "gamescope cursor: adopted a new nested Xwayland");
}
displays.push(d);
}
}
}
// Debug, not warn: with a 2 s retry a warn would flood for every Xwayland a
// `--xwayland-count` reports but that never comes up.
Err(e) if first => tracing::warn!(
dpy = %dpy, error = %e,
"gamescope cursor: skipping a nested Xwayland we can't use (will retry)"
),
Err(e) => tracing::debug!(dpy = %dpy, error = %e, "gamescope cursor: retry failed"),
}
}
}
/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
/// returning the connection, root window and this display's initial
/// [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`; the value is `None`
/// when gamescope publishes no such property here). `RustConnection` reads `XAUTHORITY` from the
/// env at connect time only, so set it under the lock (the host isn't a gamescope child), connect,
/// then restore.
type Connected = (RustConnection, Window, (Atom, Option<bool>));
/// returning the connection, root window, the root's pixel size (for [`scale_to_frame`]) and this
/// display's initial [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`;
/// the value is `None` when gamescope publishes no such property here).
type Connected = (RustConnection, Window, (u16, u16), (Atom, Option<bool>));
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
let (conn, screen_num) = {
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("XAUTHORITY");
if let Some(x) = xauthority {
std::env::set_var("XAUTHORITY", x);
}
let out = RustConnection::connect(Some(dpy));
match (&prev, xauthority) {
(Some(p), _) => std::env::set_var("XAUTHORITY", p),
(None, Some(_)) => std::env::remove_var("XAUTHORITY"),
(None, None) => {}
}
out.map_err(|e| format!("connect: {e}"))?
};
let (conn, screen_num) = connect_conn(dpy, xauthority)?;
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
conn.xfixes_query_version(5, 0)
@@ -173,12 +226,16 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
.and_then(|c| c.reply())
.map_err(|e| format!("XFixes unavailable: {e}"))?;
let root = conn
let screen = conn
.setup()
.roots
.get(screen_num)
.ok_or_else(|| format!("no X screen {screen_num}"))?
.root;
.ok_or_else(|| format!("no X screen {screen_num}"))?;
let root = screen.root;
// gamescope's nested root can be a DIFFERENT size from the output/PipeWire node it publishes
// (`-w/-h` vs `-W/-H` are independent knobs), and this is the space `QueryPointer` answers in.
// Free here — the setup reply is already parsed.
let root_size = (screen.width_in_pixels, screen.height_in_pixels);
// Wake the worker's event drain whenever the cursor shape changes (incl. hide/show).
conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR)
@@ -203,7 +260,143 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
}
let _ = conn.flush();
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
Ok((conn, root, (feedback_atom, feedback)))
Ok((conn, root, root_size, (feedback_atom, feedback)))
}
/// Establish the X connection for `dpy` using `xauthority`'s cookie, WITHOUT touching this process's
/// environment.
///
/// `RustConnection::connect` reads `XAUTHORITY` from the env, so the original implementation
/// `set_var`'d it around each connect under [`XAUTH_LOCK`]. That is unsound from a live
/// multithreaded host: the lock serialises this source against itself, but `getenv` takes no lock,
/// so any concurrent reader (libspa's plugin load, EGL/CUDA init — running at exactly this moment,
/// since the PipeWire thread is starting up) could read the swapped value or race the environ
/// rewrite outright. The project already has a process-wide env-lock discipline elsewhere, but
/// sharing it would be the wrong layer AND would still not fix `getenv`.
///
/// So: parse the MIT-MAGIC-COOKIE-1 entry out of the file ourselves and hand it to
/// `connect_to_stream_with_auth_info`, which is what `RustConnection::connect` does internally with
/// the cookie IT found. The env swap survives only as a fallback for a file we cannot parse (an
/// unexpected layout, or an auth family whose entry we decline to guess at).
fn connect_conn(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, usize), String> {
let Some(path) = xauthority else {
// No per-display cookie file to inject: the ambient environment is already what this
// connect should use, so there is nothing to swap and nothing to parse.
return RustConnection::connect(Some(dpy)).map_err(|e| format!("connect: {e}"));
};
match mit_magic_cookie(path, dpy) {
Some((name, data)) => match connect_with_cookie(dpy, name, data) {
Ok(v) => return Ok(v),
Err(e) => tracing::debug!(
dpy = %dpy, xauthority = %path, error = %e,
"gamescope cursor: cookie connect failed — falling back to the XAUTHORITY env swap"
),
},
None => tracing::debug!(
dpy = %dpy, xauthority = %path,
"gamescope cursor: no MIT-MAGIC-COOKIE-1 entry for this display — falling back to the \
XAUTHORITY env swap"
),
}
connect_via_env_swap(dpy, path)
}
/// Connect to `dpy` and complete the setup handshake with an explicit cookie — the same two steps
/// `RustConnection::connect` performs, minus its env-derived auth lookup.
fn connect_with_cookie(
dpy: &str,
auth_name: Vec<u8>,
auth_data: Vec<u8>,
) -> Result<(RustConnection, usize), String> {
let parsed = x11rb::reexports::x11rb_protocol::parse_display::parse_display(Some(dpy))
.map_err(|e| format!("parse display {dpy}: {e}"))?;
let screen = usize::from(parsed.screen);
let mut stream = None;
let mut last_err = None;
for addr in parsed.connect_instruction() {
match DefaultStream::connect(&addr) {
Ok((s, _peer)) => {
stream = Some(s);
break;
}
Err(e) => last_err = Some(e),
}
}
let stream = stream.ok_or_else(|| match last_err {
Some(e) => format!("connect: {e}"),
None => "connect: no usable address".to_string(),
})?;
RustConnection::connect_to_stream_with_auth_info(stream, screen, auth_name, auth_data)
.map(|c| (c, screen))
.map_err(|e| format!("setup: {e}"))
}
/// LEGACY fallback (see [`connect_conn`]): swap `XAUTHORITY`, connect, restore. Serialised against
/// this source's own concurrent connects, but NOT against other threads' `getenv` — which is why it
/// is a fallback and not the path taken.
fn connect_via_env_swap(dpy: &str, xauthority: &str) -> Result<(RustConnection, usize), String> {
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("XAUTHORITY");
std::env::set_var("XAUTHORITY", xauthority);
let out = RustConnection::connect(Some(dpy));
match prev {
Some(p) => std::env::set_var("XAUTHORITY", p),
None => std::env::remove_var("XAUTHORITY"),
}
out.map_err(|e| format!("connect: {e}"))
}
/// The `MIT-MAGIC-COOKIE-1` `(name, data)` for `dpy` from the `.Xauthority`-format file at `path`.
///
/// Entry layout, all integers big-endian: `u16 family`, then four length-prefixed byte strings —
/// `address`, `number` (the display number in ASCII), `name`, `data`. An entry with an empty
/// `number` matches any display.
///
/// Deliberately does NOT match on family/address, unlike libxcb's own lookup. A gamescope Xwayland
/// gets its own single-entry cookie file, so there is nothing to disambiguate; and a wrong pick
/// cannot do damage — the server rejects the cookie, and [`connect_conn`] falls back to the env
/// path, which does the full match. Guessing the peer address for a `LOCAL`-family unix socket, on
/// the other hand, is exactly the sort of detail that would rot.
fn mit_magic_cookie(path: &str, dpy: &str) -> Option<(Vec<u8>, Vec<u8>)> {
let bytes = std::fs::read(path).ok()?;
let want = display_number(dpy);
// An entry whose `number` is empty matches any display — only used if no exact match is found.
let mut wildcard = None;
let mut p = 0usize;
'entries: while p + 2 <= bytes.len() {
p += 2; // family
let mut fields: [&[u8]; 4] = [&[]; 4];
for f in fields.iter_mut() {
let Some(lb) = bytes.get(p..p + 2) else {
break 'entries; // truncated — keep whatever we already found
};
let len = usize::from(u16::from_be_bytes([lb[0], lb[1]]));
p += 2;
let Some(v) = bytes.get(p..p + len) else {
break 'entries;
};
*f = v;
p += len;
}
let [_address, number, name, data] = fields;
if name != MIT_MAGIC_COOKIE_1 {
continue;
}
if want.as_deref().is_some_and(|w| number == w) {
return Some((name.to_vec(), data.to_vec()));
}
if number.is_empty() && wildcard.is_none() {
wildcard = Some((name.to_vec(), data.to_vec()));
}
}
wildcard
}
/// The display NUMBER of an X display string as ASCII bytes: `":2"`, `"host:2"` and `":2.0"` all
/// yield `b"2"`. `None` when there is no numeric display component to match on.
fn display_number(dpy: &str) -> Option<Vec<u8>> {
let num = dpy.rsplit_once(':')?.1.split('.').next()?;
(!num.is_empty() && num.bytes().all(|b| b.is_ascii_digit())).then(|| num.as_bytes().to_vec())
}
/// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or
@@ -227,6 +420,9 @@ struct XDisplay {
name: String,
conn: RustConnection,
root: Window,
/// The nested root's size in pixels — the space `QueryPointer` reports in, which is NOT
/// necessarily the captured frame's (see [`scale_to_frame`]).
root_size: (u16, u16),
/// Last polled pointer position — a change since the previous tick marks this display FOCUSED.
last_pos: Option<(i32, i32)>,
/// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`.
@@ -247,12 +443,14 @@ impl XDisplay {
name: String,
conn: RustConnection,
root: Window,
root_size: (u16, u16),
(feedback_atom, gs_visible): (Atom, Option<bool>),
) -> Self {
XDisplay {
name,
conn,
root,
root_size,
last_pos: None,
shape: Shape::default(),
need_shape: true,
@@ -288,11 +486,39 @@ struct Shape {
visible: bool,
}
/// Map a root-space pointer position into FRAME space.
///
/// `QueryPointer` answers in the nested root's coordinates, but `CursorOverlay::x/y`'s contract is
/// FRAME pixels — and gamescope's `-w/-h` (nested root) and `-W/-H` (output + PipeWire node) are
/// independent knobs. Measured on this box with gamescope 3.16.23 at `-W 1280 -H 720 -w 640 -h 360`
/// the two spaces differ by 2×, so publishing root coordinates verbatim drew the pointer at a
/// fraction of its real position. `frame` is `(0, 0)` until the PipeWire format negotiates, in which
/// case the position passes through unscaled — the same as before, and correct for the common case
/// where the two spaces agree.
///
/// The cursor BITMAP is not scaled: it stays at the shape's native size, so on a mismatched session
/// the pointer lands in the right place but is drawn at root scale.
fn scale_to_frame((x, y): (i32, i32), root: (u16, u16), frame: (u32, u32)) -> (i32, i32) {
let (rw, rh) = (u32::from(root.0), u32::from(root.1));
let (fw, fh) = frame;
if rw == 0 || rh == 0 || fw == 0 || fh == 0 || (rw, rh) == (fw, fh) {
return (x, y);
}
(
((i64::from(x) * i64::from(fw)) / i64::from(rw)) as i32,
((i64::from(y) * i64::from(fh)) / i64::from(rh)) as i32,
)
}
fn run(
mut displays: Vec<XDisplay>,
slot: Arc<Mutex<Option<CursorOverlay>>>,
stop: Arc<AtomicBool>,
targets: GamescopeCursorTargets,
frame_size: Arc<AtomicU64>,
) {
let mut last_discover = std::time::Instant::now();
let mut warned_scale = false;
let mut active = 0usize;
// The overlay serial must bump whenever the DRAWN cursor changes — either the active display's
// shape OR which display is active (per-display XFixes serials aren't comparable across
@@ -309,6 +535,11 @@ fn run(
if resync {
last_resync = std::time::Instant::now();
}
// Adopt Xwaylands that appeared since we started (a game's, above all) and retry dead ones.
if last_discover.elapsed() >= REDISCOVER {
last_discover = std::time::Instant::now();
rediscover(&mut displays, &targets, false);
}
// 1) Poll every display's pointer; note which moved since last tick (the fallback focus
// signal, used only when this gamescope publishes no cursor verdict).
@@ -395,8 +626,27 @@ fn run(
// a `None` here would leave the last visible overlay standing on repeat frames.
let d = &displays[active];
let drawn = d.shape.visible && !hidden_by_gamescope;
// Root space → frame space (see `scale_to_frame`). The negotiated size arrives from the
// PipeWire thread's `param_changed`, packed `(w << 32) | h`; `0` = not negotiated yet.
let packed = frame_size.load(Ordering::Relaxed);
let frame = ((packed >> 32) as u32, packed as u32);
if !warned_scale
&& frame.0 != 0
&& (u32::from(d.root_size.0), u32::from(d.root_size.1)) != frame
{
warned_scale = true;
tracing::warn!(
dpy = %d.name,
root = %format!("{}x{}", d.root_size.0, d.root_size.1),
negotiated = %format!("{}x{}", frame.0, frame.1),
"gamescope cursor: the nested root and the captured frame are different sizes \
(gamescope -w/-h vs -W/-H) scaling the pointer POSITION into frame space; the \
cursor bitmap stays at root scale"
);
}
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
(Some((px, py)), false) => {
(Some(pos), false) => {
let (px, py) = scale_to_frame(pos, d.root_size, frame);
let key = (active, d.shape.serial);
if key != last_key {
out_serial += 1;
@@ -511,7 +761,147 @@ fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
#[cfg(test)]
mod tests {
use super::pick_active;
use super::{
display_number, mit_magic_cookie, pick_active, scale_to_frame, MIT_MAGIC_COOKIE_1,
};
/// One `.Xauthority` entry in wire form: `u16 family` + four length-prefixed byte strings.
fn entry(family: u16, address: &[u8], number: &[u8], name: &[u8], data: &[u8]) -> Vec<u8> {
let mut v = family.to_be_bytes().to_vec();
for f in [address, number, name, data] {
v.extend_from_slice(&(f.len() as u16).to_be_bytes());
v.extend_from_slice(f);
}
v
}
fn write_xauth(bytes: &[u8]) -> std::path::PathBuf {
// The scratch file lives beside the test binary's temp dir; unique per call via the address
// of a local (no rand dependency, and the tests do not run concurrently on one path).
let mut p = std::env::temp_dir();
let uniq = format!(
"pf-xauth-test-{}-{:p}",
std::process::id(),
bytes as *const [u8]
);
p.push(uniq);
std::fs::write(&p, bytes).expect("write scratch xauth");
p
}
#[test]
fn display_number_handles_every_display_spelling() {
assert_eq!(display_number(":2").as_deref(), Some(&b"2"[..]));
assert_eq!(display_number(":2.0").as_deref(), Some(&b"2"[..]));
assert_eq!(display_number("host:13.1").as_deref(), Some(&b"13"[..]));
// Nothing to match on — the caller then accepts only a wildcard entry.
assert_eq!(display_number("bogus"), None);
assert_eq!(display_number(":"), None);
assert_eq!(display_number(":abc"), None);
}
/// The cookie reader is the one PARSER in this file fed a file we did not write, so it gets the
/// hostile-input treatment: exact-match, wildcard fallback, skipping other auth protocols, and
/// truncation.
#[test]
fn mit_magic_cookie_picks_the_matching_entry() {
let mut file = Vec::new();
// A different protocol on the display we want — must be skipped, not returned.
file.extend(entry(256, b"host", b"2", b"XDM-AUTHORIZATION-1", b"nope"));
// Another display's cookie.
file.extend(entry(
256,
b"host",
b"7",
MIT_MAGIC_COOKIE_1,
b"other-display",
));
// Ours.
file.extend(entry(256, b"host", b"2", MIT_MAGIC_COOKIE_1, b"the-cookie"));
let p = write_xauth(&file);
let got = mit_magic_cookie(p.to_str().unwrap(), ":2");
assert_eq!(
got,
Some((MIT_MAGIC_COOKIE_1.to_vec(), b"the-cookie".to_vec()))
);
// A display with no entry at all yields nothing (⇒ the caller falls back to the env path).
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":9"), None);
let _ = std::fs::remove_file(p);
}
#[test]
fn mit_magic_cookie_falls_back_to_a_wildcard_entry() {
// An empty `number` matches any display — but an exact match still wins.
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
file.extend(entry(256, b"host", b"3", MIT_MAGIC_COOKIE_1, b"exact"));
let p = write_xauth(&file);
let path = p.to_str().unwrap();
assert_eq!(
mit_magic_cookie(path, ":3").map(|(_, d)| d),
Some(b"exact".to_vec())
);
assert_eq!(
mit_magic_cookie(path, ":4").map(|(_, d)| d),
Some(b"wildcard".to_vec())
);
let _ = std::fs::remove_file(p);
}
#[test]
fn a_truncated_xauthority_keeps_what_it_already_parsed() {
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
// A second entry cut off mid-length-prefix.
file.extend(entry(256, b"host", b"5", MIT_MAGIC_COOKIE_1, b"truncated"));
file.truncate(file.len() - 4);
let p = write_xauth(&file);
// No panic, no over-read: the wildcard found before the truncation still serves.
assert_eq!(
mit_magic_cookie(p.to_str().unwrap(), ":5").map(|(_, d)| d),
Some(b"wildcard".to_vec())
);
let _ = std::fs::remove_file(p);
}
#[test]
fn a_garbage_xauthority_is_declined_not_fatal() {
// A length prefix that claims far more than the file holds.
let p = write_xauth(&[0, 0, 0xff, 0xff, 1, 2, 3]);
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":0"), None);
let _ = std::fs::remove_file(p);
// A missing file is simply "no cookie".
assert_eq!(mit_magic_cookie("/nonexistent/pf-xauth", ":0"), None);
}
/// L6: gamescope's `-w/-h` (nested root, the space `QueryPointer` answers in) and `-W/-H`
/// (output + PipeWire node, the space `CursorOverlay` is contracted in) are independent.
#[test]
fn pointer_positions_are_mapped_into_frame_space() {
// The measured case: root 640x360 inside a 1280x720 output — 2x.
assert_eq!(
scale_to_frame((320, 180), (640, 360), (1280, 720)),
(640, 360)
);
assert_eq!(scale_to_frame((0, 0), (640, 360), (1280, 720)), (0, 0));
// Down-scaling works the same way.
assert_eq!(
scale_to_frame((1280, 720), (1280, 720), (640, 360)),
(640, 360)
);
// Equal spaces are a pass-through (the common case — no rounding drift).
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (1920, 1080)), (7, 9));
// Not negotiated yet, or a degenerate root: pass through rather than divide by zero.
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (0, 0)), (7, 9));
assert_eq!(scale_to_frame((7, 9), (0, 0), (1920, 1080)), (7, 9));
}
/// A 5K frame times a 5K coordinate overflows `i32` — the scale must be computed wide.
#[test]
fn scaling_does_not_overflow_at_5k() {
assert_eq!(
scale_to_frame((2879, 1619), (2880, 1620), (5120, 2880)),
(5118, 2878)
);
}
/// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's.
const BPM: usize = 0;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,648 @@
//! The P010 colour SELF-TEST and its helpers — the `hdr-p010-selftest` subcommand's whole
//! implementation, plus the f64 reference math it compares against and the f16 encoder it uploads
//! with.
//!
//! Split out of `windows/dxgi.rs` in sweep Phase 5.5: it was ~560 of that file's 1,374 lines and
//! none of it runs in a session. What remains in the parent is the production path (the win32u hook,
//! the shader sources, the three converters); this is the validation path.
//!
//! `hdr_p010_selftest_at` and `hdr_p010_convert_bars_on_luid` are re-exported by the parent, so
//! every existing `crate::capture::dxgi::…` / `pf_capture::dxgi::…` path keeps resolving.
use super::*;
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
/// Used by [`hdr_p010_selftest`].
#[cfg(target_os = "windows")]
fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
fn pq_oetf(l: f64) -> f64 {
let l = l.clamp(0.0, 1.0);
let m1 = 0.1593017578125;
let m2 = 78.84375;
let c1 = 0.8359375;
let c2 = 18.8515625;
let c3 = 18.6875;
let lp = l.powf(m1);
((c1 + c2 * lp) / (1.0 + c3 * lp)).powf(m2)
}
// scRGB -> nits -> BT.2020 linear (row-major matrix, mul(M, v)).
let (r, g, b) = (r.max(0.0) * 80.0, g.max(0.0) * 80.0, b.max(0.0) * 80.0);
let m = [
[0.627403914, 0.329283038, 0.043313048],
[0.069097292, 0.919540405, 0.011362303],
[0.016391439, 0.088013308, 0.895595253],
];
let lr = m[0][0] * r + m[0][1] * g + m[0][2] * b;
let lg = m[1][0] * r + m[1][1] * g + m[1][2] * b;
let lb = m[2][0] * r + m[2][1] * g + m[2][2] * b;
// PQ encode (normalize to 10k nits).
let pr = pq_oetf(lr / 10000.0);
let pg = pq_oetf(lg / 10000.0);
let pb = pq_oetf(lb / 10000.0);
// BT.2020 non-constant-luminance, limited 10-bit.
let (kr, kg, kb) = (0.2627, 0.6780, 0.0593);
let y = kr * pr + kg * pg + kb * pb;
let cb = (pb - y) / 1.8814;
let cr = (pr - y) / 1.4746;
let yc = (64.0 + 876.0 * y).clamp(64.0, 940.0);
let cbc = (512.0 + 896.0 * cb).clamp(64.0, 960.0);
let crc = (512.0 + 896.0 * cr).clamp(64.0, 960.0);
(yc, cbc, crc)
}
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
/// (325,448,598) (226,650,535) (64,512,512).
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub fn hdr_p010_convert_bars_on_luid(
luid: [u8; 8],
w: u32,
h: u32,
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
}
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
const BARS: [(f32, f32, f32); 8] = [
(1.0, 1.0, 1.0),
(1.0, 1.0, 0.0),
(0.0, 1.0, 1.0),
(0.0, 1.0, 0.0),
(1.0, 0.0, 1.0),
(1.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
(0.0, 0.0, 0.0),
];
let bar_w = (w / 8).max(1) as usize;
let mut fp16 = vec![0u16; (w * h * 4) as usize];
for y in 0..h as usize {
for x in 0..w as usize {
let (r, g, b) = BARS[(x / bar_w).min(7)];
let i = (y * w as usize + x) * 4;
fp16[i] = f32_to_f16(r);
fp16[i + 1] = f32_to_f16(g);
fp16[i + 2] = f32_to_f16(b);
fp16[i + 3] = f32_to_f16(1.0);
}
}
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
// their references.
unsafe {
let luid = windows::Win32::Foundation::LUID {
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
};
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice(
&adapter,
D3D_DRIVER_TYPE_UNKNOWN,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
.context("D3D11CreateDevice(luid) for bars convert")?;
let device = device.context("null device")?;
let context = context.context("null context")?;
let src_desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
..Default::default()
};
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: fp16.as_ptr() as *const c_void,
SysMemPitch: w * 8,
SysMemSlicePitch: 0,
};
let mut src_tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
.context("CreateTexture2D(fp16 bars)")?;
let src_tex = src_tex.context("null src tex")?;
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
.context("CreateShaderResourceView(fp16 bars)")?;
let src_srv = src_srv.context("null src srv")?;
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
let p010_desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_P010,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
..Default::default()
};
let mut p010: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
.context("CreateTexture2D(P010 bars dst)")?;
let p010 = p010.context("null p010 tex")?;
let conv = HdrP010Converter::new(&device, w, h)?;
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, w, h)?;
Ok((device, p010))
}
}
/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware
/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0)
/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64
/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
///
/// `w`/`h` must be even and non-zero. Run it at the FIELD capture size, not a toy one: sessions run
/// at resolutions whose height is not 16-aligned (1080 → the encoder's align16 pool seam) and a
/// driver may treat the planar RTVs differently at real sizes. `vendor` pins the adapter by PCI
/// vendor id (`0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD) — it matters on dual-GPU boxes where
/// the default adapter is not the one the session encodes on. The chosen adapter is always printed,
/// because a PASS only means anything for the GPU it actually ran on.
#[cfg(target_os = "windows")]
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
}
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
#[allow(non_snake_case)]
let (W, H) = (w, h);
const BLK: u32 = 16;
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
let named: [(&str, f32, f32, f32); 8] = [
("red1.0", 1.0, 0.0, 0.0),
("green0.5", 0.0, 0.5, 0.0),
("blue4.0", 0.0, 0.0, 4.0),
("white1.0", 1.0, 1.0, 1.0),
("black", 0.0, 0.0, 0.0),
("gray0.5", 0.5, 0.5, 0.5),
("white4.0", 4.0, 4.0, 4.0),
("amber2.0", 2.0, 1.0, 0.0),
];
let grid_cols = W / BLK; // 4
let pixel_rgb = |x: u32, y: u32| -> (f32, f32, f32, bool) {
let idx = ((y / BLK) * grid_cols + (x / BLK)) as usize;
if idx < named.len() {
let (_, r, g, b) = named[idx];
(r, g, b, true)
} else {
// Gradient (distinct per pixel; Y-only compare), within HDR scRGB range.
let r = (x as f32 / W as f32) * 3.0;
let g = (y as f32 / H as f32) * 3.0;
let b = ((x + y) as f32 / (W + H) as f32) * 3.0;
(r, g, b, false)
}
};
// Build the scRGB FP16 (R16G16B16A16_FLOAT) source as f16 bits.
let mut fp16 = vec![0u16; (W * H * 4) as usize];
let mut flat = vec![false; (W * H) as usize];
for y in 0..H {
for x in 0..W {
let (r, g, b, is_flat) = pixel_rgb(x, y);
let i = ((y * W + x) * 4) as usize;
fp16[i] = f32_to_f16(r);
fp16[i + 1] = f32_to_f16(g);
fp16[i + 2] = f32_to_f16(b);
fp16[i + 3] = f32_to_f16(1.0);
flat[(y * W + x) as usize] = is_flat;
}
}
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
// both checked non-null) and uses ONLY that device for the rest of the block: every
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
// `Map` is invoked on that device or its context, so all resources share one device and run on this
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
// proven individually at the `read_u16` closure below.
unsafe {
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
// the GPU it actually tested.
let adapter: Option<IDXGIAdapter> = match vendor {
None => None,
Some(want) => {
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
let mut found = None;
for i in 0.. {
let Ok(a) = factory.EnumAdapters(i) else {
break;
};
let desc = a.GetDesc().context("adapter desc")?;
if desc.VendorId == want {
found = Some(a);
break;
}
}
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
}
};
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice(
adapter.as_ref(),
if adapter.is_some() {
D3D_DRIVER_TYPE_UNKNOWN
} else {
D3D_DRIVER_TYPE_HARDWARE
},
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
let device = device.context("null device")?;
let context = context.context("null context")?;
{
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
device.cast().context("device -> IDXGIDevice")?;
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
let name = String::from_utf16_lossy(
&desc.Description[..desc
.Description
.iter()
.position(|&c| c == 0)
.unwrap_or(desc.Description.len())],
);
println!(
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
);
}
// Source FP16 texture (initialized) + SRV.
let src_desc = D3D11_TEXTURE2D_DESC {
Width: W,
Height: H,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
..Default::default()
};
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: fp16.as_ptr() as *const c_void,
SysMemPitch: W * 8, // 4 channels * 2 bytes
SysMemSlicePitch: 0,
};
let mut src_tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
.context("CreateTexture2D(fp16 src)")?;
let src_tex = src_tex.context("null src tex")?;
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
.context("CreateShaderResourceView(fp16 src)")?;
let src_srv = src_srv.context("null src srv")?;
// P010 destination texture (render-target bindable).
let p010_desc = D3D11_TEXTURE2D_DESC {
Width: W,
Height: H,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_P010,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
..Default::default()
};
let mut p010: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
.context("CreateTexture2D(P010 dst)")?;
let p010 = p010.context("null p010 tex")?;
let conv = HdrP010Converter::new(&device, W, H)?;
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, W, H)?;
// Staging copy of the whole P010 texture (both planes), MAP_READ.
let stage_desc = D3D11_TEXTURE2D_DESC {
Width: W,
Height: H,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_P010,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_STAGING,
BindFlags: 0,
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
..Default::default()
};
let mut staging: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&stage_desc, None, Some(&mut staging))
.context("CreateTexture2D(P010 staging)")?;
let staging = staging.context("null staging")?;
context.CopyResource(&staging, &p010);
let mut map = D3D11_MAPPED_SUBRESOURCE::default();
context
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
.context("Map(P010 staging)")?;
let row_pitch = map.RowPitch as usize; // bytes per luma row (in 16-bit samples: /2)
let base = map.pData as *const u8;
// DIAGNOSTIC (the uncertain layout spot — verify on the box if chroma is wrong): the mapped
// P010 plane offsets. Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2
// *interleaved* (Cb,Cr) u16 pairs. P010 packs plane 1 after plane 0 at the SAME row pitch; the
// chroma plane begins at byte offset RowPitch * (luma height). For a STAGING texture that
// height is the created H (no inter-plane alignment). DepthPitch (total mapped size) lets us
// sanity-check: it should be ~ RowPitch * H * 3/2. If chroma reads garbage on the box, print
// these and adjust `chroma_base` (e.g. an aligned luma height).
tracing::info!(
row_pitch,
depth_pitch = map.DepthPitch,
expected_chroma_base = row_pitch * H as usize,
expected_total = row_pitch * H as usize * 3 / 2,
"hdr-p010-selftest: mapped P010 layout (verify chroma plane offset here if chroma is wrong)"
);
// Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2 *interleaved* (Cb,Cr)
// u16 pairs, i.e. W u16 per chroma row. P010 packs plane 1 immediately after plane 0 at the
// SAME row pitch; per spec the chroma plane begins at an allocation offset of
// RowPitch * Height (luma rows). We read it from there. (DepthPitch is the full surface size;
// not all drivers report the chroma offset, so RowPitch*Height is the portable choice.)
let read_u16 = |byte_off: usize| -> u16 {
// SAFETY: `base` is the mapped staging pointer; all offsets are within the P010 surface
// (luma H*RowPitch + chroma (H/2)*RowPitch ≤ DepthPitch). Already in the fn's unsafe scope.
let p = base.add(byte_off) as *const u16;
p.read_unaligned()
};
// Luma codes: stored u16 in the high 10 bits -> code10 = stored >> 6.
let mut y_codes = vec![0u16; (W * H) as usize];
for y in 0..H {
for x in 0..W {
let off = (y as usize) * row_pitch + (x as usize) * 2;
y_codes[(y * W + x) as usize] = read_u16(off) >> 6;
}
}
let cw = W / 2;
let ch = H / 2;
let chroma_base = row_pitch * H as usize; // plane 1 offset
let mut cb_codes = vec![0u16; (cw * ch) as usize];
let mut cr_codes = vec![0u16; (cw * ch) as usize];
for cy in 0..ch {
for cx in 0..cw {
// Interleaved (Cb, Cr) per chroma sample → 2 u16 = 4 bytes per sample.
let off = chroma_base + (cy as usize) * row_pitch + (cx as usize) * 4;
cb_codes[(cy * cw + cx) as usize] = read_u16(off) >> 6;
cr_codes[(cy * cw + cx) as usize] = read_u16(off + 2) >> 6;
}
}
context.Unmap(&staging, 0);
// Compare Y over every pixel.
let mut max_y_err = 0.0f64;
for y in 0..H {
for x in 0..W {
let (r, g, b, _) = pixel_rgb(x, y);
let (ry, _, _) = p010_reference(r as f64, g as f64, b as f64);
let got = y_codes[(y * W + x) as usize] as f64;
max_y_err = max_y_err.max((got - ry).abs());
}
}
// Compare Cb/Cr over flat blocks only (uniform 2x2 footprint → exact reference).
let mut max_u_err = 0.0f64;
let mut max_v_err = 0.0f64;
for cy in 0..ch {
for cx in 0..cw {
let (sx, sy) = (cx * 2, cy * 2);
let all_flat =
(0..2).all(|dy| (0..2).all(|dx| flat[((sy + dy) * W + (sx + dx)) as usize]));
if !all_flat {
continue;
}
let (r, g, b, _) = pixel_rgb(sx, sy);
let (_, rcb, rcr) = p010_reference(r as f64, g as f64, b as f64);
let gu = cb_codes[(cy * cw + cx) as usize] as f64;
let gv = cr_codes[(cy * cw + cx) as usize] as f64;
max_u_err = max_u_err.max((gu - rcb).abs());
max_v_err = max_v_err.max((gv - rcr).abs());
}
}
// Per-colour table.
println!("HDR P010 self-test ({W}x{H}, BT.2020 PQ, 10-bit limited range)");
println!(
" {:<10} {:>14} {:>14} {:>14}",
"color", "Y exp/got", "Cb exp/got", "Cr exp/got"
);
for (idx, (name, r, g, b)) in named.iter().enumerate() {
let bx = (idx as u32 % grid_cols) * BLK + BLK / 2;
let by = (idx as u32 / grid_cols) * BLK + BLK / 2;
let (ey, ecb, ecr) = p010_reference(*r as f64, *g as f64, *b as f64);
let gy = y_codes[(by * W + bx) as usize] as f64;
let (ccx, ccy) = (bx / 2, by / 2);
let gu = cb_codes[(ccy * cw + ccx) as usize] as f64;
let gv = cr_codes[(ccy * cw + ccx) as usize] as f64;
println!(
" {:<10} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0}",
name, ey, gy, ecb, gu, ecr, gv
);
}
println!(
" max abs error: Y={max_y_err:.2} (≤4) Cb={max_u_err:.2} (≤5) Cr={max_v_err:.2} (≤5)"
);
if max_y_err <= 4.0 && max_u_err <= 5.0 && max_v_err <= 5.0 {
println!("PASS");
Ok(())
} else {
println!("FAIL");
bail!(
"HDR P010 self-test FAILED (Y={max_y_err:.2} Cb={max_u_err:.2} Cr={max_v_err:.2})"
);
}
}
}
/// Minimal f32 → IEEE-754 half (f16) bit pattern, for uploading the FP16 scRGB self-test pattern. Not
/// on any hot path; handles normals, subnormals, and the 1.0/0.0 constants we feed. (round-to-nearest)
#[cfg(target_os = "windows")]
fn f32_to_f16(v: f32) -> u16 {
let bits = v.to_bits();
let sign = ((bits >> 16) & 0x8000) as u16;
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
let mant = bits & 0x007f_ffff;
if exp <= 0 {
// Subnormal / zero in half precision.
if exp < -10 {
return sign; // too small → ±0
}
let mant = mant | 0x0080_0000; // implicit 1
let shift = (14 - exp) as u32;
let half_mant = (mant >> shift) as u16;
// Round to nearest.
let round = ((mant >> (shift - 1)) & 1) as u16;
sign | (half_mant + round)
} else if exp >= 0x1f {
sign | 0x7c00 // Inf/NaN → Inf (our inputs never hit this)
} else {
let half_exp = (exp as u16) << 10;
let half_mant = (mant >> 13) as u16;
let round = ((mant >> 12) & 1) as u16;
// ADD, never OR. `half_mant + round` can carry out of the 10-bit mantissa (all ones, then
// rounded up), and that carry must INCREMENT the exponent — which is exactly what an
// IEEE-754 round-to-nearest overflow means. `sign | half_exp | (…)` instead ORed it into bit
// 10, so for every ODD biased exponent (bit 10 already set) the carry vanished and the
// result came back a factor of ~2 low: `f32_to_f16(1.9998779) → 0x3C00 = 1.0`,
// `0.49996948 → 0.25`. Only values one ULP below a power of two are affected — which is
// precisely what a gradient test pattern is full of, so this made `hdr-p010-selftest` FAIL a
// correct shader. The subnormal branch above was already additive.
sign | (half_exp + half_mant + round)
}
}
#[cfg(test)]
mod f16_tests {
use super::f32_to_f16;
/// Round-trip through the reference conversion the rest of the test uses as an oracle.
fn f16_to_f32(h: u16) -> f32 {
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
let exp = ((h >> 10) & 0x1f) as i32;
let mant = (h & 0x3ff) as f32;
match exp {
0 => sign * mant * 2f32.powi(-24), // subnormal
31 => sign * f32::INFINITY, // our encoder never emits NaN
e => sign * (1.0 + mant / 1024.0) * 2f32.powi(e - 15),
}
}
/// W7: the rounding carry out of the mantissa must INCREMENT the exponent. The composition used
/// `sign | half_exp | (half_mant + round)`, which swallowed that carry for every odd biased
/// exponent — a silent factor-of-2 error on exactly the values a gradient test pattern is full
/// of, which made `hdr-p010-selftest` fail a correct shader.
#[test]
fn a_rounding_carry_increments_the_exponent() {
// The plan's canonical case: biased exponent 127 (2^0) with a mantissa that rounds up out
// of 10 bits ⇒ 2.0 = 0x4000, NOT 1.0 = 0x3C00.
assert_eq!(f32_to_f16(f32::from_bits((127 << 23) | 0x7FF000)), 0x4000);
// The two measured regressions, by value.
assert_eq!(
f32_to_f16(1.9998779),
0x4000,
"1.9998779 must not read as 1.0"
);
assert_eq!(
f32_to_f16(0.49996948),
0x3800,
"0.49996948 must not read as 0.25"
);
// …and an EVEN biased exponent, where the bug happened to be invisible (bit 10 clear), so
// the fix must not change it.
assert_eq!(f32_to_f16(f32::from_bits((128 << 23) | 0x7FF000)), 0x4400); // → 4.0
}
#[test]
fn the_constants_the_selftest_uploads_are_exact() {
assert_eq!(f32_to_f16(0.0), 0x0000);
assert_eq!(f32_to_f16(-0.0), 0x8000);
assert_eq!(f32_to_f16(1.0), 0x3C00);
assert_eq!(f32_to_f16(-1.0), 0xBC00);
assert_eq!(f32_to_f16(0.5), 0x3800);
assert_eq!(f32_to_f16(2.0), 0x4000);
assert_eq!(f32_to_f16(4.0), 0x4400);
}
/// Every HDR scRGB value the self-test patterns use must survive the round trip to within one
/// f16 ULP — the property the P010 comparison actually depends on.
#[test]
fn hdr_scrgb_values_round_trip_within_one_ulp() {
for &v in &[
0.0f32, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 0.1, 0.3, 0.7, 1.9998779, 0.49996948, 2.5,
3.999, 0.001,
] {
let back = f16_to_f32(f32_to_f16(v));
// One ULP at this magnitude: f16 carries 11 significand bits.
let ulp = (v.abs() / 1024.0).max(2f32.powi(-24));
assert!(
(back - v).abs() <= ulp,
"{v} round-tripped to {back} (ulp {ulp})"
);
}
}
#[test]
fn out_of_range_magnitudes_saturate_rather_than_wrap() {
// Above f16's max finite (65504) our encoder reports Inf; below its subnormal floor, ±0.
assert_eq!(f32_to_f16(1.0e30), 0x7C00);
assert_eq!(f32_to_f16(-1.0e30), 0xFC00);
assert_eq!(f32_to_f16(1.0e-30), 0x0000);
assert_eq!(f32_to_f16(-1.0e-30), 0x8000);
}
}
#[cfg(test)]
mod hdr_selftests {
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
#[test]
#[ignore]
fn hdr_p010_selftest_intel_1080_live() {
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
//! The LAST-RESORT DWM compose kick — synthetic pointer input that dirties a specific virtual
//! display so DWM presents it.
//!
//! Split out of `idd_push.rs` in sweep Phase 5.4. It is self-contained (one function plus a
//! process-global throttle) and it is the one piece of the capture path that reaches for synthetic
//! INPUT, which is worth keeping visibly separate from the frame machinery: it is unreliable by
//! nature, user-visible in the sibling-display case, and only ever a fallback for the driver's own
//! `FrameStash` republish.
use super::*;
/// LAST-RESORT fallback: nudge DWM into composing THE TARGET virtual display. DWM presents a
/// display only when something DIRTIES it — an idle desktop never does, so a freshly-attached ring
/// (session open, or a mid-session ring recreate) can sit at E_PENDING with no first frame even
/// though everything is healthy.
///
/// The PRIMARY first-frame mechanism is the driver's `FrameStash` (frame_transport.rs): the driver
/// retains the last composed frame and republishes it into every freshly-attached ring, so with a
/// stash-capable driver the first frame lands milliseconds after the channel delivery and this kick
/// never fires. It remains for pre-stash drivers and for the empty-stash cold start (a monitor that
/// has NEVER composed — normally the activation compose covers that). Synthetic input is inherently
/// unreliable — blocked on the secure desktop, defeated by a fullscreen game's ClipCursor, and
/// user-visible in the sibling-display case — which is exactly why it was demoted to fallback.
///
/// pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
/// the frame — a guaranteed real present onto the IDD swap-chain (empirically what
/// `punktfunk-probe --input-test` always relied on).
///
/// The cursor only dirties the display it is ON — proven on-glass in the Stage-W3 two-display
/// validation: display B's session-open kicks wiggled the cursor on display A and B never composed
/// a first frame. So the kick is per-TARGET: when the cursor already sits inside `target_id`'s
/// desktop region (always true single-display), two net-zero 1 px relative moves (the historical
/// behavior, pointer ends exactly where it started); when it sits on a SIBLING display, jump the
/// cursor to the target's center and straight back (`SetCursorPos` ×2 — each absolute move dirties
/// the cursor layer of the display it lands on, so the target composes at least one frame).
/// Best-effort — injection can be unavailable on the secure desktop, where a fresh compose just
/// happened anyway.
///
/// **COST:** the sibling-display branch SLEEPS 35 ms on the calling thread between the two
/// `SetCursorPos`es. The dwell is load-bearing (see the comment at that branch: a sub-tick
/// jump-and-return never dirties anything), but the caller is the capture/encode thread, so a kick
/// on that branch costs ~2 frames of latency at 60 Hz. Every call site is a first-frame or
/// post-recreate recovery window where no frames are flowing anyway, and the global 50 ms throttle
/// plus the callers' own 600800 ms schedules bound how often it can happen.
///
/// **HID-first**: when the host has registered [`HID_COMPOSE_KICK`] (the resident pf-mouse virtual
/// HID pointer), the kick goes through it INSTEAD of the `SendInput` paths below. A report from a
/// HID device is real input to win32k — delivered regardless of this process's session or the
/// active desktop, it wakes a powered-off display subsystem (lid-closed laptop / display idle-off /
/// modern standby) and counts as user presence — every condition under which `SendInput` is
/// silently impotent (wrong session → wrong input queue; secure desktop → blocked; display off →
/// nothing composes at all). That set is exactly the lid-closed field-report state.
pub(super) fn kick_dwm_compose(target_id: u32) {
// Process-GLOBAL throttle (Stage W3): with N parallel capturers each nudging on its own
// schedule, DWM needs only one dirty per composition window — and the nudge is synthetic INPUT
// (global, user-visible pointer state), so it must not multiply with capturer count. 50 ms
// covers every composition interval we ship (≥ 60 Hz) while staying far under the callers' own
// 600800 ms per-capturer schedules.
static LAST_KICK: Mutex<Option<Instant>> = Mutex::new(None);
{
let mut last = LAST_KICK.lock().unwrap();
let now = Instant::now();
if last.is_some_and(|t| now.duration_since(t) < Duration::from_millis(50)) {
return;
}
*last = Some(now);
}
// Where is the cursor, and where does the target display live in desktop space?
let mut pos = POINT::default();
// SAFETY: plain FFI; `pos` is a valid out-param for this synchronous call.
let have_pos = unsafe { GetCursorPos(&mut pos) }.is_ok();
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned local
// buffers; the `Copy` target id crosses by value.
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
// HID-first (see the doc comment): the registered virtual-mouse kick works from any
// session/desktop and wakes an off display. Both geometries come from CCD (global database),
// NOT per-session GDI metrics, so the aim is right even from a non-console session. Fall
// through to SendInput only when the hook isn't registered / the mouse isn't up.
if let (Some(kick), Some(rect)) = (crate::HID_COMPOSE_KICK.get(), rect) {
// SAFETY: `desktop_bounds` only runs the CCD QueryDisplayConfig FFI over owned local
// buffers.
let bounds = unsafe { pf_win_display::win_display::desktop_bounds() };
if let Some(bounds) = bounds {
if kick(rect, bounds) {
return;
}
}
}
if let (true, Some((x, y, w, h))) = (have_pos, rect) {
let inside = pos.x >= x && pos.x < x + w.max(1) && pos.y >= y && pos.y < y + h.max(1);
if !inside {
// The cursor is on a sibling display — a wiggle there dirties the WRONG display. Jump
// to the target's center, DWELL one composition interval, then restore. The dwell is
// load-bearing (proven on-glass, Stage W3): DWM computes dirty state from the CURRENT
// cursor position at the next vsync tick, so a sub-tick jump-and-return is invisible
// and the target never composes — 35 ms covers a 30 Hz tick with margin. The cursor
// visibly leaves the sibling display for those ~2 frames; kicks only fire during THIS
// display's session-open / recovery windows (throttled), so the blip is rare and brief.
// SAFETY: plain FFI; coordinates are plain ints, and the second call restores the
// observed original position.
unsafe {
let _ = SetCursorPos(x + w / 2, y + h / 2);
}
std::thread::sleep(Duration::from_millis(35));
// SAFETY: as above.
unsafe {
let _ = SetCursorPos(pos.x, pos.y);
}
return;
}
}
let mk = |dx: i32| INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx,
dy: 0,
mouseData: 0,
dwFlags: MOUSEEVENTF_MOVE,
time: 0,
dwExtraInfo: 0,
},
},
};
// SAFETY: plain FFI; the input slice is valid, fully-initialized local data for this synchronous
// call, and `cbsize` is the true element size.
unsafe {
let _ = SendInput(&[mk(1), mk(-1)], std::mem::size_of::<INPUT>() as i32);
}
}
@@ -57,57 +57,62 @@ pub(super) struct CursorBlendPass {
impl CursorBlendPass {
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
let mut vs = None;
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
let mut ps = None;
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
let sd = D3D11_SAMPLER_DESC {
// LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the
// half-texel edges; linear keeps them soft instead of ringing.
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
ComparisonFunc: D3D11_COMPARISON_NEVER,
MaxLOD: f32::MAX,
..Default::default()
};
let mut sampler = None;
device.CreateSamplerState(&sd, Some(&mut sampler))?;
// Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha.
let mut bd = D3D11_BLEND_DESC::default();
bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
BlendEnable: true.into(),
SrcBlend: D3D11_BLEND_SRC_ALPHA,
DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
BlendOp: D3D11_BLEND_OP_ADD,
SrcBlendAlpha: D3D11_BLEND_ONE,
DestBlendAlpha: D3D11_BLEND_ONE,
BlendOpAlpha: D3D11_BLEND_OP_ADD,
RenderTargetWriteMask: 0x0F,
};
let mut blend = None;
device.CreateBlendState(&bd, Some(&mut blend))?;
let cbd = D3D11_BUFFER_DESC {
ByteWidth: 16, // float to_linear + float3 pad
Usage: D3D11_USAGE_DYNAMIC,
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
..Default::default()
};
let mut cbuf = None;
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
Ok(Self {
vs: vs.context("cursor blend vs")?,
ps: ps.context("cursor blend ps")?,
sampler: sampler.context("cursor blend sampler")?,
blend: blend.context("cursor blend state")?,
cbuf: cbuf.context("cursor blend cbuf")?,
cbuf_scale: None,
shape: None,
})
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
// literals (its contract).
unsafe {
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
let mut vs = None;
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
let mut ps = None;
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
let sd = D3D11_SAMPLER_DESC {
// LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the
// half-texel edges; linear keeps them soft instead of ringing.
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
ComparisonFunc: D3D11_COMPARISON_NEVER,
MaxLOD: f32::MAX,
..Default::default()
};
let mut sampler = None;
device.CreateSamplerState(&sd, Some(&mut sampler))?;
// Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha.
let mut bd = D3D11_BLEND_DESC::default();
bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
BlendEnable: true.into(),
SrcBlend: D3D11_BLEND_SRC_ALPHA,
DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
BlendOp: D3D11_BLEND_OP_ADD,
SrcBlendAlpha: D3D11_BLEND_ONE,
DestBlendAlpha: D3D11_BLEND_ONE,
BlendOpAlpha: D3D11_BLEND_OP_ADD,
RenderTargetWriteMask: 0x0F,
};
let mut blend = None;
device.CreateBlendState(&bd, Some(&mut blend))?;
let cbd = D3D11_BUFFER_DESC {
ByteWidth: 16, // float to_linear + float3 pad
Usage: D3D11_USAGE_DYNAMIC,
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
..Default::default()
};
let mut cbuf = None;
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
Ok(Self {
vs: vs.context("cursor blend vs")?,
ps: ps.context("cursor blend ps")?,
sampler: sampler.context("cursor blend sampler")?,
blend: blend.context("cursor blend state")?,
cbuf: cbuf.context("cursor blend cbuf")?,
cbuf_scale: None,
shape: None,
})
}
}
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
@@ -116,42 +121,48 @@ impl CursorBlendPass {
device: &ID3D11Device,
ov: &pf_frame::CursorOverlay,
) -> Result<()> {
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
return Ok(());
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
// `device` borrow. `init.pSysMem` points into `ov.rgba`, which the length check above proves
// holds at least `ov.w * ov.h * 4` bytes for the declared `SysMemPitch = ov.w * 4`, and which
// outlives the synchronous upload.
unsafe {
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
return Ok(());
}
if ov.rgba.len() < (ov.w as usize) * (ov.h as usize) * 4 || ov.w == 0 || ov.h == 0 {
bail!("malformed cursor overlay ({}x{})", ov.w, ov.h);
}
let desc = D3D11_TEXTURE2D_DESC {
Width: ov.w,
Height: ov.h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
..Default::default()
};
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: ov.rgba.as_ptr().cast(),
SysMemPitch: ov.w * 4,
SysMemSlicePitch: 0,
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
.context("CreateTexture2D(cursor shape)")?;
let tex = tex.context("null cursor shape texture")?;
let mut srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&tex, None, Some(&mut srv))
.context("CreateShaderResourceView(cursor shape)")?;
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
Ok(())
}
if ov.rgba.len() < (ov.w as usize) * (ov.h as usize) * 4 || ov.w == 0 || ov.h == 0 {
bail!("malformed cursor overlay ({}x{})", ov.w, ov.h);
}
let desc = D3D11_TEXTURE2D_DESC {
Width: ov.w,
Height: ov.h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
..Default::default()
};
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: ov.rgba.as_ptr().cast(),
SysMemPitch: ov.w * 4,
SysMemSlicePitch: 0,
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
.context("CreateTexture2D(cursor shape)")?;
let tex = tex.context("null cursor shape texture")?;
let mut srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&tex, None, Some(&mut srv))
.context("CreateShaderResourceView(cursor shape)")?;
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
Ok(())
}
/// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch).
@@ -167,50 +178,63 @@ impl CursorBlendPass {
ov: &pf_frame::CursorOverlay,
linear_scale: f32,
) -> Result<()> {
self.ensure_shape(device, ov)?;
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
if self.cbuf_scale != Some(linear_scale) {
let cb: [f32; 4] = [linear_scale, 0.0, 0.0, 0.0];
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
if ctx
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
.is_ok()
{
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
ctx.Unmap(&self.cbuf, 0);
// SAFETY: all D3D11 work on the caller's live `device`/`ctx` borrows. The
// `copy_nonoverlapping` writes `cb.len()` `f32`s into the pointer the immediately preceding
// `Map` of `self.cbuf` (16 bytes = 4×`f32`, DYNAMIC/WRITE_DISCARD) returned, inside the
// `is_ok()` arm and before the paired `Unmap`. `ensure_shape` forwards this fn's `device`
// borrow, and every `*Set*`/`Draw` takes borrowed slices of live locals or clones of live COM
// interfaces.
unsafe {
self.ensure_shape(device, ov)?;
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
if self.cbuf_scale != Some(linear_scale) {
let cb: [f32; 4] = [linear_scale, 0.0, 0.0, 0.0];
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
if ctx
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
.is_ok()
{
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
ctx.Unmap(&self.cbuf, 0);
// Cache ONLY on a successful upload. Caching unconditionally meant one transient
// `Map` failure wedged the HDR/SDR cursor scale for the rest of the session: the
// buffer still held the OLD value while this believed it held the new one, and
// no later call would retry. On failure the cache is left alone and the next
// blend tries again — a stale scale for a frame instead of forever.
self.cbuf_scale = Some(linear_scale);
}
}
self.cbuf_scale = Some(linear_scale);
}
let mut rtv: Option<ID3D11RenderTargetView> = None;
device
.CreateRenderTargetView(dst, None, Some(&mut rtv))
.context("CreateRenderTargetView(cursor blend scratch)")?;
let rtv = rtv.context("null cursor blend rtv")?;
let mut rtv: Option<ID3D11RenderTargetView> = None;
device
.CreateRenderTargetView(dst, None, Some(&mut rtv))
.context("CreateRenderTargetView(cursor blend scratch)")?;
let rtv = rtv.context("null cursor blend rtv")?;
ctx.OMSetRenderTargets(Some(&[Some(rtv)]), None);
ctx.OMSetBlendState(&self.blend, None, 0xffff_ffff);
ctx.VSSetShader(&self.vs, None);
ctx.PSSetShader(&self.ps, None);
ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())]));
ctx.IASetInputLayout(None);
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
// Placement IS the viewport: the VS fills it, the OS clips it to the target.
let vp = D3D11_VIEWPORT {
TopLeftX: ov.x as f32,
TopLeftY: ov.y as f32,
Width: *w as f32,
Height: *h as f32,
MinDepth: 0.0,
MaxDepth: 1.0,
};
ctx.RSSetViewports(Some(&[vp]));
ctx.Draw(3, 0);
// Unbind so the scratch can be bound as a conversion INPUT without a hazard warning.
ctx.OMSetRenderTargets(None, None);
let none_srv: [Option<ID3D11ShaderResourceView>; 1] = [None];
ctx.PSSetShaderResources(0, Some(&none_srv));
Ok(())
ctx.OMSetRenderTargets(Some(&[Some(rtv)]), None);
ctx.OMSetBlendState(&self.blend, None, 0xffff_ffff);
ctx.VSSetShader(&self.vs, None);
ctx.PSSetShader(&self.ps, None);
ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())]));
ctx.IASetInputLayout(None);
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
// Placement IS the viewport: the VS fills it, the OS clips it to the target.
let vp = D3D11_VIEWPORT {
TopLeftX: ov.x as f32,
TopLeftY: ov.y as f32,
Width: *w as f32,
Height: *h as f32,
MinDepth: 0.0,
MaxDepth: 1.0,
};
ctx.RSSetViewports(Some(&[vp]));
ctx.Draw(3, 0);
// Unbind so the scratch can be bound as a conversion INPUT without a hazard warning.
ctx.OMSetRenderTargets(None, None);
let none_srv: [Option<ID3D11ShaderResourceView>; 1] = [None];
ctx.PSSetShaderResources(0, Some(&none_srv));
Ok(())
}
}
}
@@ -85,11 +85,23 @@ impl CursorPoller {
/// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop`
/// syscalls/s are not.
const REATTACH: Duration = Duration::from_millis(250);
/// Cadence of the same-handle extent re-probe (see the [`run`] loop). Display-scale changes are
/// human/OS-timescale events and the probe reads dimensions only — no pixel copy — so 4 Hz is
/// both ample and negligible, and a ≤250 ms lag on the pointer's size is imperceptible.
const EXTENT_PROBE: Duration = Duration::from_millis(250);
/// Spawn the poller for the virtual display `target_id`. `rect` = the target's desktop rect
/// Spawn the poller for the virtual display `target_id`. `rect` SEEDS the target's desktop rect
/// (`source_desktop_rect` order: x, y, w, h) — cursor positions are desktop-global; the
/// overlay wants frame-relative, and a pointer outside the rect reports `visible: false`
/// (per-output semantics, matching the driver shm path and the Linux portal).
///
/// A SEED, not the value: the poll thread re-queries the rect on its [`Self::REATTACH`] cadence.
/// It used to be captured once here and used forever for BOTH the desktop→frame offset and the
/// `in_rect` test, while both mid-session mode-change paths (`resize_output` and
/// `poll_display_hdr` → `recreate_ring`) keep the same poller — so after an in-place resize the
/// pointer was clipped to the OLD rect and offset by a stale origin. Re-querying on the poll
/// thread is what keeps the CCD call off the capture/encode thread, which is the whole reason
/// this poller exists (see `DescriptorPoller`).
pub(super) fn spawn(target_id: u32, rect: (i32, i32, i32, i32)) -> Self {
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
let stop = Arc::new(AtomicBool::new(false));
@@ -139,7 +151,7 @@ impl Drop for CursorPoller {
/// The poll loop. Owns the thread's input-desktop binding and the shape cache.
fn run(
target_id: u32,
rect: (i32, i32, i32, i32),
mut rect: (i32, i32, i32, i32),
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
stop: &AtomicBool,
secure: &AtomicBool,
@@ -161,12 +173,35 @@ fn run(
let mut failed_handle: isize = 0; // don't re-rasterise a failing handle every tick
let mut serial: u64 = 0;
let mut logged_live = false;
let mut last_extent = Instant::now();
while !stop.load(Ordering::Relaxed) {
std::thread::sleep(CursorPoller::INTERVAL);
if last_attach.elapsed() >= CursorPoller::REATTACH {
last_attach = Instant::now();
publish_secure(secure, desktop.reattach());
// …and re-read the target's desktop rect on the same cadence: a mid-session resize (or
// an HDR recreate, or the user moving this display in the desktop arrangement) changes
// BOTH the origin the position is made relative to and the extent `in_rect` tests
// against, and this poller outlives all of them. `None` keeps the last good value — a
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
// report every position invisible.
//
// SAFETY: `source_desktop_rect` is an `unsafe fn` running the read-only CCD
// `QueryDisplayConfig` over owned local buffers; the `Copy` target id crosses by value
// and it returns owned `(x, y, w, h)` values, borrowing nothing.
let fresh = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
if let Some(fresh) = fresh {
if fresh != rect {
tracing::info!(
target_id,
from = ?rect,
to = ?fresh,
"cursor poller: target desktop rect changed — re-basing pointer positions"
);
rect = fresh;
}
}
}
let mut ci = CURSORINFO {
@@ -191,6 +226,42 @@ fn run(
// bitmap to have been seen. v1: animated cursors publish their first frame (the OBS
// behavior); frame cycling via DrawIconEx istep is a known follow-up.
let handle = ci.hCursor.0 as isize;
// …but the handle alone CANNOT see a re-render. Windows rebuilds the system cursors at a
// new size whenever the scale under the pointer changes — crossing to a differently-scaled
// monitor, or a monitor's own scale settling after a mode change (a fresh virtual display
// is created at the RECOMMENDED scale and gets the client's saved `PerMonitorSettings`
// override a beat later) — while the SHARED handle stays put for the session's life (the
// arrow is 0x10003 throughout). Keyed on the handle alone the cache latched whatever size
// the pointer happened to have when the poller started and never let go: a session that
// sampled inside that pre-settle window forwarded — and composited — a 96 px pointer over
// a 100 % desktop until it ended, which is exactly the "cursor is 3× too big while
// everything else is fine" report. Re-read the bitmap's EXTENT on a slow cadence
// (dimensions only, no pixel copy) and drop the cache when it moved.
if showing && handle != 0 && handle == cached_handle {
if last_extent.elapsed() >= CursorPoller::EXTENT_PROBE {
last_extent = Instant::now();
if let (Some(now), Some(s)) = (cursor_extent(ci.hCursor), shape.as_ref()) {
if now != (s.w, s.h) {
tracing::info!(
target_id,
"cursor: the pointer bitmap resized under a stable handle \
({}x{} -> {}x{}) re-rasterising (the scale under the pointer moved)",
s.w,
s.h,
now.0,
now.1
);
cached_handle = 0; // re-rasterise below, on this same tick
}
}
}
} else {
// A handle change re-rasterises on its own — hold the probe off so it can't fire on
// the very next tick against a shape that is current by construction.
last_extent = Instant::now();
}
if showing && handle != 0 && handle != cached_handle && handle != failed_handle {
match rasterize(ci.hCursor) {
Some((rgba, w, h, hot_x, hot_y)) => {
@@ -355,6 +426,57 @@ fn rasterize(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Raste
type RasterOut = Option<(Vec<u8>, u32, u32, u32, u32)>;
/// The CURRENT bitmap extent of `hcursor` — exactly the `(w, h)` [`convert`] would derive, without
/// the pixel read. Feeds the poll loop's staleness check (a re-render keeps the handle, see there).
/// `None` on any failure, which the caller reads as "no verdict" and keeps its cached shape.
fn cursor_extent(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Option<(u32, u32)> {
// CopyIcon first, for the reason `rasterize` does it: the owning process can destroy its
// HCURSOR between GetCursorInfo and the reads below; the copy is ours.
// SAFETY: `HICON(hcursor.0)` reinterprets the cursor handle as an icon handle (cursors ARE
// icons in user32); CopyIcon yields an owned HICON destroyed below.
let icon = unsafe { CopyIcon(HICON(hcursor.0)) }.ok()?;
let mut ii = ICONINFO::default();
// SAFETY: `ii` is a live out-param. On Ok it hands us COPIES of the mask/color bitmaps — both
// deleted below (GDI-handle leak otherwise).
let got = unsafe { GetIconInfo(icon, &mut ii) };
// Mirrors `convert`'s two families: a color cursor's extent is its color bitmap's; a
// monochrome one's mask carries the AND plane OVER the XOR plane, so its height is doubled.
let extent = got.is_ok().then_some(()).and_then(|()| {
if !ii.hbmColor.is_invalid() {
bitmap_extent(ii.hbmColor)
} else {
let (w, h) = bitmap_extent(ii.hbmMask)?;
(h >= 2 && h % 2 == 0).then_some((w, h / 2))
}
});
// SAFETY: deleting the two bitmap copies GetIconInfo returned (null-safe: DeleteObject on a
// null HGDIOBJ fails harmlessly) and the icon copy — each exactly once.
unsafe {
let _ = DeleteObject(ii.hbmColor.into());
let _ = DeleteObject(ii.hbmMask.into());
let _ = DestroyIcon(icon);
}
extent
}
/// A GDI bitmap's dimensions, under [`read_bitmap_32`]'s sanity caps so the two agree on what a
/// plausible cursor is — a bitmap `rasterize` would reject must not read here as a size CHANGE.
fn bitmap_extent(hbm: HBITMAP) -> Option<(u32, u32)> {
let mut bm = BITMAP::default();
// SAFETY: `bm` is a live out-param sized exactly as passed; GetObjectW only writes into it.
let n = unsafe {
GetObjectW(
hbm.into(),
std::mem::size_of::<BITMAP>() as i32,
Some((&mut bm as *mut BITMAP).cast()),
)
};
if n == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 1024 {
return None;
}
Some((bm.bmWidth as u32, bm.bmHeight as u32))
}
/// Convert the ICONINFO bitmaps to straight RGBA. Two families:
/// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style
/// cursors) the AND mask supplies it (mask bit 1 = transparent).
@@ -372,15 +494,13 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
let color = read_bitmap_32(dc, ii.hbmColor)?;
let (w, h) = (color.w as u32, color.h as u32);
let mut rgba = bgra_to_rgba(&color.bgra);
if rgba.chunks_exact(4).all(|p| p[3] == 0) {
if alpha_is_empty(&rgba) {
// Alpha-less color cursor: transparency lives in the AND mask.
let mask = read_bitmap_32(dc, ii.hbmMask)?;
if mask.w != color.w || mask.h < color.h {
return None;
}
for (px, m) in rgba.chunks_exact_mut(4).zip(mask.bgra.chunks_exact(4)) {
px[3] = if m[0] != 0 { 0 } else { 0xFF }; // mask white (AND=1) = transparent
}
apply_and_mask_alpha(&mut rgba, &mask.bgra);
}
Some((rgba, w, h))
} else {
@@ -389,42 +509,8 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
return None;
}
let (w, h) = (mask.w as usize, (mask.h / 2) as usize);
let row = w * 4;
let (and_plane, xor_plane) = mask.bgra.split_at(h * row);
let mut rgba = vec![0u8; w * h * 4];
let mut invert = vec![false; w * h];
for i in 0..w * h {
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
let px = &mut rgba[i * 4..i * 4 + 4];
match (a, x) {
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
(true, false) => {} // transparent (already zeroed)
(true, true) => {
px.copy_from_slice(&[0, 0, 0, 0xFF]);
invert[i] = true;
}
}
}
// White outline around invert regions so the (now black) shape survives dark
// backgrounds: any transparent 8-neighbor of an invert pixel turns opaque white.
for y in 0..h as i32 {
for x in 0..w as i32 {
if !invert[(y * w as i32 + x) as usize] {
continue;
}
for (dx, dy) in NEIGHBORS {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let o = (ny * w as i32 + nx) as usize * 4;
if rgba[o + 3] == 0 {
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
}
}
}
}
let (and_plane, xor_plane) = mask.bgra.split_at(h * w * 4);
let rgba = mono_planes_to_rgba(and_plane, xor_plane, w, h);
Some((rgba, w as u32, h as u32))
}
})();
@@ -506,3 +592,204 @@ fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
}
out
}
/// Whether a 32bpp RGBA buffer's alpha channel is entirely zero — the "old-style cursor with no
/// alpha" test, whose transparency lives in the AND mask instead ([`apply_and_mask_alpha`]).
fn alpha_is_empty(rgba: &[u8]) -> bool {
rgba.chunks_exact(4).all(|p| p[3] == 0)
}
/// Take alpha from an expanded AND mask: mask WHITE (AND bit 1) means transparent, black opaque.
/// `mask_bgra` is the 32bpp expansion `GetDIBits` produces from the 1bpp mask, so any non-zero
/// channel byte is "set".
fn apply_and_mask_alpha(rgba: &mut [u8], mask_bgra: &[u8]) {
for (px, m) in rgba.chunks_exact_mut(4).zip(mask_bgra.chunks_exact(4)) {
px[3] = if m[0] != 0 { 0 } else { 0xFF };
}
}
/// The monochrome-cursor truth table, plus the white outline that makes an INVERT region legible.
///
/// A monochrome `HCURSOR` has no colour bitmap: `hbmMask` is DOUBLE height — the AND plane over the
/// XOR plane — and the pair encodes four states (the WebRTC/Chromium table):
///
/// | AND | XOR | meaning | straight-alpha result |
/// |-----|-----|-------------|------------------------------------------|
/// | 0 | 0 | black | opaque black |
/// | 0 | 1 | white | opaque white |
/// | 1 | 0 | transparent | fully transparent |
/// | 1 | 1 | INVERT dst | opaque black + a grown white outline |
///
/// INVERT is unrepresentable in straight alpha (it is a per-pixel XOR against whatever is behind
/// it), so it becomes opaque black and every TRANSPARENT 8-neighbour of an invert pixel is turned
/// opaque white. That outline is what keeps the text I-beam — which is almost entirely invert
/// pixels — legible over dark content; the earlier translucent-grey stand-in did not.
///
/// Extracted from `convert`'s GDI plumbing (sweep Phase 6.6) so the table is testable: the caller
/// needs a live `HCURSOR` and a screen DC, this needs two byte slices.
fn mono_planes_to_rgba(and_plane: &[u8], xor_plane: &[u8], w: usize, h: usize) -> Vec<u8> {
let mut rgba = vec![0u8; w * h * 4];
let mut invert = vec![false; w * h];
for i in 0..w * h {
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
let px = &mut rgba[i * 4..i * 4 + 4];
match (a, x) {
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
(true, false) => {} // transparent (already zeroed)
(true, true) => {
px.copy_from_slice(&[0, 0, 0, 0xFF]);
invert[i] = true;
}
}
}
for y in 0..h as i32 {
for x in 0..w as i32 {
if !invert[(y * w as i32 + x) as usize] {
continue;
}
for (dx, dy) in NEIGHBORS {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let o = (ny * w as i32 + nx) as usize * 4;
if rgba[o + 3] == 0 {
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
}
}
}
}
rgba
}
#[cfg(test)]
mod tests {
use super::*;
/// Expand a 1-bit-per-pixel plane (as `GetDIBits` does) into the 32bpp form the converters read:
/// any non-zero channel byte means "bit set".
fn plane(bits: &[u8]) -> Vec<u8> {
bits.iter()
.flat_map(|&b| {
let v = if b != 0 { 0xFF } else { 0 };
[v, v, v, 0]
})
.collect()
}
fn px(rgba: &[u8], i: usize) -> [u8; 4] {
rgba[i * 4..i * 4 + 4].try_into().unwrap()
}
const OPAQUE_BLACK: [u8; 4] = [0, 0, 0, 0xFF];
const OPAQUE_WHITE: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF];
const TRANSPARENT: [u8; 4] = [0, 0, 0, 0];
/// All four AND/XOR states, in one 4×1 row — the table `mono_planes_to_rgba` documents.
#[test]
fn the_monochrome_truth_table_is_exact() {
// (0,0) black (0,1) white (1,0) transparent (1,1) invert
let and = plane(&[0, 0, 1, 1]);
let xor = plane(&[0, 1, 0, 1]);
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
assert_eq!(px(&out, 0), OPAQUE_BLACK, "AND=0 XOR=0 ⇒ black");
assert_eq!(px(&out, 1), OPAQUE_WHITE, "AND=0 XOR=1 ⇒ white");
// Pixel 2 is transparent by the table, but it is an 8-neighbour of the invert pixel at 3,
// so the outline claims it — that IS the documented behaviour.
assert_eq!(
px(&out, 2),
OPAQUE_WHITE,
"outline grows into adjacent transparency"
);
assert_eq!(px(&out, 3), OPAQUE_BLACK, "AND=1 XOR=1 ⇒ black + outline");
}
/// Transparency survives when there is no invert pixel next to it.
#[test]
fn transparent_pixels_stay_transparent_without_an_invert_neighbour() {
let and = plane(&[1, 1, 1, 1]);
let xor = plane(&[0, 0, 0, 0]);
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
for i in 0..4 {
assert_eq!(px(&out, i), TRANSPARENT, "pixel {i}");
}
}
/// The outline grows into all eight neighbours, and only into TRANSPARENT ones — it must not
/// repaint a black or white shape pixel.
#[test]
fn the_invert_outline_covers_eight_neighbours_and_overwrites_nothing() {
// 3×3, invert at the centre, everything else transparent.
let and = plane(&[1, 1, 1, 1, 1, 1, 1, 1, 1]);
let mut xor = plane(&[0; 9]);
for b in &mut xor[4 * 4..4 * 4 + 3] {
*b = 0xFF; // centre pixel's XOR bit
}
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
assert_eq!(px(&out, 4), OPAQUE_BLACK, "the invert pixel itself");
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
assert_eq!(px(&out, i), OPAQUE_WHITE, "neighbour {i} outlined");
}
// Now surround it with BLACK shape pixels (AND=0, XOR=0): the outline must leave them alone.
let and = plane(&[0, 0, 0, 0, 1, 0, 0, 0, 0]);
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
assert_eq!(
px(&out, i),
OPAQUE_BLACK,
"neighbour {i} must not be repainted"
);
}
}
/// The outline must clip at the bitmap edges rather than wrap to the opposite side.
#[test]
fn the_outline_clips_at_the_edges() {
// 2×2 with the invert at (0, 0): only (1,0), (0,1) and (1,1) can be outlined.
let and = plane(&[1, 1, 1, 1]);
let mut xor = plane(&[0; 4]);
for b in &mut xor[0..3] {
*b = 0xFF;
}
let out = mono_planes_to_rgba(&and, &xor, 2, 2);
assert_eq!(px(&out, 0), OPAQUE_BLACK);
for i in [1, 2, 3] {
assert_eq!(px(&out, i), OPAQUE_WHITE, "in-bounds neighbour {i}");
}
}
// ---- the alpha-less colour path ---------------------------------------------------------
#[test]
fn an_empty_alpha_channel_is_detected() {
assert!(alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 0]));
assert!(!alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 1]));
assert!(alpha_is_empty(&[]), "no pixels ⇒ vacuously empty");
}
/// Mask WHITE (AND bit 1) = transparent, black = opaque — and the colour bytes are untouched.
#[test]
fn the_and_mask_supplies_alpha_for_an_alpha_less_cursor() {
let mut rgba = vec![
10, 20, 30, 0, // pixel 0
40, 50, 60, 0, // pixel 1
];
let mask = plane(&[1, 0]); // pixel 0 masked out, pixel 1 kept
apply_and_mask_alpha(&mut rgba, &mask);
assert_eq!(px(&rgba, 0), [10, 20, 30, 0], "masked ⇒ transparent");
assert_eq!(px(&rgba, 1), [40, 50, 60, 0xFF], "unmasked ⇒ opaque");
}
/// A mask with FEWER pixels than the colour bitmap must not panic — `zip` stops at the shorter
/// side, leaving the tail at whatever alpha it had (the caller has already required
/// `mask.h >= color.h`, so this is the belt).
#[test]
fn a_short_mask_does_not_panic() {
let mut rgba = vec![1, 2, 3, 0, 4, 5, 6, 0, 7, 8, 9, 0];
apply_and_mask_alpha(&mut rgba, &plane(&[0]));
assert_eq!(px(&rgba, 0), [1, 2, 3, 0xFF]);
assert_eq!(px(&rgba, 1), [4, 5, 6, 0]);
}
}
@@ -0,0 +1,866 @@
//! IDD-push CONSTRUCTION: everything that runs once, before frames flow.
//!
//! The sealed channel's whole bring-up — the render-adapter resolution and its one TEX_FAIL rebind,
//! the advanced-colour (HDR) negotiation, the shared header + ring + event creation, the channel
//! delivery, the cursor-channel/poller opt-in, and the bounded first-frame gate — plus the two types
//! that exist only for it ([`SharedObjectSa`], [`AttachTexFail`]).
//!
//! Split out of `idd_push.rs` in sweep Phase 5.4. The steady state (`try_consume`, `repeat_last`,
//! the pollers, the `Capturer` impl) deliberately stays with the parent: this file is the part you
//! read when a session will not START, and the parent is the part you read when one stops flowing.
//! A `#[path]` child sees the parent's private items through `use super::*`, so nothing had to be
//! made more visible to move here.
use super::*;
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM only** — `D:P(A;;GA;;;SY)`, protected
/// (no inherited ACEs), `bInheritHandle: false`. The sealed channel makes this the strictly-minimal
/// DACL: the objects are UNNAMED and the driver reaches them via **duplicated handles** (which carry the
/// source handle's access — `OpenSharedResourceByName`/`OpenSharedResource1` on a handle does not
/// re-check the object DACL against the opener), so the pf_vdisplay WUDFHost (LocalService) no longer
/// needs a DACL ACE. Dropping the `LS` ACE removes the last theoretical surface where a leaked handle or
/// a name-grown-by-accident could be opened by the (many-service-shared) LocalService SID. Empirically
/// confirmed unreachable regardless: a LocalService token is DACL-denied `OpenProcess` on the WUDFHost
/// (`PROCESS_DUP_HANDLE`/`VM_READ`/even `QUERY_LIMITED` → ACCESS_DENIED, tested on the RTX box
/// 2026-07-03), so it cannot dup the handles out either. History: `Global\`-named + world-openable
/// (`WD`, security-review 2026-06-28 #5) → SY+LS-scoped → nameless → now SY-only. See
/// `design/idd-push-security.md`.
///
/// RAII, because the descriptor is a `LocalAlloc` the caller must `LocalFree` and the previous
/// `(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)` tuple never did — leaking it twice per open (once
/// for the header section, once for the frame-ready event) and again on every ring recreate. Pairing
/// them in one owner also enforces the "descriptor must outlive the attributes" rule structurally:
/// `sa.lpSecurityDescriptor` points at the allocation and [`as_ptr`](Self::as_ptr) only lends a
/// borrow, so the attributes cannot escape this value's lifetime. Moving the struct is fine — the
/// pointer targets the heap allocation, not a field.
struct SharedObjectSa {
sa: SECURITY_ATTRIBUTES,
psd: PSECURITY_DESCRIPTOR,
}
impl SharedObjectSa {
fn new() -> Result<Self> {
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `ConvertStringSecurityDescriptorToSecurityDescriptorW` reads the `w!()` literal and
// writes the descriptor it allocates into the live local `psd`; `?` rejects a failure before
// `psd` is read.
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:P(A;;GA;;;SY)"),
SDDL_REVISION_1,
&mut psd,
None,
)
.context("build SDDL for IDD-push shared objects")?;
}
Ok(Self {
sa: SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
},
psd,
})
}
/// The `SECURITY_ATTRIBUTES` to hand a create call, borrowed from this owner.
fn as_ptr(&self) -> *const SECURITY_ATTRIBUTES {
&self.sa
}
}
impl Drop for SharedObjectSa {
fn drop(&mut self) {
// SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW`
// allocated for this value and nothing else owns it; `LocalFree` releases it exactly once
// (this `Drop` runs once, and `as_ptr` only ever lends a borrow of `sa`).
unsafe {
let _ = LocalFree(Some(HLOCAL(self.psd.0)));
}
}
}
impl IddPushCapturer {
/// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
/// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
/// the duplicate the [`ChannelBroker`] sends after the ring is published.
pub(super) unsafe fn create_ring_slots(
device: &ID3D11Device,
w: u32,
h: u32,
format: DXGI_FORMAT,
) -> Result<Vec<HostSlot>> {
// SAFETY: every D3D11/DXGI call is `?`-checked on the live `device` borrow, over
// fully-initialized stack descriptors and live out-params; `&sa` stays valid for the whole loop
// because `_psd`, the security descriptor backing it, is held in scope alongside.
// `OwnedHandle::from_raw_handle` adopts the handle `CreateSharedHandle` JUST minted for this
// slot — a unique, still-open NT handle owned by this process — making the slot its sole owner.
unsafe {
let sa = SharedObjectSa::new()?;
let mut slots = Vec::new();
for _ in 0..RING_LEN {
let desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
// Match the OS-composed swap-chain surfaces so the driver's CopyResource into the slot +
// its format-guard both succeed.
Format: format,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
CPUAccessFlags: 0,
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0
| D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0)
as u32,
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, None, Some(&mut tex))
.context("CreateTexture2D(IDD-push ring slot)")?;
let tex = tex.context("null ring texture")?;
let res1: IDXGIResource1 = tex.cast()?;
let shared = res1
.CreateSharedHandle(
Some(sa.as_ptr()),
DXGI_SHARED_RESOURCE_RW,
PCWSTR::null(), // UNNAMED — reachable only through the broker's duplicate
)
.context("CreateSharedHandle(IDD-push ring slot)")?;
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
let shared = OwnedHandle::from_raw_handle(shared.0 as _);
let mutex: IDXGIKeyedMutex = tex.cast()?;
let mut srv: Option<ID3D11ShaderResourceView> = None;
device
.CreateShaderResourceView(&tex, None, Some(&mut srv))
.context("CreateShaderResourceView(IDD-push ring slot)")?;
let srv = srv.context("null slot srv")?;
slots.push(HostSlot {
tex,
mutex,
shared,
srv,
});
}
Ok(slots)
}
}
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
#[allow(clippy::too_many_arguments)]
pub fn open(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_444: bool,
pyrowave: bool,
keepalive: Box<dyn Send>,
sender: crate::FrameChannelSender,
cursor_sender: Option<crate::CursorChannelSender>,
cursor_forward: Option<crate::CursorForwardSender>,
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
pf_win_display::display_events::spawn_once();
match Self::open_inner(
target,
preferred,
client_10bit,
want_444,
pyrowave,
sender,
cursor_sender,
cursor_forward,
) {
Ok(mut me) => {
me._keepalive = keepalive;
Ok(me)
}
Err(e) => Err((e, keepalive)),
}
}
#[allow(clippy::too_many_arguments)]
fn open_inner(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_444: bool,
pyrowave: bool,
sender: crate::FrameChannelSender,
cursor_sender: Option<crate::CursorChannelSender>,
cursor_forward: Option<crate::CursorForwardSender>,
) -> Result<Self> {
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
// ADD, so on a healthy box they agree, and NVENC gets a device on a real GPU adapter.
// (`target.adapter_luid` is NOT that adapter: the ADD reply carries
// `IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` = the IddCx DISPLAY adapter — verified
// on-glass; it stays a last-resort fallback for a pickerless box only.) When the pick and
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
// this open, or a stale kept monitor across an adapter re-init — the driver reports
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
let luid = pf_gpu::resolve_render_adapter_luid().unwrap_or(LUID {
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
HighPart: (target.adapter_luid >> 32) as i32,
});
match Self::open_on(
target.clone(),
preferred,
client_10bit,
want_444,
pyrowave,
luid,
sender.clone(),
cursor_sender.clone(),
cursor_forward.clone(),
) {
Ok(me) => Ok(me),
Err(e) => {
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
// adapter its swap-chain ACTUALLY renders on (a stale monitor across an adapter
// re-init, or a driver that ignored SET_RENDER_ADAPTER). Rebinding the ring to that
// adapter beats failing the session — the outer pipeline retries would repeat the
// exact same mismatch.
let driver_luid = e
.downcast_ref::<AttachTexFail>()
.map(|tf| tf.driver_luid)
.filter(|d| *d != 0 && *d != crate::dxgi::pack_luid(luid));
let Some(packed) = driver_luid else {
return Err(e);
};
let drv = LUID {
LowPart: (packed & 0xffff_ffff) as u32,
HighPart: (packed >> 32) as i32,
};
tracing::warn!(
ring_adapter = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
driver_adapter = format!("{:08x}:{:08x}", drv.HighPart, drv.LowPart),
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
driver's reported adapter"
);
Self::open_on(
target,
preferred,
client_10bit,
want_444,
pyrowave,
drv,
sender,
cursor_sender,
cursor_forward,
)
.context("IDD-push rebind to the driver's reported render adapter")
}
}
}
#[allow(clippy::too_many_arguments)]
fn open_on(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_444: bool,
pyrowave: bool,
luid: LUID,
sender: crate::FrameChannelSender,
cursor_sender: Option<crate::CursorChannelSender>,
cursor_forward: Option<crate::CursorForwardSender>,
) -> Result<Self> {
let (pw, ph, _hz) = preferred
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
// Size the ring to the display's ACTUAL current resolution if it differs from the negotiated mode:
// a fullscreen game can hold the virtual display at a different mode (esp. across a reconnect), so
// matching the actual mode lets the first frame flow instead of being dropped (game-capture bug
// GB1). Falls back to the negotiated mode when the CCD read is unavailable.
// SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a
// copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from
// us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`).
let (w, h) = unsafe { pf_win_display::win_display::active_resolution(target.target_id) }
.unwrap_or((pw, ph));
if (w, h) != (pw, ph) {
tracing::info!(
target_id = target.target_id,
negotiated = format!("{pw}x{ph}"),
actual = format!("{w}x{h}"),
"IDD push: sizing the ring to the display's actual mode (differs from negotiated)"
);
}
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
// COMMIT_MODES2 colorspace/rgb_bpc log). For a 10-bit-capable client we PROACTIVELY enable
// advanced color so HDR streams without the user toggling anything, then TRACK the display's
// actual mode (a mid-session "Use HDR" flip; the driver's format-guard drops a mismatch), polling
// the live state here and on every recreate. An SDR-only client instead forces advanced color OFF
// and is PINNED there (below + the descriptor poller), so the SDR negotiation is honored and the
// encoder never emits the in-band PQ upgrade to a client that asked for SDR.
// SAFETY: one block over the whole ring setup; every operation in it is sound:
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `SharedObjectSa::new`,
// `CreateFileMappingW`, `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all
// `?`-checked, so every returned interface/handle/view is non-error before use;
// `sa.as_ptr()`/`&adapter`/`&device` are live borrows that outlive each synchronous call, and
// `sa.lpSecurityDescriptor` stays valid because the owning `SharedObjectSa` is held in scope
// for the whole block (and frees the descriptor on the way out).
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`.
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
// that orders all preceding writes before the driver may observe `MAGIC`.
// - `broker.send` requires live `header`/`event` handles of this process: both borrow the just-
// created owned section/event for the duration of that synchronous call.
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
// into `me` leaves it valid (see the `MappedSection` doc comment).
unsafe {
// An SDR-NEGOTIATED session (either codec) must run on an SDR (BGRA) composition, so
// actively turn advanced color OFF — undoing any leftover HDR state from a prior 10-bit
// session on a reused/lingering monitor, the driver's default, or the host's global
// "Use HDR" — and settle before sizing the ring. Non-optional for two reasons:
// - PyroWave: its CSC reads 8-bit BGRA and the NVIDIA D3D11 VideoProcessor can't ingest
// the FP16 ring at all.
// - H.26x: off an HDR composition the capturer emits P010 and the encoder stamps
// Main10 + BT.2020 PQ from the pixel format alone (the in-band HDR upgrade), sending a
// 10-bit PQ stream to a client that advertised SDR-only ("HDR off = never send me
// 10-bit"). On a client whose monitor is HDR-capable but has "Use HDR" off, that PQ
// lands on an SDR desktop and blows out — the composition must honor the negotiation.
// An HDR-negotiated (10-bit) session instead enables HDR below and rides the FP16 scRGB
// ring (design/pyrowave-444-hdr.md Phase 3 for PyroWave; the H.26x P010 path otherwise).
if !client_10bit {
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
let settle = Instant::now();
while settle.elapsed() < Duration::from_millis(250) {
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
== Some(false)
{
break;
}
std::thread::sleep(Duration::from_millis(25));
}
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
== Some(true)
{
tracing::error!(
target = target.target_id,
pyrowave,
"IDD push: SDR session but advanced color (HDR) could NOT be turned off on the \
virtual display (a physical display forcing HDR?) PyroWave will likely fail \
its first frame; H.26x would emit PQ the SDR-only client never asked for"
);
} else {
tracing::info!(
target = target.target_id,
pyrowave,
settle_ms = settle.elapsed().as_millis() as u64,
"IDD push: SDR-negotiated session — advanced color forced OFF (SDR/BGRA composition)"
);
}
}
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
let enabled_hdr = client_10bit
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
if enabled_hdr {
// Let the colorspace change settle before the driver composes + we size the ring:
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
// ceiling = the old 250 ms. A read that never flips within the ceiling proceeds
// exactly like the fixed sleep did — the ring is sized FP16 from `enabled_hdr`
// either way (the set succeeded; only the driver's compose flip may lag, which the
// stash/format-guard machinery absorbs).
let hdr_settle = Instant::now();
while hdr_settle.elapsed() < Duration::from_millis(250) {
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
== Some(true)
{
break;
}
std::thread::sleep(Duration::from_millis(25));
}
tracing::debug!(
target_id = target.target_id,
settle_ms = hdr_settle.elapsed().as_millis() as u64,
"IDD push: advanced-color (HDR) enable settle"
);
}
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
// An SDR-negotiated session (either codec) forced advanced color OFF above and composes
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
// poller re-asserts OFF; PyroWave's format guard/stash absorbs any lingering FP16 compose).
// Keep the raw observation so Downgrade point D below can say whether the read reported
// OFF or failed outright — "we asked, it said no" and "we could not tell" have different
// causes and different fixes.
let observed_hdr =
pf_win_display::win_display::advanced_color_enabled(target.target_id);
let display_hdr = client_10bit && (enabled_hdr || observed_hdr.unwrap_or(false));
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
// BT.709, so the client's label overstates the stream until the descriptor poller sees
// HDR come on. Loud, because every frame of this session is affected.
if client_10bit && !display_hdr {
tracing::error!(
target = target.target_id,
want_hdr = true,
set_advanced_color_returned = enabled_hdr,
observed_hdr = ?observed_hdr,
"IDD push: 10-bit HDR was negotiated but enabling advanced color on the \
virtual display FAILED encoding 8-bit SDR while the client was told HDR \
(check the display driver / Windows HDR support on this box). \
observed_hdr=Some(false) the display reports advanced colour OFF after the \
set; None the CCD read itself failed"
);
}
let ring_fmt = if display_hdr {
DXGI_FORMAT_R16G16B16A16_FLOAT
} else {
DXGI_FORMAT_B8G8R8A8_UNORM
};
// Our device (ring + zero-copy NVENC) lives on `luid` — the selected render GPU per
// `open_inner`; the driver must render the swap-chain on the SAME adapter for the
// shared textures to open (it reports its actual render LUID into the header, which
// `open_inner` uses to rebind once if this mismatches).
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
let adapter: IDXGIAdapter1 = factory
.EnumAdapterByLuid(luid)
.context("EnumAdapterByLuid(render adapter) for IDD push")?;
let (device, context) = make_device(&adapter).context("make_device for IDD push")?;
let sa = SharedObjectSa::new()?;
let bytes = std::mem::size_of::<SharedHeader>().max(64);
// Header — UNNAMED (the sealed channel: the driver gets a duplicated handle, not a name).
let map = CreateFileMappingW(
INVALID_HANDLE_VALUE,
Some(sa.as_ptr()),
PAGE_READWRITE,
0,
bytes as u32,
PCWSTR::null(),
)
.context("CreateFileMapping(IDD-push header)")?;
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
let map = OwnedHandle::from_raw_handle(map.0 as _);
let view = MapViewOfFile(
HANDLE(map.as_raw_handle()),
FILE_MAP_ALL_ACCESS,
0,
0,
bytes,
);
if view.Value.is_null() {
bail!("MapViewOfFile failed for IDD-push header"); // `map` drops → mapping closed
}
let section = MappedSection { handle: map, view };
let generation = next_generation();
let header = section.ptr::<SharedHeader>();
std::ptr::write_bytes(header.cast::<u8>(), 0, bytes);
(*header).version = VERSION;
(*header).generation = generation;
(*header).ring_len = RING_LEN;
(*header).width = w;
(*header).height = h;
// Ring format = the display's composition format (FP16 in HDR, BGRA in SDR). The driver
// reads this into its `ring_format` and drops any surface that doesn't match.
(*header).dxgi_format = ring_fmt.0 as u32;
// The ring NAMES its monitor (proto v3, `design/idd-push-security.md` invariant #10) —
// stamped before the magic (below), never changed for the ring's life (a mid-session
// recreate reuses this mapping). The driver refuses to attach a ring naming a different
// monitor, so a stash cross-wire fails closed instead of leaking frames cross-client
// (fail-closed refusal VALIDATED on-glass 2026-07-10 via a fault-injected build: driver
// DRV_STATUS_BIND_FAIL + loud host open failure + sibling stream undisturbed).
(*header).target_id = target.target_id;
// Frame-ready event (auto-reset) — UNNAMED, like everything on this channel.
let event = CreateEventW(Some(sa.as_ptr()), false, false, PCWSTR::null())
.context("CreateEvent(IDD-push)")?;
let event = OwnedHandle::from_raw_handle(event.0 as _);
// Ring of shared keyed-mutex textures, format matched to the display's current mode.
let slots = Self::create_ring_slots(&device, w, h, ring_fmt)?;
// Publish: magic LAST (Release) — the ring must be fully initialized before the driver
// (which receives the channel strictly afterwards) can observe MAGIC.
std::sync::atomic::fence(Ordering::Release);
(*(std::ptr::addr_of!((*header).magic) as *const AtomicU32))
.store(MAGIC, Ordering::Release);
// Deliver the sealed channel: duplicate header + event + every slot texture into the
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
// broker reaps its remote duplicates on failure), and a failure fails the open — without
// the delivery the driver can never attach.
let broker = ChannelBroker::open(target.wudf_pid, sender)?;
broker
.send(
target.target_id,
generation,
HANDLE(section.handle.as_raw_handle()),
HANDLE(event.as_raw_handle()),
&slots,
)
.context("deliver IDD-push frame channel to the driver")?;
// v5 hardware-cursor channel (M2c): create + deliver the CursorShm section. Failure
// is NON-fatal — the driver never declares the hardware cursor without this delivery,
// so the session degrades to today's composited pointer (and the forwarder simply
// never sees a live overlay).
let cursor_shared = cursor_sender.as_ref().and_then(|send_cursor| {
match cursor::CursorShared::create(target.target_id) {
Ok(cs) => {
// Deliver via the shared helper (also used for RE-delivery after a
// driver-side monitor re-arrival destroyed the worker).
deliver_cursor_channel(&broker, target.target_id, &cs, send_cursor)
.then_some(cs)
}
Err(e) => {
tracing::warn!(
"cursor section creation failed — the driver will not declare a \
hardware cursor, so this session cannot forward the pointer: {e:#}"
);
None
}
}
});
// No LIVE channel this session, but the target's sticky declare (an EARLIER session's —
// irrevocable, §8.6) keeps DWM's frames pointer-free with no client drawing either:
// the only visible pointer is the one composited here, so force composite mode on.
//
// Gated on `cursor_shared`, NOT on `cursor_sender`. §8.6's rationale is "this session has
// no cursor CHANNEL", and the delivery just above is explicitly allowed to fail
// non-fatally — which is precisely the state that needs this rescue, yet the
// `cursor_sender.is_none()` test was the one state that skipped it: the host negotiated a
// channel, failed to create or deliver it, and then declined to composite, leaving a
// cursor-excluded target with NO pointer at all.
let composite_forced = target.cursor_excluded && cursor_shared.is_none();
if composite_forced {
tracing::info!(
target_id = target.target_id,
negotiated_channel = cursor_sender.is_some(),
"target carries an irrevocable hardware-cursor declare from an earlier \
desktop-mode session and this session has no LIVE cursor channel the host \
composites the pointer into frames (forced, for the session's life). \
negotiated_channel=true one was negotiated but its creation/delivery failed"
);
}
// The GDI shape poller rides the SAME gate as the delivered channel: with the driver's
// hardware cursor keeping the frame cursor-free, the poller supplies the full-fidelity
// shape (masked/monochrome included — the IddCx query can't; see cursor_poll.rs).
// Forced-composite sessions need it too — it is their only shape/position source.
let cursor_poll = (cursor_shared.is_some() || composite_forced).then(|| {
// Safety of the CCD call: read-only QueryDisplayConfig over owned locals (same
// call CursorShared::create makes) — already inside open_on's unsafe region.
let rect = pf_win_display::win_display::source_desktop_rect(target.target_id)
.unwrap_or((0, 0, i32::MAX, i32::MAX));
cursor_poll::CursorPoller::spawn(target.target_id, rect)
});
// Heal the driver's persisted cursor-forward state: a session that died on the
// secure desktop (client drops at the lock screen — the common case) leaves the
// per-target desired state `false`, and the NEXT session's channel delivery would
// adopt UNdeclared (the exact cross-session composite trap of §8.6). A fresh
// session always starts declared; the secure-desktop guard re-disables if the
// secure desktop is (still) up, via its first `poll_secure_desktop` edge.
if let (Some(_), Some(fwd)) = (cursor_shared.as_ref(), cursor_forward.as_ref()) {
if let Err(e) = fwd(true) {
tracing::debug!("cursor-forward reset at open failed (pre-v6 driver?): {e:#}");
}
}
tracing::info!(
target_id = target.target_id,
wudf_pid = target.wudf_pid,
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
mode = format!("{w}x{h}"),
display_hdr,
client_10bit,
want_444,
ring_fp16 = display_hdr,
// Whether DXGI ever reached the win32u GPU-preference hook. By this point the
// factory + `EnumAdapterByLuid` + `make_device` above have exercised DXGI, so a
// 0 here means the hook is inert on this build — the first thing to check if a
// hybrid-GPU box keeps reporting TEX_FAIL render-adapter mismatches
// (`dxgi::install_gpu_pref_hook`).
hybrid_hook_hits = crate::dxgi::hybrid_hook_hits(),
"IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
to attach + publish"
);
let mut me = Self {
device,
context,
target_id: target.target_id,
section,
header,
event,
broker,
width: w,
height: h,
slots,
generation,
client_10bit,
display_hdr,
hdr_pin_warned: false,
want_444,
pyrowave,
pyro_fence: None,
pyro_fence_handle: None,
pyro_fence_value: 0,
pyro_ring: Vec::new(),
pyro_conv: None,
pyro_last: None,
desc_poller: DescriptorPoller::spawn(
target.target_id,
DisplayDescriptor {
hdr: display_hdr,
width: w,
height: h,
},
),
desc_seq: 0,
pending_desc: None,
recovering_since: None,
last_fresh: Instant::now(),
last_liveness: Instant::now(),
last_kick: Instant::now(),
stall_watch: StallWatch::new(),
out_ring: Vec::new(),
out_idx: 0,
video_conv: None,
hdr_p010_conv: None,
last_seq: 0,
last_present: None,
status_logged: false,
cursor_shared,
cursor_poll,
cursor_sender,
cursor_forward,
secure_active: false,
composite_cursor: composite_forced,
composite_forced,
cursor_blend: None,
cursor_blend_failed: false,
cursor_shm_latched: false,
blend_scratch: None,
last_blend_key: None,
last_slot: None,
sdr_white_scale: 1.0,
// Held from BEFORE the first-frame gate (the display must not idle off while we
// wait for the first compose) until the capturer drops with the session.
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
// it back to the caller for the DDA fallback (audit §5.1).
_keepalive: Box::new(()),
};
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
// from the blend (which holds the ring slot's keyed mutex — see
// `refresh_sdr_white_scale`). No-op on an SDR composition.
me.refresh_sdr_white_scale();
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
// instead of next_frame's 20 s black-then-bail.
me.wait_for_attach()?;
Ok(me)
}
}
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
/// `design/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
///
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
/// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard
/// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom).
/// A stash-capable driver republishes its retained desktop frame the moment it attaches (the
/// first-frame guarantee — `FrameStash`, driver frame_transport.rs), so the normal case clears this
/// gate in milliseconds even on an idle desktop; failing that, at session open the OS activates the
/// virtual display → DWM composites it → a frame arrives within ~1 s, plus the compose-kick fallback
/// below — no frame within the window = genuinely broken.
fn wait_for_attach(&self) -> Result<()> {
// Symmetric host-side binding sanity (proto v3 §3.2): OUR header must still name OUR
// monitor. The stamp is ours and nothing legitimate rewrites it, so a mismatch means a
// host-side bug (a stash/capturer cross-wire) — the exact class the driver-side check
// catches from the other end; failing here names the culprit in the same release.
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping (same access
// pattern as the `driver_status` read below); no reference into the shared region is formed.
let stamped = unsafe { (*self.header).target_id };
if stamped != self.target_id {
bail!(
"IDD-push: our ring header names target {stamped} but this capturer serves target \
{} host-side ringmonitor cross-wire (bug); failing the open",
self.target_id
);
}
let deadline = Instant::now() + Duration::from_secs(4);
// First-frame expectation: a stash-capable driver republishes its retained desktop frame
// the moment it attaches (`FrameStash`, frame_transport.rs), so on a healthy pairing the
// gate below clears in milliseconds even on a perfectly idle desktop. The compose-kick
// schedule is the FALLBACK for pre-stash drivers / an empty stash (a display that has
// never composed): DWM only presents a display something DIRTIED, so on an idle desktop
// an attach would otherwise sit at E_PENDING forever and fail this gate — the
// "idle desktop → no frames" gotcha. Give the natural post-activate compose (and the
// stash republish) a moment, then nudge; log when we do, so field logs show whether the
// stash path is working.
let mut next_kick = Instant::now() + Duration::from_millis(600);
loop {
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
// `>= size_of::<SharedHeader>()`, page-aligned), so the field read is in-bounds + aligned, and
// no reference into the shared region is formed. Plain read: the driver writes this `u32`
// cross-process, but an aligned `u32` read can't tear and `driver_status` is best-effort
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
// log_driver_status_once).
let st = unsafe { (*self.header).driver_status };
if st == DRV_STATUS_TEX_FAIL {
// The driver wrote its render LUID BEFORE attempting the texture opens
// (frame_transport.rs step 2), so it is valid here.
let (_, detail, lo, hi) = self.driver_diag();
// Typed so `open_inner` can rebind the ring to the driver's adapter once.
return Err(anyhow::Error::new(AttachTexFail {
detail,
driver_luid: ((hi as i64) << 32) | (lo as i64 & 0xffff_ffff),
}));
}
if st == DRV_STATUS_NO_DEVICE1 {
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
// through the owned, live header mapping; no reference into the shared region is formed.
let detail = unsafe { (*self.header).driver_status_detail };
bail!(
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
the driver has no ID3D11Device1 to open shared resources)"
);
}
if st == DRV_STATUS_BIND_FAIL {
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
// through the owned, live header mapping; no reference into the shared region is formed.
let claimed = unsafe { (*self.header).driver_status_detail };
bail!(
"IDD-push driver REFUSED the ring↔monitor binding (DRV_STATUS_BIND_FAIL: the \
delivered ring names target {claimed}, the monitor is {}) host \
stash/delivery cross-wire (bug); failing the open loudly (proto v3 §3.2)",
self.target_id
);
}
// Attached AND a frame has been published — the publish token's seq advances past 0.
if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 {
return Ok(());
}
if Instant::now() >= next_kick {
// Reaching a kick at all means the driver did NOT republish a retained frame
// (pre-stash driver, or a never-composed display) — worth a line in the field log.
tracing::debug!(
target_id = self.target_id,
driver_status = st,
"IDD push: no first frame after attach delivery — falling back to a synthetic \
compose kick (stash-capable drivers republish instantly; old driver?)"
);
// May BLOCK this thread ~35 ms (the cursor-on-a-sibling-display branch — see
// `kick_dwm_compose`'s COST note). Fine here: we are inside the open-time
// first-frame gate, so no frames are flowing yet.
kick_dwm_compose(self.target_id);
next_kick = Instant::now() + Duration::from_millis(800);
}
if Instant::now() > deadline {
bail!(
"IDD-push: no frame published within 4s (despite compose kicks) — {}; \
falling back",
self.no_first_frame_diagnosis(st)
);
}
// Event-driven wait (latency plan P0.6): the driver signals the frame-ready event on
// every publish, so wake on it instead of a blind sleep — the 20 ms timeout keeps the
// driver_status polls above live (status writes don't signal the event). Consuming a
// signal here is fine: `next_frame` re-checks the atomic `latest` token, never the
// event, for truth.
// SAFETY: `self.event` is this capturer's owned, live auto-reset event handle;
// `WaitForSingleObject` only reads the handle and the 20 ms timeout bounds the wait.
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 20) };
}
}
/// Name a first-frame timeout from the driver's own evidence — `driver_status` plus the live
/// OPENED detail word (proto `pack_opened_detail`) — instead of guessing. The three no-frames
/// states look identical from the host side but have disjoint causes and fixes; the lid-closed
/// field report burned days for lack of exactly this line. Appends a console-session hint when
/// the host itself is in the wrong session (display writes + input kicks can't work from there).
fn no_first_frame_diagnosis(&self, st: u32) -> String {
let what = match st {
// The delivery was never consumed: no swap-chain worker ran for this monitor at all.
DRV_STATUS_NONE => "the driver never attached — the channel delivery was never \
consumed, so the OS ran no swap-chain worker for this monitor (display not \
composed at all: console display-off / modern standby, or the mode commit \
never reached the adapter)"
.to_string(),
DRV_STATUS_OPENED => {
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping
// (same best-effort diagnostic access as the `driver_status` read in the caller);
// no reference into the shared region is formed.
let detail = unsafe { (*self.header).driver_status_detail };
match unpack_opened_detail(detail) {
Some((0, _)) => "driver attached with a live swap-chain, but DWM composed \
ZERO frames an undamaged or powered-off desktop, and the compose \
kicks didn't bite (synthetic input is blocked on the secure desktop)"
.to_string(),
Some((offered, mismatched)) => format!(
"driver attached and DWM composed {offered} frame(s), but none matched \
the ring {mismatched} dropped for a size/format mismatch (the \
display's actual mode differs from what the host sized the ring to: \
a mid-open mode-set, a fullscreen game, or a stale GDI view)"
),
// A pre-detail driver never stamps the live bit — say so rather than guess.
None => "driver attached but published nothing; this pf-vdisplay build \
predates attach diagnostics, so the cause can't be named update the \
driver for a precise line here"
.to_string(),
}
}
other => format!("driver_status={other} (unexpected at this point)"),
};
match pf_win_display::console_session_mismatch() {
Some((own, console)) => format!(
"{what} [host is in session {own} but the console is session {console} — display \
writes and input kicks cannot work from a non-console session; reconnect the \
console or run via the installed service]"
),
None => what,
}
}
}
/// `wait_for_attach`'s DRV_STATUS_TEX_FAIL as a typed error: the driver could not open the ring
/// textures, and `driver_luid` (packed, from the shared header) is the adapter its swap-chain
/// ACTUALLY renders on — `open_inner` downcasts to this to rebind the ring there once.
#[derive(Debug)]
struct AttachTexFail {
detail: u32,
driver_luid: i64,
}
impl std::fmt::Display for AttachTexFail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"IDD-push driver failed to attach (driver_status={DRV_STATUS_TEX_FAIL} \
detail=0x{:08x}): it could not open the ring textures its swap-chain renders on \
adapter {:08x}:{:08x}, not the ring's (render-adapter mismatch)",
self.detail,
(self.driver_luid >> 32) as i32,
(self.driver_luid & 0xffff_ffff) as u32,
)
}
}
impl std::error::Error for AttachTexFail {}
@@ -31,6 +31,10 @@ pub(super) struct StallWatch {
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
recent: std::collections::VecDeque<Instant>,
cadence: pf_frame::metronome::Metronome,
/// Stalls seen this session, and how many had a coinciding OS display event — the discriminator
/// [`Self::report`] uses. They were capturer fields that nothing outside the report touched.
seen: u32,
with_os_events: u32,
}
impl StallWatch {
@@ -48,6 +52,8 @@ impl StallWatch {
Self {
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
cadence: pf_frame::metronome::Metronome::new(),
seen: 0,
with_os_events: 0,
}
}
@@ -79,4 +85,78 @@ impl StallWatch {
metronomic: self.cadence.note(now),
})
}
/// Log a detected stall, correlate it against OS display events, and — once the cadence turns
/// metronomic — name the class of disturbance and its cures.
///
/// Lives here rather than in `try_consume` (sweep Phase 5.4): it is ~65 lines of log prose plus
/// a running tally, all of it about stalls and none of it about consuming a frame, in a function
/// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one
/// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window.
pub(super) fn report(&mut self, stall: &Stall, now: Instant) {
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
let window = stall.gap + Duration::from_millis(300);
let events = now
.checked_sub(window)
.map(|from| pf_win_display::display_events::events_between(from, now))
.unwrap_or_default();
self.seen = self.seen.saturating_add(1);
if !events.is_empty() {
self.with_os_events = self.with_os_events.saturating_add(1);
}
// debug (not warn): a single hole also happens when content legitimately pauses;
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
// at debug level, and the web-console debug ring captures these.
tracing::debug!(
gap_ms = stall.gap.as_millis() as u64,
os_display_events = %pf_win_display::display_events::summarize(&events),
"IDD-push capture stall — the desktop was composing at speed, then DWM \
delivered no frame for the gap; the present path stalled below capture"
);
if let Some(period) = stall.metronomic {
let suspects = pf_win_display::display_events::connected_inactive_externals();
let suspects = if suspects.is_empty() {
"none".to_string()
} else {
suspects.join(", ")
};
let correlated = format!("{}/{}", self.with_os_events, self.seen);
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
// cascade is OS-visible; otherwise the disturbance never surfaces above the
// driver. Different classes, different cures — say which one this box has.
if self.with_os_events * 2 >= self.seen {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
"capture stalls are METRONOMIC and coincide with Windows monitor \
hot-plug/re-enumeration events a connected display (or its \
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
each time. Cures, best-first: that display's OSD 'auto input \
scan/detect' OFF (and on TVs: instant-on/quick-start + CEC off), \
unplug its cable at the GPU, an HPD-holding adapter/dummy plug, or \
keep it active while streaming; the pnp_disable_monitors policy axis \
suppresses the Windows-side reaction (see connected_inactive for the \
suspects)"
);
} else {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
"capture stalls are METRONOMIC with NO coinciding OS display event — \
the disturbance is BELOW Windows: the GPU driver servicing a \
connected-but-asleep sink (standby HPD/DDC/link probing), \
display-poller software (the SteelSeries-GG/SignalRGB class \
correlate 'slow display-descriptor poll' lines), or the DWM present \
clock (try a different refresh rate). If connected_inactive lists a \
display, its standby probing is the prime suspect: unplug it at the \
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
streaming"
);
}
}
}
}
@@ -140,13 +140,17 @@ impl Capturer for SyntheticNv12Capturer {
/// # Safety
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
return Ok(a);
// SAFETY: three `?`/`Ok`-checked DXGI enumeration calls over owned locals — the factory is
// created here and the adapters it returns own their own COM references. No raw pointers.
unsafe {
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
return Ok(a);
}
}
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
}
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
}
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
@@ -177,8 +181,12 @@ unsafe fn create_nv12(
..Default::default()
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, None, Some(&mut tex))
.context("CreateTexture2D(NV12)")?;
// SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow (per the contract
// above), with a fully-initialized stack descriptor and a live `Option` out-param.
unsafe {
device
.CreateTexture2D(&desc, None, Some(&mut tex))
.context("CreateTexture2D(NV12)")?;
}
tex.context("CreateTexture2D returned a null NV12 texture")
}
+83 -2
View File
@@ -299,6 +299,23 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
}
}
/// The kind a slot DECLARES to the host ([`InputKind::GamepadArrival`]) given the user's
/// controller-type `setting` and the pad's `physical` kind: an explicit setting emulates that pad
/// for every slot, `Auto` keeps per-pad detection (what makes a mixed session honest).
///
/// This has to be applied per pad and not just in the Hello: the host builds each virtual device
/// from that pad's arrival and only falls back to the session default for a pad that never
/// declares one, so a client that always declared the detected kind would silently undo the
/// setting the moment a controller connected. The physical kind is still what the LOCAL feedback
/// paths use (DualSense raw effects, the Deck rumble keep-alive) — those talk to the controller in
/// the user's hands, not the one the host is pretending to have.
fn declared_kind(setting: GamepadPref, physical: GamepadPref) -> GamepadPref {
match setting {
GamepadPref::Auto => physical,
explicit => explicit,
}
}
/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop
/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the
/// flatpak sandbox). Cached: the answer can't change while we run.
@@ -318,6 +335,7 @@ enum Ctl {
Attach(Arc<NativeClient>),
Detach,
Pin(Option<String>),
KindOverride(GamepadPref),
MenuMode(bool),
MenuRumble(MenuPulse),
}
@@ -452,6 +470,18 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::Pin(key));
}
/// Adopt the user's explicit controller-type setting for the session about to start
/// (`GamepadPref::Auto` = detect per pad, the default).
///
/// This is NOT redundant with the session default in the Hello: a current host honors a pad's
/// [`InputKind::GamepadArrival`] over the session default, so a client that declared only the
/// detected kind would silently undo the setting the moment a controller connected. Call it
/// before [`Self::attach`] — slots declare their kind at open time and the host does not
/// hot-swap a device that already exists.
pub fn set_kind_override(&self, pref: GamepadPref) {
let _ = self.ctl.send(Ctl::KindOverride(pref));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
@@ -691,6 +721,10 @@ struct Worker {
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
/// (an explicit single-player choice); Automatic forwards every real controller.
pinned: Option<String>,
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
kind_override: GamepadPref,
attached: Option<Arc<NativeClient>>,
/// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>,
@@ -886,6 +920,7 @@ impl Worker {
Some(p) => p.pref,
None => GamepadPref::Xbox360,
};
let declared = declared_kind(self.kind_override, pref);
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
Ok(pad) => {
let mut slot = Slot::new(id, index, pref, pad);
@@ -895,7 +930,13 @@ impl Worker {
// re-sends it a few times against datagram loss; an older host ignores it and
// uses the session-default kind.
if let Some(c) = &self.attached {
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index);
send(
c,
InputKind::GamepadArrival,
declared.to_u8() as u32,
0,
index,
);
// Declare the actuator's quirks to the shared rumble policy engine. ALWAYS
// set (defaults for a well-behaved pad): wire indices are reused within a
// connection, so a Deck slot that closes must not leave its keepalive quirk
@@ -911,7 +952,13 @@ impl Worker {
};
c.set_rumble_quirks(index as u16, quirks);
}
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)");
tracing::info!(
id,
index,
pref = ?pref,
declared = ?declared,
"gamepad forwarding (slot opened)"
);
self.slots.push(slot);
}
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
@@ -1219,6 +1266,7 @@ impl Worker {
self.pinned = key;
self.refresh_active();
}
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on;
if on {
@@ -1558,6 +1606,7 @@ impl Worker {
menu_open: None,
order: Vec::new(),
pinned: None,
kind_override: GamepadPref::Auto,
attached: None,
escape_tx,
disconnect_tx,
@@ -1823,6 +1872,38 @@ mod slot_tests {
assert_eq!(lowest_free_index(&but_seven), Some(7));
}
#[test]
fn an_explicit_setting_is_what_every_pad_declares() {
// The regression this pins: the setting used to reach the Hello only, and each pad's
// arrival then re-declared the DETECTED kind — which the host honors over the session
// default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
assert_eq!(
declared_kind(GamepadPref::DualShock4, GamepadPref::DualSense),
GamepadPref::DualShock4
);
// Every physical pad in a mixed session follows the one explicit choice.
for physical in [
GamepadPref::DualSense,
GamepadPref::Xbox360,
GamepadPref::SwitchPro,
GamepadPref::SteamDeck,
] {
assert_eq!(
declared_kind(GamepadPref::Xbox360, physical),
GamepadPref::Xbox360
);
}
// Automatic keeps per-pad detection — otherwise a mixed session collapses to one type.
assert_eq!(
declared_kind(GamepadPref::Auto, GamepadPref::DualSense),
GamepadPref::DualSense
);
assert_eq!(
declared_kind(GamepadPref::Auto, GamepadPref::SteamDeck),
GamepadPref::SteamDeck
);
}
#[test]
fn hidout_pad_reads_every_variant() {
assert_eq!(
+25 -18
View File
@@ -249,9 +249,16 @@ impl Codec {
}
}
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and
/// cursor plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap
/// `Copy`; fixed for the session (an HDR toggle re-initialises the encoder — re-query if that
/// matters).
///
/// (There is deliberately NO `supports_hdr_metadata` cap: in-band HDR SEI/OBU embedding needs no
/// host-side routing — every first-party client reads the static grade exclusively out-of-band
/// (the native 0xCE datagram / the GameStream 0x010e control message), both planes send it
/// unconditionally, and the in-band grade is a decoder-side bonus for stock clients. A cap field
/// nothing reads is a contract nobody honors; it was deleted after shipping write-only.)
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EncoderCaps {
/// The encoder can perform real reference-frame invalidation — i.e.
@@ -261,10 +268,6 @@ pub struct EncoderCaps {
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
pub supports_rfi: bool,
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
/// Windows direct-NVENC path attaches it today.
pub supports_hdr_metadata: bool,
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
@@ -304,8 +307,11 @@ pub struct EncoderCaps {
///
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
/// host's call, since only the host can re-plan capture (fall back to capturer-side compositing).
/// `open_video` can only warn, which it does.
/// host's call, since only the host can re-plan capture. That call is wired now — the
/// negotiation consults the pre-open mirror ([`cursor_blend_capable`](crate::cursor_blend_capable))
/// to gate the cursor channel and to keep capture on embedded-cursor / CSC-capable shapes for
/// any backend that can't blend; `open_video`'s post-open check remains as the backstop for
/// open-time fallbacks the plan can't see.
pub blends_cursor: bool,
}
@@ -333,11 +339,10 @@ pub trait Encoder: Send {
let _ = wire_index;
self.submit(frame)
}
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
/// route by query rather than rely on the no-op/`false` defaults of
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
/// path overrides it.
/// This encoder's static [capabilities](EncoderCaps) (RFI, intra-refresh, chroma, cursor
/// blending), so the session glue can route by query rather than rely on the no-op/`false`
/// defaults of methods like [`invalidate_ref_frames`](Self::invalidate_ref_frames).
/// Default: no optional capabilities (the software / libavcodec backends).
fn caps(&self) -> EncoderCaps {
EncoderCaps::default()
}
@@ -345,10 +350,12 @@ pub trait Encoder: Send {
/// reference-frame-invalidation request). Default: no-op.
fn request_keyframe(&mut self) {}
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
/// every frame; only the direct-NVENC path consumes it.
/// in-band (HEVC/H.264 `mastering_display_colour_volume` + `content_light_level_info` SEI, or
/// AV1 metadata OBUs) on keyframes so a stock decoder — e.g. stock Moonlight — tone-maps from
/// the source's real grade. Default: no-op (SDR encoders / paths that don't attach it).
/// Cheap to call every frame; consumed by Windows direct-NVENC, native AMF, and native QSV.
/// Every first-party client reads the grade out-of-band (the 0xCE datagram) regardless, so
/// this is a bonus for stock decoders, never the primary channel.
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
@@ -1850,7 +1850,6 @@ impl Encoder for NvencCudaEncoder {
// Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot.
blends_cursor: true,
supports_rfi: self.rfi_supported,
supports_hdr_metadata: self.hdr,
chroma_444: self.chroma_444,
intra_refresh: false,
intra_refresh_recovery: false,
+412 -71
View File
@@ -94,23 +94,152 @@ type LpKey = (String, &'static str, bool);
/// The [`LP_MODE`] key for this device/codec/depth. `render_node()` is re-read rather than cached
/// so a GPU-preference change is picked up on the next open.
fn lp_key(codec: Codec, ten_bit: bool) -> LpKey {
(
render_node().to_string_lossy().into_owned(),
codec.label(),
ten_bit,
)
lp_key_for(&render_node().to_string_lossy(), codec, ten_bit)
}
/// [`lp_key`] with the render node explicit (device-free for the unit tests). Every part of the
/// key is load-bearing — see [`LP_MODE`] for the two field-motivated bugs (node: cross-GPU
/// session-killer; depth: HDR under-advertisement).
fn lp_key_for(node: &str, codec: Codec, ten_bit: bool) -> LpKey {
(node.to_owned(), codec.label(), ten_bit)
}
/// `PUNKTFUNK_VAAPI_LOW_POWER` pins the entrypoint mode (`1` = low-power only, `0` = full-feature
/// only); unset → try full-feature first, fall back to low-power.
fn low_power_override() -> Option<bool> {
match std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?.trim() {
parse_low_power(&std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?)
}
/// [`low_power_override`]'s value grammar (device-free for the unit tests). Anything outside the
/// two literal sets means "no pin" — the `[full-feature, low-power]` ladder runs.
fn parse_low_power(raw: &str) -> Option<bool> {
match raw.trim() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
/// The entrypoint modes to attempt, in order (`false` = full-feature `EncSlice`, `true` =
/// low-power VDEnc): an operator pin tries exactly that one; a cached resolution ([`LP_MODE`]:
/// 1 = full-feature, 2 = low-power) skips the known-failing attempt; anything else runs the full
/// ladder — full-feature first so AMD's first-try open stays byte-for-byte unchanged. Never
/// empty: [`open_vaapi_encoder`] relies on at least one attempt running.
fn entrypoint_ladder(pin: Option<bool>, cached: u8) -> &'static [bool] {
match pin {
Some(true) => &[true],
Some(false) => &[false],
None => match cached {
1 => &[false],
2 => &[true],
_ => &[false, true],
},
}
}
/// The [`LP_MODE`] value a successful open latches (1 = full-feature, 2 = low-power). Paired with
/// [`entrypoint_ladder`], which maps it back to the single-mode retry list.
fn latched_mode(low_power: bool) -> u8 {
if low_power {
2
} else {
1
}
}
/// The VUI colour metadata the encoder signals for the session's depth.
struct Vui {
colorspace: ffi::AVColorSpace,
range: ffi::AVColorRange,
primaries: ffi::AVColorPrimaries,
trc: ffi::AVColorTransferCharacteristic,
}
/// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 the
/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020 on the zero-copy
/// path); the client decoder auto-detects PQ from the VUI. SDR: we hand the encoder BT.709
/// *limited* NV12 (swscale CSC on the CPU path; `scale_vaapi` pinned to
/// `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range RGB input
/// tagged), so signal that VUI — else the client decoder washes the picture out.
fn vui_for(ten_bit: bool) -> Vui {
if ten_bit {
Vui {
colorspace: ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL,
range: ffi::AVColorRange::AVCOL_RANGE_MPEG,
primaries: ffi::AVColorPrimaries::AVCOL_PRI_BT2020,
trc: ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084,
}
} else {
Vui {
colorspace: ffi::AVColorSpace::AVCOL_SPC_BT709,
range: ffi::AVColorRange::AVCOL_RANGE_MPEG,
primaries: ffi::AVColorPrimaries::AVCOL_PRI_BT709,
trc: ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709,
}
}
}
/// The explicit `profile` option for this codec/depth, or `None` to let the encoder derive it.
/// HEVC Main10 is pinned explicitly so the depth is never silently dropped (`hevc_vaapi` would
/// derive it from the P010 surfaces, but a derivation can regress quietly); 10-bit AV1 is
/// input-driven — no profile knob — and every 8-bit open keeps the encoder default.
fn explicit_profile(codec: Codec, ten_bit: bool) -> Option<&'static str> {
(ten_bit && codec == Codec::H265).then_some("main10")
}
/// `PUNKTFUNK_VAAPI_ASYNC_DEPTH` grammar: 1..=8 accepted verbatim; unset, junk, and out-of-range
/// values all resolve to depth 1 — the lowest-latency structure (see the `async_depth` note at the
/// call site for why 1 is the default and what depth ≥ 2 trades).
fn async_depth(raw: Option<&str>) -> u32 {
raw.and_then(|s| s.parse::<u32>().ok())
.filter(|d| (1..=8).contains(d))
.unwrap_or(1)
}
/// The `scale_vaapi` filter args for the session's depth. The VPP's output colour is pinned to
/// exactly what the encoder's VUI signals ([`vui_for`]) — without the explicit options the
/// conversion matrix is whatever the driver defaults to for an unspecified output (Mesa: BT.601),
/// a hue shift against the signaled VUI. (The PQ transfer is per-channel and rides through the
/// matrix untouched.)
fn scale_vaapi_args(ten_bit: bool) -> &'static CStr {
if ten_bit {
c"format=p010:out_color_matrix=bt2020:out_range=limited"
} else {
c"format=nv12:out_color_matrix=bt709:out_range=limited"
}
}
/// What a (captured format, negotiated bit depth) pair resolves to at open. 10-bit rides on the
/// captured PIXELS, not the negotiated depth — see [`crate::ten_bit_input`] for the failure the
/// reverse shape produces.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DepthResolution {
/// PQ-graded 10-bit frames on a non-10-bit session: refuse the open, so PQ content is never
/// mislabeled BT.709.
RefuseMislabeledPq,
/// 10-bit negotiated but the capture stayed SDR: honestly encode 8-bit (with a warning).
SdrDowngrade,
/// Format and negotiated depth agree.
Agreed,
}
/// See [`DepthResolution`].
fn resolve_depth(format: PixelFormat, bit_depth: u8) -> DepthResolution {
if format.is_hdr_rgb10() && bit_depth != 10 {
DepthResolution::RefuseMislabeledPq
} else if bit_depth == 10 && !format.is_hdr_rgb10() {
DepthResolution::SdrDowngrade
} else {
DepthResolution::Agreed
}
}
/// Whether a codec is even eligible for the 10-bit probe: PyroWave answers 10-bit support on its
/// own path (no VAAPI involved), and a codec without a 10-bit profile can never pass.
fn ten_bit_probe_eligible(codec: Codec) -> bool {
codec.supports_10bit() && codec != Codec::PyroWave
}
/// Open the VAAPI encoder, resolving the entrypoint mode: try the full-feature entrypoint first
/// and, if the driver rejects it, retry with `low_power=1` — modern Intel (Gen12+/Arc) exposes
/// ONLY the low-power VDEnc entrypoint (ffmpeg's `vaapi_encode` defaults `low_power=0` and errors
@@ -135,15 +264,7 @@ unsafe fn open_vaapi_encoder(
.lock()
.map(|m| m.get(&key).copied().unwrap_or(0))
.unwrap_or(0);
let modes: &[bool] = match low_power_override() {
Some(true) => &[true],
Some(false) => &[false],
None => match cached {
1 => &[false],
2 => &[true],
_ => &[false, true],
},
};
let modes: &[bool] = entrypoint_ladder(low_power_override(), cached);
let mut first_err = None;
for &lp in modes {
match open_vaapi_encoder_mode(
@@ -159,7 +280,7 @@ unsafe fn open_vaapi_encoder(
) {
Ok(enc) => {
if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() {
m.insert(key.clone(), if lp { 2 } else { 1 });
m.insert(key.clone(), latched_mode(lp));
}
if lp {
tracing::info!(
@@ -216,32 +337,18 @@ unsafe fn open_vaapi_encoder_mode(
apply_low_latency_rc(&mut video, fps, bitrate_bps);
let raw = video.as_mut_ptr();
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
if ten_bit {
// HDR10: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010
// the CSC produces (swscale BT.2020 on the CPU path; scale_vaapi pinned to bt2020 on the
// zero-copy path). The client decoder auto-detects PQ from the VUI.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
} else {
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
}
let vui = vui_for(ten_bit);
(*raw).colorspace = vui.colorspace;
(*raw).color_range = vui.range;
(*raw).color_primaries = vui.primaries;
(*raw).color_trc = vui.trc;
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
let mut opts = Dictionary::new();
if ten_bit && codec == Codec::H265 {
// HEVC Main10. `hevc_vaapi` derives it from the P010 surfaces, but pin it explicitly so
// the depth is never silently dropped. (10-bit AV1 is input-driven — no profile knob.)
opts.set("profile", "main10");
if let Some(profile) = explicit_profile(codec, ten_bit) {
opts.set("profile", profile);
}
// async_depth=1: `send_frame` blocks until THIS frame's ASIC encode completes — the lowest
// latency structure libavcodec's vaapi_encode offers. Measured on the 780M at 1440p60: depth 1
@@ -251,11 +358,7 @@ unsafe fn open_vaapi_encoder_mode(
// where depth 2 restores throughput at that one-frame cost. NOTE: the per-frame block tracks
// GPU CLOCKS — a paced 60 fps trickle lets the VCN downclock (~8 ms/frame vs ~4.4 ms hot);
// see `gpuclocks` for the session clock pin that removes the ramp tax.
let depth = std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|d| (1..=8).contains(d))
.unwrap_or(1);
let depth = async_depth(std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH").ok().as_deref());
opts.set("async_depth", &depth.to_string());
if low_power {
opts.set("low_power", "1"); // VDEnc — the only encode entrypoint on modern Intel
@@ -309,7 +412,7 @@ pub fn probe_can_encode(codec: Codec) -> bool {
/// ([`crate::can_encode_10bit`]), so a non-Main10 GPU resolves every session to 8-bit SDR before
/// the Welcome (honest downgrade).
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
if !codec.supports_10bit() || codec == Codec::PyroWave {
if !ten_bit_probe_eligible(codec) {
return false;
}
if ffmpeg::init().is_err() {
@@ -834,24 +937,7 @@ impl DmabufInner {
}
init!(src, ptr::null(), "buffer");
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
// Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited SDR,
// or BT.2020 limited P010 for HDR — the PQ transfer is per-channel and rides through
// the matrix untouched). Without the explicit options the conversion matrix is
// whatever the driver defaults to for an unspecified output (Mesa: BT.601) — a hue
// shift against the signaled VUI.
if ten_bit {
init!(
scale,
c"format=p010:out_color_matrix=bt2020:out_range=limited".as_ptr(),
"scale_vaapi"
);
} else {
init!(
scale,
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
"scale_vaapi"
);
}
init!(scale, scale_vaapi_args(ten_bit).as_ptr(), "scale_vaapi");
init!(sink, ptr::null(), "buffersink");
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
@@ -1143,21 +1229,18 @@ impl VaapiEncoder {
chroma: super::ChromaFormat,
) -> Result<Self> {
// 10-bit rides on the captured format: an HDR capture (X2RGB10/X2BGR10) opens the P010 /
// Main10 / PQ-VUI variant of whichever inner path the first frame selects. A 10-bit
// request whose capture stayed SDR honestly encodes 8-bit; the reverse (PQ frames on an
// 8-bit session) is refused so PQ content is never mislabeled BT.709.
if format.is_hdr_rgb10() && bit_depth != 10 {
bail!(
// Main10 / PQ-VUI variant of whichever inner path the first frame selects.
match resolve_depth(format, bit_depth) {
DepthResolution::RefuseMislabeledPq => bail!(
"captured 10-bit HDR frames ({format:?}) on an {bit_depth}-bit VAAPI session — \
refusing to mislabel PQ content"
);
}
if bit_depth == 10 && !format.is_hdr_rgb10() {
tracing::warn!(
),
DepthResolution::SdrDowngrade => tracing::warn!(
bit_depth,
?format,
"10-bit requested but the capture stayed SDR — encoding 8-bit"
);
),
DepthResolution::Agreed => {}
}
// VAAPI 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel hardware in the
// lab exposes a HEVC 4:4:4 encode entrypoint, and the probe returns false so the host never
@@ -1307,3 +1390,261 @@ impl Encoder for VaapiEncoder {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The operator pin tries exactly one mode; a cached resolution ([`LP_MODE`]) tries only the
/// mode that worked; anything else runs the full ladder, full-feature FIRST — AMD's first-try
/// open must stay byte-for-byte unchanged.
#[test]
fn entrypoint_ladder_orders_and_pins() {
assert_eq!(entrypoint_ladder(None, 0), &[false, true]);
assert_eq!(entrypoint_ladder(None, 1), &[false]);
assert_eq!(entrypoint_ladder(None, 2), &[true]);
// A corrupt/unknown cache value degrades to the full ladder, never to a wrong pin.
assert_eq!(entrypoint_ladder(None, 77), &[false, true]);
// The pin beats the cache in BOTH directions — what makes PUNKTFUNK_VAAPI_LOW_POWER a
// real escape hatch from a stale latch.
for cached in [0u8, 1, 2, 77] {
assert_eq!(entrypoint_ladder(Some(true), cached), &[true]);
assert_eq!(entrypoint_ladder(Some(false), cached), &[false]);
}
}
/// The latch round-trip: what a successful open stores makes the NEXT open attempt exactly
/// the mode that worked (the "skip the known-failing attempt and its libav error spew"
/// contract — and, per [`LP_MODE`], the shape that must never cross devices or depths).
#[test]
fn latch_round_trip_pins_the_resolved_mode() {
for lp in [false, true] {
assert_eq!(entrypoint_ladder(None, latched_mode(lp)), &[lp]);
}
}
/// `PUNKTFUNK_VAAPI_LOW_POWER` grammar: the two lowercase literal sets pin; anything else —
/// including uppercase — means "no pin" (the ladder runs). Case-sensitivity is pinned as
/// shipped behavior.
#[test]
fn low_power_grammar() {
for s in ["1", "true", "yes", "on", " on ", "yes\n"] {
assert_eq!(parse_low_power(s), Some(true), "{s:?}");
}
for s in ["0", "false", "no", "off", " off "] {
assert_eq!(parse_low_power(s), Some(false), "{s:?}");
}
for s in ["", "2", "TRUE", "On", "enabled", "low_power"] {
assert_eq!(parse_low_power(s), None, "{s:?}");
}
}
/// Every part of the LP_MODE key is load-bearing (see the [`LP_MODE`] field comments): the
/// node (a GPU-preference switch must re-resolve — the old codec-only key was a
/// session-killer), the codec, and the depth (an 8-bit answer must never pin the 10-bit open —
/// the HDR under-advertisement).
#[test]
fn lp_key_separates_node_codec_and_depth() {
let base = lp_key_for("/dev/dri/renderD128", Codec::H265, false);
assert_ne!(base, lp_key_for("/dev/dri/renderD129", Codec::H265, false));
assert_ne!(base, lp_key_for("/dev/dri/renderD128", Codec::H264, false));
assert_ne!(base, lp_key_for("/dev/dri/renderD128", Codec::H265, true));
}
/// `PUNKTFUNK_VAAPI_ASYNC_DEPTH` grammar: 1..=8 verbatim; unset, junk, zero, and past-the-cap
/// all resolve to the lowest-latency depth 1.
#[test]
fn async_depth_grammar() {
assert_eq!(async_depth(None), 1);
assert_eq!(async_depth(Some("1")), 1);
assert_eq!(async_depth(Some("2")), 2);
assert_eq!(async_depth(Some("8")), 8);
assert_eq!(async_depth(Some("0")), 1);
assert_eq!(async_depth(Some("9")), 1);
assert_eq!(async_depth(Some("-1")), 1);
assert_eq!(async_depth(Some("fast")), 1);
}
/// The swscale source map: packed RGB/BGR and the GNOME 50+ HDR 2:10:10:10 layouts convert;
/// planar/video formats are refused (the CPU path is a packed-RGB fallback, not a general
/// converter).
#[test]
fn sws_src_accepts_packed_rgb_only() {
assert_eq!(vaapi_sws_src(PixelFormat::Bgrx).unwrap(), Pixel::BGRZ);
assert_eq!(vaapi_sws_src(PixelFormat::Rgbx).unwrap(), Pixel::RGBZ);
assert_eq!(vaapi_sws_src(PixelFormat::Bgra).unwrap(), Pixel::BGRA);
assert_eq!(vaapi_sws_src(PixelFormat::Rgba).unwrap(), Pixel::RGBA);
assert_eq!(vaapi_sws_src(PixelFormat::Rgb).unwrap(), Pixel::RGB24);
assert_eq!(vaapi_sws_src(PixelFormat::Bgr).unwrap(), Pixel::BGR24);
assert_eq!(
vaapi_sws_src(PixelFormat::X2Rgb10).unwrap(),
Pixel::X2RGB10LE
);
assert_eq!(
vaapi_sws_src(PixelFormat::X2Bgr10).unwrap(),
Pixel::X2BGR10LE
);
for f in [
PixelFormat::Nv12,
PixelFormat::P010,
PixelFormat::Rgb10a2,
PixelFormat::Yuv444,
] {
assert!(vaapi_sws_src(f).is_err(), "{f:?} must be refused");
}
}
/// VUI ↔ CSC agreement: the signaled VUI and the zero-copy VPP's pinned output colour must
/// name the SAME matrix/range per depth — a mismatch is exactly the Mesa-BT.601 hue shift the
/// pin exists to prevent.
#[test]
fn vui_and_scale_args_agree_per_depth() {
let sdr = vui_for(false);
assert!(matches!(sdr.colorspace, ffi::AVColorSpace::AVCOL_SPC_BT709));
assert!(matches!(sdr.range, ffi::AVColorRange::AVCOL_RANGE_MPEG));
assert!(matches!(
sdr.primaries,
ffi::AVColorPrimaries::AVCOL_PRI_BT709
));
assert!(matches!(
sdr.trc,
ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709
));
let args = scale_vaapi_args(false).to_str().unwrap();
for needle in ["format=nv12", "out_color_matrix=bt709", "out_range=limited"] {
assert!(
args.contains(needle),
"SDR scale args miss {needle}: {args}"
);
}
let hdr = vui_for(true);
assert!(matches!(
hdr.colorspace,
ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL
));
assert!(matches!(hdr.range, ffi::AVColorRange::AVCOL_RANGE_MPEG));
assert!(matches!(
hdr.primaries,
ffi::AVColorPrimaries::AVCOL_PRI_BT2020
));
assert!(matches!(
hdr.trc,
ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084
));
let args = scale_vaapi_args(true).to_str().unwrap();
for needle in [
"format=p010",
"out_color_matrix=bt2020",
"out_range=limited",
] {
assert!(
args.contains(needle),
"HDR scale args miss {needle}: {args}"
);
}
}
/// The honest-downgrade table at open: PQ pixels demand a 10-bit session (refused otherwise —
/// PQ content must never be mislabeled BT.709); a 10-bit session over SDR pixels downgrades
/// honestly to 8-bit; agreement passes both ways.
#[test]
fn depth_resolution_table() {
use DepthResolution::*;
assert_eq!(resolve_depth(PixelFormat::X2Rgb10, 8), RefuseMislabeledPq);
assert_eq!(resolve_depth(PixelFormat::X2Bgr10, 8), RefuseMislabeledPq);
assert_eq!(resolve_depth(PixelFormat::Bgrx, 10), SdrDowngrade);
assert_eq!(resolve_depth(PixelFormat::Bgrx, 8), Agreed);
assert_eq!(resolve_depth(PixelFormat::X2Rgb10, 10), Agreed);
assert_eq!(resolve_depth(PixelFormat::X2Bgr10, 10), Agreed);
}
/// Main10 is pinned explicitly for 10-bit HEVC ONLY: 10-bit AV1 is input-driven (no profile
/// knob), and every 8-bit open keeps the encoder's default profile.
#[test]
fn explicit_profile_is_hevc_main10_only() {
assert_eq!(explicit_profile(Codec::H265, true), Some("main10"));
assert_eq!(explicit_profile(Codec::H265, false), None);
assert_eq!(explicit_profile(Codec::Av1, true), None);
assert_eq!(explicit_profile(Codec::Av1, false), None);
assert_eq!(explicit_profile(Codec::H264, true), None);
assert_eq!(explicit_profile(Codec::H264, false), None);
}
/// The 10-bit probe gate: HEVC and AV1 are probe-eligible; H.264 (no 10-bit path here) and
/// PyroWave (answers 10-bit on its own path, no VAAPI involved) are refused before any device
/// is touched — this is what keeps the probe safe on GPU-less CI.
#[test]
fn ten_bit_probe_gate() {
assert!(ten_bit_probe_eligible(Codec::H265));
assert!(ten_bit_probe_eligible(Codec::Av1));
assert!(!ten_bit_probe_eligible(Codec::H264));
assert!(!ten_bit_probe_eligible(Codec::PyroWave));
}
/// Probe smoke on real silicon: H.264 VAAPI encode opens on every supported AMD/Intel GPU;
/// the narrower codecs are printed, not asserted (device-dependent). Build anywhere, run on a
/// VAAPI host:
/// cargo test -p pf-encode --no-run
/// <host> target/debug/deps/pf_encode-<hash> --ignored --nocapture vaapi_probe_smoke
#[test]
#[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"]
fn vaapi_probe_smoke() {
assert!(
probe_can_encode(Codec::H264),
"H.264 VAAPI encode should open on any supported AMD/Intel GPU"
);
for codec in [Codec::H265, Codec::Av1] {
eprintln!("probe_can_encode({codec:?}) = {}", probe_can_encode(codec));
eprintln!(
"probe_can_encode_10bit({codec:?}) = {}",
probe_can_encode_10bit(codec)
);
}
}
/// CPU-path encode round-trip on real silicon: open → BGRX frames → poll AUs → the first AU
/// is the IDR. Exercises the swscale CSC, the VA surface upload, and the entrypoint ladder
/// end-to-end (same recipe as [`vaapi_probe_smoke`]).
#[test]
#[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"]
fn vaapi_cpu_encode_smoke() {
let (w, h) = (256u32, 256u32);
let mut enc = VaapiEncoder::open(
Codec::H264,
PixelFormat::Bgrx,
w,
h,
30,
2_000_000,
8,
crate::ChromaFormat::Yuv420,
)
.expect("open");
let mut aus = Vec::new();
for i in 0..30u32 {
let mut buf = vec![0u8; (w * h * 4) as usize];
for px in buf.chunks_exact_mut(4) {
px.copy_from_slice(&[(i * 8) as u8, 0x40, 0xC0, 0xFF]);
}
let frame = CapturedFrame {
width: w,
height: h,
pts_ns: u64::from(i) * 33_333_333,
format: PixelFormat::Bgrx,
payload: FramePayload::Cpu(buf),
cursor: None,
};
enc.submit(&frame).expect("submit");
while let Some(au) = enc.poll().expect("poll") {
aus.push(au);
}
}
enc.flush().expect("flush");
while let Some(au) = enc.poll().expect("poll") {
aus.push(au);
}
assert!(!aus.is_empty(), "no AUs out of 30 submitted frames");
assert!(aus[0].keyframe, "the first AU must be the IDR");
}
}
+58 -60
View File
@@ -76,9 +76,11 @@ fn quality_request() -> u32 {
/// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the
/// VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies, one
/// queue submit per frame (unaligned modes go through the padded-copy staging blit). B2
/// default: ON wherever the probe passes, EXCEPT sessions that may need the CSC's cursor
/// blend (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it even on
/// cursor-blend sessions (the pointer will be missing from the stream); unset = the default.
/// default: ON wherever the probe passes, EXCEPT sessions that need the CSC's cursor blend
/// (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it on non-cursor
/// sessions; unset = the default. A cursor-blend session IGNORES `=1` — EFC cannot composite
/// the pointer, and the caps-aware negotiation promised the client a composited one, so the
/// pointer outranks the lab pin (the open logs the override).
///
/// Parses like every sibling knob (`matches!(v.trim(), …)`): anything unrecognised — including
/// an empty value and a value that is only whitespace — falls back to the default rather than
@@ -614,10 +616,6 @@ pub struct VulkanVideoEncoder {
/// GPU reset (those paths keep their aligned-size sources/staging).
native_nv12: bool,
/// One-shot warning latch: a cursor bitmap arrived on an RGB-direct or native-NV12 session
/// (neither has a compositing stage — the cursor will be missing from the stream until the
/// CSC path is used).
warned_cursor: bool,
/// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video
/// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it
/// (mid-stream) or folds it into the first frame's RESET+RC install, then promotes it into
@@ -671,7 +669,16 @@ impl VulkanVideoEncoder {
cursor_blend: bool,
) -> Result<Self> {
let native_nv12 = format == PixelFormat::Nv12;
let want_rgb = !native_nv12 && rgb_request().unwrap_or(!cursor_blend);
// A cursor-blend session must keep the compute-CSC path — the only arm with the cursor
// blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the
// negotiation promised the client a composited pointer).
if cursor_blend && rgb_request() == Some(true) {
tracing::info!(
"PUNKTFUNK_VULKAN_RGB_DIRECT=1 ignored for this session — it composites the \
pointer, which the EFC front-end cannot; using the compute-CSC path"
);
}
let want_rgb = !native_nv12 && !cursor_blend && rgb_request().unwrap_or(true);
Self::open_opts_inner(
codec,
width,
@@ -1448,7 +1455,6 @@ impl VulkanVideoEncoder {
cpu_expand: Vec::new(),
rgb: rgb_cfg,
native_nv12,
warned_cursor: false,
pending_bitrate: None,
width: w,
height: h,
@@ -2621,17 +2627,10 @@ impl VulkanVideoEncoder {
d.modifier
);
}
// No compositing stage exists here (like RGB-direct/EFC): gamescope embeds its pointer
// in the produced pixels, but any other NV12 producer's metadata cursor would be lost —
// say so once instead of silently.
if frame.cursor.is_some() && !self.warned_cursor {
self.warned_cursor = true;
tracing::warn!(
"cursor bitmap on a native-NV12 session — nothing composites it; the cursor \
will be missing from the stream (unset PUNKTFUNK_PIPEWIRE_NV12 for \
metadata-cursor captures)"
);
}
// No compositing stage exists here (like RGB-direct/EFC) — and none is needed: the
// session plan negotiates native NV12 only for a non-cursor-blend session
// (`SessionPlan::output_format` gates `nv12_native` on `!cursor_blend`), so no cursor
// bitmap ever reaches this arm. Gamescope embeds its pointer in the produced pixels.
let dev = self.device.clone();
let cmd = self.frames[slot].cmd;
let fence = self.frames[slot].fence;
@@ -2691,16 +2690,9 @@ impl VulkanVideoEncoder {
let query_pool = self.frames[slot].query_pool;
let bs_buf = self.frames[slot].bs_buf;
let ts_pool = self.frames[slot].ts_pool;
// EFC cannot composite the cursor bitmap the metadata-cursor captures hand us — say so
// once instead of silently losing the pointer (gamescope, the flagship, embeds it).
if frame.cursor.is_some() && !self.warned_cursor {
self.warned_cursor = true;
tracing::warn!(
"cursor bitmap on an RGB-direct session — EFC cannot composite it; the cursor \
will be missing from the stream (unset PUNKTFUNK_VULKAN_RGB_DIRECT for \
metadata-cursor captures)"
);
}
// EFC cannot composite a cursor bitmap — and never has to: `open` refuses the RGB-direct
// shape for a cursor-blend session (the pin override above), so no cursor bitmap ever
// reaches this arm. Gamescope, the flagship, embeds its pointer in the produced pixels.
let padded = self.rgb.as_ref().is_some_and(|r| r.padded);
// Only the padded Dmabuf arm below records timestamps (via `record_pad_blit`); the
// CPU-upload arm records its own command buffer and writes none. Default to "not written"
@@ -4486,44 +4478,50 @@ mod tests {
}
}
/// A CPU-capture source that CHANGES SIZE mid-session must not copy past the cached staging
/// image. `ensure_cpu_rgb` keys that image on (format, width, height); when it was keyed on
/// format alone the image kept the FIRST frame's size while `cmd_copy_buffer_to_image` used the
/// current frame's extent, so a same-format size increase wrote out of bounds — and `submit`
/// still returned `Ok`, so nothing upstream noticed.
///
/// The out-of-bounds copy is only *observable* through the Vulkan validation layers, so run this
/// as: `VK_LOADER_LAYERS_ENABLE='*validation*' cargo test ... -- --ignored`. Confirmed on RADV
/// PHOENIX 2026-07-25: 8 x VUID-vkCmdCopyBufferToImage-imageSubresource-07971 before the fix
/// ("extent.width (512) exceeds imageSubresource width extent (128)"), zero after.
/// The CSC arm REFUSES a source that doesn't match the session mode (the e3354b6d guard —
/// the check every sibling arm always had; a clamped-texelFetch mismatch used to stream a
/// silently cropped/edge-padded picture). This test previously DROVE mismatched sizes through
/// the lenient arm to exercise the per-slot `cpu_img` staging across a size change (the
/// format-only-keyed cache wrote out of bounds while `submit` returned `Ok`); the guard makes
/// that scenario unrepresentable through `submit`, structurally retiring the hazard — the
/// size-keyed staging from that fix stays as belt-and-braces. What's left to pin:
/// refusal in BOTH directions, and that a refused submit does not WEDGE the session — the
/// bail happens after step 1's frame-type bookkeeping, so "the next well-sized frame still
/// encodes" is a real property, not a formality (the host routes the error to its
/// encoder-rebuild path and the session must be able to continue if that path retries).
#[test]
#[ignore = "needs a real VK_KHR_video_encode_h265 device; meaningful only under validation layers"]
fn vulkan_cpu_img_survives_a_source_size_change() {
// CSC mode (rgb=false) — that is the arm where the image is sized to the SOURCE frame.
fn vulkan_csc_refuses_a_mismatched_source() {
// CSC mode (rgb=false) — the arm the guard covers.
let mut enc = VulkanVideoEncoder::open_opts(Codec::H265, 512, 512, 60, 10_000_000, false)
.expect("open");
// `cpu_img` is cached PER RING SLOT, so the small frames must first populate every slot;
// only when the ring wraps does a slot holding a 128x128 image get a 512x512 copy.
eprintln!("phase 1: 8x 128x128 — populates every ring slot with a 128x128 cpu_img");
for i in 0..8u64 {
enc.submit_indexed(&cpu_frame(512, 512, 0, [40, 40, 200, 255]), 0)
.expect("well-sized baseline");
while enc.poll().expect("poll").is_some() {}
// Smaller AND larger both refuse — the guard is equality on the MODE (render size), not
// a ceiling; the render-vs-CODED padding tolerance lives in the direct arms, untouched.
let e = enc
.submit_indexed(&cpu_frame(128, 128, 16_666_667, [200, 40, 40, 255]), 1)
.expect_err("smaller source must refuse");
assert!(e.to_string().contains("mismatched"), "{e:#}");
let e = enc
.submit_indexed(&cpu_frame(640, 640, 33_333_334, [200, 40, 40, 255]), 2)
.expect_err("larger source must refuse");
assert!(e.to_string().contains("mismatched"), "{e:#}");
// The refusals must not wedge the session: a well-sized frame still encodes and an AU
// still comes out the other end.
let mut got_au = false;
for i in 3..11u64 {
enc.submit_indexed(
&cpu_frame(128, 128, i * 16_666_667, [40, 40, 200, 255]),
&cpu_frame(512, 512, i * 16_666_667, [40, 200, 40, 255]),
i as u32,
)
.expect("submit small");
while enc.poll().expect("poll").is_some() {}
.expect("well-sized after refusal");
while let Ok(Some(_)) = enc.poll() {
got_au = true;
}
}
eprintln!("phase 2: 8x 512x512 — SAME format, so each slot REUSES its 128x128 image");
for i in 8..16u64 {
let r = enc.submit_indexed(
&cpu_frame(512, 512, i * 16_666_667, [200, 40, 40, 255]),
i as u32,
);
r.expect("submit after the source grew");
while matches!(enc.poll(), Ok(Some(_))) {}
}
let _ = enc.flush();
while matches!(enc.poll(), Ok(Some(_))) {}
assert!(got_au, "no AU after the refused submits — session wedged");
eprintln!("done — under validation layers this run must report ZERO VUID errors");
}
-7
View File
@@ -2014,9 +2014,6 @@ impl Encoder for AmfEncoder {
// frame, force a later one to re-reference it). True only when the live driver accepted
// the LTR slots at open — otherwise loss recovery falls back to a full IDR.
supports_rfi: self.ltr_active,
// In-band mastering/CLL via `*InHDRMetadata` (HEVC SEI / AV1 metadata OBU); AVC has
// no such property (and no HDR sessions negotiate H.264).
supports_hdr_metadata: self.ten_bit && self.props.hdr_metadata.is_some(),
// Permanent: VCN hardware does not encode 4:4:4.
chroma_444: false,
// True only when `PUNKTFUNK_INTRA_REFRESH` asked for the wave AND the live driver
@@ -2715,10 +2712,6 @@ mod tests {
}
};
enc.set_hdr_meta(Some(sample_hdr_meta()));
assert!(
enc.caps().supports_hdr_metadata,
"HEVC 10-bit reports HDR SEI capability"
);
let mut aus: Vec<EncodedFrame> = Vec::new();
for i in 0..6 {
let frame = CapturedFrame {
+296 -65
View File
@@ -129,9 +129,13 @@ impl WinVendor {
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
fn zerocopy_enabled(vendor: WinVendor) -> bool {
pf_host_config::config()
.zerocopy
.unwrap_or(matches!(vendor, WinVendor::Amf))
zerocopy_active(pf_host_config::config().zerocopy, vendor)
}
/// The pure half of [`zerocopy_enabled`]: an operator override wins; unset resolves to the
/// per-vendor default (AMF on, QSV off — see the validation status above).
fn zerocopy_active(override_: Option<bool>, vendor: WinVendor) -> bool {
override_.unwrap_or(matches!(vendor, WinVendor::Amf))
}
/// Upper bound on `PUNKTFUNK_FFWIN_POLL_MS`. This knob spins the **encode thread** waiting for an
@@ -164,14 +168,19 @@ const MAX_POLL_SPIN_MS: u64 = 1_000;
fn poll_spin_cap_us() -> u64 {
static CAP_US: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
*CAP_US.get_or_init(|| {
std::env::var("PUNKTFUNK_FFWIN_POLL_MS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.map(|ms| ms.min(MAX_POLL_SPIN_MS) * 1000)
.unwrap_or(0) // default: no spin — the libavcodec AMF buffer can't be spun out
parse_poll_spin_cap_us(std::env::var("PUNKTFUNK_FFWIN_POLL_MS").ok().as_deref())
})
}
/// The pure half of [`poll_spin_cap_us`]: parse, clamp to [`MAX_POLL_SPIN_MS`] BEFORE the µs
/// conversion (the ordering the doc above proves is load-bearing), and default to 0 — no spin,
/// the libavcodec AMF buffer can't be spun out.
fn parse_poll_spin_cap_us(raw: Option<&str>) -> u64 {
raw.and_then(|s| s.trim().parse::<u64>().ok())
.map(|ms| ms.min(MAX_POLL_SPIN_MS) * 1000)
.unwrap_or(0)
}
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
fn sws_src(format: PixelFormat) -> Result<Pixel> {
Ok(match format {
@@ -206,6 +215,86 @@ fn is_10bit_format(format: PixelFormat) -> bool {
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
}
/// Which lane the system-memory path routes a captured D3D11 format through. Device-free — the
/// routing DECISION, split from the D3D11 copies so it is testable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ReadbackRoute {
/// Same-format `CopyResource` + plane-by-plane copy (NV12/P010 from the video processor).
Yuv,
/// BGRA staging + swscale BGRA→NV12 — the 8-bit fallback when the capturer's video
/// processor latched off.
Bgra,
/// R10G10B10A2 staging + swscale X2BGR10→P010 — the HDR twin of that fallback.
Rgb10,
}
/// Route a captured format, guarding the mid-stream depth change first: the predicate matches
/// what the encoder was built from (`ten_bit_input`), so the guard can only fire on a GENUINE
/// depth change under the encoder — never, as it used to, on every frame of a session that
/// merely negotiated 10-bit over an 8-bit capture (see [`is_10bit_format`]).
fn readback_route(format: PixelFormat, ten_bit: bool) -> Result<ReadbackRoute> {
anyhow::ensure!(
is_10bit_format(format) == ten_bit,
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
if ten_bit { 10 } else { 8 }
);
Ok(match format {
PixelFormat::Nv12 | PixelFormat::P010 => ReadbackRoute::Yuv,
PixelFormat::Bgra | PixelFormat::Bgrx => ReadbackRoute::Bgra,
PixelFormat::Rgb10a2 => ReadbackRoute::Rgb10,
other => {
bail!("ffmpeg_win system path cannot read back captured D3D11 format {other:?}")
}
})
}
/// The vendor-specific low-latency option set for [`open_win_encoder`], pure so the latency
/// contract is pinned by tests. Unknown private options are ignored by `avcodec_open2` (left in
/// the dict), so vendor/codec-specific keys are safe to set unconditionally.
fn vendor_opts(vendor: WinVendor, amf_usage: &str) -> Vec<(&'static str, String)> {
match vendor {
WinVendor::Amf => vec![
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
// transcoding): AMF usage presets bundle driver-side pipeline behavior that varies
// by VCN generation/driver — measured on-box rather than assumed.
("usage", amf_usage.to_owned()),
("rc", "cbr".into()),
// Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
// difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
// low-latency preset choice on the NVENC path).
("quality", "speed".into()),
("preanalysis", "false".into()),
("enforce_hrd", "true".into()),
// AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
("latency", "true".into()),
// Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
// B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
("bf", "0".into()),
// VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
("header_insertion_mode", "idr".into()),
],
WinVendor::Qsv => vec![
("preset", "veryfast".into()),
("async_depth", "1".into()), // bound in-flight frames — the big QSV latency lever
("low_power", "1".into()), // VDEnc fixed-function path (lower latency)
("look_ahead", "0".into()), // (h264_qsv only; ignored on hevc/av1)
("forced_idr", "1".into()), // a forced key frame becomes a real IDR
("scenario", "displayremoting".into()),
],
}
}
/// Bind flags on the FFmpeg-allocated zero-copy pool. AMF reads it as encoder input
/// (RENDER_TARGET + SHADER_RESOURCE, matching the video-processor output); QSV maps it as an mfx
/// surface (DECODER | VIDEO_ENCODER). The `CopySubresourceRegion` into the pool works with any
/// usable DEFAULT-usage texture regardless.
fn pool_bind_flags(vendor: WinVendor) -> u32 {
match vendor {
WinVendor::Amf => (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
}
}
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
/// infinite GOP, the BT.709-limited (SDR) or BT.2020-PQ (HDR) VUI, the given `pix_fmt`, and the
/// optional hw device/frames contexts (null for the system path). Returns the opened encoder.
@@ -266,40 +355,11 @@ unsafe fn open_win_encoder(
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
}
// Low-latency tuning. Unknown private options are ignored by avcodec_open2 (left in the dict),
// so vendor-specific keys are safe to set unconditionally.
// Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned).
let mut opts = Dictionary::new();
match vendor {
WinVendor::Amf => {
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
// transcoding): AMF usage presets bundle driver-side pipeline behavior that varies by
// VCN generation/driver — measured on-box rather than assumed.
let usage =
std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
opts.set("usage", &usage);
opts.set("rc", "cbr");
// Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
// difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
// low-latency preset choice on the NVENC path).
opts.set("quality", "speed");
opts.set("preanalysis", "false");
opts.set("enforce_hrd", "true");
// AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
opts.set("latency", "true");
// Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
// B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
opts.set("bf", "0");
// VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
opts.set("header_insertion_mode", "idr");
}
WinVendor::Qsv => {
opts.set("preset", "veryfast");
opts.set("async_depth", "1"); // bound in-flight frames — the big QSV latency lever
opts.set("low_power", "1"); // VDEnc fixed-function path (lower latency)
opts.set("look_ahead", "0"); // (h264_qsv only; ignored on hevc/av1)
opts.set("forced_idr", "1"); // a forced key frame becomes a real IDR
opts.set("scenario", "displayremoting");
}
let usage = std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
for (k, v) in vendor_opts(vendor, &usage) {
opts.set(k, &v);
}
video
.open_with(opts)
@@ -528,22 +588,10 @@ impl SystemInner {
pts: i64,
idr: bool,
) -> Result<()> {
// Same predicate the encoder was built from (`ten_bit_input`), so this can only fire on a
// genuine MID-STREAM depth change — never, as it used to, on every frame of a session that
// merely negotiated 10-bit over an 8-bit capture.
let fmt_10 = is_10bit_format(format);
anyhow::ensure!(
fmt_10 == self.ten_bit,
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
if self.ten_bit { 10 } else { 8 }
);
match format {
PixelFormat::Nv12 | PixelFormat::P010 => self.readback_yuv(frame, pts, idr),
PixelFormat::Bgra | PixelFormat::Bgrx => self.readback_bgra(frame, pts, idr),
PixelFormat::Rgb10a2 => self.readback_rgb10(frame, pts, idr),
other => {
bail!("ffmpeg_win system path cannot read back captured D3D11 format {other:?}")
}
match readback_route(format, self.ten_bit)? {
ReadbackRoute::Yuv => self.readback_yuv(frame, pts, idr),
ReadbackRoute::Bgra => self.readback_bgra(frame, pts, idr),
ReadbackRoute::Rgb10 => self.readback_rgb10(frame, pts, idr),
}
}
@@ -921,14 +969,7 @@ impl ZeroCopyInner {
} else {
PixelFormat::Nv12
};
// Bind flags on the FFmpeg-allocated pool. AMF reads it as encoder input (RENDER_TARGET +
// SHADER_RESOURCE, matching the video-processor output); QSV maps it as an mfx surface
// (DECODER | VIDEO_ENCODER). The CopySubresourceRegion into the pool works with any usable
// DEFAULT-usage texture regardless.
let bind_flags = match vendor {
WinVendor::Amf => (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
};
let bind_flags = pool_bind_flags(vendor);
const POOL: c_int = 8;
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
@@ -1402,3 +1443,193 @@ impl Encoder for FfmpegWinEncoder {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Zero-copy default matrix: the operator override wins in both directions; unset resolves
/// AMF on (on-glass validated) and QSV off (opt-in until validated on Intel glass — the
/// probe-never-assume rule).
#[test]
fn zerocopy_default_is_per_vendor_and_override_wins() {
assert!(zerocopy_active(None, WinVendor::Amf));
assert!(!zerocopy_active(None, WinVendor::Qsv));
for vendor in [WinVendor::Amf, WinVendor::Qsv] {
assert!(zerocopy_active(Some(true), vendor));
assert!(!zerocopy_active(Some(false), vendor));
}
}
/// `PUNKTFUNK_FFWIN_POLL_MS` grammar: default 0 (no spin), verbatim ms → µs inside the clamp,
/// and the clamp applies BEFORE the µs conversion — the slipped-digit value that used to be a
/// 27.7-hour spin resolves to the 1 s cap, not a wedged encode thread.
#[test]
fn poll_spin_cap_clamps_before_the_us_conversion() {
assert_eq!(parse_poll_spin_cap_us(None), 0);
assert_eq!(parse_poll_spin_cap_us(Some("0")), 0);
assert_eq!(parse_poll_spin_cap_us(Some("5")), 5_000);
assert_eq!(parse_poll_spin_cap_us(Some(" 12 ")), 12_000);
assert_eq!(
parse_poll_spin_cap_us(Some("100000000")),
MAX_POLL_SPIN_MS * 1000
);
assert_eq!(
parse_poll_spin_cap_us(Some(&u64::MAX.to_string())),
MAX_POLL_SPIN_MS * 1000
);
assert_eq!(parse_poll_spin_cap_us(Some("junk")), 0);
assert_eq!(parse_poll_spin_cap_us(Some("-1")), 0);
}
/// The swscale source map: packed RGB/BGR converts; every YUV/10-bit layout is refused (the
/// swscale lane is the 8-bit BGRA fallback, not a general converter — the Linux HDR formats
/// are listed explicitly so a `PixelFormat` addition re-breaks the match on purpose).
#[test]
fn sws_src_accepts_packed_rgb_only() {
assert_eq!(sws_src(PixelFormat::Bgrx).unwrap(), Pixel::BGRZ);
assert_eq!(sws_src(PixelFormat::Rgbx).unwrap(), Pixel::RGBZ);
assert_eq!(sws_src(PixelFormat::Bgra).unwrap(), Pixel::BGRA);
assert_eq!(sws_src(PixelFormat::Rgba).unwrap(), Pixel::RGBA);
assert_eq!(sws_src(PixelFormat::Rgb).unwrap(), Pixel::RGB24);
assert_eq!(sws_src(PixelFormat::Bgr).unwrap(), Pixel::BGR24);
for f in [
PixelFormat::Nv12,
PixelFormat::P010,
PixelFormat::Rgb10a2,
PixelFormat::Yuv444,
PixelFormat::X2Rgb10,
PixelFormat::X2Bgr10,
] {
assert!(sws_src(f).is_err(), "{f:?} must be refused");
}
}
/// The readback routing table, and its depth guard: NV12/P010 take the plane copy, the
/// video-processor fallbacks take their swscale lanes, a depth CHANGE under the encoder is
/// refused (in both directions), and depth-consistent routing never trips the guard.
#[test]
fn readback_routing_and_depth_guard() {
assert_eq!(
readback_route(PixelFormat::Nv12, false).unwrap(),
ReadbackRoute::Yuv
);
assert_eq!(
readback_route(PixelFormat::P010, true).unwrap(),
ReadbackRoute::Yuv
);
assert_eq!(
readback_route(PixelFormat::Bgra, false).unwrap(),
ReadbackRoute::Bgra
);
assert_eq!(
readback_route(PixelFormat::Bgrx, false).unwrap(),
ReadbackRoute::Bgra
);
assert_eq!(
readback_route(PixelFormat::Rgb10a2, true).unwrap(),
ReadbackRoute::Rgb10
);
// Mid-stream depth changes — the genuine error the guard exists for.
assert!(readback_route(PixelFormat::P010, false).is_err());
assert!(readback_route(PixelFormat::Rgb10a2, false).is_err());
assert!(readback_route(PixelFormat::Nv12, true).is_err());
assert!(readback_route(PixelFormat::Bgra, true).is_err());
// A format neither lane can read back.
assert!(readback_route(PixelFormat::Yuv444, false).is_err());
}
/// The 10-bit predicate follows the PIXELS (P010/Rgb10a2), not the negotiated depth — see
/// `ten_bit_input` for the forever-failing-session shape the reverse produced here.
#[test]
fn ten_bit_follows_the_pixels() {
assert!(is_10bit_format(PixelFormat::P010));
assert!(is_10bit_format(PixelFormat::Rgb10a2));
assert!(!is_10bit_format(PixelFormat::Nv12));
assert!(!is_10bit_format(PixelFormat::Bgra));
assert!(!is_10bit_format(PixelFormat::Bgrx));
}
/// The QSV low-latency contract, pinned: these five knobs are the difference between
/// display-remoting latency and transcode behavior — a silent regression here changes every
/// Intel Windows session.
#[test]
fn qsv_opts_pin_the_latency_contract() {
let opts = vendor_opts(WinVendor::Qsv, "ignored");
let get = |k: &str| {
opts.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| v.as_str())
};
assert_eq!(get("async_depth"), Some("1"));
assert_eq!(get("low_power"), Some("1"));
assert_eq!(get("look_ahead"), Some("0"));
assert_eq!(get("forced_idr"), Some("1"));
assert_eq!(get("scenario"), Some("displayremoting"));
assert_eq!(get("preset"), Some("veryfast"));
assert_eq!(get("usage"), None, "AMF-only knob must not leak into QSV");
}
/// The AMF (benchmark-comparator) contract: usage passes through, B-frames are pinned OFF
/// (each one is a full frame period of latency on RDNA3+), and the low-latency submission
/// mode + IDR header insertion are requested.
#[test]
fn amf_opts_pin_no_bframes_and_the_usage_passthrough() {
let opts = vendor_opts(WinVendor::Amf, "lowlatency");
let get = |k: &str| {
opts.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| v.as_str())
};
assert_eq!(get("usage"), Some("lowlatency"));
assert_eq!(get("bf"), Some("0"));
assert_eq!(get("rc"), Some("cbr"));
assert_eq!(get("quality"), Some("speed"));
assert_eq!(get("latency"), Some("true"));
assert_eq!(get("header_insertion_mode"), Some("idr"));
assert_eq!(get("preanalysis"), Some("false"));
assert_eq!(get("enforce_hrd"), Some("true"));
}
/// The zero-copy pool's bind flags per vendor — AMF's encoder-input shape vs QSV's mfx
/// surface shape (a wrong flag set fails `av_hwframe_ctx_init`, or worse, opens and maps
/// wrong).
#[test]
fn pool_bind_flags_per_vendor() {
assert_eq!(
pool_bind_flags(WinVendor::Amf),
(D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32
);
assert_eq!(
pool_bind_flags(WinVendor::Qsv),
(D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32
);
}
/// The libavcodec encoder-name dispatch (name-selected — the codec id would pick the
/// software encoder).
#[test]
fn encoder_names_dispatch_by_vendor() {
assert_eq!(WinVendor::Qsv.encoder_name(Codec::H264), "h264_qsv");
assert_eq!(WinVendor::Qsv.encoder_name(Codec::H265), "hevc_qsv");
assert_eq!(WinVendor::Qsv.encoder_name(Codec::Av1), "av1_qsv");
assert_eq!(WinVendor::Amf.encoder_name(Codec::H265), "hevc_amf");
}
/// Probe smoke: resolve the QSV probe on this machine without crashing — `false` on a box
/// without the Intel runtime is a valid outcome; the value is printed, not asserted. Only
/// compiled in the `amf-qsv`-without-`qsv` combo (the shipped combo answers via native VPL).
/// Run on the Windows CI runner:
/// cargo test -p pf-encode --no-default-features --features amf-qsv -- --ignored ffmpeg_win
#[cfg(not(feature = "qsv"))]
#[test]
#[ignore = "needs a real FFmpeg runtime probe (run on the Windows CI runner, not a dev box)"]
fn ffmpeg_win_probe_smoke() {
for codec in [Codec::H264, Codec::H265, Codec::Av1] {
eprintln!(
"probe_can_encode(Qsv, {codec:?}) = {}",
probe_can_encode(WinVendor::Qsv, codec)
);
}
}
}
+4 -7
View File
@@ -1690,17 +1690,14 @@ impl Encoder for NvencD3d11Encoder {
}
fn caps(&self) -> EncoderCaps {
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
// session is in HDR mode. Both are the real capabilities the session glue routes on.
// RFI is probed once at open (`rfi_supported`) — the real capability the session glue
// routes on. (In-band HDR SEI needs no cap: it rides keyframes on HEVC/H.264 HDR
// sessions — see `submit` — and every first-party client reads the grade out-of-band
// via the 0xCE datagram regardless.)
EncoderCaps {
// The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
blends_cursor: false,
supports_rfi: self.rfi_supported,
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
// (see `submit`); the grade still reaches punktfunk clients out-of-band via the 0xCE
// datagram. Don't claim a capability the AV1 path doesn't have.
supports_hdr_metadata: self.hdr && self.codec != Codec::Av1,
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
chroma_444: self.chroma_444,
+8 -7
View File
@@ -313,11 +313,15 @@ fn create_session(target_luid: Option<[u8; 8]>) -> Result<(Loader, Session, (u16
.unwrap_or(&impls[0]);
if let Some(want) = target_luid {
if !(chosen.luid_valid && chosen.luid == want) {
bail!(
// Static configuration mismatch — the capture adapter has no Intel VPL
// implementation, so every rebuild re-hits this same wall. The terminal marker
// makes the stream loop end the session at once instead of spending its reset
// budget (5 futile rebuilds, then an opaque loop as the client reconnects).
return Err(anyhow::Error::new(super::TerminalEncoderError).context(
"capture device's adapter is not an Intel VPL implementation (hybrid box? \
point PUNKTFUNK_RENDER_ADAPTER / the web-console GPU preference at the Intel \
adapter for a QSV session)"
);
point PUNKTFUNK_RENDER_ADAPTER / the web-console GPU preference at the \
Intel adapter for a QSV session)",
));
}
}
// SAFETY: `loader.0` is live; `MFXCreateSession` fills `session` only on success and the
@@ -1471,9 +1475,6 @@ impl Encoder for QsvEncoder {
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
blends_cursor: false,
supports_rfi: self.ltr_active,
// In-band mastering/CLL at IDR (HEVC prefix SEI / AV1 metadata OBU); AVC sessions
// are never HDR.
supports_hdr_metadata: self.ten_bit && self.codec != Codec::H264,
chroma_444: false,
intra_refresh: self.ir_active,
// Unvalidated on-glass — the host keeps the IDR recovery path until then.
+316 -43
View File
@@ -203,14 +203,15 @@ pub fn open_video(
}
};
// The session asked for a composited pointer; say so loudly if the backend that actually opened
// cannot deliver one. `cursor_blend` was a REQUEST with no answer for most of this crate's life
// (`let _ = cursor_blend;` below), and the result was a stream with no mouse cursor and nothing
// in the logs — confirmed on the VAAPI dmabuf path and the libav-NVENC CUDA path.
//
// A warning is deliberately all this does. `open_video` cannot re-plan capture, so refusing here
// would only trade a missing pointer for a dead session; the host owns `plan.cursor_blend` and is
// the only layer that can fall back to capturer-side compositing. This makes the condition
// visible and queryable (`EncoderCaps::blends_cursor`) so that decision can be made upstream.
// cannot deliver one. Since the negotiation became caps-aware ([`cursor_blend_capable`] gates
// the cursor channel, and the session plan keeps cursor sessions off the native-NV12/RGB-direct
// shapes), no PLANNED path reaches this: capture negotiates embedded-cursor mode wherever the
// resolved backend can't blend. What remains reachable is the open-time divergence the plan
// cannot see — a Vulkan Video open failing back to VAAPI mid-`open_amd_intel`, and the
// gamescope residual (gamescope has no embedded mode, so a never-blending backend there —
// H.264→VAAPI, software — still streams cursorless). This is the backstop that keeps those
// honest in the logs; `open_video` cannot re-plan capture, so a warning is deliberately all
// it does.
if cursor_blend && !inner.caps().blends_cursor {
tracing::warn!(
backend,
@@ -607,26 +608,23 @@ fn open_video_backend(
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
// H.264. `auto` (the default) resolves from the selected render adapter's vendor.
let backend = windows_resolved_backend();
// With `auto` the backend is derived from the selected GPU, so this can only fire when an
// explicit PUNKTFUNK_ENCODER contradicts the GPU the pipeline sits on (e.g. `nvenc` forced
// while the web-console preference pins the Intel iGPU) — the open below will then fail on
// a wrong-vendor device; say why up front instead of leaving an opaque encoder error.
if let Some(sel) = pf_gpu::selected_gpu() {
let mismatched = match backend {
WindowsBackend::Nvenc => sel.info.vendor_id != pf_gpu::VENDOR_NVIDIA,
WindowsBackend::Amf => sel.info.vendor_id != pf_gpu::VENDOR_AMD,
WindowsBackend::Qsv => sel.info.vendor_id != pf_gpu::VENDOR_INTEL,
WindowsBackend::Software => false,
};
if mismatched {
tracing::warn!(
adapter = sel.info.name,
?backend,
"encoder backend does not match the selected GPU's vendor (explicit \
PUNKTFUNK_ENCODER conflicting with the GPU preference?) the encoder \
open will likely fail on this device"
);
}
// An explicit PUNKTFUNK_ENCODER pin contradicting the selected GPU's vendor was just
// overridden by the adapter-derived backend (`resolve_windows_backend`) — honoring it
// could only fail, and used to feed the reset ladder five futile rebuilds per connect
// (an unrecoverable session + client reconnect loop on hybrid boxes). Warn per session
// open so the stale pin gets removed from host.env.
if windows_pinned_backend().is_some_and(|pin| pin != backend) {
tracing::warn!(
adapter = pf_gpu::selected_gpu()
.map(|s| s.info.name)
.as_deref()
.unwrap_or("?"),
pinned = %pf_host_config::config().encoder_pref,
using = ?backend,
"explicit PUNKTFUNK_ENCODER pin does not match the selected GPU's vendor — the \
pin is overridden (remove it from host.env, or point the GPU preference at the \
pinned vendor's adapter)"
);
}
match backend {
WindowsBackend::Nvenc => {
@@ -989,6 +987,87 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
}
}
/// Whether the encode backend this session will resolve to composites [`CapturedFrame::cursor`]
/// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor
/// delivery honestly instead of discovering a cursorless stream after the fact (the
/// `blends_cursor` audit finding): a blend-capable backend takes cursor-as-metadata capture
/// (pointer-free frames + host composite on demand — the cursor channel's contract); for
/// anything else the host must have the compositor EMBED the pointer. The sibling verdict of
/// [`linux_native_nv12_ok`], threaded into the same negotiation.
///
/// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the
/// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the
/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit HDR session
/// skips Vulkan Video for libav VAAPI's P010/Main10 wiring (no blend).
#[cfg(target_os = "linux")]
pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool {
// A negotiated PyroWave session routes to that backend before the pref is consulted
// (`open_video_backend_linux`), and its wavelet CSC composites the metadata cursor.
if codec == Codec::PyroWave {
return true;
}
let direct_nvenc = {
#[cfg(feature = "nvenc")]
{
nvenc_direct_enabled()
}
#[cfg(not(feature = "nvenc"))]
{
false
}
};
let vulkan_csc = {
// The compute-CSC arm — the one that blends. Eligibility mirrors `open_amd_intel`;
// the device probe runs last (it opens a Vulkan instance, cached per GPU+codec).
#[cfg(feature = "vulkan-encode")]
{
matches!(codec, Codec::H265 | Codec::Av1)
&& vulkan_encode_enabled()
&& vulkan_encode_available(codec)
}
#[cfg(not(feature = "vulkan-encode"))]
{
false
}
};
let backend = resolve_linux_backend(
pf_host_config::config().encoder_pref.as_str(),
linux_auto_is_vaapi,
cuda_planned,
);
cursor_blend_capable_for(backend, cuda_planned, ten_bit, direct_nvenc, vulkan_csc)
}
/// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests.
/// `direct_nvenc` = the direct-SDK NVENC path is compiled in and enabled; `vulkan_csc` = the
/// Vulkan Video compute-CSC arm (the one that blends) is compiled in, enabled, and
/// device-supported for the session's codec.
#[cfg(target_os = "linux")]
fn cursor_blend_capable_for(
backend: Option<LinuxBackend>,
cuda_planned: bool,
ten_bit: bool,
direct_nvenc: bool,
vulkan_csc: bool,
) -> bool {
match backend {
// The wavelet CSC composites the metadata cursor (`linux/pyrowave.rs`).
Some(LinuxBackend::Pyrowave) => true,
// Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads —
// a CPU-payload session stays on libav NVENC, which cannot blend.
Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc,
// The Vulkan Video compute-CSC path blends; a 10-bit HDR session skips it for libav
// VAAPI (no blend). The session plan keeps a cursor-blend session off the native-NV12
// and RGB-direct shapes (`SessionPlan::output_format` / `VulkanVideoEncoder::open`),
// so CSC eligibility IS the answer.
Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => !ten_bit && vulkan_csc,
// CPU frames: the capturer composites the metadata cursor inline before the encoder
// runs, but the ENCODER blends nothing — the cursor channel's on-demand composite
// contract can't be honored. Report the encoder's truth.
Some(LinuxBackend::Software) | None => false,
}
}
/// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per
/// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
///
@@ -1387,6 +1466,23 @@ pub fn can_encode_10bit(_codec: Codec) -> bool {
false
}
/// Marker in an encoder error's `anyhow` context chain: the failure is a deterministic
/// consequence of the session's configuration, so an in-place rebuild retry can never succeed
/// (e.g. the backend binding to a wrong-vendor adapter — the same wall on every attempt). The
/// stream loop's reset ladder downcasts for this and ends the session immediately with the
/// carried cause instead of burning its rebuild budget first. Attach with
/// `anyhow::Error::new(TerminalEncoderError).context("the actual cause")`.
#[derive(Clone, Copy, Debug)]
pub struct TerminalEncoderError;
impl std::fmt::Display for TerminalEncoderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("deterministic configuration error — an encoder rebuild cannot fix this")
}
}
impl std::error::Error for TerminalEncoderError {}
// ---------------------------------------------------------------------------------------------
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the vendor of the
@@ -1394,7 +1490,8 @@ pub fn can_encode_10bit(_codec: Codec) -> bool {
// backend always matches the GPU the capture ring and virtual display sit on.
// ---------------------------------------------------------------------------------------------
#[cfg(target_os = "windows")]
// Un-gated (unlike everything else in this section): the pure reconciliation half below is
// platform-agnostic so its decision table is unit-tested on every CI leg, not just Windows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowsBackend {
Nvenc,
@@ -1411,26 +1508,79 @@ enum GpuVendor {
Intel,
}
/// Resolve the active Windows encode backend from `PUNKTFUNK_ENCODER` (`auto` → the selected
/// render adapter's vendor). Shared by [`open_video`] and the GameStream codec advertisement so
/// both agree.
/// The PCI vendor a Windows hardware backend can open on (`None` for the vendor-agnostic
/// software encoder). Capture ring, virtual display, and encoder share ONE adapter, so a backend
/// whose vendor differs from the selected GPU's can never open — the table the pin-vs-preference
/// reconciliation in [`resolve_windows_backend`] stands on.
pub fn windows_backend_vendor_id(backend: WindowsBackend) -> Option<u32> {
match backend {
WindowsBackend::Nvenc => Some(pf_gpu::VENDOR_NVIDIA),
WindowsBackend::Amf => Some(pf_gpu::VENDOR_AMD),
WindowsBackend::Qsv => Some(pf_gpu::VENDOR_INTEL),
WindowsBackend::Software => None,
}
}
/// Pure half of [`windows_resolved_backend`]: reconcile an explicit `PUNKTFUNK_ENCODER` pin with
/// the selected render adapter. A hardware pin whose vendor contradicts the selected GPU is
/// OVERRIDDEN by the adapter-derived backend (`derived`, evaluated lazily) — honoring it can only
/// produce a deterministically-failing session, observed on a hybrid box as a stale `qsv` pin vs.
/// a console NVIDIA preference: five futile in-place rebuilds, session end, client reconnect,
/// forever. The console preference is the newer, user-visible, per-box intent; the pin is usually
/// a stale provisioning artifact — [`open_video`] warns loudly whenever a pin loses. The software
/// pin has no vendor and is always honored; with no selected GPU there is nothing to reconcile
/// against, so a pin is trusted as-is.
pub fn resolve_windows_backend(
pinned: Option<WindowsBackend>,
selected_vendor_id: Option<u32>,
derived: impl FnOnce() -> WindowsBackend,
) -> WindowsBackend {
match (pinned, selected_vendor_id) {
(None, _) => derived(),
(Some(pin), Some(vendor)) => match windows_backend_vendor_id(pin) {
Some(required) if required != vendor => derived(),
_ => pin,
},
(Some(pin), None) => pin,
}
}
/// The explicit `PUNKTFUNK_ENCODER` pin as a backend — `None` for `auto`, unset, and unknown
/// spellings (which all mean "derive from the selected adapter's vendor").
#[cfg(target_os = "windows")]
pub fn windows_resolved_backend() -> WindowsBackend {
fn windows_pinned_backend() -> Option<WindowsBackend> {
// Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
match pf_host_config::config().encoder_pref.as_str() {
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
"amf" | "amd" => WindowsBackend::Amf,
"qsv" | "intel" => WindowsBackend::Qsv,
"sw" | "software" | "openh264" => WindowsBackend::Software,
_ => match windows_gpu_vendor() {
Some(GpuVendor::Nvidia) => WindowsBackend::Nvenc,
Some(GpuVendor::Amd) => WindowsBackend::Amf,
Some(GpuVendor::Intel) => WindowsBackend::Qsv,
None => WindowsBackend::Software,
},
"nvenc" | "hw" | "nvidia" | "cuda" => Some(WindowsBackend::Nvenc),
"amf" | "amd" => Some(WindowsBackend::Amf),
"qsv" | "intel" => Some(WindowsBackend::Qsv),
"sw" | "software" | "openh264" => Some(WindowsBackend::Software),
_ => None,
}
}
/// Resolve the active Windows encode backend from `PUNKTFUNK_ENCODER` (`auto` → the selected
/// render adapter's vendor; an explicit pin contradicting that vendor is overridden — see
/// [`resolve_windows_backend`]). Shared by [`open_video`] and the GameStream codec advertisement
/// so both agree.
#[cfg(target_os = "windows")]
pub fn windows_resolved_backend() -> WindowsBackend {
let pinned = windows_pinned_backend();
// The selected vendor is only consulted to reconcile a pin — skipping the query keeps the
// common auto path at ONE inventory walk (the one inside `windows_gpu_vendor`).
let selected = if pinned.is_some() {
pf_gpu::selected_gpu().map(|s| s.info.vendor_id)
} else {
None
};
resolve_windows_backend(pinned, selected, || match windows_gpu_vendor() {
Some(GpuVendor::Nvidia) => WindowsBackend::Nvenc,
Some(GpuVendor::Amd) => WindowsBackend::Amf,
Some(GpuVendor::Intel) => WindowsBackend::Qsv,
None => WindowsBackend::Software,
})
}
/// True if the session's resolved encode backend produces GPU-resident frames (so the capturer should
/// hand GPU surfaces straight through rather than CPU-stage them) — only the GPU-less software encoder
/// wants CPU staging. This is the single source for [`pf_frame::OutputFormat`]'s `gpu` bit:
@@ -1727,6 +1877,63 @@ pub fn pyrowave_mode_fits_rdo(_width: u32, _height: u32, _chroma444: bool) -> bo
mod tests {
use super::*;
/// The pin-vs-adapter reconciliation table (`resolve_windows_backend`): an explicit
/// `PUNKTFUNK_ENCODER` pin whose vendor contradicts the selected GPU must be overridden by
/// the adapter-derived backend — never "pin + proceed", which fed the reset ladder a
/// deterministic failure (the hybrid-box `qsv` pin × console NVIDIA preference loop).
#[test]
fn encoder_pin_reconciles_against_the_selected_adapter() {
use WindowsBackend::*;
let derived = |b: WindowsBackend| move || b;
let unreachable = || -> WindowsBackend { panic!("derived must not be consulted") };
// The repro: pinned qsv, console-selected NVIDIA → NVENC (the un-pinned path's answer).
assert_eq!(
resolve_windows_backend(Some(Qsv), Some(pf_gpu::VENDOR_NVIDIA), derived(Nvenc)),
Nvenc
);
// A pin matching the selected vendor is honored without deriving.
assert_eq!(
resolve_windows_backend(Some(Qsv), Some(pf_gpu::VENDOR_INTEL), unreachable),
Qsv
);
// No selected GPU → nothing to reconcile against; the pin is trusted as-is.
assert_eq!(
resolve_windows_backend(Some(Nvenc), None, unreachable),
Nvenc
);
// The software pin has no vendor: honored on any adapter.
assert_eq!(
resolve_windows_backend(Some(Software), Some(pf_gpu::VENDOR_NVIDIA), unreachable),
Software
);
// No pin (`auto`/unset) → always the adapter-derived backend.
assert_eq!(
resolve_windows_backend(None, Some(pf_gpu::VENDOR_AMD), derived(Amf)),
Amf
);
assert_eq!(
resolve_windows_backend(None, None, derived(Software)),
Software
);
}
/// The terminal-marker contract the stream loop's reset ladder relies on: a
/// [`TerminalEncoderError`] attached at the failure site must stay downcastable through the
/// context layers added on the way up (a `format!`/stringify on any layer would break it).
#[test]
fn terminal_encoder_error_survives_the_context_chain() {
use anyhow::Context as _;
let site: anyhow::Error = anyhow::Error::new(TerminalEncoderError)
.context("capture device's adapter is not an Intel VPL implementation");
let bubbled = Err::<(), _>(site)
.context("QSV lazy bring-up")
.context("encoder submit")
.unwrap_err();
assert!(bubbled.downcast_ref::<TerminalEncoderError>().is_some());
// The operator-facing rendering leads with the actual cause, not the marker.
assert!(format!("{bubbled:#}").contains("not an Intel VPL implementation"));
}
/// The probed-capability → wire-bitfield mapping the native codec advertisement is built from.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[test]
@@ -1754,6 +1961,72 @@ mod tests {
assert_eq!(none.wire_mask(), None);
}
/// The cursor-blend capability mirror, arm by arm — the table the caps-aware negotiation
/// (cursor channel grant, metadata-vs-embedded capture) stands on. Each row names the arm's
/// blending stage or the reason there is none.
#[cfg(target_os = "linux")]
#[test]
fn cursor_blend_capability_mirrors_the_dispatch() {
use LinuxBackend::*;
// PyroWave: the wavelet CSC composites, always.
assert!(cursor_blend_capable_for(
Some(Pyrowave),
false,
false,
false,
false
));
// NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads.
assert!(cursor_blend_capable_for(
Some(Nvenc),
true,
false,
true,
false
));
assert!(
!cursor_blend_capable_for(Some(Nvenc), false, false, true, false),
"a CPU payload stays on libav NVENC, which cannot blend"
);
assert!(
!cursor_blend_capable_for(Some(Nvenc), true, false, false, false),
"PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path"
);
// AMD/Intel: the Vulkan Video compute-CSC arm blends; VAAPI never does.
assert!(cursor_blend_capable_for(
Some(AmdIntel),
false,
false,
false,
true
));
assert!(
!cursor_blend_capable_for(Some(AmdIntel), false, false, false, false),
"no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \
device) resolves to libav VAAPI, which cannot blend"
);
assert!(
!cursor_blend_capable_for(Some(AmdIntel), false, true, false, true),
"a 10-bit HDR session skips Vulkan Video for VAAPI's P010 wiring — no blend"
);
assert!(cursor_blend_capable_for(
Some(Vulkan),
false,
false,
false,
true
));
// Software / unknown pref: CPU frames; the encoder blends nothing.
assert!(!cursor_blend_capable_for(
Some(Software),
false,
false,
true,
true
));
assert!(!cursor_blend_capable_for(None, false, false, true, true));
}
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
/// `TrackedEncoder`. A defaulted trait method that isn't forwarded silently no-ops through the
/// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe,
+26 -30
View File
@@ -34,6 +34,28 @@
use std::sync::OnceLock;
/// Whether a `PUNKTFUNK_*` env var reads as ON, or `None` when it is unset — the host's
/// **explicit-off** grammar: `0` / `false` / `off` / `no` (trimmed, case-insensitive) are off and ANY
/// other value is on, so a presence-style `=1` keeps working. Every "default ON" knob below shares
/// it.
///
/// Exported because callers in other crates need the SAME grammar. A hand-rolled
/// `var(k).as_deref() != Ok("0")` accepts `"0 "` (trailing space, trivially produced by a systemd
/// drop-in or a shell heredoc) and `"false"` as ON — the bug class of ed525c4c, and the reason
/// `PUNKTFUNK_PIPEWIRE_NV12` in pf-capture now routes through here.
///
/// Note this is deliberately NOT the grammar `pf-zerocopy` uses for its own flags (truthy:
/// `1|true|yes|on`, everything else off) — see the module docs: independent features that share a
/// name prefix.
pub fn env_on(name: &str) -> Option<bool> {
std::env::var(name).ok().map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
}
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
@@ -128,42 +150,16 @@ impl HostConfig {
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(2),
zerocopy: val("PUNKTFUNK_ZEROCOPY").map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
}),
zerocopy: env_on("PUNKTFUNK_ZEROCOPY"),
// Default ON, explicit-off grammar (mirrors `four_four_four`: the client's HDR setting
// is the real per-session switch; the encode probe keeps incapable GPUs honest at 8-bit).
ten_bit: val("PUNKTFUNK_10BIT")
.map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(true),
ten_bit: env_on("PUNKTFUNK_10BIT").unwrap_or(true),
// Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF —
// is the real switch; see the field doc).
four_four_four: val("PUNKTFUNK_444")
.map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(true),
four_four_four: env_on("PUNKTFUNK_444").unwrap_or(true),
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
// per-session switch; see the field doc).
chacha20: val("PUNKTFUNK_CHACHA20")
.map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(true),
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
perf: flag("PUNKTFUNK_PERF"),
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
compositor: val("PUNKTFUNK_COMPOSITOR"),
@@ -159,6 +159,10 @@ struct GroupState {
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled at
/// the group's first isolate — last-member teardown re-enables them BEFORE the CCD restore.
pnp_disabled: Vec<String>,
/// Whether `ccd_saved` was captured by an EXCLUSIVE isolate (vs `Primary`, which also
/// snapshots but deliberately keeps the physical displays active) — gates the re-assert
/// watchdog, which must never "fix" a Primary group's lit panels. Cleared with the restore.
ccd_exclusive: bool,
}
/// The manager's guarded state: the slot map + the (single) group record. One lock for both — every
@@ -183,7 +187,8 @@ impl MgrInner {
}
/// The single device-level watchdog pinger, running while ANY slot lives (any IOCTL bumps the driver
/// watchdog, so one thread serves N monitors).
/// watchdog, so one thread serves N monitors). Also reused as the stop+join pair for the
/// exclusive-topology re-assert watchdog (same lifecycle shape).
struct Pinger {
stop: Arc<AtomicBool>,
thread: JoinHandle<()>,
@@ -240,6 +245,10 @@ pub struct VirtualDisplayManager {
idd_session_stops: Mutex<std::collections::HashMap<u32, Arc<AtomicBool>>>,
/// The device-level watchdog [`Pinger`], running while any slot lives.
pinger: Mutex<Option<Pinger>>,
/// The exclusive-topology re-assert watchdog (same stop+join shape as [`Pinger`]), running
/// while an EXCLUSIVE group isolate is live — a verified isolate is not durable on hybrid
/// boxes (see [`Self::ensure_exclusive_watch`]).
exclusive_watch: Mutex<Option<Pinger>>,
// The per-client stable monitor-id map is now the process-wide `super::identity::global()`
// (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE
// resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx
@@ -248,6 +257,19 @@ pub struct VirtualDisplayManager {
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
/// Bumped on every exclusive-topology EVICTION the re-assert watchdog performs. An eviction is a
/// real topology change, and the OS drives COMMIT_MODES on every live path for those — the IDD
/// display's swap-chain is recreated driver-side while the session's capture ring keeps waiting
/// on the old attachment, so frames stop silently (on-glass: panel evicted, stream frozen). The
/// manager can only announce; the SESSION owns the ring + encoder — stream loops sample this at
/// start and rebuild their capture attachment in place when it advances.
static TOPOLOGY_REASSERT_GEN: AtomicU64 = AtomicU64::new(0);
/// The current exclusive-eviction generation (see [`TOPOLOGY_REASSERT_GEN`]).
pub fn topology_reassert_gen() -> u64 {
TOPOLOGY_REASSERT_GEN.load(Ordering::Relaxed)
}
/// Initialise the process-wide manager with `driver` (the chosen backend) and return it. Idempotent: the
/// first backend to call wins (the host runs one backend per process), so a later call ignores its driver.
pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayManager {
@@ -262,6 +284,7 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
setup_lock: Mutex::new(()),
idd_session_stops: Mutex::new(std::collections::HashMap::new()),
pinger: Mutex::new(None),
exclusive_watch: Mutex::new(None),
})
}
@@ -775,6 +798,130 @@ impl VirtualDisplayManager {
}
}
/// Start the exclusive-topology re-assert watchdog (idempotent). A verified
/// [`isolate_displays_ccd`] is NOT durable: the isolated topology is deliberately not saved to
/// the CCD database (teardown must restore the user's layout), so any later topology
/// re-resolution — our own post-isolate resize/HDR commit, the deactivated panel's own driver,
/// display-poller software — can bring the stored layout back. Field-proven on a hybrid
/// Intel+NVIDIA box (`.221`): seconds after the commit-time verify reported "sole active
/// desktop", CCD showed the internal panel ACTIVE again beside the virtual display, and the
/// host never noticed. That silently un-does exclusive mode: cursor + windows can land
/// off-stream, and the secure desktop / lock screen can land on the physical panel.
///
/// The watchdog re-queries every [`knobs::exclusive_reassert_ms`] and, when a non-managed
/// display crept back, evicts it via the full [`isolate_displays_ccd`] — the forced
/// re-commit is required (it restarts OS presentation to the virtual display), and the
/// swap-chain bounce it causes is healed by the SESSION's in-place capture re-attach, keyed
/// off [`topology_reassert_gen`]. Each cycle takes the state lock via `try_lock` —
/// teardown stops+joins this thread while HOLDING the state lock, so a blocking `lock()` here
/// would deadlock; a contended cycle just skips (the slot transition that owns the lock
/// re-isolates itself). The sleep is sliced so that stop+join is bounded by ~250 ms, not a
/// full cycle.
fn ensure_exclusive_watch(&'static self) {
let interval = Duration::from_millis(knobs::exclusive_reassert_ms());
if interval.is_zero() {
return; // knob 0 = disabled (the pre-watchdog behavior)
}
let mut guard = self.exclusive_watch.lock().unwrap();
if guard.is_some() {
return;
}
let stop = Arc::new(AtomicBool::new(false));
let stop_t = stop.clone();
let thread = thread::Builder::new()
.name("vdisplay-exclusive-watch".into())
.spawn(move || {
// Consecutive cycles that found (and evicted) a non-managed display — resets when a
// cycle comes up clean, so an actor that re-adds the panel every few minutes logs a
// WARN each time, while one fighting every cycle escalates once and then goes quiet.
let mut fighting = 0u32;
'watch: loop {
let mut slept = Duration::ZERO;
while slept < interval {
if stop_t.load(Ordering::Relaxed) {
break 'watch;
}
let slice = Duration::from_millis(250).min(interval - slept);
thread::sleep(slice);
slept += slice;
}
let Ok(inner) = vdm().state.try_lock() else {
continue;
};
if inner.group.ccd_saved.is_none() || !inner.group.ccd_exclusive {
continue; // no exclusive isolate live right now
}
let keep = inner.target_ids();
if keep.is_empty() {
continue;
}
// SAFETY: `count_other_active` runs the CCD QueryDisplayConfig FFI over a
// borrowed slice of `Copy` target ids (owned result), under the `state` lock
// (`inner` guard held to the end of this iteration).
let survivors = unsafe { count_other_active(&keep) }.unwrap_or(0);
if survivors == 0 {
if fighting > 0 {
tracing::info!(
reasserts = fighting,
"exclusive topology stable again — no non-managed display active"
);
}
fighting = 0;
continue;
}
fighting += 1;
match fighting {
1..=3 => tracing::warn!(
survivors,
round = fighting,
"exclusive topology lost — a non-managed display re-activated after \
the verified isolate (hybrid-GPU driver / display-poller software \
restoring the saved layout?); re-asserting the isolate"
),
4 => tracing::error!(
survivors,
"exclusive topology keeps being re-activated (4 consecutive \
re-asserts) something on this host is fighting the isolate; \
continuing to re-assert every cycle, further rounds log at DEBUG"
),
_ => tracing::debug!(
survivors,
round = fighting,
"re-asserting exclusive topology"
),
}
// SAFETY: `isolate_displays_ccd` drives the CCD query/apply FFI over a
// borrowed slice of `Copy` target ids, under the `state` lock — the sole
// topology mutator. The returned snapshot is discarded (the group restores
// the FIRST member's). The FULL isolate on purpose — its forced re-commit
// (SDC_FORCE_MODE_ENUMERATION → COMMIT_MODES → ASSIGN_SWAPCHAIN) is
// load-bearing here exactly as at first isolate: after the eviction's
// topology change the OS stops presenting to the virtual display until a
// forced mode re-commit restarts it (on-glass: a gentle supplied-config
// eviction left capture receiving ONE stashed frame and then nothing).
let _ = unsafe { isolate_displays_ccd(&keep) };
// That same forced re-commit hands the live IDD path a fresh swap-chain,
// orphaning the session's capture ring — announce it so the session rebuilds
// its capture attachment (same-mode ring recreate + driver re-attach + fresh
// encoder) instead of silently streaming a frozen frame. The two halves are
// a pair: eviction restarts presentation, the session re-attaches capture.
TOPOLOGY_REASSERT_GEN.fetch_add(1, Ordering::Relaxed);
}
})
.expect("spawn vdisplay-exclusive-watch");
*guard = Some(Pinger { stop, thread });
}
/// Stop + join the exclusive-topology watchdog (the group's exclusive isolate is being
/// restored). Safe under the state lock: the watchdog only ever `try_lock`s it, and its sliced
/// sleep bounds the join by ~250 ms.
fn stop_exclusive_watch(&self) {
if let Some(w) = self.exclusive_watch.lock().unwrap().take() {
w.stop.store(true, Ordering::Relaxed);
let _ = w.thread.join();
}
}
/// Arrange the live slots' desktop origins (design §6.2: the pure `vdisplay/layout.rs` engine —
/// `auto-row` default, console `manual` pins win) and commit them in one CCD apply. No-ops for a
/// single member (it sits at the origin), so the single-display path issues no positioning at
@@ -998,6 +1145,12 @@ impl VirtualDisplayManager {
);
}
}
// The verified isolate above is not durable — watch and re-assert
// while the exclusive group lives (see `ensure_exclusive_watch`).
inner.group.ccd_exclusive = inner.group.ccd_saved.is_some();
if inner.group.ccd_exclusive {
self.ensure_exclusive_watch();
}
} else {
// Grown set: re-isolate so the fresh member joins the composited set
// (its auto-activate may have lit nothing extra to deactivate, but the
@@ -1413,6 +1566,10 @@ impl VirtualDisplayManager {
// (stopped FIRST, as the per-monitor pinger was), and the group's topology restore runs
// — first-in captured it, last-out restores it (design §6.1).
self.stop_pinger();
// The exclusive re-assert watchdog must be gone BEFORE the restore below — it would
// read the restored (multi-display) topology as "lost exclusivity" and re-fight it.
// (Belt-and-braces: its cycles also gate on `ccd_exclusive`, cleared below.)
self.stop_exclusive_watch();
// EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let
// them re-arrive, so a CCD restore below re-activates paths whose monitors exist
// again (a disabled devnode would leave the restored path modeless/EDID-less).
@@ -1425,6 +1582,7 @@ impl VirtualDisplayManager {
}
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero
// displays.
inner.group.ccd_exclusive = false;
if let Some(saved) = inner.group.ccd_saved.take() {
restore_displays_ccd(&saved);
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
@@ -34,6 +34,16 @@ pub(super) fn keep_alive_forever() -> bool {
.unwrap_or(false)
}
/// Cadence of the exclusive-topology re-assert watchdog (`PUNKTFUNK_EXCLUSIVE_REASSERT_MS`,
/// default 2000, `0` disables — the pre-watchdog behavior). Why it exists: a verified isolate is
/// not durable — see `VirtualDisplayManager::ensure_exclusive_watch` in the parent module.
pub(super) fn exclusive_reassert_ms() -> u64 {
std::env::var("PUNKTFUNK_EXCLUSIVE_REASSERT_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2_000)
}
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
/// [`effective_topology`](crate::effective_topology) when configured, else the legacy
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
+55 -3
View File
@@ -43,9 +43,9 @@ use windows::Win32::Devices::Display::{
};
use windows::Win32::Foundation::POINTL;
use windows::Win32::Graphics::Gdi::{
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
DISP_CHANGE_FAILED, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT,
DM_PELSWIDTH, ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_RESET, CDS_TEST, CDS_UPDATEREGISTRY,
DEVMODEW, DISP_CHANGE_FAILED, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY,
DM_PELSHEIGHT, DM_PELSWIDTH, ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
};
use punktfunk_core::Mode;
@@ -540,6 +540,50 @@ pub unsafe fn sdr_white_level_scale(target_id: u32) -> Option<f32> {
/// mode the driver didn't advertise just leaves the default instead of erroring the session.
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD/GDI mode-set helper
// (a pf-vdisplay monitor's GDI name is a real OS device name, so it works unchanged).
/// Force a REAL mode-set at the output's CURRENT mode — `CDS_RESET` applies even when nothing
/// changed (a plain re-apply of the same mode is treated as a no-op by the OS). This is the
/// presentation-restart hammer for a virtual display DWM silently stopped composing to after an
/// exclusive-eviction topology commit: measured on-glass, the eviction's forced re-commit
/// reassigned the swap-chain and the ring re-attach delivered the driver's stashed frame, but the
/// source's new-frame rate stayed 0 forever — only a real mode-set (the lever bring-up's ADD path
/// relies on via [`set_active_mode`]) makes the OS present again. Same input-desktop retry as the
/// mode-set proper. Returns `true` when the reset applied.
pub fn force_mode_reset(gdi_name: &str) -> bool {
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
let mut dm = DEVMODEW {
dmSize: size_of::<DEVMODEW>() as u16,
..Default::default()
};
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name and `&mut dm` a live DEVMODEW
// out-param with `dmSize` set; the synchronous query only reads the name and fills `dm`.
let ok =
unsafe { EnumDisplaySettingsW(PCWSTR(wname.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm) }
.as_bool();
if !ok {
tracing::warn!("{gdi_name}: force_mode_reset — no current mode to re-apply");
return false;
}
// SAFETY: same liveness as the query above; CDS_RESET re-applies the identical mode, the two
// trailing args are null, and the API only reads its inputs. The input-desktop retry mirrors
// `set_active_mode` (a CDS write off the input desktop is refused with DISP_CHANGE_FAILED).
let rc = crate::input_desktop::retry_on_input_desktop(
|rc| *rc == DISP_CHANGE_FAILED,
|| unsafe {
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_RESET, None)
},
);
if rc != DISP_CHANGE_SUCCESSFUL {
tracing::warn!(
result = rc.0,
"{gdi_name}: force_mode_reset rejected ({})",
disp_change_reason(rc.0)
);
return false;
}
tracing::info!("{gdi_name}: forced same-mode reset applied (presentation restart)");
true
}
pub fn set_active_mode(gdi_name: &str, mode: Mode) {
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
@@ -1045,6 +1089,14 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
Some(saved)
}
// (A "gentle" eviction variant — deactivate the stray paths WITHOUT `SDC_FORCE_MODE_ENUMERATION`,
// kept paths supplied verbatim — was tried for the re-assert watchdog and REMOVED: on-glass it
// still bounced the live virtual display's swap-chain (a real topology change drives COMMIT_MODES
// on its own) AND additionally left the OS not presenting to the virtual display — capture
// received one stashed frame and then nothing. Eviction therefore always goes through
// [`isolate_displays_ccd`], whose forced re-commit restarts presentation, and the SESSION pairs it
// with an in-place capture re-attach; see the vdisplay manager's re-assert watchdog.)
/// Build the ESCALATED supplied config for [`isolate_displays_ccd`]: ONLY the paths still flagged
/// ACTIVE (the keep set — the caller already cleared ACTIVE on every doomed path), with the mode
/// table rebuilt to just the entries those paths reference (indexes remapped). Docs-wise the
+30 -15
View File
@@ -36,17 +36,6 @@ fn flag(name: &str) -> bool {
flag_opt(name).unwrap_or(false)
}
/// One-shot downgrade latch: a VAAPI-passthrough capture whose dmabuf-only offer never negotiated
/// (the compositor can't allocate a LINEAR BGRx dmabuf) flips this, so the encode loop's pipeline
/// rebuild lands on the CPU offer instead of failing the same negotiation forever. Only consulted
/// when `PUNKTFUNK_ZEROCOPY` is unset — an explicit `=1` keeps forcing the dmabuf offer.
static VAAPI_DMABUF_FAILED: AtomicBool = AtomicBool::new(false);
/// Record that the VAAPI LINEAR-dmabuf offer failed negotiation (see [`VAAPI_DMABUF_FAILED`]).
pub fn note_vaapi_dmabuf_failed() {
VAAPI_DMABUF_FAILED.store(true, Ordering::Relaxed);
}
/// True when `PUNKTFUNK_ZEROCOPY` is explicitly truthy — the operator forced the dmabuf offer, so
/// a failed negotiation keeps erroring loudly instead of silently downgrading to the CPU path.
pub fn vaapi_dmabuf_forced() -> bool {
@@ -64,11 +53,16 @@ pub fn vaapi_dmabuf_forced() -> bool {
/// capture when no importer/importable modifier is available and latches the import off after
/// repeated worker deaths. `PUNKTFUNK_ZEROCOPY=0` opts out; `PUNKTFUNK_FORCE_SHM` forces the
/// race-free SHM path.
///
/// This is the GLOBAL switch and nothing but the env var moves it. It used to fall back to
/// `!VAAPI_DMABUF_FAILED`, a latch a capture-side dmabuf *negotiation* timeout set — which made one
/// failed LINEAR-dmabuf negotiation disable zero-copy for EVERY later session on the host,
/// including the NVENC EGL→CUDA path that shares none of the failing machinery (and, because the
/// raw-passthrough offer is also taken for PyroWave sessions on any vendor, one PyroWave timeout on
/// an NVIDIA box did it). That downgrade now uses the correctly-scoped
/// [`note_raw_dmabuf_negotiation_failed`], which gates only the raw-passthrough offer.
pub fn enabled() -> bool {
match flag_opt("PUNKTFUNK_ZEROCOPY") {
Some(v) => v,
None => !VAAPI_DMABUF_FAILED.load(Ordering::Relaxed),
}
flag_opt("PUNKTFUNK_ZEROCOPY").unwrap_or(true)
}
/// Whether the tiled-GL zero-copy path converts to NV12 on the GPU and feeds NVENC native YUV —
@@ -268,6 +262,27 @@ pub fn note_raw_dmabuf_import_ok() {
RAW_DMABUF_FAILURE_STREAK.store(0, Ordering::Relaxed);
}
/// Latch the raw-dmabuf passthrough off because its dmabuf-only *offer never negotiated* — the
/// CAPTURE-side counterpart to [`note_raw_dmabuf_import_failure`]'s encoder-side streak. One
/// timeout is conclusive for this offer (a compositor that cannot allocate the requested
/// LINEAR/modifier BGRx dmabuf refuses it identically on every retry), so there is no streak to
/// count: the next capture skips the passthrough and negotiates SHM/CPU instead of re-running the
/// same 10 s timeout on every reconnect.
///
/// Scoped deliberately. This used to be `note_vaapi_dmabuf_failed`, which fed [`enabled`] and so
/// disabled ALL zero-copy host-wide — see [`enabled`]. `RAW_DMABUF_DISABLED` gates only the
/// raw-passthrough decision, so the EGL→CUDA importer that a later NVENC session builds is
/// untouched.
pub fn note_raw_dmabuf_negotiation_failed() {
if !RAW_DMABUF_DISABLED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"zero-copy raw-dmabuf passthrough disabled for this host process: the compositor never \
accepted the dmabuf-only capture offer, so later captures negotiate the CPU path \
instead of repeating that timeout (the EGLCUDA import path is NOT affected)"
);
}
}
/// True once repeated encoder import failures latched the raw-dmabuf passthrough off (see
/// [`note_raw_dmabuf_import_failure`]).
pub fn raw_dmabuf_import_disabled() -> bool {
+7 -2
View File
@@ -36,8 +36,13 @@ pub(crate) const HOST_TIMING_QUEUE: usize = 512;
pub(crate) const CLIP_EVENT_QUEUE: usize = 32;
/// Cursor-shape plane depth (control-stream [`crate::quic::CursorShape`], one per pointer-bitmap
/// change — human-paced). Overflow drops the newest (try_send); the next shape change or a
/// serial mismatch against `0xD0` state heals it visually within a shape-change period.
/// change — human-paced, but bursty: crossing a toolbar flips arrow/I-beam/hand/resize several
/// times a second, and every flip mints a fresh serial and a fresh bitmap). Overflow drops the
/// newest (try_send) and the host does NOT re-send it — it only sends on a serial CHANGE — so the
/// dropped serial stays un-backed until the pointer changes shape again. Embedders must therefore
/// hold their last worn shape when `hostCursors[serial]` misses rather than hiding the pointer
/// (the Apple client's `lastWornShape`); healing is bounded by the next shape change, not by this
/// ring.
pub(crate) const CURSOR_SHAPE_QUEUE: usize = 8;
/// Cursor-state plane depth (`0xD0`, one datagram per captured frame). Latest-wins state — the
+18 -4
View File
@@ -16,7 +16,7 @@ pub use pf_capture::{
capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer,
SyntheticCapturer,
};
// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest}` (main.rs subcommands) and
// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest_at}` (main.rs subcommands) and
// `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules.
#[cfg(target_os = "windows")]
pub use pf_capture::{dxgi, synthetic_nv12};
@@ -66,8 +66,14 @@ fn zero_copy_policy(
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. `want_hdr`
/// offers the GNOME 50+ 10-bit PQ/BT.2020 formats (pass it only when the session negotiated HDR
/// AND the mirrored monitor is in HDR mode — see [`pf_capture::gnome_hdr_monitor_active`]).
/// `want_metadata_cursor` asks for cursor-as-metadata — pass it only when the session's encode
/// backend composites `CapturedFrame::cursor` (`encode::cursor_blend_capable`); otherwise the
/// portal embeds the pointer, so no backend × cursor-mode combination streams cursorless.
#[cfg(target_os = "linux")]
pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
pub fn open_portal_monitor(
want_hdr: bool,
want_metadata_cursor: bool,
) -> Result<Box<dyn Capturer>> {
// On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
// so use a plain ScreenCast session there.
@@ -76,11 +82,19 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
// Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false, false))
pf_capture::open_portal_monitor(
anchored,
want_hdr,
want_metadata_cursor,
zero_copy_policy(false, false),
)
}
#[cfg(not(target_os = "linux"))]
pub fn open_portal_monitor(_want_hdr: bool) -> Result<Box<dyn Capturer>> {
pub fn open_portal_monitor(
_want_hdr: bool,
_want_metadata_cursor: bool,
) -> Result<Box<dyn Capturer>> {
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
}
+129
View File
@@ -110,6 +110,33 @@ pub struct StreamRef {
pub plane: Plane,
}
/// A launched game, as the `game.*` events see it.
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct GameRefPayload {
/// Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream
/// `apps.json` command, which has no library entry behind it.
#[serde(skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
/// Display title.
pub title: String,
/// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<String>,
/// Client-supplied device name of the session that launched it; may be empty.
pub client: String,
pub plane: Plane,
}
/// Why a launched game is no longer running.
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum GameEndReason {
/// The player quit it (or it crashed) — the host did not ask.
Exited,
/// The host ended it, per the session⇄game lifetime policy.
Terminated,
}
/// A device in the pairing flow.
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct DeviceRef {
@@ -140,6 +167,17 @@ pub enum EventKind {
StreamStarted { stream: StreamRef },
#[serde(rename = "stream.stopped")]
StreamStopped { stream: StreamRef },
/// A launched game was confirmed running — fires once per launch, after the host has actually
/// seen the game's process (not merely spawned its launcher).
#[serde(rename = "game.running")]
GameRunning { game: GameRefPayload },
/// A launched game is gone. `reason` distinguishes the player quitting from the host ending it
/// per the lifetime policy.
#[serde(rename = "game.exited")]
GameExited {
game: GameRefPayload,
reason: GameEndReason,
},
#[serde(rename = "pairing.pending")]
PairingPending { device: DeviceRef },
#[serde(rename = "pairing.completed")]
@@ -196,6 +234,8 @@ impl EventKind {
EventKind::SessionEnded { .. } => "session.ended",
EventKind::StreamStarted { .. } => "stream.started",
EventKind::StreamStopped { .. } => "stream.stopped",
EventKind::GameRunning { .. } => "game.running",
EventKind::GameExited { .. } => "game.exited",
EventKind::PairingPending { .. } => "pairing.pending",
EventKind::PairingCompleted { .. } => "pairing.completed",
EventKind::PairingDenied { .. } => "pairing.denied",
@@ -224,6 +264,9 @@ impl EventKind {
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
Some(&stream.client)
}
EventKind::GameRunning { game } | EventKind::GameExited { game, .. } => {
Some(&game.client)
}
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(&device.name),
@@ -251,6 +294,9 @@ impl EventKind {
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
Some(stream.plane)
}
EventKind::GameRunning { game } | EventKind::GameExited { game, .. } => {
Some(game.plane)
}
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(device.plane),
@@ -264,6 +310,11 @@ impl EventKind {
EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => {
stream.app.as_deref()
}
// A `game.*` event without a library id came from an operator-typed command; its title
// is the only handle a hook filter has, so fall back to it rather than matching nothing.
EventKind::GameRunning { game } | EventKind::GameExited { game, .. } => {
game.app.as_deref().or(Some(&game.title))
}
_ => None,
}
}
@@ -522,6 +573,84 @@ mod tests {
serde_json::to_string(&ev).unwrap(),
r#"{"seq":3,"ts_ms":1700000000000,"schema":1,"kind":"plugins.changed","id":"rom-manager"}"#
);
let ev = HostEvent {
seq: 5,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::GameRunning {
game: GameRefPayload {
app: Some("steam:570".into()),
title: "Dota 2".into(),
store: Some("steam".into()),
client: "Living Room TV".into(),
plane: Plane::Native,
},
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":5,"ts_ms":1700000000000,"schema":1,"kind":"game.running","game":{"app":"steam:570","title":"Dota 2","store":"steam","client":"Living Room TV","plane":"native"}}"#
);
// A game the host ended itself, and one with no library entry behind it (an operator-typed
// GameStream command) — the optional ids are omitted, not nulled.
let ev = HostEvent {
seq: 6,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::GameExited {
game: GameRefPayload {
app: None,
title: "Big Picture".into(),
store: None,
client: String::new(),
plane: Plane::Gamestream,
},
reason: GameEndReason::Terminated,
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":6,"ts_ms":1700000000000,"schema":1,"kind":"game.exited","game":{"title":"Big Picture","client":"","plane":"gamestream"},"reason":"terminated"}"#
);
}
/// The `game.*` events must be reachable by the same hook/SSE filters as every other kind — a
/// filterable event nobody can select is not a feature.
#[test]
fn game_events_are_filterable() {
let running = EventKind::GameRunning {
game: GameRefPayload {
app: Some("steam:570".into()),
title: "Dota 2".into(),
store: Some("steam".into()),
client: "Deck".into(),
plane: Plane::Native,
},
};
assert_eq!(running.name(), "game.running");
assert!(kind_matches("game.*", running.name()));
assert!(kind_matches("game.running", running.name()));
assert!(!kind_matches("gamestream.*", running.name()));
assert_eq!(running.client_name(), Some("Deck"));
assert_eq!(running.plane(), Some(Plane::Native));
assert_eq!(running.app(), Some("steam:570"));
// With no library id, the title is the filterable handle — otherwise an `apps.json` launch
// would be unmatchable by app.
let exited = EventKind::GameExited {
game: GameRefPayload {
app: None,
title: "Big Picture".into(),
store: None,
client: String::new(),
plane: Plane::Gamestream,
},
reason: GameEndReason::Exited,
};
assert_eq!(exited.app(), Some("Big Picture"));
assert_eq!(exited.fingerprint(), None);
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -156,6 +156,15 @@ pub struct AppState {
pub audio_params: std::sync::Mutex<audio::AudioParams>,
/// True while the video stream thread is running (also its keep-running flag).
pub streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Whether the current session is ending **deliberately** — the compat plane's answer to the
/// native plane's `QUIT_CODE` close, which RTSP has no equivalent of.
///
/// Set by the three things that mean "this is over": the client's `/cancel` (Moonlight's Quit
/// App), the management API's stop, and the launched game exiting. An ENet vanish or an
/// unreachable client leaves it clear — those are drops, and the difference decides whether the
/// virtual display skips its keep-alive linger and whether the end-game policy sees an intent or
/// a network blip. Cleared by `/launch`, which is where a session begins.
pub quit: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// True while the audio stream thread is running (also its keep-running flag).
pub audio_streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Set by the control stream when the client requests an IDR / invalidates reference
@@ -222,6 +231,17 @@ impl AppState {
was_streaming
}
/// End the session as a **decision** rather than a drop: mark it deliberate, then tear it down.
///
/// This is what a client's `/cancel`, the management stop, and a launched game's exit all use.
/// The flag is read by the virtual display's keep-alive lease (skip the linger — nobody is coming
/// back) and, at the video thread's teardown, by the end-game-on-session-end policy (which gives a
/// mere drop a reconnect window first). See [`AppState::quit`].
pub(crate) fn quit_session(&self, reason: &str) -> bool {
self.quit.store(true, std::sync::atomic::Ordering::SeqCst);
self.end_session(reason)
}
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
/// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the
/// mgmt API and the streaming loops.
@@ -239,6 +259,7 @@ impl AppState {
stream: std::sync::Mutex::new(None),
audio_params: std::sync::Mutex::new(audio::AudioParams::default()),
streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
quit: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
audio_streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
force_idr: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)),
@@ -529,6 +550,32 @@ mod session_tests {
// Idempotent: a second end (e.g. `/cancel` racing the ENet Disconnect) is a no-op.
assert!(!state.end_session("test again"));
}
/// The compat plane has no close code, so the difference between "the player stopped" and "the
/// client vanished" lives entirely in this flag — and it decides whether a display lingers and
/// whether an operator's end-game policy sees a decision or a network blip. A teardown that
/// forgets to set it silently downgrades a deliberate stop to a drop.
#[test]
fn quit_marks_a_teardown_deliberate_and_a_plain_end_does_not() {
use std::sync::atomic::Ordering;
let state = test_state();
assert!(
!state.quit.load(Ordering::SeqCst),
"a fresh session is undecided"
);
// A drop (ENet vanish / unreachable client) must leave it clear.
state.streaming.store(true, Ordering::SeqCst);
state.end_session("client unreachable");
assert!(!state.quit.load(Ordering::SeqCst));
// `/cancel`, the management stop and a game exiting all go through `quit_session`.
state.streaming.store(true, Ordering::SeqCst);
assert!(state.quit_session("client /cancel"), "video was live");
assert!(state.quit.load(Ordering::SeqCst));
// …and it still performs the full teardown.
assert!(!state.streaming.load(Ordering::SeqCst));
}
}
#[cfg(all(test, unix))]
+11 -3
View File
@@ -201,6 +201,11 @@ async fn h_launch(
session.height = h;
session.fps = f;
}
// A new session starts undecided: whatever ended the last one says nothing about this
// one (see `AppState::quit`). The game this launch reclaims — if this client left one
// waiting out its reconnect window — is reprieved by the stream thread, which resolves
// the title anyway and so needs no second library scan here.
st.quit.store(false, std::sync::atomic::Ordering::SeqCst);
*st.launch.lock().unwrap() = Some(session);
tracing::info!(
w = session.width,
@@ -250,9 +255,12 @@ async fn h_cancel(
tracing::warn!("cancel rejected — caller does not own the session");
return xml(error_xml());
}
// Quit semantics: the shared full teardown (launch cleared + both media threads stop on
// their flags) — the virtual output/gamescope teardown follows via the capturer's RAII.
st.end_session("client /cancel");
// Quit semantics, and now literally so: `/cancel` is Moonlight's "Quit App" — a decision, not a
// drop. The shared full teardown (launch cleared + both media threads stop on their flags) runs
// with the session's quit flag set, so the virtual display skips its keep-alive linger and the
// end-game-on-session-end policy treats this as the operator asking. The virtual
// output/gamescope teardown follows via the capturer's RAII.
st.quit_session("client /cancel");
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><cancel>1</cancel></root>\n".to_string())
}
@@ -279,6 +279,20 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
state.video_cap.clone(),
state.stats.clone(),
on_lost.clone(),
// The launched game's lifetime wiring. A game *exiting* is a deliberate end
// (the player finished) — the same distinction the native plane draws with
// its close code, and what the end-game-on-session-end policy keys off at
// teardown.
stream::GameLifetime {
quit: state.quit.clone(),
fingerprint: ls.owner_fp.map(hex::encode),
on_game_exit: {
let st = state.clone();
Arc::new(move || {
st.quit_session("game exited");
})
},
},
);
}
Some(_) => tracing::info!("RTSP PLAY — stream already running"),
@@ -450,6 +464,25 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
);
hdr = false;
}
// Second half of the same live gate: the process-wide HDR-capture latch. The colour-mode probe
// above only says the monitor is in BT.2100 RIGHT NOW — it says nothing about whether the
// portal will actually hand us the 10-bit PQ formats. Once a `want_hdr` negotiation has failed
// (the monitor left HDR mode between probe and negotiation, NVIDIA EGL not listing LINEAR for
// XR30, a pre-50 Mutter), `pf_capture::open_portal_monitor` permanently drops the HDR OFFER and
// captures SDR. Without this check we'd keep telling the client HDR while capturing and
// encoding SDR: it renders an SDR picture through PQ — washed out, wrong gamut, no error
// anywhere — and because the latch is sticky until host restart, every reconnect repeats it.
// Consulted here rather than folded into `host_hdr_capable` for the same reason as the probe
// above: that fn is the STATIC serverinfo capability, and this is a live per-session fact.
#[cfg(target_os = "linux")]
if hdr && pf_capture::hdr_capture_failed() {
tracing::warn!(
"client requested HDR and a monitor is in BT.2100 (HDR) colour mode, but an earlier \
HDR capture negotiation on this host failed the capturer offers SDR only for the \
rest of the process lifetime, so streaming 8-bit SDR (restart the host to retry HDR)"
);
hdr = false;
}
// The client's requested CSC (moonlight-common-c SdpGenerator.c: `encoderCscMode =
// (colorspace << 1) | fullRange` — colorspace 0=Rec601, 1=Rec709, 2=Rec2020). Moonlight
// renderers configure their YUV→RGB from this REQUESTED value (not the bitstream VUI), so a
+326 -57
View File
@@ -33,15 +33,34 @@ pub struct StreamConfig {
pub hdr: bool,
}
/// A pooled capturer plus the two PipeWire-negotiation-time properties reuse must match on —
/// its HDR-ness and its metadata-cursor mode; a mismatch on either needs a fresh screencast
/// session (see `AppState::video_cap`).
pub type PooledCapturer = (Box<dyn Capturer>, bool, bool);
/// Slot for the persistent screen capturer, shared with the control plane and reused across
/// streams so a reconnect doesn't open a second (conflicting) screencast session. The `bool` is
/// the pooled capturer's HDR-ness (see `AppState::video_cap`).
pub type CapturerSlot = Arc<std::sync::Mutex<Option<(Box<dyn Capturer>, bool)>>>;
/// streams so a reconnect doesn't open a second (conflicting) screencast session.
pub type CapturerSlot = Arc<std::sync::Mutex<Option<PooledCapturer>>>;
/// A pending client reference-frame-invalidation range (lost `firstFrame..=lastFrame`), set by the
/// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)).
pub type RfiSlot = Arc<std::sync::Mutex<Option<(i64, i64)>>>;
/// What the stream thread needs to give the launched game a lifetime
/// (design/session-game-lifetime.md). Bundled because the three only exist together — the control
/// plane builds them from the live `AppState` at RTSP PLAY, and the stream thread is where they are
/// spent.
pub struct GameLifetime {
/// The session's deliberate-quit flag ([`super::AppState::quit`]), read when the stream ends: a
/// decision may end the game, a drop gets a reconnect window first.
pub quit: Arc<AtomicBool>,
/// Hex cert fingerprint of the paired client that owns the launch, so only it can reclaim its own
/// game. `None` when the peer cert couldn't be read.
pub fingerprint: Option<String>,
/// Ends the whole session, deliberately — the action for "the launched game exited".
pub on_game_exit: super::OnSessionLost,
}
/// Spawn the video stream thread (idempotent via `running`). Stops when `running` clears.
/// `force_idr` is set by the control stream on a client recovery request; `video_cap` holds
/// the persistent capturer the thread borrows for the stream's duration.
@@ -55,6 +74,7 @@ pub fn start(
video_cap: CapturerSlot,
stats: Arc<crate::stats_recorder::StatsRecorder>,
on_lost: super::OnSessionLost,
life: GameLifetime,
) {
let _ = std::thread::Builder::new()
.name("punktfunk-video".into())
@@ -106,6 +126,7 @@ pub fn start(
&video_cap,
&stats,
&on_lost,
&life,
);
// A clean return is a stop (RTSP teardown / cancel / client unreachable) → `quit`;
// an error return is `error`. The compat plane can't tell a user stop from an idle
@@ -136,12 +157,14 @@ fn run(
running: &Arc<AtomicBool>,
force_idr: &AtomicBool,
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
video_cap: &std::sync::Mutex<Option<(Box<dyn Capturer>, bool)>>,
video_cap: &std::sync::Mutex<Option<PooledCapturer>>,
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
// encode loop); per-frame sample emission is wired by a later pass.
stats: &Arc<crate::stats_recorder::StatsRecorder>,
// Whole-session teardown for the send thread's client-unreachable detection.
on_lost: &super::OnSessionLost,
// The launched game's lifetime wiring (quit flag, launch owner, game-exit teardown).
life: &GameLifetime,
) -> Result<()> {
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
pf_frame::session_tuning::on_hot_thread();
@@ -181,6 +204,29 @@ fn run(
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
// output is released when this capturer drops at stream end (RAII via its keepalive).
if pf_host_config::config().video_source.as_deref() == Some("virtual") {
// Reference point for adopting the launched game's processes: anything the host will call
// "this session's game" has to have started after this instant. Taken HERE — before the prep
// steps, before the source (a bare-spawn gamescope nests the game inside it), before the
// launch — because a reading taken later would reject the very process it is meant to find.
// Erring early can only ever include more of our own launch, never a copy from before it.
let launch_stamp = crate::gamelease::launch_clock();
// Everything the host knows about the title being launched, resolved in ONE library scan:
// what to run, what to call it, and how to recognize it once it is up.
let target = resolve_gs_app(app);
// Moonlight has no session resume, so a client coming back for a game it left behind does it
// by launching the title again. Reprieve that game before anything starts, so the copy the
// player is about to be handed isn't killed out from under them when the old window closes.
if let Some(t) = target.as_ref() {
let reprieved =
crate::gamelease::readopt(life.fingerprint.as_deref(), t.game.id.as_deref());
if reprieved > 0 {
tracing::info!(
reprieved,
title = %t.game.title,
"gamestream: this client came back for its game — keeping it"
);
}
}
// Per-app prep steps (RFC §6): the entry's own `prep` plus a custom library title's,
// run synchronously BEFORE the virtual output opens or anything launches (an HDR
// toggle / sink switch must land first — and gamescope's nested launch happens inside
@@ -200,7 +246,8 @@ fn run(
// exactly like the native plane), create a virtual output at the client mode, and capture it.
// Re-runnable: the encode loop calls it again on a mid-stream capture loss to FOLLOW a
// Desktop<->Game switch.
let (mut capturer, compositor) = open_gs_virtual_source(cfg, app)?;
let (mut capturer, compositor) =
open_gs_virtual_source(cfg, app, target.as_ref(), &life.quit)?;
tracing::info!(
?compositor,
app = ?app.map(|a| &a.title),
@@ -217,39 +264,114 @@ fn run(
// existing title, never inject a command). An apps.json entry instead carries an
// operator-typed `cmd`. Library id wins when both are set.
#[cfg(windows)]
{
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
if let Err(e) = crate::library::launch_gamestream_library(lib_id) {
tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title");
}
} else if let Some(cmd) = app
.and_then(|a| a.cmd.as_deref())
.filter(|c| !c.trim().is_empty())
{
if let Err(e) = crate::library::launch_gamestream_command(cmd) {
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
}
if let Some(t) = target.as_ref() {
// A library title launches by its store-qualified id (the interactive-session spawner
// resolves the store's own recipe); an operator-typed command runs as itself.
let launched = match (t.game.id.as_deref(), t.command.as_deref()) {
(Some(id), _) => crate::library::launch_gamestream_library(id),
(None, Some(cmd)) => crate::library::launch_gamestream_command(cmd),
(None, None) => Ok(()),
};
if let Err(e) = launched {
tracing::warn!(title = %t.game.title, error = %e, "gamestream: could not launch app");
}
}
// Linux keeps the spawned child rather than dropping it: it is the primary liveness signal
// for a title whose store told us nothing else, and the handle the termination ladder
// signals. A gamescope bare spawn already nested the command (`set_launch_command` in the
// source open), so launching again would start it twice.
#[cfg(target_os = "linux")]
if !crate::vdisplay::launch_is_nested(compositor) {
if let Some(cmd) = crate::library::resolve_session_launch(
app.and_then(|a| a.library_id.as_deref()),
app.and_then(|a| a.cmd.as_deref()),
) {
if let Err(e) = crate::library::launch_session_command(compositor, &cmd) {
let spawned_launch = match target.as_ref().and_then(|t| t.command.as_deref()) {
Some(_) if crate::vdisplay::launch_is_nested(compositor) => None,
Some(cmd) => match crate::library::launch_session_command(compositor, cmd) {
Ok(spawned) => Some(spawned),
Err(e) => {
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
None
}
}
}
},
None => None,
};
// The launched game's lifetime, in both directions (design/session-game-lifetime.md) — the
// compat plane's half of what the native plane already does:
//
// * **its exit ends the session**, so Moonlight returns to its app list instead of leaving
// the player on a bare desktop or a hidden launcher.
// * **this session ending can end it** — never by default; only when the operator asked, and
// for a mere drop only after a reconnect window (the guard's drop). Moonlight can't resume
// a session, but the window still protects unsaved progress on a network blip, and a
// relaunch of the same title reclaims the game (above).
let _game_life = target.as_ref().map(|t| {
#[cfg(target_os = "linux")]
let nested = crate::vdisplay::launch_is_nested(compositor);
#[cfg(not(target_os = "linux"))]
let nested = false;
#[cfg(target_os = "linux")]
let child = spawned_launch.map(|s| (s.child, s.group_leader));
#[cfg(not(target_os = "linux"))]
let child = None;
let on_exit: crate::gamelease::OnExit = {
let on_game_exit = life.on_game_exit.clone();
Box::new(move || {
// Read the setting at fire time, so flipping it mid-session takes effect. The
// lease itself keeps running either way — the status surface still reports the
// game.
if !crate::session_settings::get().session_on_game_exit {
tracing::info!(
"the launched game exited, but ending the session on game exit is off — \
leaving the stream up"
);
return;
}
tracing::info!("the launched game exited — ending the session");
// Deliberate: the player finished. The display skips its keep-alive linger and
// the launch state is cleared, so Moonlight's next `/launch` starts cleanly.
on_game_exit();
})
};
let lease = crate::gamelease::open(
crate::gamelease::LeaseRequest {
game: t.game.clone(),
// RTSP carries no client device name, so the peer IP is the best label there is
// (the same one the stats capture uses).
client: client_label.clone(),
plane: crate::events::Plane::Gamestream,
spec: t.detect.clone(),
nested,
child,
launch_stamp,
},
on_exit,
);
// Declared first so it drops first: the console loses the live row before the policy
// below can replace it with a `grace` one, rather than briefly showing both.
let published = crate::session_status::publish_gamestream_game(lease.shared());
(
published,
crate::gamelease::SessionGuard::new(
lease,
life.quit.clone(),
life.fingerprint.clone(),
),
)
});
// Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live
// compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place
// without a Moonlight reconnect. (A resolution change can't be followed mid-stream on
// GameStream — WxH is locked at ANNOUNCE — but a session toggle keeps the negotiated mode.)
let rebuild = || open_gs_virtual_source(cfg, app).map(|(c, _)| c);
let rebuild =
|| open_gs_virtual_source(cfg, app, target.as_ref(), &life.quit).map(|(c, _)| c);
return stream_body(
&mut capturer,
Some(&rebuild),
// The virtual-output source never selects cursor-as-metadata (`set_hw_cursor` is
// never called → the compositor EMBEDS the pointer where it can), so the encoder
// is handed nothing to composite. gamescope remains the pointerless residual —
// its capture carries no cursor either way (the native plane's XFixes source is
// not wired on this plane).
false,
&sock,
cfg,
running,
@@ -266,13 +388,34 @@ fn run(
// pooled capturer's HDR-ness matching this stream's negotiated `cfg.hdr` — the depth is a
// PipeWire-negotiation-time property of the screencast session, so an HDR↔SDR change needs a
// fresh session (same pattern as the audio capturer's channel-count gate).
// Cursor-as-metadata only where the encode backend this session resolves to composites
// `frame.cursor` (the caps-aware negotiation — mirror of the native plane's); otherwise ask
// the portal to EMBED the pointer so no backend × cursor-mode combination streams
// cursorless. Synthetic frames carry no pointer either way.
let metadata_cursor = {
#[cfg(target_os = "linux")]
{
// Same CUDA-payload prediction SessionPlan/`handshake::cursor_forward` make:
// the NVIDIA resolution plus the zero-copy master switch.
let cuda_planned =
!crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr)
}
#[cfg(not(target_os = "linux"))]
false
};
let pooled = match video_cap.lock().unwrap().take() {
Some((c, was_hdr)) if was_hdr == cfg.hdr => Some(c),
Some((c, was_hdr)) => {
Some((c, was_hdr, was_meta)) if was_hdr == cfg.hdr && was_meta == metadata_cursor => {
Some(c)
}
Some((c, was_hdr, was_meta)) => {
tracing::info!(
was_hdr,
want_hdr = cfg.hdr,
"video source: pooled capturer depth mismatch — opening a fresh screencast session"
was_metadata_cursor = was_meta,
want_metadata_cursor = metadata_cursor,
"video source: pooled capturer depth/cursor-mode mismatch — opening a fresh \
screencast session"
);
drop(c);
None
@@ -285,8 +428,13 @@ fn run(
c
}
None if pf_host_config::config().video_source.as_deref() == Some("portal") => {
tracing::info!(hdr = cfg.hdr, "video source: portal desktop capture");
capture::open_portal_monitor(cfg.hdr).context("open portal capturer")?
tracing::info!(
hdr = cfg.hdr,
metadata_cursor,
"video source: portal desktop capture"
);
capture::open_portal_monitor(cfg.hdr, metadata_cursor)
.context("open portal capturer")?
}
None => {
tracing::info!("video source: synthetic test pattern");
@@ -298,6 +446,7 @@ fn run(
let result = stream_body(
&mut capturer,
None,
metadata_cursor,
&sock,
cfg,
running,
@@ -308,10 +457,85 @@ fn run(
on_lost,
);
capturer.set_active(false);
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr));
// Re-pool ONLY a capturer that can still produce frames. Every terminal state of the portal
// backend is sticky (`Capturer::is_alive`): a dead zerocopy-import worker, an exited PipeWire
// thread, or a compositor that went away all make the NEXT stream fail at exactly the same
// point — and this path has no rebuild closure (unlike the virtual-output path above), so a
// re-admitted dead capturer wedged GameStream portal video permanently, at 10 s per reconnect
// attempt. Dropping it instead costs one fresh screencast session on the next connect. Note
// `result` may already be `Err` here, which is itself that signal. (`metadata_cursor` rides
// along as the second reuse key, beside HDR-ness — see `PooledCapturer`.)
if result.is_ok() && capturer.is_alive() {
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr, metadata_cursor));
} else {
tracing::info!(
stream_failed = result.is_err(),
capturer_alive = capturer.is_alive(),
"video source: retiring the pooled capturer — the next stream opens a fresh screencast \
session"
);
}
result
}
/// What the compat plane resolved about the app a client launched: identity for the lease, the status
/// surface and the `game.*` events; the signals that recognize the running game; and the command to
/// run it.
struct GsApp {
game: crate::gamelease::GameRef,
detect: crate::library::DetectSpec,
/// The resolved shell command. `Some` on Linux, which runs it itself; `None` for a Windows
/// library title, which launches by id through the interactive-session spawner instead.
command: Option<String>,
}
/// Resolve a `/launch`ed catalog entry against the host's **own** library — the client sends only an
/// appid, and everything the session does with the title afterwards comes from what the host knows
/// about it.
///
/// A library pick carries its store's detect signals. An operator-typed `apps.json` command has no
/// library entry behind it, so its title is the whole identity and its own first token — when that is
/// an absolute executable — the only signal there is; the host spawns it directly anyway, so the child
/// is the primary tracking either way. `None` = nothing to launch (Desktop, or an unresolvable entry).
fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option<GsApp> {
let app = app?;
if let Some(id) = app.library_id.as_deref() {
match crate::library::resolve_launch(id) {
Some(t) => {
return Some(GsApp {
game: t.game,
detect: t.detect,
command: t.command,
})
}
// Same fallback (and same warning) the plain command resolution has always had, so a
// client picking a stale title sees why nothing started.
None => tracing::warn!(
launch_id = id,
"requested launch id not in this host's library (or no launch recipe) — ignoring"
),
}
}
let cmd = app
.cmd
.as_deref()
.map(str::trim)
.filter(|c| !c.is_empty())?;
Some(GsApp {
game: crate::gamelease::GameRef {
id: None,
store: None,
title: if app.title.trim().is_empty() {
cmd.to_string()
} else {
app.title.clone()
},
},
detect: crate::library::spec_from_command(cmd),
command: Some(cmd.to_string()),
})
}
/// Open the virtual-display video source for a GameStream session: pick the LIVE compositor + normalize
/// the session env (apply_session_env/apply_input_env — gamescope ATTACH/resize, KWin/Mutter
/// retargeting) exactly like the native plane (native.rs resolve_compositor), create a virtual
@@ -323,6 +547,12 @@ fn run(
fn open_gs_virtual_source(
cfg: StreamConfig,
app: Option<&super::apps::AppEntry>,
// The resolved title (see [`resolve_gs_app`]) — its command is what a bare-spawn gamescope nests
// and what decides whether this session is a dedicated game session at all. Resolved once by the
// caller, so a mid-stream rebuild can't re-resolve to something different.
launch: Option<&GsApp>,
// The session's deliberate-quit flag, handed to the display's keep-alive lease.
quit: &Arc<AtomicBool>,
) -> Result<(Box<dyn Capturer>, crate::vdisplay::Compositor)> {
let compositor = if let Some(c) = app.and_then(|a| a.compositor) {
c
@@ -349,11 +579,7 @@ fn open_gs_virtual_source(
// id / apps.json command), under `game_session=dedicated` with gamescope available, gets its
// own headless gamescope spawn at the client mode — same routing as the native plane. Gate on
// the resolved command so an unresolvable entry falls back to auto routing (review #9).
let has_launch = crate::library::resolve_session_launch(
app.and_then(|a| a.library_id.as_deref()),
app.and_then(|a| a.cmd.as_deref()),
)
.is_some();
let has_launch = launch.and_then(|t| t.command.as_deref()).is_some();
if crate::vdisplay::wants_dedicated_game_session(has_launch) {
crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true);
crate::vdisplay::Compositor::Gamescope
@@ -369,17 +595,12 @@ fn open_gs_virtual_source(
};
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
// Carry the resolved launch command on the backend instance (per-session) rather than a
// process-global env var, so concurrent sessions can't stomp each other's launch target. On
// Linux resolve a library-id selection to its command too, so gamescope's bare spawn nests a
// library title exactly like an apps.json command (it previously nested only `cmd`, silently
// dropping library picks).
#[cfg(target_os = "linux")]
vd.set_launch_command(crate::library::resolve_session_launch(
app.and_then(|a| a.library_id.as_deref()),
app.and_then(|a| a.cmd.as_deref()),
));
#[cfg(not(target_os = "linux"))]
vd.set_launch_command(app.and_then(|a| a.cmd.clone()));
// process-global env var, so concurrent sessions can't stomp each other's launch target. It is
// the RESOLVED command, so gamescope's bare spawn nests a library title exactly like an
// apps.json command (it previously nested only `cmd`, silently dropping library picks). Off
// Linux this is a no-op backend-side, and a library title resolves to no command at all — the
// interactive-session spawner launches it by id instead.
vd.set_launch_command(launch.and_then(|t| t.command.clone()));
// Serialize with the punktfunk/1 plane's IDD-push setup dance (Goal-1 §2.5). A GameStream
// connect used to skip it entirely, so it could ADD/reconfigure the shared monitor while a
// native session was mid-build (and vice versa), and its sealed-channel delivery would replace
@@ -407,10 +628,11 @@ fn open_gs_virtual_source(
height: cfg.height,
refresh_hz: cfg.fps,
},
// GameStream's deliberate quit is the Moonlight "Quit App" (nvhttp `h_cancel`), not a QUIC
// close code — wiring it to skip-linger is a follow-up, so this path keeps normal keep-alive
// (a fresh, never-set flag).
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
// GameStream's deliberate quit is the Moonlight "Quit App" (nvhttp `h_cancel`), the
// management stop, or the launched game exiting — not a QUIC close code. All three set the
// session's quit flag ([`super::AppState::quit`]), so the display skips its keep-alive linger
// for a stop the way it does on the native plane, and only a real drop lingers.
quit.clone(),
None, // fresh session — no display superseded
)
.context("create virtual output at client resolution")?;
@@ -420,7 +642,7 @@ fn open_gs_virtual_source(
// capturer follows the display). No-op on Linux: virtual-output capture is SDR-only upstream
// (Mutter RecordVirtual), and `host_hdr_capable` therefore keeps `cfg.hdr` false for this
// source — the Linux HDR path is the portal monitor mirror (`video_source=portal`).
let capturer = capture::capture_virtual_output(
let mut capturer = capture::capture_virtual_output(
vout,
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
crate::session_plan::CaptureBackend::resolve(),
@@ -646,6 +868,10 @@ fn stream_body(
// Re-open the video source on capture loss (virtual-display path → follow a Desktop<->Game switch);
// `None` for the portal/synthetic source, which has nothing to re-detect (propagate the error).
rebuild: Option<&dyn Fn() -> Result<Box<dyn Capturer>>>,
// The capture hands the encoder cursor bitmaps to composite (cursor-as-metadata negotiated
// because the resolved backend blends — see the callers). `false` = the pointer is embedded
// in the pixels (or absent), so the encoder is asked to composite nothing.
cursor_blend: bool,
sock: &UdpSocket,
cfg: StreamConfig,
running: &Arc<AtomicBool>,
@@ -681,9 +907,9 @@ fn stream_body(
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
encode::ChromaFormat::Yuv420,
// Desktop monitor capture negotiates cursor-as-metadata where available — the encoder
// may be handed cursor bitmaps to composite.
true,
// True only when THIS session's capture negotiated cursor-as-metadata — which the
// callers grant only where the resolved backend composites (`cursor_blend_capable`).
cursor_blend,
)
.context("open video encoder for stream")?;
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
@@ -855,7 +1081,7 @@ fn stream_body(
frame.is_cuda(),
gs_bit_depth(frame.format),
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
true, // metadata-cursor capture — see the first open
cursor_blend, // same capture cursor mode — see the first open
)
.context("reopen encoder after rebuild")?;
// A rebuilt encoder starts unconfigured — same reason as the first open above.
@@ -1171,6 +1397,49 @@ fn stream_body(
mod tests {
use super::*;
fn entry(title: &str, cmd: Option<&str>) -> super::super::apps::AppEntry {
super::super::apps::AppEntry {
id: 1,
title: title.to_string(),
compositor: None,
cmd: cmd.map(str::to_string),
library_id: None,
prep: Vec::new(),
}
}
/// What the compat plane decides to track. The negative cases are the load-bearing ones: a
/// Desktop entry launches nothing, so it must take no lease at all — the feature has to stay
/// completely inert for a plain desktop stream.
#[test]
fn only_an_entry_that_launches_something_gets_tracked() {
// Nothing selected (no `/launch` app) → nothing to track.
assert!(resolve_gs_app(None).is_none());
// Desktop: a title with no command and no library id.
assert!(resolve_gs_app(Some(&entry("Desktop", None))).is_none());
// A blank command is the same as none.
assert!(resolve_gs_app(Some(&entry("Blank", Some(" ")))).is_none());
// An operator-typed apps.json command: no library entry behind it, so the title is the whole
// identity and there is no store-qualified id for the console to match box art on.
let t = resolve_gs_app(Some(&entry(
"Steam Big Picture",
Some(" steam -gamepadui "),
)))
.expect("a command entry is tracked");
assert_eq!(t.command.as_deref(), Some("steam -gamepadui"));
assert_eq!(t.game.id, None);
assert_eq!(t.game.store, None);
assert_eq!(t.game.title, "Steam Big Picture");
// `steam` is a PATH lookup, not an absolute executable, so nothing is asserted about the
// process — the host's own child is what tracks it (see `library::spec_from_command`).
assert!(t.detect.is_empty());
// A titleless entry still shows up as something a human can read.
let t = resolve_gs_app(Some(&entry("", Some("/opt/game/run")))).expect("tracked");
assert_eq!(t.game.title, "/opt/game/run");
}
/// End-to-end check of the send thread: batches pushed on the channel arrive, complete and
/// byte-identical, at a peer socket via the paced sendmmsg path.
#[test]
+11
View File
@@ -22,6 +22,7 @@ pub(crate) use utoipa::ToSchema;
mod art;
mod custom;
mod detect;
#[cfg(windows)]
mod epic;
#[cfg(windows)]
@@ -38,6 +39,7 @@ mod xbox;
pub use art::*;
pub use custom::*;
pub use detect::*;
#[cfg(windows)]
pub use epic::*;
#[cfg(windows)]
@@ -102,6 +104,15 @@ pub struct GameEntry {
/// console uses it for attribution; `GET /library?provider=` filters on it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// How to recognize this title's process(es) once it is running ([`DetectSpec`]) — filled in by
/// each provider from paths it already read while scanning.
///
/// **Host-internal: never serialized.** It names local filesystem paths, so it stays out of both
/// the catalog JSON the client renders and the OpenAPI schema; it rides here only so the
/// providers that already hold this data don't have to be re-scanned.
#[serde(skip)]
#[schema(ignore)]
pub detect: DetectSpec,
}
/// A store that contributes titles to the library. The trait is the extension point for future
@@ -28,6 +28,15 @@ pub struct CustomEntry {
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_id: Option<String>,
/// How to recognize this title's process once it is running (design §9) — the one thing a
/// provider knows that the host cannot work out for itself.
///
/// Optional: without it the entry is still tracked by the child the host spawns for it, which
/// covers every command that stays in the foreground. It earns its keep for a command that hands
/// off and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where
/// the host would otherwise lose the game the moment the shim returns.
#[serde(default, skip_serializing_if = "DetectHint::is_empty")]
pub detect: DetectHint,
}
/// Request body to create or replace a custom entry (no `id` — the host owns it).
@@ -41,6 +50,9 @@ pub struct CustomInput {
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
/// How to recognize this title's process — see [`CustomEntry::detect`].
#[serde(default)]
pub detect: DetectHint,
}
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
@@ -57,10 +69,27 @@ pub struct ProviderEntryInput {
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
/// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its
/// titles' install directories (Playnite does) should send them: it is what lets a game launched
/// through the provider's own client still end its session when the player quits.
#[serde(default)]
pub detect: DetectHint,
}
impl From<CustomEntry> for GameEntry {
fn from(c: CustomEntry) -> Self {
// A custom/provider entry is spawned by the host itself, so its own child process is the
// primary lifetime signal; the spec is the fallback for a command that hands off and exits (a
// launcher script, a `flatpak run`). An absolute exe in the command line is all that can be
// *inferred*; anything sharper has to be stated, which is what `detect` is for (design §9).
// The inferred exe wins where both exist — it is derived from the very command being run.
let detect = c
.launch
.as_ref()
.filter(|l| l.kind == "command")
.map(|l| crate::library::spec_from_command(&l.value))
.unwrap_or_default()
.or_hint(&c.detect);
GameEntry {
id: format!("custom:{}", c.id),
store: "custom".into(),
@@ -68,6 +97,7 @@ impl From<CustomEntry> for GameEntry {
art: c.art,
launch: c.launch,
provider: c.provider,
detect,
}
}
}
@@ -157,6 +187,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
prep: input.prep,
provider: None,
external_id: None,
detect: input.detect,
};
entries.push(entry.clone());
save_custom(&entries)?;
@@ -178,6 +209,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
slot.art = input.art;
slot.launch = input.launch;
slot.prep = input.prep;
slot.detect = input.detect;
let updated = slot.clone();
save_custom(&entries)?;
emit_changed("manual");
@@ -272,6 +304,7 @@ fn reconcile_entries(
prep: input.prep,
provider: Some(provider.to_string()),
external_id: Some(input.external_id),
detect: input.detect,
});
}
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
@@ -349,6 +382,7 @@ mod tests {
prep: Vec::new(),
provider: None,
external_id: None,
detect: DetectHint::default(),
}
}
@@ -359,6 +393,7 @@ mod tests {
art: Artwork::default(),
launch: None,
prep: Vec::new(),
detect: DetectHint::default(),
}
}
+335
View File
@@ -0,0 +1,335 @@
//! How to **recognize** a launched title's running process(es) — the read-side counterpart to
//! [`super::launch`], which only knows how to *start* one.
//!
//! A launch tells the host what to run; it does not tell it what "the game" looks like once the
//! launcher has handed off. Every store here therefore contributes whatever identifying signals it
//! already has on disk (design §4): a Steam appid, an install directory, a concrete executable, an
//! environment marker. [`crate::procscan`] turns those into live pids, and
//! [`crate::gamelease`] turns pids into a lifetime.
//!
//! The signals are a **union**, not a ladder: any process matching any signal belongs to the game.
//! That is what lets one recipe (`install_dir`) cover the stores that expose nothing else, while a
//! store with something sharper (Steam's launch reaper) still gets the precise answer.
//!
//! `DetectSpec` is **host-internal and never crosses the wire** — it names local filesystem paths,
//! which no client has any business seeing. It rides on [`super::GameEntry`] as a `#[serde(skip)]`
//! field purely so the providers that already read these paths during their scan don't have to be
//! walked a second time.
use super::*;
/// An environment variable a launcher stamps onto the game's process, identifying it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnvMarker {
/// The variable name (e.g. `HEROIC_GAME_ID`).
pub key: String,
/// The exact value to require, when the launcher's value identifies *this* title. `None` matches
/// the key's mere presence — only safe for launchers that run one game at a time.
pub value: Option<String>,
}
/// The signals that identify a launched title's process(es). Every field is optional and
/// independent; an all-`None` spec means "this title can't be tracked" (the lease degrades to
/// [`crate::gamelease::LeaseKind::Untracked`] and both lifetime behaviors stay inert for it).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DetectSpec {
/// Steam appid, for titles Steam itself installed (never for non-Steam shortcuts, whose reaper
/// appid semantics differ — those carry an [`exe`](Self::exe) instead). On Linux this is the
/// sharpest signal available: Steam wraps every launch — native or Proton — in
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's.
pub steam_appid: Option<u32>,
/// A launcher-stamped environment marker.
pub env_marker: Option<EnvMarker>,
/// The game's own executable, when a store resolves one exactly.
pub exe: Option<PathBuf>,
/// The game's install directory — the universal recipe. A process whose image path (or, for
/// Proton/Wine, whose command line) sits under this directory is part of the game.
pub install_dir: Option<PathBuf>,
/// The game's executable **file name** (`Hades.exe`, `retroarch`), matched case-insensitively
/// against a process's image name and nothing else.
///
/// The weakest signal here, and the only one an operator supplies by hand
/// ([`super::DetectHint`]): a bare name says nothing about *which* copy is running. It is offered
/// because the entries that need it — an emulator launched through a front-end, a game whose
/// launcher relocates it — often expose nothing sharper, and because "started after this launch"
/// still bounds it: a copy the player already had open is never adopted.
pub process_name: Option<String>,
}
impl DetectSpec {
/// A spec with no signals at all — nothing to track.
pub fn is_empty(&self) -> bool {
self.steam_appid.is_none()
&& self.env_marker.is_none()
&& self.exe.is_none()
&& self.install_dir.is_none()
&& self.process_name.is_none()
}
/// Just a Steam appid (the manifest path; art/shortcut scanning fills the rest).
pub fn steam(appid: u32) -> Self {
Self {
steam_appid: Some(appid),
..Default::default()
}
}
/// Just an install directory — the universal recipe most stores land on.
pub fn dir(dir: impl Into<PathBuf>) -> Self {
Self {
install_dir: Some(dir.into()),
..Default::default()
}
}
/// Just a concrete executable.
pub fn exe(exe: impl Into<PathBuf>) -> Self {
Self {
exe: Some(exe.into()),
..Default::default()
}
}
/// Add an install directory to an existing spec (Steam pairs one with its appid so the Windows
/// matcher, which has no reaper, still has something to go on).
pub fn with_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.install_dir = Some(dir.into());
self
}
/// Add an environment marker to an existing spec.
pub fn with_env(mut self, key: impl Into<String>, value: Option<String>) -> Self {
self.env_marker = Some(EnvMarker {
key: key.into(),
value,
});
self
}
/// Fill in whatever this spec doesn't already know from an operator/provider hint.
///
/// The host's own findings win: a hint is a fallback for a title the scanners couldn't pin down,
/// never a way to redirect the matcher away from what the store actually reported.
pub fn or_hint(mut self, hint: &DetectHint) -> Self {
let from = Self::from(hint);
self.install_dir = self.install_dir.or(from.install_dir);
self.exe = self.exe.or(from.exe);
self.process_name = self.process_name.or(from.process_name);
self
}
}
/// What an operator (or a provider plugin) can tell the host about recognizing a title — the wire
/// half of [`DetectSpec`], and the only part of it that is ever accepted from outside.
///
/// Deliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment
/// marker) are things the host discovers for itself and would be meaningless — or dangerous — to take
/// on someone's word. What is left is what a provider genuinely knows and the host cannot guess: where
/// the title is installed, which executable is the game, what the process is called. All three are
/// optional; supplying none is the same as supplying no hint at all.
///
/// Never returned by the catalog API — see the module docs on why detect data does not cross the wire
/// outbound.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct DetectHint {
/// Where the title is installed. Any process running from under this directory is part of the
/// game — the universal recipe, and the one worth supplying if you supply only one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install_dir: Option<String>,
/// The game's own executable, as an absolute path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exe: Option<String>,
/// The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three
/// — see [`DetectSpec::process_name`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub process_name: Option<String>,
}
impl DetectHint {
/// Whether the hint says anything at all (all-empty is treated as absent).
pub fn is_empty(&self) -> bool {
self.trimmed().is_none()
}
/// The hint with blank fields dropped, or `None` if nothing is left. Console text inputs and
/// hand-written plugin payloads both produce `""` for "not set", and an empty install dir would
/// otherwise match *every* process on the box.
fn trimmed(&self) -> Option<(Option<&str>, Option<&str>, Option<&str>)> {
fn f(s: &Option<String>) -> Option<&str> {
s.as_deref().map(str::trim).filter(|v| !v.is_empty())
}
let (dir, exe, name) = (f(&self.install_dir), f(&self.exe), f(&self.process_name));
(dir.is_some() || exe.is_some() || name.is_some()).then_some((dir, exe, name))
}
}
/// A provider's hint becomes a spec — the one inbound path into [`DetectSpec`].
impl From<&DetectHint> for DetectSpec {
fn from(h: &DetectHint) -> Self {
let Some((install_dir, exe, process_name)) = h.trimmed() else {
return Self::default();
};
Self {
install_dir: install_dir.map(PathBuf::from),
exe: exe.map(PathBuf::from),
process_name: process_name.map(str::to_string),
..Default::default()
}
}
}
/// Derive a detect spec from an operator-typed shell command (the custom store's `command` kind, and
/// the provider-plugin entries that reuse it): if the command's first token is an absolute path to an
/// existing file, that's the game's executable.
///
/// Deliberately conservative — a bare `dolphin-emu --batch` yields nothing (a PATH lookup would guess
/// at which of several installs the launcher will pick, and a wrong exe is worse than none: the lease
/// would call a running game exited). Custom entries are spawned by the host anyway, so their primary
/// tracking is the child process itself; this is only the fallback for a command that shims out.
pub fn spec_from_command(cmd: &str) -> DetectSpec {
let Some(first) = shell_first_token(cmd) else {
return DetectSpec::default();
};
let p = Path::new(&first);
if p.is_absolute() && p.is_file() {
DetectSpec::exe(p)
} else {
DetectSpec::default()
}
}
/// The first token of a shell command, honoring single/double quotes around it (`"/opt/My Game/run"`).
/// Not a shell parser — just enough to recover a quoted absolute path, which is the only case that
/// matters here.
fn shell_first_token(cmd: &str) -> Option<String> {
let cmd = cmd.trim_start();
let mut chars = cmd.chars();
match chars.next()? {
q @ ('"' | '\'') => {
let rest: String = chars.collect();
let end = rest.find(q)?;
Some(rest[..end].to_string())
}
_ => cmd.split_whitespace().next().map(str::to_string),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_spec_is_untrackable() {
assert!(DetectSpec::default().is_empty());
assert!(!DetectSpec::steam(570).is_empty());
assert!(!DetectSpec::dir("/games/x").is_empty());
assert!(!DetectSpec::exe("/games/x/run").is_empty());
assert!(!DetectSpec::default()
.with_env("HEROIC_GAME_ID", Some("abc".into()))
.is_empty());
}
#[test]
fn builders_compose() {
let s = DetectSpec::steam(570).with_dir("/games/dota");
assert_eq!(s.steam_appid, Some(570));
assert_eq!(s.install_dir.as_deref(), Some(Path::new("/games/dota")));
let h = DetectSpec::dir("/games/quail").with_env("HEROIC_GAME_ID", Some("Quail".into()));
assert_eq!(
h.env_marker,
Some(EnvMarker {
key: "HEROIC_GAME_ID".into(),
value: Some("Quail".into())
})
);
}
#[test]
fn command_spec_only_trusts_an_absolute_existing_file() {
// A PATH-relative command is not guessed at.
assert!(spec_from_command("dolphin-emu --batch").is_empty());
// An absolute path that doesn't exist is not asserted either.
assert!(spec_from_command("/nope/not/here --x").is_empty());
// A real absolute file (this test binary) is picked up, quoted or bare.
let me = std::env::current_exe().expect("current exe");
let bare = format!("{} --flag", me.display());
assert_eq!(spec_from_command(&bare).exe.as_deref(), Some(me.as_path()));
let quoted = format!("\"{}\" --flag", me.display());
assert_eq!(
spec_from_command(&quoted).exe.as_deref(),
Some(me.as_path())
);
assert!(spec_from_command(" ").is_empty());
}
/// A hint is operator/plugin input, so the blank-field case is the norm, not an edge: a console
/// form and a hand-written plugin payload both send `""` for "not set". An empty install dir that
/// reached the matcher would prefix-match every process on the box — and this feature can end
/// processes.
#[test]
fn a_blank_hint_says_nothing() {
assert!(DetectHint::default().is_empty());
let blank = DetectHint {
install_dir: Some("".into()),
exe: Some(" ".into()),
process_name: Some("\t".into()),
};
assert!(blank.is_empty());
assert!(DetectSpec::from(&blank).is_empty(), "nothing to match on");
let hint = DetectHint {
install_dir: Some(" /games/quail ".into()),
exe: None,
process_name: Some("quail".into()),
};
assert!(!hint.is_empty());
let spec = DetectSpec::from(&hint);
assert_eq!(spec.install_dir.as_deref(), Some(Path::new("/games/quail")));
assert_eq!(spec.process_name.as_deref(), Some("quail"));
assert_eq!(spec.exe, None);
}
/// The host's own findings outrank a hint. A provider that guessed wrong (or a stale export)
/// must not be able to point the matcher — and therefore the termination ladder — at something
/// other than what the store itself reported.
#[test]
fn a_hint_only_fills_gaps() {
let found = DetectSpec::dir("/games/real");
let hint = DetectHint {
install_dir: Some("/games/wrong".into()),
exe: Some("/games/real/run".into()),
process_name: None,
};
let merged = found.or_hint(&hint);
assert_eq!(
merged.install_dir.as_deref(),
Some(Path::new("/games/real")),
"the store's own answer stands"
);
assert_eq!(
merged.exe.as_deref(),
Some(Path::new("/games/real/run")),
"but a field the store had nothing for is filled in"
);
// Nothing found + nothing hinted stays untrackable.
assert!(DetectSpec::default()
.or_hint(&DetectHint::default())
.is_empty());
}
#[test]
fn first_token_handles_quotes_and_spaces() {
assert_eq!(
shell_first_token("\"/opt/My Game/run\" -w").as_deref(),
Some("/opt/My Game/run")
);
assert_eq!(
shell_first_token("'/opt/My Game/run'").as_deref(),
Some("/opt/My Game/run")
);
assert_eq!(shell_first_token(" plain --x").as_deref(), Some("plain"));
assert_eq!(shell_first_token("").as_deref(), None);
// An unterminated quote yields nothing rather than a bogus token.
assert_eq!(shell_first_token("\"/opt/oops").as_deref(), None);
}
}
+11
View File
@@ -88,6 +88,16 @@ fn epic_entry(
} else {
app_name.clone()
};
// Detect signals: the manifest's own `LaunchExecutable` (relative to the install dir) is exact
// when present; the install dir covers the rest (Epic hands off to its launcher, so the host
// never owns the game's process).
let detect = match s("LaunchExecutable")
.map(|rel| Path::new(install).join(rel))
.filter(|p| p.is_file())
{
Some(exe) => DetectSpec::exe(exe).with_dir(install),
None => DetectSpec::dir(install),
};
Some(GameEntry {
provider: None,
id: format!("epic:{app_name}"),
@@ -98,6 +108,7 @@ fn epic_entry(
kind: "epic".into(),
value,
}),
detect,
})
}
+4
View File
@@ -52,6 +52,9 @@ fn gog_games() -> Vec<GameEntry> {
// Art (public api.gog.com) is resolved off the hot path by the background warmer; read
// whatever it has cached (title-only until warmed).
let art = cached_art(&id).unwrap_or_default();
// GOG launches the game's exe directly (no Galaxy), so the host owns the process and the
// spec is only the fallback for a stub launcher that hands off; both signals are exact here.
let detect = DetectSpec::exe(&exe).with_dir(&path);
out.push(GameEntry {
provider: None,
id,
@@ -62,6 +65,7 @@ fn gog_games() -> Vec<GameEntry> {
kind: "gog".into(),
value: format!("{exe}\t{args}\t{workdir}"),
}),
detect,
});
}
out
+11 -4
View File
@@ -72,14 +72,16 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
{
continue; // the cache also lists owned-but-not-installed titles
}
let install_ok = g
// The install dir doubles as this title's detect signal (Heroic hands off to
// legendary/gogdl/nile, so the host never sees the game's own process any other way).
let install_path = g
.get("install")
.and_then(|i| i.get("install_path"))
.and_then(|p| p.as_str())
.is_some_and(|p| Path::new(p).is_dir());
if !install_ok {
.filter(|p| Path::new(p).is_dir());
let Some(install_path) = install_path else {
continue;
}
};
let Some(app_name) = g
.get("app_name")
.and_then(|v| v.as_str())
@@ -115,6 +117,11 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
kind: "heroic".into(),
value: format!("{runner}:{app_name}"),
}),
// The install dir is the reliable signal. `HEROIC_APP_NAME` is also stamped on the game's
// env by Heroic's launch path; it is carried as a second, cheap signal (a union — if a
// Heroic version doesn't set it, the install dir still matches).
detect: DetectSpec::dir(install_path)
.with_env("HEROIC_APP_NAME", Some(app_name.to_string())),
});
}
Ok(games)
+100 -47
View File
@@ -8,27 +8,71 @@ use super::*;
#[cfg(windows)]
use super::{epic::epic_launch_uri, gog::gog_spawn};
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell
/// command the host should run for it — looked up in the host's OWN library so a client can only
/// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a
/// malformed Steam appid.
/// Everything a session needs about the title it is launching, resolved in **one** library scan:
/// what to run, what to call it, and how to recognize it once it is running.
///
/// **Linux only**: the resolved command is run nested inside the per-session gamescope. On Windows
/// there is no gamescope to nest into; the host launches a title into the interactive user session
/// via [`launch_title`] instead.
/// Enumerating the library touches every installed store's on-disk metadata, so the launch path
/// resolves this once at handshake time and threads it into the data plane rather than looking the
/// same id up again per use.
pub struct LaunchTarget {
/// Identity for the status surface and the `game.*` events.
pub game: crate::gamelease::GameRef,
/// How to recognize the running game ([`DetectSpec`]); empty when the store offers nothing.
pub detect: DetectSpec,
/// The resolved shell command. `Some` on Linux (where the host runs it); `None` on Windows,
/// which launches by library id through the interactive-session spawner instead.
pub command: Option<String>,
}
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`, or carried on a
/// GameStream catalog entry) against the host's **own** library — so a client can only pick an
/// existing title, never inject a command. `None` = unknown id, or — on Linux — a title with no
/// runnable recipe.
///
/// This is the single lookup, shared by both planes: the client sends only an id, and everything the
/// session does with the title afterwards comes from what the host itself knows about it.
///
/// **Linux**: the resolved command is run by the host (nested into a per-session gamescope, or
/// spawned into the live session). **Windows** has no gamescope to nest into and resolves the
/// concrete process at launch time instead, via [`launch_title`].
pub fn resolve_launch(id: &str) -> Option<LaunchTarget> {
let entry = all_games().into_iter().find(|g| g.id == id)?;
let game = crate::gamelease::GameRef {
id: Some(entry.id.clone()),
store: Some(entry.store.clone()),
title: entry.title.clone(),
};
#[cfg(not(windows))]
{
// Linux runs the command itself, so a title without one has nothing to launch — same answer
// (and same warning path) as before this resolution existed.
let command = entry.launch.as_ref().and_then(command_for)?;
Some(LaunchTarget {
game,
detect: entry.detect,
command: Some(command),
})
}
#[cfg(windows)]
{
// Windows resolves the concrete process at launch time (`launch_title`), which is also where
// a missing recipe is reported — so an entry with no Windows recipe still yields a target and
// the existing warning fires there.
Some(LaunchTarget {
game,
detect: entry.detect,
command: None,
})
}
}
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
/// [`resolve_launch`], split out so the appid-validation can be tested without a Steam install).
///
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
#[cfg(not(windows))]
pub fn launch_command(id: &str) -> Option<String> {
let spec = all_games().into_iter().find(|g| g.id == id)?.launch?;
command_for(&spec)
}
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
/// [`launch_command`], split out so the appid-validation can be tested without a Steam install).
#[cfg(not(windows))]
fn command_for(spec: &LaunchSpec) -> Option<String> {
match spec.kind.as_str() {
"steam_appid" => valid_steam_appid(&spec.value)
@@ -47,7 +91,7 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
}
/// Windows: launch a store-qualified library id into the **interactive user session** — the Windows
/// analogue of the Linux gamescope-nested [`launch_command`]. The id is resolved against the host's
/// analogue of the Linux gamescope-nested [`resolve_launch`]. The id is resolved against the host's
/// OWN library (the client never sends a command), mapped to a concrete process by
/// [`windows_launch_for`], and spawned via [`crate::interactive::spawn_in_active_session`].
///
@@ -166,16 +210,31 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
/// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user
/// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can
/// only ever pick an existing title — never inject a command. Linux resolves the id via
/// [`launch_command`] and goes through [`launch_session_command`] instead.
/// [`resolve_launch`] and goes through [`launch_session_command`] instead.
#[cfg(windows)]
pub fn launch_gamestream_library(id: &str) -> Result<()> {
launch_title(id)
}
/// The child a session launch produced.
///
/// Handed back to the caller so the game's lifetime can be tracked
/// (design/session-game-lifetime.md) instead of the process being forgotten the moment it starts.
#[cfg(target_os = "linux")]
pub struct SpawnedLaunch {
pub child: std::process::Child,
/// Whether the child leads its own process group — true for the plain session spawn in [`launch_session_command`], which
/// deliberately creates one so the whole wrapper tree can be signalled as a unit. False for the
/// gamescope-session spawn, which shares the host's group (see
/// [`crate::gamelease::OwnedChild::group_leader`]: a non-leader must never be signalled by
/// negative pid).
pub group_leader: bool,
}
/// Launch a resolved shell command into the **live Linux session** for the session's compositor —
/// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called
/// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved
/// (a library id via [`launch_command`], or an operator-typed apps.json/custom command) — never a
/// (a library id via [`resolve_launch`], or an operator-typed apps.json/custom command) — never a
/// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed)
/// desktop/session rather than tearing the stream down.
///
@@ -190,18 +249,29 @@ pub fn launch_gamestream_library(id: &str) -> Result<()> {
/// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope
/// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`).
#[cfg(target_os = "linux")]
pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str) -> Result<()> {
pub fn launch_session_command(
compositor: crate::vdisplay::Compositor,
cmd: &str,
) -> Result<SpawnedLaunch> {
use std::os::unix::process::CommandExt;
let cmd = cmd.trim();
anyhow::ensure!(!cmd.is_empty(), "empty command");
let child = match compositor {
let (child, group_leader) = match compositor {
crate::vdisplay::Compositor::Gamescope => {
crate::vdisplay::launch_into_gamescope_session(cmd)?
(crate::vdisplay::launch_into_gamescope_session(cmd)?, false)
}
_ => std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.spawn()
.context("spawn launch command")?,
_ => (
std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
// Its own process group, so ending this game later signals the shell *and* the game
// it exec'd or forked — the whole tree the host started — and nothing else. Also
// detaches it from any signal the host's own group receives.
.process_group(0)
.spawn()
.context("spawn launch command")?,
true,
),
};
tracing::info!(
command = %cmd,
@@ -209,27 +279,10 @@ pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str
compositor = compositor.id(),
"launched app into the live session"
);
Ok(())
}
/// Resolve the launch command for a session app selection on Linux: a store-qualified library id
/// (from either plane) wins, else the operator-typed command. `None` = nothing to launch (or an
/// unknown/recipe-less id — warned, so a client picking a stale title sees why nothing started).
#[cfg(target_os = "linux")]
pub fn resolve_session_launch(library_id: Option<&str>, command: Option<&str>) -> Option<String> {
if let Some(id) = library_id {
match launch_command(id) {
Some(cmd) => return Some(cmd),
None => tracing::warn!(
launch_id = id,
"requested launch id not in this host's library (or no launch recipe) — ignoring"
),
}
}
command
.map(str::trim)
.filter(|c| !c.is_empty())
.map(str::to_string)
Ok(SpawnedLaunch {
child,
group_leader,
})
}
#[cfg(test)]
+24 -5
View File
@@ -55,20 +55,33 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)?
};
let mut stmt = conn.prepare(
"SELECT id, slug, name FROM games \
// `directory` (the game's install dir — our detect signal) is not load-bearing for the library, so
// a pga.db schema without it must not cost the whole Lutris store: try the richer query first and
// fall back to the historical one on any prepare error.
const SELECT_WITH_DIR: &str = "SELECT id, slug, name, directory FROM games \
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
ORDER BY name COLLATE NOCASE",
)?;
ORDER BY name COLLATE NOCASE";
const SELECT_PLAIN: &str = "SELECT id, slug, name, NULL FROM games \
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
ORDER BY name COLLATE NOCASE";
let mut stmt = match conn.prepare(SELECT_WITH_DIR) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "lutris pga.db has no `directory` column — listing without \
install dirs (game-exit detection unavailable for Lutris titles)");
conn.prepare(SELECT_PLAIN)?
}
};
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
))
})?;
let mut games = Vec::new();
for (id, slug, name) in rows.flatten() {
for (id, slug, name, directory) in rows.flatten() {
games.push(GameEntry {
provider: None,
id: format!("lutris:{id}"),
@@ -79,6 +92,12 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
kind: "lutris_id".into(),
value: id.to_string(),
}),
// Lutris stamps no per-game env marker we can rely on, so the install dir is the whole
// recipe; a game with none (an emulator entry pointing at a bare ROM) stays untracked.
detect: directory
.filter(|d| !d.trim().is_empty())
.map(DetectSpec::dir)
.unwrap_or_default(),
});
}
Ok(games)
+57 -13
View File
@@ -17,25 +17,32 @@ impl LibraryProvider for SteamProvider {
}
fn list(&self) -> Vec<GameEntry> {
let mut by_appid: std::collections::BTreeMap<u32, String> = Default::default();
let mut by_appid: std::collections::BTreeMap<u32, Installed> = Default::default();
for steamapps in steam_library_dirs() {
for (appid, name) in scan_manifests(&steamapps) {
by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids
for app in scan_manifests(&steamapps) {
// First library wins; dedups appids present in several libraries.
by_appid.entry(app.appid).or_insert(app);
}
}
let mut games: Vec<GameEntry> = by_appid
.into_iter()
.filter(|(appid, name)| !is_steam_tool(*appid, name))
.map(|(appid, title)| GameEntry {
.into_values()
.filter(|app| !is_steam_tool(app.appid, &app.name))
.map(|app| GameEntry {
provider: None,
id: format!("steam:{appid}"),
id: format!("steam:{}", app.appid),
store: "steam".into(),
title,
art: steam_art(appid),
art: steam_art(app.appid),
launch: Some(LaunchSpec {
kind: "steam_appid".into(),
value: appid.to_string(),
value: app.appid.to_string(),
}),
// The appid alone is authoritative on Linux (Steam's launch reaper); the install dir
// is what the Windows matcher — which has no reaper to watch — keys off instead.
detect: match app.install_dir {
Some(dir) => DetectSpec::steam(app.appid).with_dir(dir),
None => DetectSpec::steam(app.appid),
},
title: app.name,
})
.collect();
// Non-Steam shortcuts have no `appmanifest` — [`scan_manifests`] can't see them, so the
@@ -274,8 +281,17 @@ fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
Some(&after[..after.find('"')?])
}
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → (appid, name) of installed titles.
fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> {
/// One installed Steam title, as read from its `appmanifest_<appid>.acf`.
struct Installed {
appid: u32,
name: String,
/// `<steamapps>/common/<installdir>`, when the manifest names one and it exists on disk — the
/// game's own files, used to recognize its processes ([`DetectSpec::install_dir`]).
install_dir: Option<PathBuf>,
}
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → the installed titles it describes.
fn scan_manifests(steamapps: &Path) -> Vec<Installed> {
let Ok(rd) = std::fs::read_dir(steamapps) else {
return Vec::new();
};
@@ -290,7 +306,17 @@ fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> {
let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid"));
let name = text.lines().find_map(|l| vdf_value(l.trim(), "name"));
if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::<u32>), name) {
out.push((appid, name.to_string()));
// `installdir` is a bare folder name relative to this library's `common/`.
let install_dir = text
.lines()
.find_map(|l| vdf_value(l.trim(), "installdir"))
.map(|d| steamapps.join("common").join(d))
.filter(|p| p.is_dir());
out.push(Installed {
appid,
name: name.to_string(),
install_dir,
});
}
}
}
@@ -318,6 +344,9 @@ struct Shortcut {
appid: u32,
/// Display name (`AppName`).
name: String,
/// The shortcut's target (`Exe`), as stored — Steam quotes it. This *is* the game (a shortcut
/// points straight at it, with no launcher in between), so it doubles as the detect signal.
exe: String,
/// Whether Steam has this shortcut hidden from the library (`IsHidden`) — we honor that.
hidden: bool,
}
@@ -361,9 +390,22 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
kind: "steam_appid".into(),
value: shortcut_gameid(sc.appid).to_string(),
}),
detect: shortcut_detect(&sc.exe),
})
}
/// Detect signals for a non-Steam shortcut: its `Exe` target is the game itself, so the executable
/// (and its folder, which catches a launcher script that execs a sibling binary) identifies it. Steam
/// stores the target quoted and may include trailing arguments; only an existing absolute path is
/// asserted — a guess would be worse than no tracking at all.
fn shortcut_detect(exe: &str) -> DetectSpec {
let mut spec = crate::library::spec_from_command(exe);
if let Some(dir) = spec.exe.as_deref().and_then(Path::parent) {
spec.install_dir = Some(dir.to_path_buf());
}
spec
}
/// Every `userdata/<id>/config/shortcuts.vdf` under each Steam root — one file per Steam account
/// that has signed in on this host.
fn shortcuts_files() -> Vec<PathBuf> {
@@ -489,6 +531,7 @@ fn parse_one_shortcut(buf: &[u8], pos: &mut usize) -> Option<Shortcut> {
Some(Shortcut {
appid,
name,
exe,
hidden,
})
}
@@ -704,6 +747,7 @@ mod tests {
let sc = Shortcut {
appid: 2_456_789_012,
name: "My Emulator".into(),
exe: "\"/opt/emu/run.sh\"".into(),
hidden: false,
};
let entry = shortcut_entry(sc).unwrap();
@@ -78,6 +78,9 @@ fn xbox_games() -> Vec<GameEntry> {
kind: "aumid".into(),
value: format!("{pfn}!{app_id}"),
}),
// AUMID activation goes through the shell, so the host never owns the process: the
// title's `Content` dir (which holds the game's binaries) is the detect signal.
detect: DetectSpec::dir(title_dir.join("Content")),
});
}
}
+22 -1
View File
@@ -41,6 +41,13 @@ mod encode {
pub(crate) use pf_encode::*;
}
mod events;
// The lifetime of a launched game: whether it is running, when it exits (which can end the session),
// and how to end it (which a session ending can ask for) — design/session-game-lifetime.md.
mod gamelease;
// The Win32 half of ending a game: WM_CLOSE onto the interactive desktop, then TerminateProcess.
#[cfg(target_os = "windows")]
#[path = "windows/game_term.rs"]
mod game_term;
mod gamestream;
#[cfg(target_os = "linux")]
#[path = "linux/gpuclocks.rs"]
@@ -66,11 +73,17 @@ mod native;
mod native_pairing;
mod pipeline;
mod plugins;
// Finding a launched game's processes from its store's detect signals — the read side of the
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
mod procscan;
mod send_pacing;
#[cfg(target_os = "windows")]
#[path = "windows/service.rs"]
mod service;
mod session_plan;
// Operator policy for the session⇄game lifetime binding (`session-settings.json`).
mod session_settings;
mod session_status;
mod sleep_inhibit;
mod spike;
@@ -338,7 +351,15 @@ fn real_main() -> Result<()> {
// adapter, which may not be the one that encodes).
let mut size = (64u32, 64u32);
let mut vendor = None;
for a in args.iter().skip(2) {
// `args` starts AT the subcommand (main builds it with `env::args().skip(1)`), so the
// optional arguments begin at index 1 — cf. `args.get(1)` in the `service` arm. This
// read `skip(2)` and so silently swallowed the first optional argument: the documented
// `hdr-p010-selftest 1920x1080 nvidia` picked up the vendor but ran at the 64x64
// DEFAULT, and a size-only invocation parsed nothing at all and still printed PASS.
// The size is the whole point of the flag — 1080 is not 16-aligned and takes a
// different driver path — so the self-test was passing on the one geometry that
// exercises the least.
for a in args.iter().skip(1) {
match a.as_str() {
"intel" => vendor = Some(0x8086),
"nvidia" => vendor = Some(0x10de),
+5
View File
@@ -214,6 +214,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(native::deny_pending_device))
.routes(routes!(session::stop_session))
.routes(routes!(session::request_idr))
.routes(routes!(
session::get_session_settings,
session::set_session_settings
))
.routes(routes!(session::end_game))
.routes(routes!(library::get_library))
.routes(routes!(library::list_library_scanners))
.routes(routes!(library::set_library_scanner))
+30
View File
@@ -66,6 +66,11 @@ pub(crate) struct GpuState {
/// `PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is
/// `auto`; a manual preference overrides it.
env_override: Option<String>,
/// `PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`
/// (e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected
/// GPU is overridden at session open — the adapter wins — so the console can warn that the
/// pin is stale rather than letting the selection look broken.
encoder_pin: Option<String>,
/// The GPU the next session will use.
selected: Option<ApiSelectedGpu>,
/// The GPU live sessions use right now (absent while nothing is streaming).
@@ -143,11 +148,36 @@ pub(crate) fn gpu_state() -> GpuState {
.render_adapter
.clone()
.filter(|s| !s.is_empty()),
encoder_pin: encoder_pin_of(&pf_host_config::config().encoder_pref),
selected,
active,
}
}
/// The `PUNKTFUNK_ENCODER` value worth surfacing to the console: `None` for unset/empty and for
/// an explicit `auto` (both mean "derive from the selected adapter" — nothing is pinned).
fn encoder_pin_of(pref: &str) -> Option<String> {
match pref {
"" | "auto" => None,
pin => Some(pin.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Only a real pin surfaces to the console — the `auto` spellings pin nothing, and showing
/// them would put a permanent scary note under every default install's GPU card.
#[test]
fn encoder_pin_surfaces_only_real_pins() {
assert_eq!(encoder_pin_of(""), None);
assert_eq!(encoder_pin_of("auto"), None);
assert_eq!(encoder_pin_of("qsv"), Some("qsv".into()));
assert_eq!(encoder_pin_of("software"), Some("software".into()));
}
}
/// GPU inventory and selection
///
/// Lists the host's hardware GPUs, the persisted auto/manual preference, the GPU the next session
+59
View File
@@ -112,6 +112,38 @@ pub(crate) struct RuntimeStatus {
/// The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's
/// mode/codec/bitrate. `null` when nothing is streaming.
stream: Option<StreamInfo>,
/// Every launched game the host is tracking: one row per live session that launched a title, plus
/// any game whose session has ended and which is waiting out its reconnect window before being
/// ended (`state: "grace"`). Empty when nothing was launched — a plain desktop stream has no game.
games: Vec<ActiveGame>,
}
/// One launched game, for the console's running-game card.
#[derive(Serialize, ToSchema)]
pub(crate) struct ActiveGame {
/// The session streaming it; `null` for a game waiting out its reconnect window.
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<u64>,
/// Client-supplied device name of the session that launched it; may be empty.
client: String,
/// Store-qualified library id (`steam:570`) — the key the console matches against `GET /library`
/// to show box art. Absent for an operator-typed GameStream command.
#[serde(skip_serializing_if = "Option::is_none")]
app_id: Option<String>,
/// Display title.
title: String,
/// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known.
#[serde(skip_serializing_if = "Option::is_none")]
store: Option<String>,
/// `native` or `gamestream`.
plane: crate::events::Plane,
/// `launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is
/// gone and it will be ended when the reconnect window closes).
#[schema(example = "running")]
state: String,
/// Seconds until this game is ended — only present on a `grace` row.
#[serde(skip_serializing_if = "Option::is_none")]
grace_remaining_s: Option<u64>,
}
/// Client-requested launch parameters (key material is never exposed here).
@@ -175,6 +207,13 @@ pub(crate) struct LocalSummary {
/// the tray/console surface them so the clash is visible before pairing silently fails.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
conflicts: Vec<String>,
/// Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).
///
/// The countdown form is the one that matters: it means the game's client is gone and the host
/// will end the game when the window closes — something a user at the machine should be able to
/// see (and stop) without opening the console. Empty when nothing was launched.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
games: Vec<String>,
}
/// Liveness probe
@@ -373,6 +412,19 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
active_sessions: native.len() as u32 + u32::from(gs_video),
session,
stream,
games: crate::session_status::games()
.into_iter()
.map(|g| ActiveGame {
session_id: g.session_id,
client: g.client,
app_id: g.app_id,
title: g.title,
store: g.store,
plane: g.plane,
state: g.state.to_string(),
grace_remaining_s: g.grace_remaining_s,
})
.collect(),
})
}
@@ -444,5 +496,12 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
// Cached at `serve` startup (empty when nothing was detected / never scanned) — no per-poll
// process enumeration.
conflicts: crate::detect::summary_labels(crate::detect::snapshot()),
games: crate::session_status::games()
.into_iter()
.map(|g| match g.grace_remaining_s {
Some(left) => format!("{} (closing in {}:{:02})", g.title, left / 60, left % 60),
None => g.title,
})
.collect(),
})
}
+125 -2
View File
@@ -8,6 +8,9 @@ use std::sync::atomic::Ordering;
///
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
/// state. Idempotent — succeeds even when nothing is streaming.
///
/// Counts as a **deliberate** stop, exactly like a client pressing Stop: the display skips its
/// keep-alive linger, and the end-game-on-session-end policy (if the operator enabled one) applies.
#[utoipa::path(
delete,
path = "/session",
@@ -19,11 +22,11 @@ use std::sync::atomic::Ordering;
)
)]
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
let was_streaming = st.app.end_session("management API stop");
let was_streaming = st.app.quit_session("management API stop");
// Native plane: the GameStream teardown above doesn't reach it (it runs its own loops off the shared
// session registry), so signal every live native session to tear down too.
let native = crate::session_status::count();
crate::session_status::stop_all();
crate::session_status::stop_all_quit();
tracing::info!(
was_streaming,
native_sessions = native,
@@ -32,6 +35,126 @@ pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode
StatusCode::NO_CONTENT
}
/// End a launched game
///
/// Ends a game whose session has already gone and which is waiting out its reconnect window — the
/// console's "End now" for a game the host is about to close anyway. `app_id` picks one title; omit it
/// to end every waiting game.
///
/// This does **not** touch a game whose session is still live: ending that is session management
/// (`DELETE /session`), and how the game is treated then follows the operator's policy.
#[utoipa::path(
post,
path = "/game/end",
tag = "session",
operation_id = "endGame",
request_body = EndGameRequest,
responses(
(status = OK, description = "How many waiting games were ended", body = EndGameResult),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = CONFLICT, description = "No game is waiting to be ended", body = ApiError),
)
)]
pub(crate) async fn end_game(ApiJson(req): ApiJson<EndGameRequest>) -> Response {
let ended = crate::gamelease::end_pending(req.app_id.as_deref());
if ended == 0 {
return api_error(StatusCode::CONFLICT, "no game is waiting to be ended");
}
tracing::info!(app_id = ?req.app_id, ended, "management API: game ended");
Json(EndGameResult { ended }).into_response()
}
/// Request body for `endGame`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct EndGameRequest {
/// Store-qualified library id (`steam:570`) to end; omit to end every waiting game.
#[serde(default)]
pub app_id: Option<String>,
}
/// Result of an `endGame`.
#[derive(Serialize, ToSchema)]
pub(crate) struct EndGameResult {
/// How many waiting games were ended.
ended: usize,
}
/// The session⇄game lifetime settings, plus which axes this build acts on.
#[derive(Serialize, ToSchema)]
pub(crate) struct SessionSettingsState {
/// The stored settings (or the built-in defaults when this host has never been configured).
settings: crate::session_settings::SessionSettings,
/// Whether an operator has ever saved these settings (`false` ⇒ `settings` are the defaults).
configured: bool,
/// Which fields this build actually enforces. Empty on a platform with no launch path (macOS),
/// so the console can say so instead of offering a switch that does nothing.
enforced: Vec<String>,
}
fn session_settings_state() -> SessionSettingsState {
let store = crate::session_settings::store();
SessionSettingsState {
settings: store.get(),
configured: store.configured(),
enforced: crate::session_settings::enforced(),
}
}
/// Session⇄game lifetime settings
///
/// Whether a launched game's exit ends the streaming session, and whether a session ending ends the
/// game (with the reconnect window that protects a dropped client's unsaved progress). See
/// `design/session-game-lifetime.md`.
#[utoipa::path(
get,
path = "/session/settings",
tag = "session",
operation_id = "getSessionSettings",
responses(
(status = OK, description = "Stored settings + which axes this build enforces", body = SessionSettingsState),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_session_settings() -> Json<SessionSettingsState> {
Json(session_settings_state())
}
/// Set the session⇄game lifetime settings
///
/// Persists the settings (clamped) and applies them from the next decision — including to a session
/// that is already streaming, since the policy is read when a session ends rather than when it starts.
#[utoipa::path(
put,
path = "/session/settings",
tag = "session",
operation_id = "setSessionSettings",
request_body = crate::session_settings::SessionSettings,
responses(
(status = OK, description = "Settings stored; the new state", body = SessionSettingsState),
(status = BAD_REQUEST, description = "Malformed settings body", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Settings could not be persisted", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn set_session_settings(
ApiJson(settings): ApiJson<crate::session_settings::SessionSettings>,
) -> Response {
if let Err(e) = crate::session_settings::store().set(settings) {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("persist session settings: {e:#}"),
);
}
let state = session_settings_state();
tracing::info!(
game_on_session_end = state.settings.game_on_session_end.as_str(),
session_on_game_exit = state.settings.session_on_game_exit,
grace_s = state.settings.disconnect_grace_seconds,
"management API: session⇄game lifetime settings updated"
);
Json(state).into_response()
}
/// Force a keyframe
///
/// Asks the encoder for an IDR frame on the active video stream (what a client requests
+21 -11
View File
@@ -306,17 +306,20 @@ fn fake_native_session(
fps: u32,
) -> crate::session_status::LiveSessionGuard {
let packed = ((width as u64) << 32) | ((height as u64) << 16) | fps as u64;
crate::session_status::register(
Arc::new(std::sync::atomic::AtomicU64::new(packed)),
Arc::new(std::sync::atomic::AtomicU32::new(20_000)),
Codec::H265,
Arc::new(std::sync::atomic::AtomicBool::new(false)),
Arc::new(std::sync::atomic::AtomicBool::new(false)),
"test-client".into(),
false,
Arc::new(std::sync::atomic::AtomicU32::new(0)),
Arc::new(std::sync::atomic::AtomicU32::new(0)),
)
crate::session_status::register(crate::session_status::Registration {
mode: Arc::new(std::sync::atomic::AtomicU64::new(packed)),
bitrate_kbps: Arc::new(std::sync::atomic::AtomicU32::new(20_000)),
codec: Codec::H265,
stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
quit: Arc::new(std::sync::atomic::AtomicBool::new(false)),
force_idr: Arc::new(std::sync::atomic::AtomicBool::new(false)),
client: "test-client".into(),
hdr: false,
ttff_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)),
last_resize_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)),
// No launch: a desktop stream, which must show no game row.
game: None,
})
}
/// A native (punktfunk/1) session — the DEFAULT plane — must read as streaming in the tray's
@@ -1234,6 +1237,13 @@ async fn gpu_endpoints_list_and_validate() {
assert_eq!(s, StatusCode::OK);
assert!(b["gpus"].is_array());
assert!(b["mode"].is_string());
// The host.env encoder pin is part of the schema (null when nothing is pinned) — the
// console warns off it when a pin contradicts the selected GPU (the pin is overridden at
// session open, and without this field the selection would just look broken).
assert!(
b.as_object().unwrap().contains_key("encoder_pin"),
"listGpus must carry encoder_pin"
);
// Unknown mode → 400.
let (s, _) = send(
+40 -19
View File
@@ -1012,9 +1012,10 @@ async fn serve_session(
// just never fires then.
let (cursor_shape_tx, cursor_shape_rx) =
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
// Negotiated cursor forwarding: MUST match the HOST_CAP_CURSOR bit the Welcome advertised
// (handshake::cursor_forward is the single predicate both read).
let cursor_forward = handshake::cursor_forward(hello.client_caps, compositor);
// Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
// rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
// blend-capability gate — re-running it here could drift, and would re-probe).
let cursor_forward = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
// Who renders the pointer RIGHT NOW (client `CursorRenderMode`, flipped live by the mouse-
// model chord): `true` = client draws (exclude + forward), `false` = host composites (the
// capture model). Starts true — the pre-message behavior for cap sessions. Control task
@@ -1302,24 +1303,43 @@ async fn serve_session(
// to its shell command HERE against the host's own library — a client can only ever pick an
// existing title, never send a command — and the data plane runs it per-backend (nested into a
// bare-spawn gamescope, or spawned into the live session once capture is up).
// ONE library lookup for the whole session: enumerating the installed stores touches every
// launcher's on-disk metadata, and the data plane needs three things out of it — what to run, what
// to call the title, and how to recognize its process once a launcher has handed off
// (design/session-game-lifetime.md §4).
let launch_target =
hello
.launch
.as_deref()
.and_then(|id| match crate::library::resolve_launch(id) {
Some(t) => {
tracing::info!(
launch_id = id,
title = %t.game.title,
command = t.command.as_deref().unwrap_or("-"),
"resolved library launch for this session"
);
Some(t)
}
None => {
tracing::warn!(
launch_id = id,
"client requested a launch id not in this host's library — ignoring"
);
None
}
});
#[cfg(target_os = "windows")]
let launch_for_dp = hello.launch.clone();
let launch_for_dp = launch_target.as_ref().and(hello.launch.clone());
#[cfg(not(target_os = "windows"))]
let launch_for_dp = hello.launch.as_deref().and_then(|id| {
match crate::library::launch_command(id) {
Some(cmd) => {
tracing::info!(launch_id = id, command = %cmd, "resolved library launch for this session");
Some(cmd)
}
None => {
tracing::warn!(
launch_id = id,
"client requested a launch id not in this host's library — ignoring"
);
None
}
}
});
let launch_for_dp = launch_target.as_ref().and_then(|t| t.command.clone());
// A client reconnecting inside its game's reconnect window takes the game back: nothing is ended,
// and this session adopts it. Matched on (this client, this title) so it can only ever reclaim its
// own game.
if let Some(target) = launch_target.as_ref() {
let fp = punktfunk_core::quic::endpoint::peer_fingerprint(&conn).map(hex::encode);
crate::gamelease::readopt(fp.as_deref(), target.game.id.as_deref());
}
// Per-title prep steps (RFC §6) for a launched CUSTOM library title: run synchronously
// before the data plane starts (so before the display opens and the title spawns); the
// guard's drop — any serve_session exit — runs the undos in reverse, best-effort.
@@ -1465,6 +1485,7 @@ async fn serve_session(
stats: stats_dp,
client_label,
launch: launch_for_dp,
launch_target,
client_hdr,
bringup: bringup_dp,
resize_ms: resize_ms_dp,
+46 -17
View File
@@ -12,31 +12,46 @@ use super::*;
/// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the
/// capture path can deliver cursor metadata separately from the frame — the Linux portal
/// `SPA_META_Cursor` path (not gamescope, whose capture paints no cursor at all), or Windows
/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c). THE single
/// predicate: the Welcome's `HOST_CAP_CURSOR` bit and the session's forwarding/blend-off
/// wiring both read it, so they can never disagree.
/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c) — AND, on
/// Linux, the encode backend this session resolves to can composite the pointer on demand
/// (`encode::cursor_blend_capable`): the channel's capture-mouse flip (`CursorRenderMode`,
/// `client_draws = false`) makes the HOST draw the pointer, and on Linux the encoder is that
/// compositing stage — granting the channel over a backend that can't blend (libav
/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied, the
/// session keeps the pre-channel path: the compositor EMBEDS the pointer and the client never
/// draws — never cursorless, never doubled. THE single predicate: the Welcome's
/// `HOST_CAP_CURSOR` bit is computed from it, and the session wiring reads that bit back.
pub(super) fn cursor_forward(
client_caps: u8,
compositor: Option<crate::vdisplay::Compositor>,
codec: crate::encode::Codec,
bit_depth: u8,
) -> bool {
if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 {
return false;
}
#[cfg(target_os = "linux")]
{
// CUDA-payload prediction — the same one `SessionPlan` makes: the NVIDIA resolution
// plus the zero-copy master switch. It decides direct-SDK NVENC (blends) vs libav
// NVENC (doesn't) inside the capability mirror.
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope)
&& crate::encode::cursor_blend_capable(codec, cuda_planned, bit_depth == 10)
}
#[cfg(target_os = "windows")]
{
// Windows (M2c): the pf-vdisplay driver must speak the v5 hardware-cursor channel —
// DWM composites the pointer into the IDD frame otherwise, and forwarding a second
// copy would double it. The probe latches by opening the control device once.
let _ = compositor;
// copy would double it. The probe latches by opening the control device once. The
// encoder is deliberately NOT consulted: the IDD capturer itself composites on the
// capture-mouse flip (`set_cursor_forward`), so no Windows encode backend blends.
let _ = (compositor, codec, bit_depth);
crate::vdisplay::manager::hw_cursor_capable()
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
let _ = compositor;
let _ = (compositor, codec, bit_depth);
false
}
}
@@ -190,12 +205,14 @@ pub(super) async fn negotiate(
// (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on
// whether the launch id actually RESOLVES to a command in the host's library — an unknown
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
// (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.)
// (review #9). (dedicated is Linux-only, and only there does `resolve_launch` carry a
// command — on Windows the concrete process is resolved at launch time instead.)
#[cfg(not(target_os = "windows"))]
let has_resolvable_launch = hello
.launch
.as_deref()
.and_then(crate::library::launch_command)
.and_then(crate::library::resolve_launch)
.and_then(|t| t.command)
.is_some();
#[cfg(target_os = "windows")]
let has_resolvable_launch = false;
@@ -252,6 +269,12 @@ pub(super) async fn negotiate(
// *monitor* streams only — the GameStream portal-mirror path uses that; see
// `gamestream::host_hdr_capable`), so a Linux native session honestly stays 8-bit SDR even
// though `can_encode_10bit` now probes true on a Main10-capable GPU.
//
// That `false` is also why this plane needs no equivalent of the GameStream path's
// `pf_capture::hdr_capture_failed()` check (rtsp.rs): that latch is a fact about the PORTAL
// capturer's HDR offer, and the native plane captures a virtual output instead — it never
// reaches the portal path, and on Linux it never negotiates 10-bit at all. Revisit both halves
// together if Mutter ever gains HDR for RecordVirtual streams.
let capture_supports_hdr = crate::capture::capturer_supports_hdr();
// The GPU probe may open a tiny encoder on first use, so run it off the reactor like the
// 4:4:4 probe below (blocking probes → spawn_blocking), short-circuited behind the cheap
@@ -291,9 +314,14 @@ pub(super) async fn negotiate(
let host_wants_444 = pf_host_config::config().four_four_four;
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
// The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
// gate. Linux's portal capturer can; the Windows IDD-push path delivers subsampled NV12/P010
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
// gate. Linux's portal capturer always can (`capturer_supports_444` returns `true`
// unconditionally). On WINDOWS the IDD-push path CAN too — for an SDR 4:4:4 session it passes
// the BGRA ring slot straight through, skipping the NV12 VideoConverter — but only a backend
// that ingests RGB and CSCs it to 4:4:4 itself can consume that, so the Windows arm forwards
// `resolved_backend_ingests_rgb_444()` (today: direct-NVENC only; AMF can't 4:4:4 at all and
// the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR display still downgrades to 4:2:0
// at capture time — there is no 10-bit full-chroma source — and the encoder's caps cross-check
// reports that truth. (Replaces the old `single_process` gate — single-process is now the only
// topology, and 4:4:4 routed to DDA, which was removed.)
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real
@@ -488,9 +516,10 @@ pub(super) async fn negotiate(
0
}
// Cursor channel granted (client asked + this capture path can deliver cursor
// metadata out of the frame) — the client turns its local renderer on ONLY when
// it sees this bit, and serve_session wires forwarding from the same predicate.
| if cursor_forward(hello.client_caps, compositor) {
// metadata out of the frame + the resolved encoder can composite on the
// capture-mouse flip) — the client turns its local renderer on ONLY when it sees
// this bit, and serve_session wires forwarding by reading the bit back.
| if cursor_forward(hello.client_caps, compositor, codec, bit_depth) {
punktfunk_core::quic::HOST_CAP_CURSOR
} else {
0
@@ -536,9 +565,9 @@ pub(super) async fn negotiate(
let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::<SessionContext>(1);
let client_identity = endpoint::peer_fingerprint(conn);
let client_hdr = hello.display_hdr;
// Same predicate the Welcome's HOST_CAP_CURSOR bit used — the prepared display and
// the session wiring must agree with what we just advertised.
let cursor_fw = cursor_forward(hello.client_caps, Some(comp));
// The bit the Welcome just advertised — read back rather than recomputed, so the
// prepared display and the session wiring cannot disagree with it.
let cursor_fw = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
let trace = bringup.clone();
std::thread::Builder::new()
+250 -69
View File
@@ -988,6 +988,10 @@ pub(super) struct SessionContext {
/// command already resolved against the host's own library — nested into gamescope's bare spawn
/// via `set_launch_command`, or spawned into the live session once capture is up.
pub(super) launch: Option<String>,
/// Identity + detection metadata for the launched title, resolved once at handshake time
/// alongside `launch`. `None` when nothing was launched. Drives the game's lifetime — its exit
/// can end this session, and this session ending can end it (design/session-game-lifetime.md).
pub(super) launch_target: Option<crate::library::LaunchTarget>,
/// The client display's HDR colour volume (`Hello::display_hdr`; `None` = older client / SDR).
/// Threaded into the vdisplay backend before `create` (→ the pf-vdisplay EDID's CTA HDR block,
/// so host apps tone-map to the client's real panel) and preferred over the generic baseline
@@ -1022,8 +1026,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
// the blend must be built for every gamescope session.
ctx.compositor == pf_vdisplay::Compositor::Gamescope || ctx.cursor_forward,
// the blend must be built for every gamescope session. (`cursor_forward` is already
// blend-gated: `handshake::cursor_forward` grants the channel only where
// `encode::cursor_blend_capable` says the resolved backend composites.)
crate::session_plan::cursor_blend_for(
ctx.cursor_forward,
ctx.compositor == pf_vdisplay::Compositor::Gamescope,
),
ctx.cursor_forward,
);
// gamescope: the XFixes cursor source feeds the always-on composite (Phase C). Set after
@@ -1072,10 +1081,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
stats,
client_label,
launch,
launch_target,
client_hdr,
bringup,
resize_ms,
} = ctx;
// Reference point for adopting the launched game's processes: anything the host will call "this
// session's game" has to have started after this instant. Taken HERE, before the display (and
// therefore before a bare-spawn gamescope's nested child) exists, because a reading taken after
// the launch would reject the very process it is meant to find. Erring early is the safe
// direction: it can only ever include more of our own launch, never a copy from before it.
let launch_stamp = crate::gamelease::launch_clock();
// Streamed-AU wire mode: the client's cap AND the host escape hatch (`PUNKTFUNK_STREAMED_AU=0`
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
@@ -1206,54 +1222,95 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
}
}
#[cfg(target_os = "linux")]
if let Some(cmd) = launch.as_deref() {
if crate::vdisplay::launch_is_nested(compositor) {
let spawned_launch = match launch.as_deref() {
Some(cmd) if crate::vdisplay::launch_is_nested(compositor) => {
tracing::info!(command = %cmd, "launch nested into the per-session gamescope");
} else if let Err(e) = crate::library::launch_session_command(compositor, cmd) {
tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session");
None
}
}
Some(cmd) => match crate::library::launch_session_command(compositor, cmd) {
Ok(spawned) => Some(spawned),
Err(e) => {
tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session");
None
}
},
None => None,
};
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
let _ = &launch;
// Dedicated Steam launch: end the stream cleanly when the LAUNCHED GAME exits. The node-death
// check in the capture-loss branch below can't see this for Steam — the nested `steam` is the
// resident client and stays up after a game quits, so gamescope (and its node) never dies and the
// stream would sit on a hidden Steam session forever. Watch the game process directly (Steam's
// `SteamLaunch AppId=<id>` reaper, whose lifetime == the game's) and close with APP_EXITED when
// it's gone, so a launcher client returns to its library. Non-Steam nested launches keep the
// node-death path (gamescope's child IS the game). Cancelled via `stop` when the session ends for
// another reason first; the thread self-terminates, so we don't join it.
#[cfg(target_os = "linux")]
let _game_watch = launch
.as_deref()
.filter(|_| crate::vdisplay::launch_is_nested(compositor))
.and_then(crate::vdisplay::steam_appid_from_launch)
.map(|appid| {
// The launched game's lifetime, in both directions (design/session-game-lifetime.md):
//
// * **its exit ends this session** — so a client returns to its library instead of sitting on a
// hidden launcher or a bare desktop. This generalizes what used to be a Steam-and-gamescope-only
// watch: the game is now recognized from whatever its store told us (appid, install dir, exe,
// env marker), which covers every compositor and every store. The node-death check in the
// capture-loss branch below stays as the backstop for a nested launch we can't otherwise see.
// * **this session ending can end it** — never by default; only when the operator asked, and for
// a mere disconnect only after a reconnect window (`_game_life`'s drop, below).
let game_lease = launch_target.as_ref().map(|target| {
#[cfg(target_os = "linux")]
let nested = crate::vdisplay::launch_is_nested(compositor);
#[cfg(not(target_os = "linux"))]
let nested = false;
#[cfg(target_os = "linux")]
let child = spawned_launch.map(|s| (s.child, s.group_leader));
#[cfg(not(target_os = "linux"))]
let child = None;
let on_exit: crate::gamelease::OnExit = {
let conn = conn.clone();
let stop = stop.clone();
let quit = quit.clone();
std::thread::Builder::new()
.name("pf1-gamewatch".into())
.spawn(move || {
if crate::vdisplay::watch_steam_game_exit(appid, &stop) {
tracing::info!(
appid,
"dedicated Steam game exited — ending the session cleanly (APP_EXITED)"
);
// Close FIRST so APP_EXITED is the winning close code (quinn keeps the first
// application close), then set the flags: `quit` skips the display lease's
// keep-alive linger and `stop` wakes the encode/send loops out.
conn.close(
punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(),
b"game exited",
);
quit.store(true, Ordering::SeqCst);
stop.store(true, Ordering::SeqCst);
}
})
.ok()
});
Box::new(move || {
// Read the setting at fire time, so flipping it mid-session takes effect. The lease
// itself keeps running either way — the status surface still reports the game.
if !crate::session_settings::get().session_on_game_exit {
tracing::info!(
"the launched game exited, but ending the session on game exit is off — \
leaving the stream up"
);
return;
}
tracing::info!(
"the launched game exited — ending the session cleanly (APP_EXITED)"
);
// Close FIRST so APP_EXITED is the winning close code (quinn keeps the first
// application close), then set the flags: `quit` skips the display lease's
// keep-alive linger and `stop` wakes the encode/send loops out.
conn.close(
punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(),
b"game exited",
);
quit.store(true, Ordering::SeqCst);
stop.store(true, Ordering::SeqCst);
})
};
crate::gamelease::open(
crate::gamelease::LeaseRequest {
game: target.game.clone(),
client: client_label.clone(),
plane: crate::events::Plane::Native,
spec: target.detect.clone(),
nested,
child,
launch_stamp,
},
on_exit,
)
});
let game_shared = game_lease.as_ref().map(|l| l.shared());
// Declared here so it drops *after* the live-session registration below (reverse declaration
// order): `session.ended` fires first, then the game policy runs — the order an operator reading
// the log expects. The fingerprint is what lets a reconnecting client reclaim its own game and
// nothing else.
let _game_life = game_lease.map(|lease| {
crate::gamelease::SessionGuard::new(
lease,
quit.clone(),
endpoint::peer_fingerprint(&conn).map(hex::encode),
)
});
let perf = pf_host_config::config().perf;
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ the cap bursts out
@@ -1324,17 +1381,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// (`GET /status`) shows the native stream — resolution/fps/codec/bitrate resolve live from the
// same handles a mid-stream mode switch / adaptive-bitrate change updates. The guard clears the
// entry when this loop exits (return / `?` / panic), so the Dashboard tracks the session's life.
let _live_session = crate::session_status::register(
live_mode.clone(),
live_bitrate.clone(),
plan.codec,
stop.clone(),
force_idr.clone(),
client_label,
plan.hdr,
bringup.total_slot(),
resize_ms.clone(),
);
let _live_session = crate::session_status::register(crate::session_status::Registration {
mode: live_mode.clone(),
bitrate_kbps: live_bitrate.clone(),
codec: plan.codec,
stop: stop.clone(),
quit: quit.clone(),
force_idr: force_idr.clone(),
client: client_label,
hdr: plan.hdr,
ttff_ms: bringup.total_slot(),
last_resize_ms: resize_ms.clone(),
game: game_shared,
});
// Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit
// PUNKTFUNK_COMPOSITOR pin). It self-baselines and signals the loop below to swap the backend in
@@ -1372,6 +1431,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
let mut cur_mode = mode;
const MAX_CAPTURE_REBUILDS: u32 = 5;
let mut capture_rebuilds: u32 = 0;
// Exclusive-topology eviction generation last seen (Windows IDD-push; see the recovery block
// in the loop): the vdisplay watchdog bumps it on every eviction, each of which drives
// COMMIT_MODES on the live IDD path and orphans this pipeline's capture ring.
#[cfg(target_os = "windows")]
let mut seen_reassert_gen = crate::vdisplay::manager::topology_reassert_gen();
// Encode-stall watchdog: AMF/QSV (and async NVENC) poll non-blocking, so a wedged driver
// shows up as poll() returning None forever while submits keep succeeding — `inflight` grows,
// no AU ever reaches the send thread, and the client freezes on the last frame with nothing
@@ -1600,6 +1664,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
plan,
&quit,
resize_trace.as_ref(),
false,
);
#[cfg(not(target_os = "windows"))]
let fast_done = false;
@@ -1696,6 +1761,55 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
resize_trace.finish("pipeline_rebuilt");
}
}
// Exclusive-topology eviction recovery (Windows IDD-push): the vdisplay watchdog just
// evicted a display that crept back into the "exclusive" desktop, via the full isolate —
// its forced re-commit restarts OS presentation to the virtual display (a gentle
// supplied-config eviction left capture one stashed frame and then nothing, on-glass),
// but it also hands the live IDD path a fresh swap-chain while this pipeline's ring
// keeps waiting on the old attachment; with an unchanged descriptor the poller's
// two-strike debounce never trips, so frames would just stop. Rebuild the capture
// attachment in place at the CURRENT mode (same-mode ring recreate + driver re-attach +
// fresh encoder — the resize fast path's cost). If even that fails, end the session with
// a clear error: the client's reconnect rebuilds from scratch, which beats streaming a
// frozen image forever.
#[cfg(target_os = "windows")]
if plan.capture == crate::session_plan::CaptureBackend::IddPush {
let reassert_gen = crate::vdisplay::manager::topology_reassert_gen();
if reassert_gen != seen_reassert_gen {
seen_reassert_gen = reassert_gen;
tracing::info!(
"exclusive-topology eviction bounced the virtual display's modes — rebuilding \
the capture attachment in place at the current mode"
);
let trace = crate::bringup::Trace::start("reassert-recover", resize_ms.clone());
if try_inplace_resize(
&mut vd,
&mut capturer,
&mut enc,
&mut frame,
&mut interval,
cur_mode,
bitrate_kbps,
bit_depth,
plan,
&quit,
trace.as_ref(),
true,
) {
// The owed AUs died with the old encoder — same bookkeeping as a resize.
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
last_forced_idr = Some(std::time::Instant::now());
trace.finish("pipeline_rebuilt");
} else {
return Err(anyhow!(
"exclusive-topology eviction recovery failed — ending the session for a \
clean reconnect (a fresh bring-up re-attaches capture)"
));
}
}
}
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
// several times while we stream) and retarget the ENCODER ONLY — the mode didn't change,
// so capture and the virtual output are untouched. Preferred lever: an IN-PLACE
@@ -1997,8 +2111,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// write-only variable under `-D warnings` (the `let _ = &launch` idiom above).
#[cfg(not(target_os = "linux"))]
let _ = &cur_node_id;
// Backstop for a nested launch the lease can't recognize (no detect signals): a
// bare-spawn gamescope exits with its child, so its node staying gone means the game
// quit. Honors the same operator setting as the lease's own exit path — with
// end-session-on-game-exit off, a lost capture is just a rebuild.
#[cfg(target_os = "linux")]
if launch.is_some()
&& crate::session_settings::get().session_on_game_exit
&& crate::vdisplay::launch_is_nested(compositor)
&& crate::vdisplay::dedicated_game_exited(cur_node_id)
{
@@ -2098,8 +2217,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// capture-mode channel); a switch AWAY restores the prior
// gating. `plan` is `Copy` — this is the value the rebuild
// (and its `build_pipeline` attach) reads.
plan.cursor_blend = plan.cursor_forward
|| c == crate::vdisplay::Compositor::Gamescope;
plan.cursor_blend = crate::session_plan::cursor_blend_for(
plan.cursor_forward,
c == crate::vdisplay::Compositor::Gamescope,
);
plan.gamescope_cursor =
c == crate::vdisplay::Compositor::Gamescope;
gamescope_composite =
@@ -2322,6 +2443,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// assign next. The RFI backends pin their frame numbering to it.
let wire_index = au_seq.wrapping_add(inflight.len() as u32);
if let Err(e) = enc.submit_indexed(&frame, wire_index) {
// A typed-terminal error is a deterministic configuration failure — the identical
// wall on every attempt, so rebuilds can't help. End the session at once with the
// carried cause (observed: a stale PUNKTFUNK_ENCODER pin vs. the selected adapter
// burned all 5 rebuilds per connect while the client reconnected forever).
if e.downcast_ref::<crate::encode::TerminalEncoderError>()
.is_some()
{
tracing::error!(
error = %format!("{e:#}"),
"encoder failed with a deterministic configuration error — ending the video \
session without rebuild attempts (see the error for the remedy)");
return Err(e).context("encoder submit");
}
// The input half of an encode stall: once the driver stops draining AUs, libavcodec's
// one-frame buffer fills and avcodec_send_frame starts failing (EAGAIN) — the same
// wedge the watchdog below catches, seen from submit. Rebuild the encoder in place
@@ -2839,6 +2973,10 @@ fn try_inplace_resize(
plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
trace: &crate::bringup::Trace,
// Same-mode swap-chain recovery (the exclusive re-assert bounced the IDD's modes): recreate
// the ring even though the size is unchanged — `resize_output`'s same-size fast path would
// no-op exactly the case being recovered.
recover_ring: bool,
) -> bool {
let Some(cur_target) = capturer.capture_target_id() else {
return false; // not an IDD-push capturer — nothing to reuse
@@ -2869,7 +3007,12 @@ fn try_inplace_resize(
);
return false;
}
if !capturer.resize_output(new_mode.width, new_mode.height) {
let ring_ok = if recover_ring {
capturer.recreate_ring_in_place()
} else {
capturer.resize_output(new_mode.width, new_mode.height)
};
if !ring_ok {
return false;
}
trace.mark("ring_recreated");
@@ -2896,6 +3039,38 @@ fn try_inplace_resize(
}
}
};
// Liveness gate for the eviction recovery: the driver re-delivers its STASH on re-attach, so
// the first frame proves only the ring — not that the OS resumed presenting (measured: the
// stash arrives in ~50 ms, then new_fps=0 forever). Require a SECOND, newer present — the
// forced mode reset just triggered a full redraw, so a live display produces one promptly —
// before declaring recovery; a stash-only re-attach must FAIL so the caller ends the session
// cleanly (a reconnect's fresh bring-up always recovers) instead of streaming a frozen frame.
let new_frame = if recover_ring {
let first_pts = new_frame.pts_ns;
let live_deadline = std::time::Instant::now() + std::time::Duration::from_millis(1500);
loop {
match capturer.try_latest() {
Ok(Some(f)) if f.pts_ns != first_pts => break f,
Ok(_) => {
if std::time::Instant::now() >= live_deadline {
tracing::warn!(
"eviction recovery: ring re-attached but only the stashed frame \
arrived the OS is not presenting; failing the in-place recovery"
);
return false;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"),
"eviction recovery: capture failed while waiting for a live frame");
return false;
}
}
}
} else {
new_frame
};
trace.mark("first_new_frame");
// Fresh encoder at the delivered size — the one component that can't follow a resolution
// change in place today (P2.4 stays unimplemented: `open_video` is ms-scale, measured).
@@ -2987,7 +3162,10 @@ pub(super) fn prepare_display(
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
compositor == pf_vdisplay::Compositor::Gamescope || cursor_forward,
crate::session_plan::cursor_blend_for(
cursor_forward,
compositor == pf_vdisplay::Compositor::Gamescope,
),
cursor_forward,
);
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;
@@ -3248,20 +3426,23 @@ fn build_pipeline(
let mut capturer =
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
.context("capture virtual output")?;
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer gamescope's
// nested Xwayland — it reads the pointer over X11 (XFixes shape + QueryPointer position) and
// feeds `cursor()`, which the encode loop composites. A failed discovery/connect leaves the
// stream cursorless (today's behaviour); non-gamescope plans skip this entirely.
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer a way to
// reach gamescope's nested Xwaylands — it reads the pointer over X11 (XFixes shape +
// QueryPointer position) and feeds `cursor()`, which the encode loop composites.
// Non-gamescope plans skip this entirely.
//
// A PROVIDER, not the discovered list: gamescope creates the game's Xwayland when the game
// launches and advertises only the FIRST in any child's environ, so a list captured here misses
// it — and the cursor source would then blank the pointer for the whole game session (it asks
// the connected display "are you drawing the pointer?" and gets "no"). The source re-runs this
// every couple of seconds, so a stream that starts before the game converges, and a display
// that dies is retried. Same one-way-edge shape as the Windows channel senders: the closure
// wraps the host's discovery, and pf-capture never reaches back into pf-vdisplay.
#[cfg(target_os = "linux")]
if plan.gamescope_cursor {
let targets = pf_vdisplay::gamescope_xwayland_cursor_targets();
if targets.is_empty() {
tracing::warn!(
"gamescope cursor: no nested Xwayland discovered — no in-video pointer this session"
);
} else {
capturer.attach_gamescope_cursor(targets);
}
capturer.attach_gamescope_cursor(std::sync::Arc::new(
pf_vdisplay::gamescope_xwayland_cursor_targets,
));
}
if let Some(t) = trace {
t.mark("capture_attached");
+92
View File
@@ -0,0 +1,92 @@
//! Finding a launched game's processes, from the signals its store gave us
//! ([`crate::library::DetectSpec`]).
//!
//! Read-only by construction: the host enumerates processes and reads metadata it already has
//! permission to see. No ptrace, no injection, no handles held open. On Linux it looks only at
//! processes owned by its **own uid**; on Windows it runs as SYSTEM and therefore *can* see
//! everything, which makes the two rules below the load-bearing part rather than an afterthought.
//!
//! ### The two rules
//!
//! 1. **Never adopt a process that predates the launch.** A player may already have the game open
//! when a session starts; treating that instance as "this session's game" would let a session end
//! kill a process it never started. Candidates are filtered by start time against
//! [`launch_stamp`], taken before anything spawns.
//! 2. **Never trust a bare pid.** Pids are recycled — aggressively so on Windows — and a lease can
//! outlive its game by a grace window, so every remembered process carries its start time and is
//! re-verified against it ([`Scanner::alive`]) before it is counted as running, or signalled.
//!
//! ### Per-OS implementations
//!
//! The two have nothing in common beyond that contract, so they are separate modules presenting the
//! same [`Scanner`] surface: `/proc` on Linux, a Toolhelp snapshot on Windows. Everything above the
//! scanner ([`crate::gamelease`]) is platform-neutral.
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
pub use linux::Scanner;
#[cfg(windows)]
mod windows;
#[cfg(windows)]
pub use windows::Scanner;
/// A process the matcher adopted: its pid plus a start stamp that pins that pid to *this* process,
/// so a recycled pid can never be mistaken for it.
///
/// `start` is opaque and only ever compared for equality against a later read of the same pid — its
/// units differ per platform (clock ticks since boot on Linux, a creation `FILETIME` on Windows).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProcRef {
pub pid: u32,
pub start: u64,
}
/// Tolerance on the "started after the launch" test, in seconds.
///
/// The launch stamp is taken just before the spawn, but start times are quantized (~10 ms on Linux)
/// and a launcher can race ahead of the host's own bookkeeping, so an exact comparison would
/// occasionally reject the real game. Two seconds is far below the time any launcher takes to bring a
/// game up, so it cannot let a *pre-existing* instance through.
pub const START_SLACK_SECS: f64 = 2.0;
/// The reference instant for adopting a launch's processes, in **seconds on the platform's
/// process-start timeline** — seconds since boot on Linux, seconds since the Windows epoch on
/// Windows. Only ever compared against a process's own start time on the same platform, never
/// interpreted as a wall clock or persisted.
///
/// Call it **before** anything spawns; see [`crate::gamelease::LeaseRequest::launch_stamp`]. `None`
/// when the platform has no matcher (macOS) or the clock could not be read, which disables the
/// start-time filter rather than rejecting everything.
pub fn launch_stamp() -> Option<f64> {
#[cfg(any(target_os = "linux", windows))]
{
Scanner::system().now_stamp()
}
#[cfg(not(any(target_os = "linux", windows)))]
{
None
}
}
/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan.
///
/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the
/// primary signal. `Some(true)` = something else believes it is up, so hold off; `Some(false)` = that
/// something agrees it is gone; `None` = no opinion available, which is the common case.
///
/// Linux has none by design: Steam's launch reaper is both the sharpest signal and already a *process*,
/// so it is covered by the scan itself. Windows has no reaper, which is exactly where a second opinion
/// earns its keep.
pub fn running_hint(spec: &crate::library::DetectSpec) -> Option<bool> {
#[cfg(windows)]
{
spec.steam_appid.and_then(windows::steam_running_hint)
}
#[cfg(not(windows))]
{
let _ = spec;
None
}
}
+594
View File
@@ -0,0 +1,594 @@
//! The Linux matcher: `/proc`.
//!
//! Contract, rules, and the shared vocabulary live in [`super`]; this module is only the reading of
//! `/proc` — and the parsing that gets wrong more often than it looks (`stat`'s `comm` field can
//! contain spaces *and* parentheses, so fields must be counted from the last `)`).
use super::{ProcRef, START_SLACK_SECS};
use crate::library::DetectSpec;
use std::path::{Path, PathBuf};
/// Largest `cmdline`/`environ` blob we will read from a single process. Both are bounded by `ARG_MAX`
/// in practice; the cap exists so a pathological process can't make the host allocate without bound
/// during a once-a-second scan.
const MAX_PROC_BLOB: u64 = 512 * 1024;
/// A `/proc` reader. Parameterized on its root, uid filter, and clock rate so the matching logic can
/// be unit-tested against a fixture tree instead of the live system.
pub struct Scanner {
root: PathBuf,
/// Only consider processes owned by this uid. `None` (tests only) considers all.
uid: Option<u32>,
ticks_per_sec: f64,
}
impl Scanner {
/// The live system: `/proc`, this host process's own uid, the kernel's clock rate.
pub fn system() -> Self {
// SAFETY: a parameterless POSIX call that always succeeds and touches no memory.
let uid = unsafe { libc::getuid() };
// SAFETY: `sysconf` reads a static system limit by name; no memory of ours is involved, and a
// non-positive answer (which the caller below handles) is its documented failure signal.
let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
Self {
root: PathBuf::from("/proc"),
uid: Some(uid),
// A non-positive sysconf answer would poison every start-time comparison; fall back to
// the universal Linux value rather than divide by nonsense.
ticks_per_sec: if ticks > 0 { ticks as f64 } else { 100.0 },
}
}
/// Seconds since boot — the Linux process-start timeline (see [`super::launch_stamp`]). `None`
/// when `/proc/uptime` can't be read, which disables start-time filtering rather than rejecting
/// every candidate.
pub fn now_stamp(&self) -> Option<f64> {
let text = std::fs::read_to_string(self.root.join("uptime")).ok()?;
text.split_whitespace().next()?.parse().ok()
}
/// Every process matching any of `spec`'s signals, restricted to those that started at or after
/// `min_start` (seconds since boot; `None` disables the filter).
///
/// The signals are a union: a Steam appid, an env marker, an exact executable, or an install
/// directory each independently qualify a process. That is deliberate — the store with the
/// sharpest signal wins, but a store that only knows its install directory is still covered.
pub fn find(&self, spec: &DetectSpec, min_start: Option<f64>) -> Vec<ProcRef> {
if spec.is_empty() {
return Vec::new();
}
// Resolve symlinks in the install dir once per scan: a game reached through a symlinked
// library folder would otherwise never prefix-match its own canonical image path.
let dir = spec
.install_dir
.as_deref()
.map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf()));
let exe = spec
.exe
.as_deref()
.map(|e| e.canonicalize().unwrap_or_else(|_| e.to_path_buf()));
let steam_tok = spec.steam_appid.map(|id| format!("AppId={id}"));
let Ok(entries) = std::fs::read_dir(&self.root) else {
return Vec::new();
};
let mut out = Vec::new();
for e in entries.flatten() {
let name = e.file_name();
let Some(pid) = name.to_str().and_then(|s| s.parse::<u32>().ok()) else {
continue; // not a pid dir
};
let dir_path = e.path();
if let Some(uid) = self.uid {
let owned = std::fs::metadata(&dir_path)
.map(|m| {
use std::os::unix::fs::MetadataExt;
m.uid() == uid
})
.unwrap_or(false);
if !owned {
continue;
}
}
let Some(start_ticks) = self.start_ticks(&dir_path) else {
continue; // vanished mid-scan, or an unparseable stat
};
if let Some(min) = min_start {
let started = start_ticks as f64 / self.ticks_per_sec;
if started + START_SLACK_SECS < min {
continue; // predates this launch — never ours (rule 1)
}
}
if self.matches(
&dir_path,
spec,
steam_tok.as_deref(),
exe.as_deref(),
dir.as_deref(),
) {
out.push(ProcRef {
pid,
start: start_ticks,
});
}
}
out
}
/// Which of `procs` are still the same live processes — pid present **and** start time unchanged,
/// so a recycled pid is never reported alive (rule 2).
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
procs
.iter()
.copied()
.filter(|p| self.start_ticks(&self.root.join(p.pid.to_string())) == Some(p.start))
.collect()
}
/// Does this process match any of the spec's signals?
fn matches(
&self,
dir_path: &Path,
spec: &DetectSpec,
steam_tok: Option<&str>,
exe: Option<&Path>,
install_dir: Option<&Path>,
) -> bool {
// The process's own image, resolved through /proc/<pid>/exe. Absent for a kernel thread or a
// process whose binary was replaced.
let image = std::fs::read_link(dir_path.join("exe")).ok();
if let Some(want) = exe {
if image.as_deref() == Some(want) {
return true;
}
}
if let Some(dir) = install_dir {
if image.as_deref().is_some_and(|i| i.starts_with(dir)) {
return true;
}
}
// A bare executable name, the operator-supplied fallback. Case-insensitive because it is typed
// by hand and the cost of a case mismatch (the game is never recognized) far outweighs the
// cost of a case collision (two differently-cased binaries, both started since this launch).
if let Some(want) = spec.process_name.as_deref() {
let named = image
.as_deref()
.and_then(|i| i.file_name())
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case(want));
if named {
return true;
}
}
// The command line covers what the image can't: a Proton/Wine title's image is the *runtime*
// (`…/proton`, `wine64-preloader`), and the game only appears as an argument; Steam's launch
// reaper is likewise identified by its argv, not its binary.
let cmdline = read_capped(&dir_path.join("cmdline"));
if let Some(cmdline) = cmdline.as_deref() {
if let Some(tok) = steam_tok {
// Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and
// Steam's own (non-reaper) helper steps aren't mistaken for the game.
let mut launch = false;
let mut appid = false;
for arg in cmdline.split(|&b| b == 0) {
if arg == b"SteamLaunch" {
launch = true;
} else if arg == tok.as_bytes() {
appid = true;
}
}
if launch && appid {
return true;
}
}
if let Some(dir) = install_dir {
// Require a path separator after the directory, so an install dir of `/games/x` is
// not satisfied by an unrelated `/games/xyz/…` argument. (The image-path check above
// needs no such care — `Path::starts_with` already compares whole components.)
let needle = dir.as_os_str().as_encoded_bytes();
let under_dir = |arg: &[u8]| {
arg.strip_prefix(needle)
.is_some_and(|rest| rest.first() == Some(&b'/'))
};
if cmdline.split(|&b| b == 0).any(under_dir) {
return true;
}
}
if let Some(want) = exe {
let needle = want.as_os_str().as_encoded_bytes();
if cmdline.split(|&b| b == 0).any(|arg| arg == needle) {
return true;
}
}
}
// Env markers last: reading another process's environment is the most invasive of these
// reads, so it only happens for a spec that actually asked for one. The contents are matched
// and discarded — never logged.
if let Some(marker) = &spec.env_marker {
if let Some(env) = read_capped(&dir_path.join("environ")) {
let want: Vec<u8> = match &marker.value {
Some(v) => format!("{}={v}", marker.key).into_bytes(),
None => format!("{}=", marker.key).into_bytes(),
};
let hit = env.split(|&b| b == 0).any(|kv| match marker.value {
// An exact `KEY=VALUE` entry.
Some(_) => kv == want.as_slice(),
// Presence of the key with any value.
None => kv.starts_with(want.as_slice()),
});
if hit {
return true;
}
}
}
false
}
/// `/proc/<pid>/stat` field 22 (`starttime`). The `comm` field is parenthesized and may itself
/// contain spaces and parentheses, so fields are counted from the **last** `)`: the token right
/// after it is field 3 (`state`), which puts `starttime` at index 19 of the remainder.
fn start_ticks(&self, dir_path: &Path) -> Option<u64> {
let stat = std::fs::read_to_string(dir_path.join("stat")).ok()?;
let tail = &stat[stat.rfind(')')? + 1..];
tail.split_whitespace().nth(19)?.parse().ok()
}
}
/// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished
/// or the file is unreadable — both routine during a scan.
fn read_capped(path: &Path) -> Option<Vec<u8>> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
let mut buf = Vec::new();
file.take(MAX_PROC_BLOB).read_to_end(&mut buf).ok()?;
Some(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::DetectSpec;
/// Build a fake `/proc` tree. Each process gets a `stat` (with a deliberately nasty `comm`), and
/// optionally a `cmdline`, an `environ`, and an `exe` symlink.
struct FakeProc {
pid: u32,
start: u64,
exe: Option<PathBuf>,
cmdline: Vec<&'static str>,
environ: Vec<&'static str>,
}
impl FakeProc {
fn new(pid: u32, start: u64) -> Self {
Self {
pid,
start,
exe: None,
cmdline: Vec::new(),
environ: Vec::new(),
}
}
fn exe(mut self, p: impl Into<PathBuf>) -> Self {
self.exe = Some(p.into());
self
}
fn cmdline(mut self, args: &[&'static str]) -> Self {
self.cmdline = args.to_vec();
self
}
fn environ(mut self, kvs: &[&'static str]) -> Self {
self.environ = kvs.to_vec();
self
}
}
fn fake_proc_root(uptime: f64, procs: &[FakeProc]) -> tempfile::TempDir {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("uptime"), format!("{uptime} 1000.0\n")).unwrap();
// Non-pid entries must be skipped, not parsed.
std::fs::create_dir_all(td.path().join("self")).unwrap();
std::fs::write(td.path().join("cmdline"), b"").unwrap();
for p in procs {
let dir = td.path().join(p.pid.to_string());
std::fs::create_dir_all(&dir).unwrap();
// `stat` is `pid (comm) state ppid … starttime …`, with starttime as field 22. Counting
// from the last ')', index 0 is `state` (field 3), so starttime lands at index 19. The
// comm is hostile on purpose — a space and a close-paren inside the parens, which is
// exactly what naive field-splitting gets wrong.
let mut tail = vec!["0".to_string(); 20];
tail[0] = "S".to_string(); // field 3
tail[19] = p.start.to_string(); // field 22
std::fs::write(
dir.join("stat"),
format!("{} (evil ) name) {}\n", p.pid, tail.join(" ")),
)
.unwrap();
if !p.cmdline.is_empty() {
let mut blob = Vec::new();
for a in &p.cmdline {
blob.extend_from_slice(a.as_bytes());
blob.push(0);
}
std::fs::write(dir.join("cmdline"), blob).unwrap();
}
if !p.environ.is_empty() {
let mut blob = Vec::new();
for a in &p.environ {
blob.extend_from_slice(a.as_bytes());
blob.push(0);
}
std::fs::write(dir.join("environ"), blob).unwrap();
}
if let Some(exe) = &p.exe {
std::os::unix::fs::symlink(exe, dir.join("exe")).unwrap();
}
}
td
}
fn scanner(root: &Path) -> Scanner {
Scanner {
root: root.to_path_buf(),
uid: None, // the fixture's owner is the test user; don't couple the test to it
ticks_per_sec: 100.0,
}
}
fn pids(mut v: Vec<ProcRef>) -> Vec<u32> {
v.sort_by_key(|p| p.pid);
v.into_iter().map(|p| p.pid).collect()
}
#[test]
fn empty_spec_matches_nothing() {
let td = fake_proc_root(100.0, &[FakeProc::new(1, 100).exe("/games/x/run")]);
let s = scanner(td.path());
assert!(s.find(&DetectSpec::default(), None).is_empty());
}
#[test]
fn matches_exact_exe_and_install_dir() {
let td = fake_proc_root(
1000.0,
&[
FakeProc::new(10, 50_000).exe("/games/hades/Hades"),
FakeProc::new(11, 50_000).exe("/games/hades/tools/crash.bin"),
FakeProc::new(12, 50_000).exe("/usr/bin/firefox"),
],
);
let s = scanner(td.path());
// Exact exe → only that process.
assert_eq!(
pids(s.find(&DetectSpec::exe("/games/hades/Hades"), None)),
vec![10]
);
// Install dir → every process running out of the game's tree, but nothing outside it.
assert_eq!(
pids(s.find(&DetectSpec::dir("/games/hades"), None)),
vec![10, 11]
);
}
/// The operator-supplied fallback ([`DetectSpec::process_name`]): the image's own file name and
/// nothing else — never a directory that happens to be called the same, and never a process whose
/// path merely contains the name.
#[test]
fn matches_a_bare_process_name_against_the_image_name_only() {
let td = fake_proc_root(
1000.0,
&[
FakeProc::new(20, 50_000).exe("/opt/retroarch/bin/RetroArch"),
FakeProc::new(21, 50_000).exe("/usr/bin/retroarch-assets-helper"),
FakeProc::new(22, 50_000).exe("/home/p/retroarch/launcher.sh"),
],
);
let s = scanner(td.path());
let spec = DetectSpec {
process_name: Some("retroarch".into()),
..Default::default()
};
// Case-insensitive (the name is typed by hand), but a whole-name match: neither the
// longer-named helper nor the script living in a `retroarch/` directory qualifies.
assert_eq!(pids(s.find(&spec, None)), vec![20]);
}
#[test]
fn install_dir_does_not_match_a_sibling_with_the_same_prefix() {
// `/games/x` must not adopt a process running out of `/games/xyz` — a real hazard when one
// library folder's name is a prefix of another's.
let td = fake_proc_root(
1000.0,
&[
FakeProc::new(90, 50_000).exe("/games/xyz/other"),
FakeProc::new(91, 50_000).cmdline(&["wrapper", "/games/xyz/other"]),
],
);
let s = scanner(td.path());
assert!(s.find(&DetectSpec::dir("/games/x"), None).is_empty());
// The genuine directory still matches, by image path and by argument.
assert_eq!(
pids(s.find(&DetectSpec::dir("/games/xyz"), None)),
vec![90, 91]
);
}
#[test]
fn matches_proton_style_game_only_in_the_cmdline() {
// The image is the Proton runtime; the game appears only as an argument.
let td = fake_proc_root(
1000.0,
&[FakeProc::new(20, 50_000)
.exe("/steam/runtime/proton")
.cmdline(&["proton", "waitforexitandrun", "/games/elden/eldenring.exe"])],
);
let s = scanner(td.path());
assert_eq!(
pids(s.find(&DetectSpec::dir("/games/elden"), None)),
vec![20]
);
}
#[test]
fn matches_steam_reaper_exactly() {
let td = fake_proc_root(
1000.0,
&[
FakeProc::new(30, 50_000).cmdline(&[
"reaper",
"SteamLaunch",
"AppId=570",
"--",
"dota",
]),
// A different appid that shares a prefix must NOT match (AppId=57 vs 570).
FakeProc::new(31, 50_000).cmdline(&[
"reaper",
"SteamLaunch",
"AppId=57",
"--",
"other",
]),
// `SteamLaunch` without the appid, and the appid without `SteamLaunch`, are both no.
FakeProc::new(32, 50_000).cmdline(&["reaper", "SteamLaunch", "--", "shader"]),
FakeProc::new(33, 50_000).cmdline(&["something", "AppId=570"]),
],
);
let s = scanner(td.path());
assert_eq!(pids(s.find(&DetectSpec::steam(570), None)), vec![30]);
assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]);
}
#[test]
fn matches_env_marker_by_exact_value_or_presence() {
let td = fake_proc_root(
1000.0,
&[
FakeProc::new(40, 50_000).environ(&["HOME=/home/u", "HEROIC_APP_NAME=Quail"]),
FakeProc::new(41, 50_000).environ(&["HEROIC_APP_NAME=OtherGame"]),
FakeProc::new(42, 50_000).environ(&["HOME=/home/u"]),
],
);
let s = scanner(td.path());
let exact = DetectSpec::default().with_env("HEROIC_APP_NAME", Some("Quail".to_string()));
assert_eq!(pids(s.find(&exact, None)), vec![40]);
// Presence-only matches any value, but still nothing that lacks the key.
let any = DetectSpec::default().with_env("HEROIC_APP_NAME", None);
assert_eq!(pids(s.find(&any, None)), vec![40, 41]);
}
#[test]
fn never_adopts_a_process_that_predates_the_launch() {
// Uptime 1000 s; the launch happened at t=900. pid 50 started at t=500 (a copy the player
// already had open), pid 51 at t=950 (ours).
let td = fake_proc_root(
1000.0,
&[
FakeProc::new(50, 50_000).exe("/games/hades/Hades"),
FakeProc::new(51, 95_000).exe("/games/hades/Hades"),
],
);
let s = scanner(td.path());
let spec = DetectSpec::dir("/games/hades");
assert_eq!(pids(s.find(&spec, Some(900.0))), vec![51]);
// Without the filter both are visible — proving the filter is what excluded it.
assert_eq!(pids(s.find(&spec, None)), vec![50, 51]);
}
#[test]
fn start_slack_tolerates_tick_granularity() {
// Started a hair (0.5 s) BEFORE the recorded launch instant: still ours.
let td = fake_proc_root(1000.0, &[FakeProc::new(60, 89_950).exe("/games/x/run")]);
let s = scanner(td.path());
assert_eq!(
pids(s.find(&DetectSpec::dir("/games/x"), Some(900.0))),
vec![60]
);
// A full minute early is not.
assert!(s.find(&DetectSpec::dir("/games/x"), Some(960.0)).is_empty());
}
#[test]
fn alive_rejects_a_recycled_pid() {
let td = fake_proc_root(1000.0, &[FakeProc::new(70, 50_000).exe("/games/x/run")]);
let s = scanner(td.path());
let same = ProcRef {
pid: 70,
start: 50_000,
};
let recycled = ProcRef {
pid: 70,
start: 99_999,
};
let gone = ProcRef {
pid: 71,
start: 50_000,
};
assert_eq!(s.alive(&[same]), vec![same]);
assert!(s.alive(&[recycled]).is_empty());
assert!(s.alive(&[gone]).is_empty());
}
/// Everything above runs against a fixture tree, which proves the parsing but not that the
/// parsing describes this kernel. This one scans the **real** `/proc` for a process it just
/// started — the only version of this test that would notice `/proc/<pid>/stat` field ordering,
/// the uid check, or the uptime clock being wrong on a real box.
#[test]
fn finds_a_real_process_it_just_started() {
// A real game's *binary* lives under its install dir, which is what makes the install-dir
// recipe work. So the stand-in must too: copy a long-running binary into the directory and run
// it from there. (A wrapper script that `exec`s something outside the directory would leave no
// trace of the directory in either the image path or the command line — worth knowing, and the
// reason a copied binary is the honest fixture here.)
let td = tempfile::tempdir().expect("tempdir");
let game = td.path().join("game");
std::fs::copy("/bin/sleep", &game).expect("copy a stand-in game binary");
let s = Scanner::system();
let before = s.now_stamp().expect("real /proc/uptime is readable");
let mut child = std::process::Command::new(&game)
.arg("20")
.spawn()
.expect("spawn the fake game");
// Give it a moment to be visible in /proc.
std::thread::sleep(std::time::Duration::from_millis(300));
let found = s.find(&DetectSpec::dir(td.path()), Some(before));
assert!(
found.iter().any(|p| p.pid == child.id()),
"scanning the real /proc did not find pid {} under {}; found {found:?}",
child.id(),
td.path().display()
);
// The same process is still alive when re-verified by (pid, start time).
assert!(!s.alive(&found).is_empty());
// The exact-exe recipe finds it too, on the same live process.
assert!(s
.find(&DetectSpec::exe(&game), Some(before))
.iter()
.any(|p| p.pid == child.id()));
// A launch reference AFTER this process started must exclude it — the rule that keeps a
// pre-existing copy of a game from being adopted, checked against a real start time.
let after = s.now_stamp().unwrap() + 60.0;
assert!(s.find(&DetectSpec::dir(td.path()), Some(after)).is_empty());
let _ = child.kill();
let _ = child.wait();
// Once it is gone and reaped, neither the scan nor the liveness re-check sees it.
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(s.find(&DetectSpec::dir(td.path()), Some(before)).is_empty());
assert!(s.alive(&found).is_empty());
}
#[test]
fn parses_uptime_and_hostile_comm() {
let td = fake_proc_root(4321.5, &[FakeProc::new(80, 1234)]);
let s = scanner(td.path());
assert_eq!(s.now_stamp(), Some(4321.5));
// The comm field contains a space and a ')' — start time must still parse.
assert_eq!(s.start_ticks(&td.path().join("80")), Some(1234));
}
}
@@ -0,0 +1,379 @@
//! The Windows matcher: a Toolhelp snapshot plus each process's full image path.
//!
//! Contract and rules live in [`super`]. Two differences from the Linux side shape everything here:
//!
//! * **The host is SYSTEM**, so it can open essentially any process. That makes rule 1 (never adopt a
//! process that predates the launch) load-bearing rather than a nicety — without it a scan would
//! happily adopt a copy of the game the player started an hour ago, and a session ending could kill
//! it.
//! * **There is no launch reaper and no readable environment.** Steam's `SteamLaunch AppId=` argv
//! trick is Linux-only, and reading another process's environment block on Windows needs
//! `NtQueryInformationProcess` against an undocumented layout — not something to build a
//! game-killing decision on. So the recipe is the image path: exactly the game's executable, or any
//! executable under its install directory. Every Windows store the library scans reports one or
//! both ([`crate::library::DetectSpec`]).
use super::{ProcRef, START_SLACK_SECS};
use crate::library::DetectSpec;
use std::path::{Path, PathBuf};
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
};
use windows::Win32::System::Threading::{
GetProcessTimes, OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT,
PROCESS_QUERY_LIMITED_INFORMATION,
};
/// 100-nanosecond `FILETIME` ticks per second — the unit every Win32 time API here reports in.
const FILETIME_TICKS_PER_SEC: f64 = 10_000_000.0;
/// Image-path buffer, in UTF-16 units. Deliberately well past `MAX_PATH` (260): a game installed under
/// a deep library folder can exceed it, and a truncated path would silently fail to match. A path
/// longer than this makes `QueryFullProcessImageNameW` fail, which skips that process — the same
/// outcome as any other unreadable one.
const IMAGE_PATH_MAX: usize = 4096;
/// A process scanner over the live system. Unit (unlike the Linux one, which is parameterized for its
/// fixture tests): there is no way to hand Win32 a fake process table, so the Windows matching logic
/// is tested through its pure helpers ([`under_dir`], [`same_path`]) plus a live-process test.
pub struct Scanner;
impl Scanner {
pub fn system() -> Self {
Self
}
/// Seconds on the Windows process-start timeline — the `FILETIME` epoch, which is what
/// `GetProcessTimes` reports a creation time in. `None` if the clock could not be read.
///
/// Derived from `SystemTime` rather than a Win32 call because both are the same UTC epoch offset
/// by a constant, and the only use is comparing against a creation time read moments later; a
/// clock step between the two would need to exceed [`START_SLACK_SECS`] to matter.
pub fn now_stamp(&self) -> Option<f64> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?;
/// Seconds between the `FILETIME` epoch (1601-01-01) and the Unix epoch.
const FILETIME_EPOCH_OFFSET_SECS: f64 = 11_644_473_600.0;
Some(now.as_secs_f64() + FILETIME_EPOCH_OFFSET_SECS)
}
/// Every process matching any of `spec`'s signals, restricted to those that started at or after
/// `min_start` (seconds on the [`Self::now_stamp`] timeline; `None` disables the filter).
pub fn find(&self, spec: &DetectSpec, min_start: Option<f64>) -> Vec<ProcRef> {
// Only the image-based signals exist on Windows: no reaper argv, no readable environment. A
// spec carrying none must match *nothing* — falling through would scan on an empty predicate.
if spec.exe.is_none() && spec.install_dir.is_none() && spec.process_name.is_none() {
return Vec::new();
}
let exe = spec
.exe
.as_deref()
.map(|e| e.canonicalize().unwrap_or_else(|_| e.to_path_buf()));
let dir = spec
.install_dir
.as_deref()
.map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf()));
let mut out = Vec::new();
for pid in snapshot_pids() {
// pid 0 (System Idle) and 4 (System) are neither openable nor ever a game.
if pid <= 4 {
continue;
}
let Some((start, image)) = process_start_and_image(pid) else {
continue; // exited mid-scan, or a protected process we can't query — never a game
};
if let Some(min) = min_start {
if start as f64 / FILETIME_TICKS_PER_SEC + START_SLACK_SECS < min {
continue; // predates this launch — never ours (rule 1)
}
}
let hit = exe.as_deref().is_some_and(|w| same_path(&image, w))
|| dir.as_deref().is_some_and(|d| under_dir(&image, d))
// The operator-supplied fallback: the image's file name alone. Windows paths are
// case-insensitive anyway, so this matches the platform rather than relaxing anything.
|| spec
.process_name
.as_deref()
.is_some_and(|w| same_name(&image, w));
if hit {
out.push(ProcRef { pid, start });
}
}
out
}
/// Which of `procs` are still the same live processes — pid present **and** creation time
/// unchanged, so a recycled pid is never reported alive (rule 2). Windows reuses pids briskly, so
/// this check is what makes signalling a remembered pid safe at all.
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
procs
.iter()
.copied()
.filter(|p| process_start_and_image(p.pid).is_some_and(|(start, _)| start == p.start))
.collect()
}
}
/// An out-of-band opinion on whether the game is still running, used only to **veto** declaring it
/// gone (see [`super::running_hint`]).
///
/// For a Steam title: Steam records a per-app `Running` flag in the logged-in user's hive. That flag is
/// not trustworthy on its own — Steam sets it around updates and DLC installs too, and leaves it stale
/// if it crashes — but as a veto it is exactly right. If the matcher can't see the game (its executable
/// lives outside the install dir the manifest named, or a nested launcher owns it) while Steam still
/// says the app is running, ending the session would be a false positive the player feels immediately.
/// Erring towards "still running" only ever leaves a stream up.
///
/// Reads every **loaded** user hive under `HKEY_USERS` rather than resolving the interactive user's SID
/// through `WTSQueryUserToken`: only logged-in users' hives are loaded, which is the same set, and it
/// avoids a token dance for a best-effort hint.
pub fn steam_running_hint(appid: u32) -> Option<bool> {
use winreg::enums::{HKEY_USERS, KEY_READ};
use winreg::RegKey;
let users = RegKey::predef(HKEY_USERS);
let mut saw_key = false;
for sid in users.enum_keys().flatten() {
// The `…_Classes` companion hives hold no Steam state.
if sid.ends_with("_Classes") {
continue;
}
let path = format!("{sid}\\Software\\Valve\\Steam\\Apps\\{appid}");
let Ok(app) = users.open_subkey_with_flags(&path, KEY_READ) else {
continue;
};
saw_key = true;
if app.get_value::<u32, _>("Running").unwrap_or(0) != 0 {
return Some(true);
}
}
// A key that exists and says 0 is a real "not running"; no key at all means Steam has never run
// this app on this box, which is no opinion rather than a negative one.
saw_key.then_some(false)
}
/// Every pid in a Toolhelp snapshot. Mirrors the walk in [`crate::detect`]'s Windows facts, which
/// wants basenames rather than pids.
fn snapshot_pids() -> Vec<u32> {
let mut out = Vec::new();
// SAFETY: the canonical Toolhelp walk. `entry` is zeroed with `dwSize` set before the first read
// (the 260-wide `szExeFile` array has no usable `Default`), and the snapshot handle is closed on
// every exit path.
unsafe {
let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
return out;
};
let mut entry = PROCESSENTRY32W {
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
..std::mem::zeroed()
};
if Process32FirstW(snap, &mut entry).is_ok() {
loop {
out.push(entry.th32ProcessID);
if Process32NextW(snap, &mut entry).is_err() {
break;
}
}
}
let _ = CloseHandle(snap);
}
out
}
/// A process's creation time (`FILETIME` ticks) and full image path.
///
/// Opened with `PROCESS_QUERY_LIMITED_INFORMATION` — the least privilege that answers both questions,
/// and the one that works against elevated and protected-ish processes without asking for the right to
/// read their memory. `None` for anything that can't be opened or has already exited, which is the
/// routine case during a scan and never a reason to log.
fn process_start_and_image(pid: u32) -> Option<(u64, PathBuf)> {
// SAFETY: `OpenProcess` yields an owned handle only on `Ok`; it is closed exactly once below on
// every path. `GetProcessTimes` writes four `FILETIME`s we fully own; `QueryFullProcessImageNameW`
// writes into `buf` and updates `len` in place, bounded by the `len` we pass in (buf.len()).
unsafe {
let handle: HANDLE = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
let mut created = Default::default();
let (mut exit, mut kernel, mut user) =
(Default::default(), Default::default(), Default::default());
let times =
GetProcessTimes(handle, &mut created, &mut exit, &mut kernel, &mut user).is_ok();
let mut buf = [0u16; IMAGE_PATH_MAX];
let mut len = buf.len() as u32;
// `PROCESS_NAME_FORMAT(0)` is `PROCESS_NAME_WIN32` — a drive-letter path, which is what the
// library's store-derived paths look like (the alternative, `PROCESS_NAME_NATIVE`, yields
// `\Device\HarddiskVolume…` and would never compare equal to one).
let named = QueryFullProcessImageNameW(
handle,
PROCESS_NAME_FORMAT(0),
windows::core::PWSTR(buf.as_mut_ptr()),
&mut len,
)
.is_ok();
let _ = CloseHandle(handle);
if !times || !named {
return None;
}
let start = ((created.dwHighDateTime as u64) << 32) | created.dwLowDateTime as u64;
let image = PathBuf::from(String::from_utf16_lossy(&buf[..len as usize]));
Some((start, image))
}
}
/// Is `image` the same file as `want`? Windows paths are case-insensitive, and the scanner compares a
/// canonicalized store-derived path against a live image path, so the comparison must be too.
fn same_path(image: &Path, want: &Path) -> bool {
eq_ignore_case(image, want)
}
/// Does `image` live under `dir`? Case-insensitive, and requires a separator after the directory so an
/// install dir of `…\Games\X` is not satisfied by `…\Games\XY\game.exe`.
fn under_dir(image: &Path, dir: &Path) -> bool {
let (i, d) = (wide_lower(image), wide_lower(dir));
let Some(rest) = i.strip_prefix(d.as_str()) else {
return false;
};
rest.starts_with('\\') || rest.starts_with('/')
}
/// Is `image`'s file name `want`? The operator-supplied [`DetectSpec::process_name`] fallback: a bare
/// name, compared against the image's last component only, so `Hades.exe` never matches a path that
/// merely contains it.
fn same_name(image: &Path, want: &str) -> bool {
image
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case(want.trim()))
}
fn eq_ignore_case(a: &Path, b: &Path) -> bool {
wide_lower(a) == wide_lower(b)
}
/// Lowercase a path for comparison, normalizing the `\\?\` prefix `canonicalize` prepends (a
/// store-derived path canonicalizes to the verbatim form while a live image path does not, and the two
/// must still compare equal).
fn wide_lower(p: &Path) -> String {
let s = p.to_string_lossy();
let s = s.strip_prefix(r"\\?\").unwrap_or(&s);
s.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_dir_match_is_case_insensitive_and_separator_aware() {
let dir = Path::new(r"C:\Games\Hades");
assert!(under_dir(Path::new(r"c:\games\hades\Hades.exe"), dir));
assert!(under_dir(Path::new(r"C:\Games\Hades\bin\crash.exe"), dir));
// A sibling whose name merely starts with the same text is not under it.
assert!(!under_dir(Path::new(r"C:\Games\HadesII\game.exe"), dir));
// The directory itself is not "under" itself (no process image is a bare directory anyway).
assert!(!under_dir(dir, dir));
assert!(!under_dir(Path::new(r"D:\Games\Hades\Hades.exe"), dir));
}
#[test]
fn exe_match_is_case_insensitive_and_ignores_the_verbatim_prefix() {
// `canonicalize` yields the `\\?\` verbatim form; a live image path never has it.
assert!(same_path(
Path::new(r"C:\Games\Hades\Hades.exe"),
Path::new(r"\\?\c:\games\hades\hades.exe")
));
assert!(!same_path(
Path::new(r"C:\Games\Hades\Hades.exe"),
Path::new(r"C:\Games\Hades\Other.exe")
));
}
/// The operator-supplied fallback ([`DetectSpec::process_name`]): the image's own file name and
/// nothing else — never a path that merely contains the name.
#[test]
fn process_name_matches_the_image_name_only() {
assert!(same_name(
Path::new(r"C:\Games\Hades\Hades.exe"),
"hades.exe"
));
assert!(same_name(
Path::new(r"C:\Games\Hades\Hades.exe"),
" Hades.exe "
));
// A longer name that starts the same way is a different program.
assert!(!same_name(
Path::new(r"C:\Games\Hades\HadesLauncher.exe"),
"Hades.exe"
));
// A directory called the same thing doesn't qualify its contents.
assert!(!same_name(
Path::new(r"C:\Games\Hades.exe\other.exe"),
"Hades.exe"
));
// …and it reaches the live scan, which the `find` early-return would otherwise skip.
let me = std::env::current_exe().expect("current exe");
let name = me.file_name().and_then(|n| n.to_str()).expect("exe name");
let spec = DetectSpec {
process_name: Some(name.to_ascii_uppercase()),
..Default::default()
};
assert!(Scanner::system()
.find(&spec, None)
.iter()
.any(|p| p.pid == std::process::id()));
}
#[test]
fn a_spec_with_no_path_signal_matches_nothing() {
// No reaper and no environment reading on Windows: an appid-only or env-only spec has nothing
// to match here, and must not fall through to matching everything.
let s = Scanner::system();
assert!(s.find(&DetectSpec::steam(570), None).is_empty());
assert!(s
.find(
&DetectSpec::default().with_env("HEROIC_APP_NAME", Some("Quail".into())),
None
)
.is_empty());
assert!(s.find(&DetectSpec::default(), None).is_empty());
}
/// The live check: scan the real process table for this test process itself, which is the only way
/// to notice a wrong `PROCESSENTRY32W` size, a failing `OpenProcess` access mask, or creation
/// times read from the wrong `FILETIME` half.
#[test]
fn finds_this_process_by_its_own_image_path() {
let me = std::env::current_exe().expect("current exe");
let s = Scanner::system();
let pid = std::process::id();
let found = s.find(&DetectSpec::exe(&me), None);
assert!(
found.iter().any(|p| p.pid == pid),
"scanning the real process table did not find this test process ({pid}) as {}",
me.display()
);
// Its own directory finds it too.
let dir = me.parent().expect("exe has a parent");
assert!(s
.find(&DetectSpec::dir(dir), None)
.iter()
.any(|p| p.pid == pid));
// The same process re-verifies as alive by (pid, creation time)…
let mine: Vec<ProcRef> = found.into_iter().filter(|p| p.pid == pid).collect();
assert_eq!(s.alive(&mine), mine);
// …and a wrong creation time for the same pid does not.
let recycled = vec![ProcRef {
pid,
start: mine[0].start ^ 0xFFFF,
}];
assert!(s.alive(&recycled).is_empty());
// A launch reference in the future excludes it — rule 1, against a real creation time.
let future = s.now_stamp().unwrap() + 3_600.0;
assert!(s.find(&DetectSpec::exe(&me), Some(future)).is_empty());
}
}
+35 -9
View File
@@ -104,9 +104,13 @@ pub struct SessionPlan {
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
pub wire_chunk: Option<usize>,
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
/// captures — every non-gamescope compositor; gamescope embeds the pointer itself).
/// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their
/// blending path when this is set, so the pointer never silently vanishes from the stream.
/// captures). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only
/// where the ENCODER is the compositing stage (Linux cursor-forward and gamescope sessions);
/// Windows is always `false` (the IDD capturer composites the pointer itself). Encoders
/// whose fast path cannot blend (the Vulkan EFC RGB-direct source, native NV12) stay off
/// those shapes when this is set — see [`Self::output_format`] and
/// `encode::cursor_blend_capable`, the pre-open mirror that gates the cursor channel — so
/// the pointer never silently vanishes from the stream.
pub cursor_blend: bool,
/// The session negotiated the cursor-forward channel (M2/M2c): the client draws the pointer
/// locally, so `cursor_blend` is off AND (on Windows) the capturer sets the driver's
@@ -198,13 +202,15 @@ impl SessionPlan {
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
// backend — resolved HERE from the plan's codec so the capturer never reaches back
// into encode (the same one-way edge as `gpu` above). BUT the native-NV12 encode path
// has no CSC stage to fold the cursor into (it assumes gamescope embeds its pointer,
// which it does NOT into the PipeWire node) — so a gamescope-cursor session (Phase C)
// must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend that draws
// `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the native-NV12 cursor
// blend is the perf-preserving follow-up.
// has no CSC stage to fold the cursor into — so ANY cursor-compositing session
// (gamescope Phase C, whose XFixes pointer is absent from the PipeWire node, AND a
// cursor-forward session, whose capture-mouse flip needs the host composite on
// demand) must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend
// that draws `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the
// native-NV12 cursor blend is the perf-preserving follow-up. (`cursor_blend`
// subsumes `gamescope_cursor` — see [`cursor_blend_for`].)
#[cfg(target_os = "linux")]
nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.gamescope_cursor,
nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.cursor_blend,
#[cfg(not(target_os = "linux"))]
nv12_native: false,
}
@@ -217,6 +223,26 @@ pub(crate) fn resolve_topology() -> SessionTopology {
SessionTopology::SingleProcess
}
/// THE rule for [`SessionPlan::cursor_blend`], shared by every resolve caller (initial plan and
/// the mid-stream compositor re-gate) so they can't drift:
/// * **Linux**: the encoder is the compositing stage — blend for a cursor-forward session (the
/// capture-mouse flip needs the host composite on demand) and for gamescope (its capture
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video).
/// * **Windows**: never — the IDD capturer composites the pointer itself (`cursor_blend.rs` /
/// DWM), and no Windows encode backend reads `frame.cursor`. Asking the encoder anyway made
/// `open_video`'s blends-cursor backstop fire spuriously on every cursor-channel session.
pub(crate) fn cursor_blend_for(cursor_forward: bool, gamescope: bool) -> bool {
#[cfg(target_os = "windows")]
{
let _ = (cursor_forward, gamescope);
false
}
#[cfg(not(target_os = "windows"))]
{
cursor_forward || gamescope
}
}
#[cfg(target_os = "windows")]
fn resolve_encoder() -> EncoderBackend {
match crate::encode::windows_resolved_backend() {
@@ -0,0 +1,294 @@
//! Session⇄game lifetime settings (`<config>/session-settings.json`).
//!
//! Two operator choices, both about what a session and its game owe each other
//! (design/session-game-lifetime.md §8):
//!
//! * [`SessionSettings::session_on_game_exit`] — end the streaming session when the launched game
//! exits, so the client returns to its library instead of a bare desktop. **On by default**: it is
//! what a player expects, and it is already the shipped behavior for dedicated game sessions.
//! * [`SessionSettings::game_on_session_end`] — end the launched game when the session ends.
//! **Off by default** ([`GameOnSessionEnd::Keep`]), because ending a game can cost unsaved
//! progress. `Always` additionally waits out [`SessionSettings::disconnect_grace_seconds`] so a
//! network blip never costs a save.
//!
//! Deliberately its own small store rather than more axes on the display policy: keep-alive is about
//! how long a *display* survives a disconnect (default 10 s), while this is about a *game* and the
//! player's unsaved progress (default 5 minutes). Sharing one number would force one of them wrong.
//!
//! Storage follows the same shape as `DisplayPolicyStore` / `pf_gpu::GpuPrefStore`: a missing **or**
//! corrupt file means "unconfigured" with a warning (a settings file must never fail host startup),
//! writes are private-dir + temp + atomic rename, and the in-memory value changes only after the
//! write lands.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use utoipa::ToSchema;
/// What to do with the launched game when its session ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum GameOnSessionEnd {
/// Leave it running (the historical behavior, and the default — nothing is ever killed unless
/// the operator asks for it).
#[default]
Keep,
/// End it when the client stops the session deliberately, but leave it running if the client
/// merely vanished — so a dropped connection stays reconnectable.
OnQuit,
/// End it whenever the session ends. A deliberate stop ends it at once; a drop starts the
/// reconnect window and ends it only if nobody comes back.
Always,
}
impl GameOnSessionEnd {
pub fn as_str(self) -> &'static str {
match self {
Self::Keep => "keep",
Self::OnQuit => "on_quit",
Self::Always => "always",
}
}
}
/// The default reconnect window before `Always` ends a game — long enough to cover a Wi-Fi
/// roam, a client crash-and-restart, or a walk to another room, because the cost of being wrong is
/// the player's unsaved progress.
const DEFAULT_GRACE_SECS: u32 = 300;
/// Clamp bounds for the window. The floor keeps a mis-typed `1` from making `Always` behave like an
/// instant kill; the ceiling (24 h) keeps a stray large value from pinning a lease forever.
const MIN_GRACE_SECS: u32 = 10;
const MAX_GRACE_SECS: u32 = 86_400;
fn default_grace() -> u32 {
DEFAULT_GRACE_SECS
}
fn default_true() -> bool {
true
}
fn one() -> u32 {
1
}
/// The persisted settings.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct SessionSettings {
#[serde(default = "one")]
pub version: u32,
/// End the launched game when the session ends. See [`GameOnSessionEnd`].
#[serde(default)]
pub game_on_session_end: GameOnSessionEnd,
/// End the streaming session when the launched game exits.
#[serde(default = "default_true")]
pub session_on_game_exit: bool,
/// How long a vanished client has to reconnect before `Always` ends its game. Ignored by the
/// other two policies.
#[serde(default = "default_grace")]
pub disconnect_grace_seconds: u32,
}
impl Default for SessionSettings {
fn default() -> Self {
Self {
version: 1,
game_on_session_end: GameOnSessionEnd::default(),
session_on_game_exit: true,
disconnect_grace_seconds: DEFAULT_GRACE_SECS,
}
}
}
impl SessionSettings {
/// Clamp anything a hand-edited file (or a plugin) could get wrong.
pub fn sanitized(mut self) -> Self {
self.version = 1;
self.disconnect_grace_seconds = self
.disconnect_grace_seconds
.clamp(MIN_GRACE_SECS, MAX_GRACE_SECS);
self
}
}
/// The store: the loaded file value (`None` when no file exists) behind its path.
pub struct SessionSettingsStore {
path: PathBuf,
cur: Mutex<Option<SessionSettings>>,
}
impl SessionSettingsStore {
/// Load from `path`. Missing ⇒ unconfigured; corrupt ⇒ unconfigured **with a warning**, never a
/// startup failure.
pub fn load_from(path: PathBuf) -> Self {
let cur = match std::fs::read(&path) {
Ok(bytes) => match serde_json::from_slice::<SessionSettings>(&bytes) {
Ok(s) => Some(s.sanitized()),
Err(e) => {
tracing::warn!(path = %path.display(),
"session-settings.json unreadable — using built-in defaults: {e}");
None
}
},
Err(_) => None,
};
Self {
path,
cur: Mutex::new(cur),
}
}
/// The stored settings, or the defaults when unconfigured.
pub fn get(&self) -> SessionSettings {
self.cur
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.unwrap_or_default()
}
/// Whether an operator has ever configured this host (drives the console's "using defaults" hint).
pub fn configured(&self) -> bool {
self.cur.lock().unwrap_or_else(|e| e.into_inner()).is_some()
}
/// Persist + adopt (sanitized first). Memory changes only if the write lands, so a full disk
/// can't leave the running host disagreeing with its own file.
pub fn set(&self, settings: SessionSettings) -> Result<()> {
let settings = settings.sanitized();
if let Some(dir) = self.path.parent() {
pf_paths::create_private_dir(dir)?;
}
let tmp = self.path.with_extension("json.tmp");
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&settings)?)?;
std::fs::rename(&tmp, &self.path)?;
*self.cur.lock().unwrap_or_else(|e| e.into_inner()) = Some(settings);
Ok(())
}
}
/// The process-wide store, loaded once on first access — the same global-accessor shape as
/// `vdisplay::prefs()` / `pf_gpu::prefs()`, because the lifetime decisions happen deep in the session
/// teardown path where no app state is threaded.
pub fn store() -> &'static SessionSettingsStore {
static STORE: OnceLock<SessionSettingsStore> = OnceLock::new();
STORE.get_or_init(|| {
SessionSettingsStore::load_from(pf_paths::config_dir().join("session-settings.json"))
})
}
/// The effective settings (shorthand for `store().get()`).
pub fn get() -> SessionSettings {
store().get()
}
/// Which lifetime axes this build actually acts on, for the console to grey out the rest. Both
/// directions need a launch path and a way to see processes; macOS has neither today.
pub fn enforced() -> Vec<String> {
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
vec![
"session_on_game_exit".to_string(),
"game_on_session_end".to_string(),
"disconnect_grace_seconds".to_string(),
]
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_the_documented_ones() {
let d = SessionSettings::default();
// End-session-on-game-exit ships ON; ending games ships OFF.
assert!(d.session_on_game_exit);
assert_eq!(d.game_on_session_end, GameOnSessionEnd::Keep);
assert_eq!(d.disconnect_grace_seconds, 300);
}
#[test]
fn an_empty_object_decodes_to_the_defaults() {
// Every field is `#[serde(default)]`, so a file written by an older/partial writer keeps
// working instead of failing the load.
let s: SessionSettings = serde_json::from_str("{}").expect("empty object");
assert!(s.session_on_game_exit);
assert_eq!(s.game_on_session_end, GameOnSessionEnd::Keep);
assert_eq!(s.disconnect_grace_seconds, 300);
assert_eq!(s.version, 1);
}
#[test]
fn policy_names_are_snake_case_on_the_wire() {
// These strings are the API contract with the console.
let json = serde_json::to_string(&GameOnSessionEnd::OnQuit).unwrap();
assert_eq!(json, "\"on_quit\"");
let back: GameOnSessionEnd = serde_json::from_str("\"always\"").unwrap();
assert_eq!(back, GameOnSessionEnd::Always);
assert_eq!(GameOnSessionEnd::Keep.as_str(), "keep");
}
#[test]
fn grace_is_clamped_both_ways() {
let low = SessionSettings {
disconnect_grace_seconds: 0,
..Default::default()
}
.sanitized();
assert_eq!(low.disconnect_grace_seconds, MIN_GRACE_SECS);
let high = SessionSettings {
disconnect_grace_seconds: u32::MAX,
..Default::default()
}
.sanitized();
assert_eq!(high.disconnect_grace_seconds, MAX_GRACE_SECS);
}
#[test]
fn missing_file_is_unconfigured_and_corrupt_file_does_not_fail() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("session-settings.json");
let store = SessionSettingsStore::load_from(path.clone());
assert!(!store.configured());
assert_eq!(store.get().game_on_session_end, GameOnSessionEnd::Keep);
std::fs::write(&path, b"{ not json").unwrap();
let store = SessionSettingsStore::load_from(path.clone());
assert!(
!store.configured(),
"corrupt file must read as unconfigured"
);
assert!(store.get().session_on_game_exit, "defaults still apply");
}
#[test]
fn set_persists_sanitized_and_round_trips() {
let td = tempfile::tempdir().unwrap();
let path = td.path().join("session-settings.json");
let store = SessionSettingsStore::load_from(path.clone());
store
.set(SessionSettings {
version: 99,
game_on_session_end: GameOnSessionEnd::Always,
session_on_game_exit: false,
disconnect_grace_seconds: 5, // below the floor
})
.expect("write");
assert!(store.configured());
let got = store.get();
assert_eq!(got.game_on_session_end, GameOnSessionEnd::Always);
assert!(!got.session_on_game_exit);
assert_eq!(got.disconnect_grace_seconds, MIN_GRACE_SECS);
assert_eq!(got.version, 1, "version is normalized, not echoed");
// A fresh load sees the same thing, and no temp file is left behind.
let reloaded = SessionSettingsStore::load_from(path.clone());
assert_eq!(reloaded.get().game_on_session_end, GameOnSessionEnd::Always);
assert!(!path.with_extension("json.tmp").exists());
}
}
+226 -14
View File
@@ -33,6 +33,10 @@ struct LiveSession {
codec: Codec,
/// The session's teardown flag ([`stop_all`] → mgmt `DELETE /session`).
stop: Arc<AtomicBool>,
/// The session's *deliberate*-stop flag ([`stop_all_quit`]). Distinct from `stop`: it marks the
/// teardown as intended rather than a drop, which is what skips the display's keep-alive linger
/// and what the end-game-on-session-end policy keys off.
quit: Arc<AtomicBool>,
/// One-shot force-keyframe flag ([`force_idr_all`] → mgmt `POST /session/idr`); the encode loop
/// drains it alongside a client's decode-recovery keyframe request.
force_idr: Arc<AtomicBool>,
@@ -45,6 +49,9 @@ struct LiveSession {
ttff_ms: Arc<AtomicU32>,
/// Most recent completed mid-stream resize (reconfigure → pipeline rebuilt), ms; 0 = none yet.
last_resize_ms: Arc<AtomicU32>,
/// The launched game's lease, when this session launched a title — so `/status` can report what
/// is actually running and the console can show it (design/session-game-lifetime.md §8a).
game: Option<Arc<crate::gamelease::LeaseShared>>,
}
/// A resolved read of one live session, for the `/status` view.
@@ -82,21 +89,49 @@ fn session_ref(s: &LiveSession) -> crate::events::SessionRef {
}
}
/// What [`register`] needs to publish a session. A named struct rather than a positional argument
/// list: half of these are same-typed `Arc<Atomic…>` handles, so a transposed pair would compile
/// cleanly and quietly report the wrong number on the Dashboard.
pub struct Registration {
/// Packed `w:16|h:16|hz:16` ([`crate::native::pack_mode`]); updated on a mode switch.
pub mode: Arc<AtomicU64>,
/// Live encoder target (kbps); updated on an adaptive-bitrate change.
pub bitrate_kbps: Arc<AtomicU32>,
pub codec: Codec,
/// Teardown flag ([`stop_all`]).
pub stop: Arc<AtomicBool>,
/// Deliberate-stop flag ([`stop_all_quit`]).
pub quit: Arc<AtomicBool>,
/// One-shot force-keyframe flag ([`force_idr_all`]).
pub force_idr: Arc<AtomicBool>,
/// Short client label (cert-fingerprint prefix / peer IP).
pub client: String,
pub hdr: bool,
/// Bring-up total slot (hello → first packet), ms.
pub ttff_ms: Arc<AtomicU32>,
/// Most recent mid-stream resize total, ms.
pub last_resize_ms: Arc<AtomicU32>,
/// The launched game's lease, when this session launched a title.
pub game: Option<Arc<crate::gamelease::LeaseShared>>,
}
/// Registers a live native session; the returned guard removes it on drop (session end).
/// Emits the `session.started` lifecycle event; the guard's drop emits `session.ended` — RAII,
/// so every exit path (return, `?`, panic-unwind) pairs them.
#[allow(clippy::too_many_arguments)]
pub fn register(
mode: Arc<AtomicU64>,
bitrate_kbps: Arc<AtomicU32>,
codec: Codec,
stop: Arc<AtomicBool>,
force_idr: Arc<AtomicBool>,
client: String,
hdr: bool,
ttff_ms: Arc<AtomicU32>,
last_resize_ms: Arc<AtomicU32>,
) -> LiveSessionGuard {
pub fn register(reg: Registration) -> LiveSessionGuard {
let Registration {
mode,
bitrate_kbps,
codec,
stop,
quit,
force_idr,
client,
hdr,
ttff_ms,
last_resize_ms,
game,
} = reg;
let id = next_id();
let session = LiveSession {
id,
@@ -104,11 +139,13 @@ pub fn register(
bitrate_kbps,
codec,
stop,
quit,
force_idr,
client,
hdr,
ttff_ms,
last_resize_ms,
game,
};
crate::events::emit(crate::events::EventKind::SessionStarted {
session: session_ref(&session),
@@ -167,14 +204,135 @@ pub fn snapshot() -> Vec<SessionSnapshot> {
.collect()
}
/// Signals every live native session to tear down (mgmt `DELETE /session`). Best-effort: the video
/// + send loops observe the flag and exit, ending the stream; the guard then clears the entry.
/// One launched game, as `/status` reports it.
pub struct GameSnapshot {
/// The session streaming it, or `None` for a game whose session is gone and which is waiting out
/// its reconnect window.
pub session_id: Option<u64>,
pub client: String,
pub app_id: Option<String>,
pub title: String,
pub store: Option<String>,
pub plane: crate::events::Plane,
/// `launching` / `running` / `exited`, or `grace` for a game on its reconnect window.
pub state: &'static str,
/// Seconds left before the game is ended, for a `grace` row.
pub grace_remaining_s: Option<u64>,
}
/// The compat plane's launched game, while it has one.
///
/// GameStream sessions are not in the live-session registry above — that registry holds the native
/// loop's own `Arc` handles (mode, bitrate, the stop/quit flags), none of which the compat plane has.
/// It is single-session by construction (one `AppState.launch`), so one slot is the whole story, and
/// this is what keeps a Moonlight client's game visible on the Dashboard alongside a native one.
fn gs_game() -> &'static Mutex<Option<Arc<crate::gamelease::LeaseShared>>> {
static SLOT: OnceLock<Mutex<Option<Arc<crate::gamelease::LeaseShared>>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
/// Publish the compat plane's game for the status surface; the returned guard retracts it when the
/// stream loop exits by any path (the plane's counterpart to [`LiveSessionGuard`]).
pub fn publish_gamestream_game(shared: Arc<crate::gamelease::LeaseShared>) -> GamestreamGameGuard {
*gs_game().lock().unwrap_or_else(|e| e.into_inner()) = Some(shared);
GamestreamGameGuard
}
/// Clears the compat plane's published game on drop.
pub struct GamestreamGameGuard;
impl Drop for GamestreamGameGuard {
fn drop(&mut self) {
*gs_game().lock().unwrap_or_else(|e| e.into_inner()) = None;
}
}
/// Every launched game the host currently knows about: the live sessions' games first, then the
/// compat plane's, then any game whose session has ended and which is waiting out its reconnect
/// window before being ended.
///
/// The sources are deliberately separate — a grace-pending game has no session to attribute it
/// to, and hiding it would make "the host is about to close my game" invisible in the UI.
pub fn games() -> Vec<GameSnapshot> {
let mut out: Vec<GameSnapshot> = registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.filter_map(|s| {
let g = s.game.as_ref()?;
Some(GameSnapshot {
session_id: Some(s.id),
client: g.client.clone(),
app_id: g.game.id.clone(),
title: g.game.title.clone(),
store: g.game.store.clone(),
plane: g.plane,
state: g.state().as_str(),
grace_remaining_s: None,
})
})
.collect();
out.extend(
gs_game()
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|g| GameSnapshot {
// The compat plane has no session id to attribute it to; the console tells it from a
// grace row by the state, which is never `grace` while a session is streaming it.
session_id: None,
client: g.client.clone(),
app_id: g.game.id.clone(),
title: g.game.title.clone(),
store: g.game.store.clone(),
plane: g.plane,
state: g.state().as_str(),
grace_remaining_s: None,
}),
);
out.extend(
crate::gamelease::pending_snapshot()
.into_iter()
.map(|(g, remaining)| GameSnapshot {
session_id: None,
client: g.client.clone(),
app_id: g.game.id.clone(),
title: g.game.title.clone(),
store: g.game.store.clone(),
plane: g.plane,
state: "grace",
grace_remaining_s: Some(remaining),
}),
);
out
}
/// Signals every live native session to tear down. Best-effort: the video + send loops observe the
/// flag and exit, ending the stream; the guard then clears the entry.
///
/// This is the *drop*-flavored stop — the session ends, but nothing treats it as intended. Prefer
/// [`stop_all_quit`] for an operator action.
pub fn stop_all() {
for s in registry().lock().unwrap().iter() {
s.stop.store(true, Ordering::SeqCst);
}
}
/// Signals every live native session to tear down **deliberately** (mgmt `DELETE /session`, the
/// console's and a plugin's Stop).
///
/// An operator pressing Stop is a decision, and the host now says so: `quit` before `stop` makes the
/// teardown look exactly like a client's own Stop, so the display skips its keep-alive linger and the
/// end-game-on-session-end policy sees an intent rather than a network drop. (Before this, a
/// management stop was indistinguishable from a client vanishing, which left the display lingering
/// for a session nobody was coming back to.)
pub fn stop_all_quit() {
for s in registry().lock().unwrap().iter() {
s.quit.store(true, Ordering::SeqCst);
s.stop.store(true, Ordering::SeqCst);
}
}
/// Requests a forced keyframe on every live native session (mgmt `POST /session/idr`). The encode
/// loop drains the flag exactly like a client decode-recovery request.
pub fn force_idr_all() {
@@ -182,3 +340,57 @@ pub fn force_idr_all() {
s.force_idr.store(true, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A Moonlight client's game has no live-session entry to hang off, so without the compat-plane
/// slot it would be missing from `/status` entirely — the Dashboard would show a stream with no
/// game while one was plainly running. Publishing must also be strictly scoped to the stream: the
/// row has to vanish with the guard, or a finished session leaves a ghost game on the console.
#[test]
fn a_gamestream_game_is_visible_only_while_its_stream_runs() {
let id = "steam:1701";
let mine = || {
games()
.into_iter()
.find(|g| g.app_id.as_deref() == Some(id))
};
let lease = crate::gamelease::open(
crate::gamelease::LeaseRequest {
game: crate::gamelease::GameRef {
id: Some(id.to_string()),
store: Some("steam".into()),
title: "Test Title".into(),
},
client: "192.0.2.7".into(),
plane: crate::events::Plane::Gamestream,
// No signals: an inert lease, so no watcher thread races this test's assertions.
spec: crate::library::DetectSpec::default(),
nested: false,
child: None,
launch_stamp: None,
},
Box::new(|| {}),
);
assert!(mine().is_none(), "not published yet");
{
let _pub = publish_gamestream_game(lease.shared());
let row = mine().expect("the compat plane's game is reported");
assert_eq!(
row.session_id, None,
"no live-session entry to attribute it to"
);
assert_eq!(row.plane, crate::events::Plane::Gamestream);
assert_eq!(row.client, "192.0.2.7");
assert_eq!(row.title, "Test Title");
// Never `grace` while the stream is up — that state is what the console keys its
// countdown and "End now" off.
assert_ne!(row.state, "grace");
}
assert!(mine().is_none(), "the row goes with the stream");
}
}
+3 -1
View File
@@ -94,7 +94,9 @@ pub fn run(opts: Options) -> Result<()> {
want_hdr,
"spike source: xdg ScreenCast portal (live monitor)"
);
capture::open_portal_monitor(want_hdr).context("open portal capturer")?
// Embedded cursor: the spike passes `cursor_blend = false` to its encoder open, so
// a metadata pointer would be composited by nothing.
capture::open_portal_monitor(want_hdr, false).context("open portal capturer")?
}
Source::KwinVirtual => {
let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin);
@@ -0,0 +1,169 @@
//! Asking a game to close on Windows, and killing it if it won't.
//!
//! The two halves of [`crate::gamelease`]'s termination ladder that need Win32: post `WM_CLOSE` to the
//! game's windows, then `TerminateProcess` whatever ignored it. Kept out of `procscan` (read-only by
//! construction) and out of `gamelease` (platform-neutral orchestration).
//!
//! ### Why the desktop dance
//!
//! The polite half is the reason this module is not three lines. `EnumWindows` enumerates the windows
//! of the **calling thread's desktop** — and the host runs as SYSTEM in session 0, whose desktop has
//! none of the interactive user's windows on it. Without first binding the thread to the input desktop
//! the enumeration comes back empty and the ladder silently degrades to killing every game outright,
//! which is exactly the unsaved-progress outcome the grace window exists to avoid.
//!
//! Same `OpenInputDesktop`/`SetThreadDesktop` pattern the input injector uses for `SendInput`
//! (`pf-inject`'s `sendinput.rs`), for the same reason: a UAC prompt, the lock screen or
//! Ctrl-Alt-Del swaps the input desktop out from under us.
use windows::Win32::Foundation::{CloseHandle, HWND, LPARAM, WPARAM};
use windows::Win32::System::StationsAndDesktops::{
CloseDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS,
HDESK,
};
use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetWindowThreadProcessId, IsWindowVisible, PostMessageW, WM_CLOSE,
};
/// Exit code reported for a game the host killed. Arbitrary but non-zero, so anything reading it can
/// tell it apart from a clean quit.
const KILLED_EXIT_CODE: u32 = 1;
/// `GENERIC_ALL` for a desktop open. The `windows` crate models desktop rights as their own type, so
/// the generic right isn't reachable through it — the same local const the input backends define
/// (`pf-inject`'s `sendinput.rs` / `pointer_windows.rs`).
const DESKTOP_GENERIC_ALL: u32 = 0x1000_0000;
/// Binds the calling thread to the desktop currently receiving input, for as long as it is held.
///
/// The thread this runs on is dedicated and short-lived (`pf1-gameterm`), so the previous desktop is
/// not restored — only the handle is released.
struct InputDesktop(HDESK);
impl InputDesktop {
/// `None` when the input desktop can't be opened or bound (an unprivileged host, or a secure
/// desktop we aren't allowed onto). Callers degrade rather than fail: without it the polite pass
/// finds no windows, and the kill pass still works.
fn attach() -> Option<Self> {
// SAFETY: FFI calls taking by-value args only (constant desktop flags, a bool, an access
// mask). `OpenInputDesktop` yields an owned `HDESK` only on `Ok`; it is then either installed
// on this thread and owned by the returned guard (closed exactly once in `Drop`), or closed
// here on the failure path. `SetThreadDesktop` rebinds only the calling thread — which owns
// this guard — and succeeds only when that thread has no windows or hooks, true of the fresh
// termination thread.
unsafe {
let h = OpenInputDesktop(
DESKTOP_CONTROL_FLAGS(0),
false,
DESKTOP_ACCESS_FLAGS(DESKTOP_GENERIC_ALL),
)
.ok()?;
if SetThreadDesktop(h).is_ok() {
Some(Self(h))
} else {
let _ = CloseDesktop(h);
None
}
}
}
}
impl Drop for InputDesktop {
fn drop(&mut self) {
// SAFETY: `self.0` is the handle this guard owns and has not closed; `CloseDesktop` runs once
// here with no later use.
unsafe {
let _ = CloseDesktop(self.0);
}
}
}
/// What the enumeration callback needs, passed through `LPARAM`.
///
/// Owns its pid list rather than borrowing: the value round-trips through a raw pointer, and a
/// borrowed lifetime there is a subtlety with no upside for one small allocation per termination.
struct CloseCtx {
pids: Vec<u32>,
posted: usize,
}
/// Ask every visible top-level window belonging to `pids` to close, the way clicking its X would —
/// so a game runs its own shutdown path and gets the chance to save. Returns how many windows were
/// asked.
///
/// Zero is a perfectly ordinary answer: a game may be mid-load with no window yet, or the host may not
/// have reached the input desktop. The caller's job is to wait and then insist, not to treat this as
/// success.
pub fn request_close(pids: &[u32]) -> usize {
if pids.is_empty() {
return 0;
}
// Without the input desktop this enumerates session 0 and finds nothing (see the module docs), so
// failing to attach means skipping the polite pass rather than doing it uselessly. Held for the
// whole enumeration.
let Some(_desktop) = InputDesktop::attach() else {
tracing::debug!(
"could not bind to the input desktop — skipping the polite close and going straight to \
the kill pass"
);
return 0;
};
let mut ctx = CloseCtx {
pids: pids.to_vec(),
posted: 0,
};
// SAFETY: `EnumWindows` invokes `enum_close` synchronously for each top-level window on this
// thread's desktop and returns before this frame exits, so the `&mut ctx` pointer it carries in
// the `LPARAM` stays valid for the whole call and is not aliased (nothing else touches `ctx`
// while the enumeration runs).
unsafe {
let _ = EnumWindows(Some(enum_close), LPARAM(&mut ctx as *mut CloseCtx as isize));
}
ctx.posted
}
/// `EnumWindows` callback: post `WM_CLOSE` to each visible window owned by one of the target pids.
///
/// Visible top-level windows only — a game's message-only and tool windows would swallow the close, and
/// posting to them achieves nothing while making the count meaningless.
unsafe extern "system" fn enum_close(hwnd: HWND, lparam: LPARAM) -> windows::core::BOOL {
// SAFETY: `lparam` is the `&mut CloseCtx` `request_close` passed in, valid for the whole
// enumeration; the callback is invoked synchronously on the same thread, so this is the only live
// reference to it.
let ctx = unsafe { &mut *(lparam.0 as *mut CloseCtx) };
let mut pid = 0u32;
// SAFETY: `hwnd` is the window the enumeration handed us; `pid` is a live local we own.
unsafe {
GetWindowThreadProcessId(hwnd, Some(&mut pid));
if ctx.pids.contains(&pid) && IsWindowVisible(hwnd).as_bool() {
// Posted, not sent: a `SendMessage` would block this thread on a hung game's message pump.
if PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0)).is_ok() {
ctx.posted += 1;
}
}
}
true.into() // keep enumerating — a game can own several windows
}
/// Kill `pids` outright. Returns how many were terminated.
///
/// The caller must have re-verified each pid against its recorded start time immediately before
/// calling this ([`crate::procscan::Scanner::alive`]) — Windows recycles pids briskly, and this call is
/// unforgiving about being pointed at the wrong one.
pub fn kill(pids: &[u32]) -> usize {
let mut killed = 0;
for &pid in pids {
// SAFETY: `OpenProcess` yields an owned handle only on `Ok`, which is closed exactly once
// below; `TerminateProcess` takes it by value plus a plain exit code.
unsafe {
if let Ok(h) = OpenProcess(PROCESS_TERMINATE, false, pid) {
if TerminateProcess(h, KILLED_EXIT_CODE).is_ok() {
killed += 1;
}
let _ = CloseHandle(h);
}
}
}
killed
}
+25
View File
@@ -25,6 +25,8 @@ and nothing you configure here runs anywhere near the streaming path.
| `client.connected` / `client.disconnected` | a client session is admitted / goes away | device name, cert fingerprint, plane (`native`/`gamestream`); disconnect adds `reason`: `quit` (user stop), `timeout` (vanished), `error` |
| `session.started` / `session.ended` | an A/V session registers / ends | session id, client label, mode (`3840x2160@120`), HDR |
| `stream.started` / `stream.stopped` | video actually starts / stops | mode, HDR, client name, launched app id/title (when one was requested), plane |
| `game.running` | a launched game's own process is seen running (not merely its launcher) | app id, title, store, client, plane |
| `game.exited` | a launched game is gone | the same, plus `reason`: `exited` (the player quit it) or `terminated` (the host closed it, per your [session⇄game settings](/docs/virtual-displays#when-a-game-ends-and-when-a-session-does)) |
| `pairing.pending` | an unpaired device knocks (once per device, not per retry) | device name, fingerprint, plane |
| `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane |
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
@@ -124,6 +126,29 @@ best-effort, even if the session crashed:
A `do` that fails logs, keeps going, and its own `undo` is skipped (it never took effect).
## Reacting to a game, not a stream
`stream.stopped` tells you the *stream* ended; `game.exited` tells you the *game* did. They are
often the same moment, but not always — a desktop stream has no game at all, and a stream can
outlive its game if you turned off "end the session when the game exits".
If you have been polling the host to work out when a game finished, you don't need to any more:
```json
{ "hooks": [
{ "on": "game.running", "run": "~/.config/punktfunk/scripts/game-up.sh" },
{ "on": "game.exited", "run": "~/.config/punktfunk/scripts/game-down.sh" }
] }
```
Both carry the title in `PF_EVENT_GAME_TITLE` / `PF_EVENT_GAME_APP`, and `game.exited` adds
`PF_EVENT_REASON` so a script can tell "the player quit" (`exited`) from "the host closed it"
(`terminated`) — worth checking before you, say, power the TV off.
Ending the session yourself when a game exits needs no script at all: it is the default behavior,
under Host → *Virtual displays*
[When a game or a session ends](/docs/virtual-displays#when-a-game-ends-and-when-a-session-does).
## The event stream (`GET /api/v1/events`)
For code, subscribe to the SSE stream on the management API (loopback + bearer token — the
+2 -1
View File
@@ -38,7 +38,7 @@ redundant or stale.
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. |
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). |
| `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). On a multi-GPU Windows box a forced hardware backend whose vendor contradicts the selected GPU (web-console preference) is **overridden** — the adapter wins and the host logs a warning; remove the stale pin. |
| `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. |
Resolution and refresh are **not** set here — **the client chooses them.** When a device connects,
@@ -117,6 +117,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| `PUNKTFUNK_VDISPLAY` | `pf` | Virtual-display backend. The bundled pf-vdisplay IddCx driver is the only backend now — informational; leave as `pf`. |
| `PUNKTFUNK_SECURE_DDA` | `1` | Capture the secure desktop (UAC / lock / login) so the stream survives those transitions. |
| `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. Superseded by the console's **Keep alive** setting — see [Virtual displays](/docs/virtual-displays). |
| `PUNKTFUNK_EXCLUSIVE_REASSERT_MS` | ms (default `2000`), `0` = off | How often the host re-checks that **exclusive** display topology actually held. Windows (or a GPU driver / display-poller tool) can quietly re-activate a physical panel moments after the host disabled it — seen on hybrid Intel+NVIDIA laptops — putting windows, the cursor, and the lock screen on a screen that isn't streamed. The host re-asserts and logs when that happens; `0` restores the old fire-and-forget behavior. |
| `PUNKTFUNK_RENDER_ADAPTER` | description substring | Multi-GPU boxes only: force the NVENC/capture GPU by adapter Description substring (e.g. `4090`). Leave unset on single-GPU machines. |
| `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. |
+43 -2
View File
@@ -136,14 +136,55 @@ Per-backend support:
refresh**, with just the game inside. The game boots straight in — no Steam Big Picture to navigate,
no game-mode desktop. Steam titles launch with the client hidden (`steam -silent`); non-Steam titles
start almost instantly (gamescope up in ~1 s, then the game's own boot). Combined with **keep alive**,
the game keeps running when you disconnect and you re-attach straight back into it; when you quit the
game, the session ends cleanly and your client returns to its library.
the game keeps running when you disconnect and you re-attach straight back into it.
Dedicated needs `gamescope` installed on the host; if it isn't, a launch falls back to **Auto**
routing. This axis is independent of the preset — pick it under Host → *Virtual displays*. On a box
that's already in Steam game mode, a dedicated Steam launch frees game mode's Steam first and restores
it when the session ends. (GameStream / Moonlight launches follow the same routing.)
## When a game ends, and when a session does
A streaming session and the game the host launched for it can share a fate. Two switches, under
Host → *Virtual displays***When a game or a session ends**. They apply to every store and both
protocols — and only ever to a game **this host launched for the session**: a game you started
yourself is never touched.
### When the game exits
**End the session** (default). Quit the game and your client goes back to its own library instead of
staring at your desktop. This is what a dedicated game session has always done; it now works on
every path — your live KDE/GNOME/Sway desktop, an attached gamescope, and Moonlight.
**Keep streaming** if you stream the desktop and treat the game as incidental.
### When the session ends
Whether stopping — or losing — a session also closes the game.
- **Leave it running** (default). Nothing is ever closed. Disconnect, and the game plays on for when
you come back.
- **Close it on Stop** — closing the client, or pressing *Stop* in the console, closes the game.
A network drop does not: you get your game back when you reconnect.
- **Always close it** — a drop closes it too, but only after a **reconnect window** (5 minutes by
default). Reconnect inside the window and nothing happens; the console shows the countdown while
it runs, with an **End now** button if you'd rather not wait.
Closing a game costs whatever it hadn't saved, which is why nothing closes by default. The host asks
first — a polite close, the same thing clicking the window's X does, so the game runs its own
shutdown — and only forces the issue after ten seconds of being ignored.
> **Keep alive and this setting are different clocks.** Keep-alive decides how long the *display*
> outlives a disconnect (10 s by default); the reconnect window decides how long the *game* does
> (5 minutes). A display set to **Forever** stays up regardless of what happens to the game — a
> pinned display is a deliberate "this box is a game host" choice, and closing a game doesn't undo it.
### Automation
The host publishes `game.running` and `game.exited` events (the latter says whether the player quit
it or the host closed it), so a hook or plugin can react without polling. See
[Automation](/docs/automation).
## Persistent scaling
Set your display **scaling** once and have it stick across reconnects. This works by giving each
+3 -1
View File
@@ -85,7 +85,9 @@ and enter the PIN on your [client](/docs/clients). The host's own management API
The service reads `%ProgramData%\punktfunk\host.env`. The defaults work out of the box; common knobs:
- `PUNKTFUNK_ENCODER=auto``auto` picks NVENC/AMF/QSV by GPU vendor. Force one with `nvenc`, `amf`,
`qsv`, or `sw` (software).
`qsv`, or `sw` (software). On a multi-GPU box the [web console's GPU preference](/docs/web-console)
wins: a forced backend whose vendor doesn't match the selected GPU is ignored (the host logs a
warning) — remove the stale line rather than fighting it.
- `PUNKTFUNK_HOST_CMD` — the service runs `serve --gamestream` by default (native punktfunk/1 **plus**
the GameStream/Moonlight-compat planes). Set it to `serve` for a **secure native-only** host with no
GameStream surface (GameStream pairs over plain HTTP and uses weaker legacy encryption — trusted LAN
@@ -1,5 +1,6 @@
;/*++
; punktfunk virtual DualSense — UMDF2 HID minidriver INF (M0 spike).
; punktfunk virtual PlayStation/Valve pads — UMDF2 HID minidriver INF (M0 spike).
; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck.
; Adapted from the WDK vhidmini2 UMDF2 sample (VhidminiUm.inx).
; Depends on MsHidUmdf.inf (build >= 22000).
; Install: devgen /add /hardwareid "root\pf_dualsense" (after pnputil /add-driver /install)
@@ -27,10 +28,19 @@ pf_dualsense.dll=1
[pf.NT$ARCH$.10.0...22000]
; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense`
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` for the host's
; virtual DualShock 4 / DualSense Edge — the same driver binds all of them and serves the matching
; identity per the device_type byte the host stamps into shared memory.
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4, pf_dualsenseedge, pf_steamdeck
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck`
; for the host's other virtual pads — ONE driver binds all of them (every model line below installs
; the same `pfDualSense` section) and serves the matching HID identity per the device_type byte the
; host stamps into shared memory.
;
; Each id carries its OWN description: Device Manager reads this string, and a single shared
; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been
; ignored. The HID layer (VID/PID, report descriptor, product string) was always per-type; this
; makes the human-readable name agree with it.
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense
%DeviceDescDS4%=pfDualSense, pf_dualshock4
%DeviceDescEdge%=pfDualSense, pf_dualsenseedge
%DeviceDescDeck%=pfDualSense, pf_steamdeck
[pfDualSense.NT]
CopyFiles=UMDriverCopy
@@ -78,4 +88,9 @@ ProviderString ="punktfunk"
ManufacturerString ="punktfunk"
ClassName ="HID device"
Disk_Description ="punktfunk DualSense Installation Disk"
; One per hardware id — these are what Device Manager shows. Keep them aligned with the product
; strings the driver serves per device_type (src/lib.rs `on_get_string`).
DeviceDesc ="punktfunk Virtual DualSense"
DeviceDescDS4 ="punktfunk Virtual DualShock 4"
DeviceDescEdge ="punktfunk Virtual DualSense Edge"
DeviceDescDeck ="punktfunk Virtual Steam Deck Controller"
+22 -1
View File
@@ -45,7 +45,7 @@ export default definePluginKit({
| `HostClient`, `PluginInfo` | the `pf` facade as services (`request` = the skew-safe untyped seam) |
| `makeConfigService` | Schema-driven config: raw shape on disk, defaults ONLY in the Schema (`withDecodingDefaultKey` + `encodingStrategy: "omit"`), atomic writes, world-writable refusal, `changes` stream |
| `makeCacheStore` | disposable derived state (corrupt/absent → empty, write-through) |
| `ProviderClient` + wire schemas | typed library-provider reconcile over the untyped wire |
| `ProviderClient` + wire schemas | typed library-provider reconcile over the untyped wire — including the optional `detect` hint (see below) |
| `makeSyncEngine` | poll + fs-watch + debounce + single-flight coalescing + fingerprint skip + status feed |
| `serveUi` / `httpApiEnv` | an `effect/unstable/httpapi` HttpApi behind the SDK's `servePluginUi`, core-only layers |
| `sseRoute` | the status SSE endpoint (httpapi has no event-stream media type) |
@@ -54,6 +54,27 @@ export default definePluginKit({
| `@punktfunk/plugin-kit/react` | browser glue: `createPluginRouter` (path→hash→fallback deep-link restore + `pf-ui:navigate`), `resolvePluginBase`, `useIsEmbedded`, `ResultGate`, `sseAtom` |
| `@punktfunk/plugin-kit/theme.css` | the console's violet identity for plugin UIs (import first in your Tailwind entry) |
## Telling the host how to recognize a running title (`detect`)
A `ProviderEntry` may carry an optional `detect` hint:
```ts
{ external_id: "playnite:9f2…", title: "Hades",
launch: { kind: "command", value: "playnite://playnite/start/9f2…" },
detect: { install_dir: "D:\\Games\\Hades" } }
```
It is what lets the host tell that the *game* has exited — which ends the streaming session, so the
player's client returns to its library instead of showing a bare desktop — and what lets an operator
who opted into it end the game when the session ends.
Omit it and nothing breaks: the host tracks the process it spawns for your launch command. It matters
when that command **hands off and exits** — a launcher client, `flatpak run`, a front-end that starts
an emulator — because then there is nothing left for the host to watch, and both behaviors go quiet
for that title. Send whatever you genuinely know; `install_dir` is the one to send if you send only
one, since any process running from under it counts as the game. The host never lets a hint override
what it worked out itself, and never adopts a process that was already running before the launch.
## Publishing
Tag `plugin-kit-vX.Y.Z` (matching `package.json`) — `.gitea/workflows/plugin-kit-publish.yml`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-kit",
"version": "0.1.4",
"version": "0.2.0",
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
"type": "module",
"license": "MIT OR Apache-2.0",
+1
View File
@@ -20,6 +20,7 @@ export { type ConfigService, makeConfigService } from "./config.js";
export { type CacheStore, makeCacheStore } from "./cache-store.js";
export {
Artwork,
DetectHint,
LaunchSpec,
PrepStep,
ProviderClient,
+23
View File
@@ -24,11 +24,34 @@ export const PrepStep = Schema.Struct({
});
export type PrepStep = typeof PrepStep.Type;
/**
* How the host should recognize a title's process once it is running.
*
* Every field is optional, and omitting the whole thing is fine: the host tracks the process it
* spawns for the entry anyway. It matters when your launch command hands off and exits a launcher
* client, a `flatpak run`, a front-end that starts an emulator because then the host has nothing
* left to watch, and the two behaviors this feeds ("end the session when the game exits" and "end the
* game when the session ends") go quiet for that title.
*
* Send whatever you actually know. `install_dir` is the one worth sending if you send only one: any
* process running from under it counts as the game.
*/
export const DetectHint = Schema.Struct({
/** Where the title is installed (absolute path on the host). */
install_dir: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** The game's own executable (absolute path on the host). */
exe: Schema.optionalKey(Schema.NullOr(Schema.String)),
/** The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest signal. */
process_name: Schema.optionalKey(Schema.NullOr(Schema.String)),
});
export type DetectHint = typeof DetectHint.Type;
export const ProviderEntry = Schema.Struct({
external_id: Schema.String,
title: Schema.String,
art: Schema.optionalKey(Artwork),
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
prep: Schema.optionalKey(Schema.Array(PrepStep)),
detect: Schema.optionalKey(DetectHint),
});
export type ProviderEntry = typeof ProviderEntry.Type;
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
#
# Type-check and lint punktfunk's WINDOWS-only Rust from a Linux dev box.
#
# scripts/wincheck.sh # clippy -D warnings, the default
# scripts/wincheck.sh check # cargo check instead
# scripts/wincheck.sh clippy --fix # extra args are passed through to cargo
#
# Linux CI never compiles `#[cfg(target_os = "windows")]` code, so a Windows-side edit is otherwise
# unverifiable until the Windows CI job runs (minutes) or someone tests on a real box. This gets the
# same answer in ~1 s warm, against the WORKING TREE — the generated workspace symlinks each `src`
# at the real crate directory, so there is nothing to keep in sync and no copies to go stale.
#
# WHY A SEPARATE WORKSPACE — the obvious in-tree command
#
# cargo check --target x86_64-pc-windows-msvc -p pf-capture
#
# fails, and NOT because of the Windows code: it dies in build scripts that compile C for the
# target. `audiopus_sys` first (cmake wants a Visual Studio generator), then `ring` (needs an MSVC C
# compiler plus the Windows SDK headers; clang-cl and lld-link exist locally but there is no SDK).
# Both enter the graph through `punktfunk-core`'s `quic` feature. Stubbing opus with a fake
# `opus.pc` gets past audiopus but not ring.
#
# The whole Windows capture stack uses exactly TWO items from punktfunk-core — `quic::HdrMeta` and
# `Mode`, both plain data (re-verify with:
# `grep -rho 'punktfunk_core::[A-Za-z_:]*' crates/pf-capture/src crates/pf-frame/src crates/pf-win-display/src | sort -u`).
# So this script generates a ~30-line stub core carrying just those, and rustls/ring/opus never
# enter the graph at all. Every path dep that is NOT a generated member points at the REAL crate by
# absolute path, so their own relative deps and `version.workspace` inheritance still resolve.
#
# Note: `cargo fmt` needs none of this — rustfmt follows `mod`/`#[path]` without evaluating cfg, so
# it already reaches Windows-only files. Mechanical edits can be normalised with plain `cargo fmt`.
set -euo pipefail
REPO="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
OUT="${WINCHECK_DIR:-$REPO/target/wincheck}"
TARGET=x86_64-pc-windows-msvc
# The generated members: crates whose Windows code we lint, plus the stub core they share. A path
# dep naming one of these must stay RELATIVE (resolving to the generated member); anything else is
# rewritten to the real crate. Getting that backwards silently drags the real punktfunk-core — and
# with it ring and opus — back in through pf-frame, which is the entire thing this avoids.
MEMBERS_RE='punktfunk-core|pf-frame|pf-win-display|pf-capture'
REAL_MEMBERS=(pf-frame pf-win-display pf-capture)
cmd="${1:-clippy}"
[ $# -gt 0 ] && shift
case "$cmd" in
check | clippy) ;;
*)
echo "usage: $(basename "$0") [check|clippy] [extra cargo args...]" >&2
exit 2
;;
esac
if ! rustc --print target-list | grep -qx "$TARGET"; then
echo "wincheck: rustc does not know $TARGET" >&2
exit 1
fi
if ! rustup target list --installed 2>/dev/null | grep -qx "$TARGET"; then
echo "wincheck: target $TARGET not installed for the active toolchain — run:" >&2
echo " rustup target add $TARGET" >&2
exit 1
fi
rm -rf "$OUT"
mkdir -p "$OUT"
# Mirror the real [workspace.package] so the members' `version.workspace = true` resolves.
{
echo '# GENERATED by scripts/wincheck.sh — do not edit; re-run the script.'
echo '[workspace]'
echo 'resolver = "2"'
echo 'members = ["punktfunk-core", "pf-frame", "pf-win-display", "pf-capture"]'
echo
echo '[workspace.package]'
sed -n '/^\[workspace\.package\]/,/^$/p' "$REPO/Cargo.toml" | sed '1d;/^$/d'
} > "$OUT/Cargo.toml"
mkdir -p "$OUT/punktfunk-core/src"
cat > "$OUT/punktfunk-core/Cargo.toml" <<'EOF'
# GENERATED by scripts/wincheck.sh — a STUB, not the real crate.
[package]
name = "punktfunk-core"
version.workspace = true
edition = "2021"
rust-version.workspace = true
[features]
default = []
# Present so dependents' `features = ["quic"]` resolves; carries no quinn/rustls/opus.
quic = []
EOF
# Field layout and derives must match the real definitions, or the cross-check goes stale exactly
# where it matters (the capture code builds HdrMeta literally and compares Modes).
cat > "$OUT/punktfunk-core/src/lib.rs" <<'EOF'
//! GENERATED by scripts/wincheck.sh — a STUB of punktfunk-core carrying only the two items the
//! Windows capture stack uses. Mirrors crates/punktfunk-core: `Mode` from config.rs, `HdrMeta`
//! from quic/datagram.rs. If either grows a field, mirror it here.
/// Mirrors `punktfunk_core::Mode`.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Mode {
pub width: u32,
pub height: u32,
pub refresh_hz: u32,
}
pub mod quic {
/// Mirrors `punktfunk_core::quic::HdrMeta`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct HdrMeta {
pub display_primaries: [[u16; 2]; 3],
pub white_point: [u16; 2],
pub max_display_mastering_luminance: u32,
pub min_display_mastering_luminance: u32,
pub max_cll: u16,
pub max_fall: u16,
}
}
EOF
for name in "${REAL_MEMBERS[@]}"; do
mkdir -p "$OUT/$name"
# Park the member paths behind a sentinel first: a plain self-substitution would be a no-op and
# the next rule would rewrite them to absolute along with everything else.
{
echo "# GENERATED by scripts/wincheck.sh from crates/$name/Cargo.toml — src/ is a symlink."
sed -E "s#path = \"\.\./($MEMBERS_RE)\"#path = \"@@M@@\1\"#g; \
s#path = \"\.\./([A-Za-z0-9_-]+)\"#path = \"$REPO/crates/\1\"#g; \
s#path = \"@@M@@#path = \"../#g" "$REPO/crates/$name/Cargo.toml"
} > "$OUT/$name/Cargo.toml"
ln -sfn "$REPO/crates/$name/src" "$OUT/$name/src"
done
cd "$OUT"
for name in "${REAL_MEMBERS[@]}"; do
echo "=== $cmd $name ($TARGET) ==="
if [ "$cmd" = clippy ]; then
cargo clippy --target "$TARGET" -p "$name" --all-targets "$@" -- -D warnings
else
cargo check --target "$TARGET" -p "$name" --all-targets "$@"
fi
done
File diff suppressed because one or more lines are too long
+38
View File
@@ -43,6 +43,22 @@ export const DeviceRef = S.Struct({
});
export type DeviceRef = S.Schema.Type<typeof DeviceRef>;
/** A launched game, as the `game.*` events identify it. */
export const GameRef = S.Struct({
/** Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream command. */
app: S.optional(S.String),
title: S.String,
store: S.optional(S.String),
/** Client-supplied device name of the session that launched it; may be empty. */
client: S.String,
plane: Plane,
});
export type GameRef = S.Schema.Type<typeof GameRef>;
/** Why a launched game is no longer running. */
export const GameEndReason = S.Literals(["exited", "terminated"]);
export type GameEndReason = S.Schema.Type<typeof GameEndReason>;
/** The `{seq, ts_ms, schema}` envelope every event carries. */
const envelope = {
seq: S.Number,
@@ -81,6 +97,26 @@ export const StreamStopped = S.Struct({
kind: S.Literal("stream.stopped"),
stream: StreamRef,
});
/**
* A launched game's process was seen running not merely its launcher spawned. Fires once per
* session that launched a title.
*/
export const GameRunning = S.Struct({
...envelope,
kind: S.Literal("game.running"),
game: GameRef,
});
/**
* A launched game is gone. `reason` separates the player quitting (`exited` which is what ends the
* streaming session, when that is enabled) from the host ending it per the lifetime policy
* (`terminated`).
*/
export const GameExited = S.Struct({
...envelope,
kind: S.Literal("game.exited"),
game: GameRef,
reason: GameEndReason,
});
export const PairingPending = S.Struct({
...envelope,
kind: S.Literal("pairing.pending"),
@@ -131,6 +167,8 @@ export const HostEvent = S.Union([
SessionEnded,
StreamStarted,
StreamStopped,
GameRunning,
GameExited,
PairingPending,
PairingCompleted,
PairingDenied,
+26 -1
View File
@@ -56,6 +56,8 @@
"gpu_none": "Keine GPUs erkannt.",
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.",
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
"host_displays": "Virtuelle Displays",
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
"display_config_title": "Konfiguration",
@@ -374,5 +376,28 @@
"store_phase_rolling_back": "Wird zurückgerollt",
"store_phase_recording": "Herkunft wird vermerkt",
"store_phase_restarting": "Plugin-Runner startet neu",
"store_phase_done": "Fertig"
"store_phase_done": "Fertig",
"games_title": "Laufende Spiele",
"games_state_launching": "Startet",
"games_state_running": "Läuft",
"games_state_exited": "Beendet",
"games_state_grace": "Wartet auf Client",
"games_closing_in": "Client ist weg wird in {time} geschlossen, falls er nicht zurückkommt",
"games_end_now": "Jetzt beenden",
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
"session_game_on_exit": "Wenn das Spiel endet",
"session_game_on_exit_help": "Wer das Spiel beendet, landet wieder in der eigenen Bibliothek statt auf deinem Desktop. Schalte es aus, wenn du den Desktop streamst und das Spiel nebensächlich ist.",
"session_game_on_exit_end": "Sitzung beenden",
"session_game_on_exit_keep": "Weiter streamen",
"session_game_end_game": "Wenn die Sitzung endet",
"session_game_end_game_help": "Ob das Stoppen (oder der Verlust) einer Sitzung auch das gestartete Spiel schließt. Gilt nie für ein Spiel, das du selbst gestartet hast nur für eines, das dieser Host für die Sitzung gestartet hat.",
"session_game_end_keep": "Weiterlaufen lassen",
"session_game_end_on_quit": "Beim Stoppen schließen",
"session_game_end_always": "Immer schließen",
"session_game_always_warning": "Ein Spiel zu schließen kostet alles, was es nicht gespeichert hat. Der Host bittet es zuerst höflich und erzwingt es nur, wenn es sich weigert aber ein Verbindungsabbruch ist kein bewusstes Stoppen, deshalb bekommt ein abgerissener Client erst das Zeitfenster unten. Eine dauerhaft offen gehaltene Anzeige bleibt davon unberührt: Diese Einstellung regelt das Spiel, nicht den Bildschirm.",
"session_game_grace": "Zeitfenster für die Rückkehr",
"session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.",
"session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert",
"session_game_inert": "Dieser Host kann keine Spiele starten hier bewirken diese Einstellungen nichts"
}
+26 -1
View File
@@ -56,6 +56,8 @@
"gpu_none": "No GPUs detected.",
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.",
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
"host_displays": "Virtual displays",
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
"display_config_title": "Configuration",
@@ -374,5 +376,28 @@
"store_phase_rolling_back": "Rolling back",
"store_phase_recording": "Recording provenance",
"store_phase_restarting": "Restarting the plugin runner",
"store_phase_done": "Done"
"store_phase_done": "Done",
"games_title": "Running games",
"games_state_launching": "Starting",
"games_state_running": "Running",
"games_state_exited": "Ended",
"games_state_grace": "Waiting for client",
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
"games_end_now": "End now",
"session_game_title": "When a game or a session ends",
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
"session_game_on_exit": "When the game exits",
"session_game_on_exit_help": "Quitting the game hands the client back to its own library instead of leaving it on your desktop. Turn this off if you stream the desktop and treat the game as incidental.",
"session_game_on_exit_end": "End the session",
"session_game_on_exit_keep": "Keep streaming",
"session_game_end_game": "When the session ends",
"session_game_end_game_help": "Whether stopping (or losing) a session also closes the game it launched. Never applies to a game you started yourself — only one this host launched for the session.",
"session_game_end_keep": "Leave it running",
"session_game_end_on_quit": "Close it on Stop",
"session_game_end_always": "Always close it",
"session_game_always_warning": "Closing a game costs whatever it had not saved. The host asks it to close first and only forces the issue if it refuses — but a network drop is not someone pressing Stop, so a dropped client gets the reconnect window below before anything happens. A display kept forever stays up regardless; this setting governs the game, not the screen.",
"session_game_grace": "Reconnect window",
"session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.",
"session_game_saved": "Session and game settings saved",
"session_game_inert": "This host has no way to launch games, so these settings do nothing here"
}
+138
View File
@@ -0,0 +1,138 @@
import { Gamepad2, XCircle } from "lucide-react";
import type { FC } from "react";
import type { ActiveGame } from "@/api/gen/model/activeGame";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* What the host is running, and when a client has vanished how long its game has left.
*
* The `grace` rows are the reason this card exists at all. A game whose session is gone is on a
* countdown to being closed, which costs unsaved progress; that has to be visible somewhere, with a
* way to say "yes, do it now" or (by reconnecting) "no". The live rows come almost free alongside.
*/
export const RunningGames: FC<{
games: ActiveGame[];
/** The catalog, for box art — matched by `app_id`. Absent while `/library` is still loading. */
library?: GameEntry[];
onEnd: (game: ActiveGame) => void;
isEnding: boolean;
}> = ({ games, library, onEnd, isEnding }) => {
if (games.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gamepad2 className="size-4" />
{m.games_title()}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{games.map((g) => (
<GameRow
// A host can run the same title for two clients at once (native admits concurrent
// sessions), so the title alone is not a key.
key={`${g.plane}:${g.session_id ?? "grace"}:${g.app_id ?? g.title}`}
game={g}
art={coverFor(g, library)}
onEnd={() => onEnd(g)}
isEnding={isEnding}
/>
))}
</CardContent>
</Card>
);
};
const GameRow: FC<{
game: ActiveGame;
art?: string;
onEnd: () => void;
isEnding: boolean;
}> = ({ game, art, onEnd, isEnding }) => {
const waiting = game.state === "grace";
return (
<div className="flex items-center gap-3">
<div className="h-16 w-11 shrink-0 overflow-hidden rounded bg-muted">
{art && (
<img
src={art}
alt=""
loading="lazy"
className="size-full object-cover"
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium">{game.title}</span>
<Badge variant={waiting ? "destructive" : stateVariant(game.state)}>
{stateLabel(game.state)}
</Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{waiting
? m.games_closing_in({
time: formatCountdown(game.grace_remaining_s ?? 0),
})
: [game.client, planeLabel(game.plane)].filter(Boolean).join(" · ")}
</p>
</div>
<Button
variant={waiting ? "destructive" : "outline"}
size="sm"
disabled={isEnding}
onClick={onEnd}
>
<XCircle className="size-3.5" />
{m.games_end_now()}
</Button>
</div>
);
};
/**
* Box art for a running game, matched against the already-fetched catalog by store-qualified id
* so this card costs no extra request. `undefined` for an operator-typed GameStream command (no
* catalog entry behind it) or a title whose entry carries no art.
*/
function coverFor(game: ActiveGame, library?: GameEntry[]): string | undefined {
if (!game.app_id || !library) return undefined;
const entry = library.find((e) => e.id === game.app_id);
return entry?.art.portrait ?? entry?.art.header ?? undefined;
}
/** `mm:ss` — the countdown reads as a duration, not a number of seconds. */
function formatCountdown(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function stateLabel(state: string): string {
switch (state) {
case "launching":
return m.games_state_launching();
case "running":
return m.games_state_running();
case "exited":
return m.games_state_exited();
case "grace":
return m.games_state_grace();
default:
return state;
}
}
function stateVariant(state: string): "success" | "secondary" | "outline" {
if (state === "running") return "success";
if (state === "launching") return "secondary";
return "outline";
}
/** The compat plane is worth naming: it is why a row may show an IP instead of a device name. */
function planeLabel(plane: string): string {
return plane === "gamestream" ? "GameStream" : "";
}
+33 -1
View File
@@ -1,7 +1,13 @@
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import { getGetStatusQueryKey, useGetStatus } from "@/api/gen/host/host";
import { useRequestIdr, useStopSession } from "@/api/gen/session/session";
import { useGetLibrary } from "@/api/gen/library/library";
import type { ActiveGame } from "@/api/gen/model/activeGame";
import {
useEndGame,
useRequestIdr,
useStopSession,
} from "@/api/gen/session/session";
import { useLocale } from "@/lib/i18n";
import { DashboardView } from "./view";
@@ -10,19 +16,45 @@ export const SectionDashboard: FC = () => {
const qc = useQueryClient();
// Poll live status every 2s so the console tracks an active session.
const status = useGetStatus({ query: { refetchInterval: 2_000 } });
// The catalog, for the running-game card's box art. Fetched once and held: a library scan touches
// every installed store's on-disk metadata, so it must not ride the 2 s status poll.
const library = useGetLibrary(undefined, {
query: { staleTime: 5 * 60_000 },
});
const stop = useStopSession();
const idr = useRequestIdr();
const endGame = useEndGame();
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetStatusQueryKey() });
/**
* "End now" means two different things, and which one is right follows from the row's state: a
* game whose session is still live ends by stopping that session (what then happens to the game
* follows the operator's policy stopping a session is not licence to close a game), while a
* game already waiting out its reconnect window has no session left to stop and is ended directly.
*/
const onEndGame = (game: ActiveGame) => {
if (game.state === "grace") {
endGame.mutate(
{ data: { app_id: game.app_id ?? null } },
{ onSuccess: invalidate },
);
} else {
stop.mutate(undefined, { onSuccess: invalidate });
}
};
return (
<DashboardView
status={status}
library={library.data}
onStopSession={() => stop.mutate(undefined, { onSuccess: invalidate })}
onRequestIdr={() => idr.mutate(undefined)}
onEndGame={onEndGame}
isStopping={stop.isPending}
isRequestingIdr={idr.isPending}
isEndingGame={endGame.isPending || stop.isPending}
/>
);
};
+25 -1
View File
@@ -1,6 +1,8 @@
import Section from "@unom/ui/section";
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
import type { FC, ReactNode } from "react";
import type { ActiveGame } from "@/api/gen/model/activeGame";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
@@ -8,14 +10,27 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { RunningGames } from "./RunningGames";
export const DashboardView: FC<{
status: Loadable<RuntimeStatus>;
library?: GameEntry[];
onStopSession: () => void;
onRequestIdr: () => void;
onEndGame: (game: ActiveGame) => void;
isStopping: boolean;
isRequestingIdr: boolean;
}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => {
isEndingGame: boolean;
}> = ({
status,
library,
onStopSession,
onRequestIdr,
onEndGame,
isStopping,
isRequestingIdr,
isEndingGame,
}) => {
const s = status.data;
return (
<Section maxWidth={false}>
@@ -61,6 +76,15 @@ export const DashboardView: FC<{
</Card>
</div>
{/* Above the session card: a game the host is about to close is the most
time-sensitive thing on this page. */}
<RunningGames
games={s.games}
library={library}
onEnd={onEndGame}
isEnding={isEndingGame}
/>
<Card>
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
@@ -0,0 +1,206 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import type { GameOnSessionEnd, SessionSettings } from "@/api/gen/model";
import {
getGetSessionSettingsQueryKey,
useGetSessionSettings,
useSetSessionSettings,
} from "@/api/gen/session/session";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
const END_POLICIES: GameOnSessionEnd[] = ["keep", "on_quit", "always"];
/**
* Whether a launched game and its streaming session share a fate
* (design/session-game-lifetime.md), next to the display keep-alive policy because the two interact:
* a kept display and a kept game are separate decisions with separate timers, and `keep_alive:
* forever` outranks the game policy for the display itself.
*
* Every axis saves on change (like the display policy above) there is no Save button to miss.
*/
export const SessionGameCard: FC = () => {
const qc = useQueryClient();
const q = useGetSessionSettings();
const save = useSetSessionSettings();
const server = q.data?.settings;
// Which axes this build acts on. Empty on a platform with no launch path (macOS), where the
// controls are shown disabled rather than hidden — "does nothing here" is information.
const enforced = q.data?.enforced ?? [];
const acts = (field: string) =>
enforced.length === 0 || enforced.includes(field);
// The grace field is free text while being typed, so it gets a local buffer; the other two axes
// are discrete and go straight to the host.
const [grace, setGrace] = useState("");
useEffect(() => {
if (server) setGrace(String(server.disconnect_grace_seconds ?? 300));
}, [server]);
const apply = (patch: Partial<SessionSettings>) => {
if (!server) return;
save.mutate(
{ data: { ...server, ...patch } },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetSessionSettingsQueryKey() });
toast.success(m.session_game_saved());
},
},
);
};
const busy = save.isPending;
const error = save.error instanceof ApiError ? save.error.message : undefined;
return (
<Card>
<CardHeader>
<CardTitle>{m.session_game_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.session_game_help()}
</p>
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
{server && (
<div className="space-y-6">
<Field
label={m.session_game_on_exit()}
help={m.session_game_on_exit_help()}
>
<div className="flex flex-wrap gap-2">
<Choice
selected={server.session_on_game_exit === true}
disabled={busy || !acts("session_on_game_exit")}
onClick={() => apply({ session_on_game_exit: true })}
>
{m.session_game_on_exit_end()}
</Choice>
<Choice
selected={server.session_on_game_exit === false}
disabled={busy || !acts("session_on_game_exit")}
onClick={() => apply({ session_on_game_exit: false })}
>
{m.session_game_on_exit_keep()}
</Choice>
</div>
</Field>
<Field
label={m.session_game_end_game()}
help={m.session_game_end_game_help()}
>
<div className="flex flex-wrap gap-2">
{END_POLICIES.map((p) => (
<Choice
key={p}
selected={(server.game_on_session_end ?? "keep") === p}
disabled={busy || !acts("game_on_session_end")}
onClick={() => apply({ game_on_session_end: p })}
>
{END_POLICY_LABEL[p]()}
</Choice>
))}
</div>
{(server.game_on_session_end ?? "keep") === "always" && (
<p className="max-w-prose text-xs text-muted-foreground">
{m.session_game_always_warning()}
</p>
)}
</Field>
{(server.game_on_session_end ?? "keep") === "always" && (
<Field
label={m.session_game_grace()}
help={m.session_game_grace_help()}
>
<div className="flex items-center gap-2">
<Input
type="number"
min={10}
max={86400}
className="w-28"
value={grace}
disabled={busy || !acts("disconnect_grace_seconds")}
onChange={(e) => setGrace(e.target.value)}
onBlur={() => {
const n = Number(grace);
if (!Number.isFinite(n)) {
setGrace(
String(server.disconnect_grace_seconds ?? 300),
);
return;
}
// The host clamps to 10..=86400 and returns what it stored, so
// a nonsense number is corrected rather than rejected.
if (n !== server.disconnect_grace_seconds) {
apply({ disconnect_grace_seconds: n });
}
}}
/>
<span className="text-sm text-muted-foreground">
{m.display_keep_alive_seconds()}
</span>
</div>
</Field>
)}
{enforced.length === 0 && (
<Badge variant="outline">{m.session_game_inert()}</Badge>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)}
</QueryState>
</CardContent>
</Card>
);
};
const END_POLICY_LABEL: Record<GameOnSessionEnd, () => string> = {
keep: () => m.session_game_end_keep(),
on_quit: () => m.session_game_end_on_quit(),
always: () => m.session_game_end_always(),
};
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
label,
help,
children,
}) => (
<div className="space-y-3">
<Label className="block">{label}</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</div>
);
const Choice: FC<{
selected: boolean;
disabled: boolean;
onClick: () => void;
children: ReactNode;
}> = ({ selected, disabled, onClick, children }) => (
<Button
type="button"
variant={selected ? "default" : "outline"}
size="sm"
disabled={disabled}
aria-pressed={selected}
className={cn(disabled && "opacity-60")}
onClick={onClick}
>
{children}
</Button>
);
+6
View File
@@ -3,11 +3,16 @@ import type { FC } from "react";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { DisplaySection } from "./DisplayCard";
import { SessionGameCard } from "./SessionGameCard";
/**
* The **Virtual displays** page (design/display-management.md): the host's virtual-display policy
* (presets + every axis) plus the live-display list + multi-monitor arrangement. Its own nav
* section the config surface is large enough to warrant the room, and it kept the Host page busy.
*
* The sessiongame lifetime card sits here rather than on its own page because it is the same
* question one step further out: keep-alive decides how long a *display* outlives a disconnect, and
* this decides whether the *game* does.
*/
export const SectionDisplays: FC = () => {
useLocale();
@@ -16,6 +21,7 @@ export const SectionDisplays: FC = () => {
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
<DisplaySection />
<SessionGameCard />
</div>
</Section>
);
+44
View File
@@ -38,6 +38,49 @@ export const GpuSection: FC = () => {
const fmtVram = (mb: number) =>
mb >= 1024 ? `${Math.round(mb / 1024)} GiB` : `${mb} MiB`;
/**
* The vendor an explicit `PUNKTFUNK_ENCODER` pin can open on (display name) the console mirror
* of the host's backendvendor table. Vendor-agnostic pins (software) and unknown/multi-vendor
* spellings (vaapi, vulkan, pyrowave) map to nothing: no conflict to warn about.
*/
const encoderPinVendor: Record<string, string> = {
nvenc: "NVIDIA",
nvidia: "NVIDIA",
cuda: "NVIDIA",
hw: "NVIDIA",
amf: "AMD",
amd: "AMD",
qsv: "Intel",
intel: "Intel",
};
/**
* The host.env encoder pin, surfaced so a conflicting GPU choice doesn't just look broken: amber
* when the pin's vendor contradicts the next session's GPU (the host overrides the pin at session
* open the stale pin should be removed), a muted note otherwise.
*/
const EncoderPinNote: FC<{ state: GpuState; pin: string }> = ({
state,
pin,
}) => {
const vendor = encoderPinVendor[pin];
const conflicting =
vendor && state.selected && state.selected.vendor !== vendor.toLowerCase();
return conflicting && state.selected ? (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.gpu_encoder_pin_warning({
value: pin,
vendor,
name: state.selected.name,
})}
</p>
) : (
<p className="text-xs text-muted-foreground">
{m.gpu_encoder_pin_note({ value: pin })}
</p>
);
};
/**
* GPU list in the compositors-card style: per-GPU badges for the manual pick ("Preferred"), what
* the next session will use ("Next session"), and what live sessions encode on right now
@@ -139,6 +182,7 @@ export const GpuCard: FC<{
{m.gpu_env_note({ value: s.env_override })}
</p>
)}
{s?.encoder_pin && <EncoderPinNote state={s} pin={s.encoder_pin} />}
</QueryState>
</CardContent>
</Card>
+8 -1
View File
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DashboardView } from "@/sections/Dashboard/view";
import { statusActive, statusIdle } from "./lib/fixtures";
import { statusActive, statusGrace, statusIdle } from "./lib/fixtures";
const meta = {
title: "Pages/Dashboard",
@@ -8,8 +8,10 @@ const meta = {
args: {
onStopSession: () => {},
onRequestIdr: () => {},
onEndGame: () => {},
isStopping: false,
isRequestingIdr: false,
isEndingGame: false,
},
} satisfies Meta<typeof DashboardView>;
@@ -23,3 +25,8 @@ export const ActiveSession: Story = {
export const Idle: Story = {
args: { status: { data: statusIdle, isLoading: false, error: null } },
};
/** A game whose client vanished: the host closes it when the countdown runs out. */
export const GameWaitingForItsClient: Story = {
args: { status: { data: statusGrace, isLoading: false, error: null } },
};
+31
View File
@@ -58,6 +58,17 @@ export const statusActive: RuntimeStatus = {
min_fec: 5,
packet_size: 1392,
},
games: [
{
session_id: 1,
client: "Living room TV",
app_id: "steam:1145360",
title: "Hades",
store: "steam",
plane: "native",
state: "running",
},
],
};
export const statusIdle: RuntimeStatus = {
@@ -68,6 +79,26 @@ export const statusIdle: RuntimeStatus = {
active_sessions: 0,
session: null,
stream: null,
games: [],
};
/**
* Idle, but a game its client walked away from is still running on a countdown to being closed.
* The state the running-game card exists for.
*/
export const statusGrace: RuntimeStatus = {
...statusIdle,
games: [
{
client: "Living room TV",
app_id: "steam:1145360",
title: "Hades",
store: "steam",
plane: "native",
state: "grace",
grace_remaining_s: 252,
},
],
};
export const pairedClients: PairedClient[] = [