Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 d08893383a chore(release): bump workspace version to 0.21.0
audit / cargo-audit (push) Successful in 2m21s
audit / bun-audit (push) Successful in 14s
apple / swift (push) Successful in 5m3s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m9s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m51s
ci / bench (push) Successful in 5m35s
ci / rust-arm64 (push) Successful in 9m19s
ci / rust (push) Successful in 22m18s
windows-host / package (push) Successful in 12m9s
android / android (push) Successful in 11m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m23s
android-screenshots / screenshots (push) Successful in 3m5s
release / apple (push) Successful in 30m41s
deb / build-publish (push) Successful in 9m31s
arch / build-publish (push) Successful in 12m29s
decky / build-publish (push) Successful in 21s
deb / build-publish-host (push) Successful in 9m45s
deb / build-publish-client-arm64 (push) Successful in 7m34s
flatpak / build-publish (push) Successful in 6m19s
linux-client-screenshots / screenshots (push) Successful in 7m11s
web-screenshots / screenshots (push) Successful in 3m5s
apple / screenshots (push) Successful in 23m51s
windows-host / winget-source (push) Successful in 37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m40s
docker / build-push-arm64cross (push) Successful in 11s
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 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 8s
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
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m53s
docker / deploy-docs (push) Successful in 10s
42 commits since v0.20.0 (v0.20.1 was tagged but never announced;
superseded by this release rather than repointed, since real new
functionality landed after it was cut). Minor, not patch: the
headline is streaming one of the machine's own physical monitors
instead of always creating a virtual display — pick it from a new
console card or pin it in host.env, on KWin, Mutter, sway and
Hyprland, in both the Punktfunk app and Moonlight.

Also folds in the full v0.20.1 fix bundle: three Windows install
blockers found within hours of 0.20.0, GameStream/Moonlight compat
back to opt-in on fresh installs, three gamescope Game Mode takeover
faults, a laptop-panel stall misdiagnosis, and the PyroWave
high-bitrate latency-creep bundle — plus a KWin 6.7 regression found
while building the monitor feature (silent kscreen-doctor fallback
on every session) and a winget release-verification CI fix.

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows
virtual-display driver protocol at 6. GET /display/monitors is a new
additive endpoint; the display policy gains an optional
capture_monitor field. No embedder rebuild required.

Notes authored ahead of the tag per docs/releases/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:03:44 +02:00
enricobuehlerandClaude Sonnet 5 74179c4c2e fix(ci/winget): envs: must live under with:, not as a step sibling
24d2f97e added envs: GITHUB_REF_NAME to fix "Verify the served
catalogue" failing on every tag with `GITHUB_REF_NAME: unbound
variable` — but placed it as a sibling of with:/env: on the step,
which is not part of the step schema and is silently ignored by
Gitea's runner. appleboy/ssh-action never received it as an input,
so the step kept failing identically on v0.20.1's first tag run.

docker.yml and deploy-services.yml already forward REGISTRY_TOKEN
this way correctly: envs: nested inside with:. Moved to match.
Confirmed by parsing the workflow and checking envs lands in the
step's with: dict, not as a bare step key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:01:45 +02:00
enricobuehlerandClaude Opus 5 8fe71be424 fix(web): applying a saved preset kept switching the streamed screen off
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m17s
apple / swift (push) Successful in 5m27s
ci / bench (push) Successful in 7m9s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 9m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
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
android / android (push) Canceled after 10m20s
apple / screenshots (push) Canceled after 4m46s
arch / build-publish (push) Canceled after 10m24s
ci / rust (push) Canceled after 10m25s
ci / rust-arm64 (push) Canceled after 10m24s
deb / build-publish (push) Canceled after 9m14s
deb / build-publish-host (push) Canceled after 9m6s
deb / build-publish-client-arm64 (push) Canceled after 3m14s
decky / build-publish (push) Canceled after 0s
`applyCustomPreset` builds a FRESH policy object instead of spreading the
draft, so every field it doesn't name is dropped. `capture_monitor` was
not named — so picking one of your own saved presets silently took a host
that was mirroring a real monitor and put it back on a virtual display.
The same omission in the Custom switch would have done it there too.

Found on-glass against a running serve on .136: the PUT is whole-object,
so the console is the only thing keeping the orthogonal axes alive, and
these two sites were the ones building a policy from scratch rather than
from what was already in force.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 a98174ebfa feat(host): anchor-test, and libei says which output absolute input landed on
The absolute-region ladder existed for one case a unit test can only
simulate — two heads of the SAME size, where matching a libei region by
the streamed mode is a coin flip — and that case had never been run
against a real compositor. It was also unobservable: the log said which
regions the EIS server offered, never which one a coordinate went into,
so "the pointer is on the wrong monitor" could only be discovered by
looking at a monitor.

libei now reports the chosen region once per distinct answer, beside the
existing miss warning: one covers an anchor that named nothing, this one
covers an anchor that matched something, by saying which.

`anchor-test` is the gate that reads it, in the shape mirror-test
established: it enumerates the heads, SAYS whether the rig actually has
two of the same size (a green run on a single-head box proves nothing),
anchors at a named one (or `--none` for the A/B that makes the anchored
run mean anything), and walks the corners. It refuses to run off libei
rather than emitting a green result about a ladder that isn't there.

On-glass on KWin 6.7.3 with `kwin_wayland --virtual --output-count 2`
(regions 1920x1080+0+0 and +1920+0): unanchored picks +0+0, anchored at
Virtual-1 picks +1920+0, anchored at Virtual-0 picks +0+0. That is open
item 3 of design/per-monitor-portal-capture.md §8b, closed.

Clearing the pin now logs too — setting one always did, and the streamed
screen going back to virtual is the same size of change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 ea0c61fb0f docs: streaming a real monitor, and the CLI that names one
The console grew a "Streamed screen" card and the host grew a
PUNKTFUNK_CAPTURE_MONITOR knob and a list-monitors command, and the docs
knew about none of it — a control with no explanation anywhere.

virtual-displays.md gets the feature section: what it is for, that the
monitor is never touched, that its resolution wins and a client scales,
that a bad name is a hard error rather than a different screen, and that
there is no chooser dialog on any of the four backends (which is what
makes it work unattended). Plus the three troubleshooting entries the
shape of the feature predicts: settings that do nothing while mirroring,
a console card the env var has locked, and a pin that names no head.

host-cli.md documents list-monitors and mirror-test; configuration.md
gets the knob, including that it outranks the console on purpose;
running-as-a-service.md gets the desktop-session drop-in and states that
the host needs nothing exported to find its session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 19deac75fe feat(packaging): a desktop-login host restarts with its desktop
The shipped unit's PartOf=punktfunk-kde-session.service covers the
APPLIANCE route, where we start the compositor ourselves. A host on a
machine somebody logs into has no such unit: when Plasma or GNOME
restarts, the daemon keeps running while holding a Wayland socket and a
portal D-Bus connection that both died with the old compositor, and it
cannot recover either in-process. It fails quietly — the host still
listens, still answers, and every session it then serves dies at capture.
A host that mirrors a monitor idles for days between sessions, which is
the shape that finds this at the worst moment.

The drop-in binds the host to graphical-session.target: PartOf takes it
down with the session, WantedBy brings it back with the new one. Shipped
under /usr/share rather than as an active drop-in, because it is wrong
for the appliance route (which may never reach that target at all, and
would then leave the host permanently stopped) — the operator opts in.

Closes the restart half of design/per-monitor-portal-capture.md §6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 45f04f50fe test(mgmt): gamescope reports no monitors, and that is not a missing explanation
/display/monitors must never answer empty AND silent — the console reads
that as "the host is broken". But gamescope is nested: it owns no
physical heads by construction, so empty-and-silent is the correct answer
there, not an unexplained one.

Any dev box that has ever been in game mode keeps gamescope-0 sockets in
its runtime dir, so detect() resolves gamescope and the test failed on
the machine rather than on the code (found running the suite on .136).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 6afc05155b feat(vdisplay/session): derive SWAYSOCK — the last session var a --user host lacked
Session detection already hands every backend the live session's env:
WAYLAND_DISPLAY from a socket scan, XDG_RUNTIME_DIR from the uid, the
session bus, XDG_CURRENT_DESKTOP, and Hyprland's instance signature. sway
was the exception — SWAYSOCK had to be INHERITED from the login shell, so
a systemd --user host that never saw one had no sway IPC at all: no
output enumeration, no capture chooser, no wlroots backend (its
is_available() keys off that very variable).

It is derivable, and by identity rather than guess: sway names its socket
sway-ipc.<uid>.<pid>.sock, and detection already knows the compositor
PID. Ladder: a valid inherited value, then the exact PID's socket, then
the newest one we own (a sway re-exec can leave the name pointing at a
PID we didn't see). None on river — the other wlroots desktop ships no
sway IPC, and saying so is what stops apply_session_env exporting a
SWAYSOCK that points at nothing.

Closes the session-env half of design/per-monitor-portal-capture.md §6
for a desktop-login host: nothing has to be exported for the host to find
its session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 829bcb7962 fix(gamestream): the streamed-screen pin applies to the portal source too
PUNKTFUNK_CAPTURE_MONITOR is documented as "instead of creating a virtual
display OR taking whichever head the portal hands back", and the console
presents it as a host-wide setting. The compat plane's portal source did
neither: it went straight to the chooser, so a Moonlight client on a
pinned host silently got whatever screen the portal picked. That is the
one outcome the whole feature exists to prevent — showing the wrong
monitor is worse than showing none.

The portal arm now mirrors the pinned head instead, through the same
MirrorDisplay the virtual source reaches via vdisplay::open. It launches
nothing and creates no virtual output, so it needs neither the
game-lifetime machinery nor the registry (External ownership passes
through it anyway).

Which screen a pooled capturer is showing is now the third reuse key
beside HDR-ness and cursor mode. The pin is a LIVE setting — the console
can re-aim the host between two connects — so without it in the key the
second connect would keep streaming the previous screen, which is the
same silent-wrong-monitor failure by another route (§7.3, open item 5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 de3ef50434 fix(native): a mirrored monitor has a fixed mode — refuse to resize it
A physical head runs at the mode its owner set, and MirrorDisplay::create
ignores the requested one on purpose. So a mid-stream Reconfigure against
a mirror could only ever tear the cast down and rebuild the identical one
at the identical size — a hitch that buys nothing, and an invitation to
the worse reflex of reconfiguring the display someone is sitting at.

reconfig_allowed grows a third gate beside gamescope and per-client-mode
identity, and the session captures it at bring-up like the others: this
session opened its display under whatever the pin said then, so a console
change mid-session must not retroactively change the answer it gives.

Linux-only, because vdisplay::open only routes to the mirror there — a
pin left in a Windows host's settings streams nothing different and must
not disable resize as a side effect.

design/per-monitor-portal-capture.md §7.3, open item 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 b08226dbf1 feat(vdisplay): mirror a pinned monitor on sway and Hyprland
P5 of design/per-monitor-portal-capture.md, and the end of a hole worth
naming: the pin was accepted, persisted and offered in the console while
`create` bailed on these two backends, so a sway or Hyprland host with a
monitor selected failed EVERY session.

Both already ship the mechanism — a managed portal-chooser config plus a
per-session selection file (xdpw's `Monitor: NAME`, xdph's
`[SELECTION]screen:NAME`) — so mirroring is that same chooser pointed at a
physical connector instead of a headless output we created. The keepalive
stops the cast and nothing else; the monitor is the compositor's.

The selection write is now serialized against the handshake that reads it
(SELECTION_LOCK, applied to the virtual-output paths too). That file is one
per-user path: whoever writes last before the portal reads wins, and the
loser does not fail — it silently captures the other session's output.

`managed` is no longer grounds for refusal everywhere. It is conclusive
only where the name is ours by construction (KWin's `Virtual-punktfunk`,
Hyprland's `PF-N`); sway names EVERY headless output `HEADLESS-N`, its own
included, so the old rule would have refused to mirror any output on a
headless sway box — exactly the remote setup this feature serves. Found
on-glass, and now a test.

Verified on a two-output headless sway: asking for HEADLESS-1 yields
1280x720 frames and HEADLESS-2 yields 1920x1080, so the chooser really does
steer per request. Hyprland is compile-only — no Hyprland box exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 d461d889c3 feat: the streamed screen is a persisted setting, pickable from the console
P4 of design/per-monitor-portal-capture.md. The pin was an env var read
once at startup, which a console picker can never write — so it becomes a
field of the display policy (orthogonal to presets, like game_session), and
`vdisplay::capture_monitor()` resolves env-over-policy: an appliance that
pinned in its unit's host.env stays pinned there, and a console click
cannot re-aim a machine whose operator already declared the answer.

Read per `open` rather than cached, so a picker change takes effect on the
next session instead of the next host restart. The host re-resolves the
input anchor on every policy write — including clearing it when the pin is
cleared, or a later virtual-display session inherits an anchor aimed at a
monitor it is not showing.

`with_manual_layout` carries the pin through like the other orthogonal
axes: without that, saving a display ARRANGEMENT would silently swap the
streamed screen back to virtual.

Console: a "Streamed screen" card listing the host's real monitors beside
"Virtual screen (default)", saving on selection. A disabled head is listed
but not selectable (so "why isn't it here?" has an answer), and an
env-pinned host renders read-only with the reason rather than offering
controls that silently lose to the environment.

Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes
sessions to the mirror and anchors input; setting the env to a different
connector overrides it, exactly as documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 358cfa4be4 feat(host): mirror-test — the on-glass gate for per-monitor capture
Opens the display backend exactly as a session would, attaches a capturer
and reports the frames a named head actually produces, with no client
involved. `--monitor` names the head explicitly (it cannot go through
PUNKTFUNK_CAPTURE_MONITOR: pf_host_config parses the environment once and
startup already read it, so a tool setting it for itself would still see
the old snapshot — hence vdisplay::open_mirror); with no argument it
exercises the production routing through the pin.

A frame timeout is NOT treated as failure. Compositor capture is
damage-driven — the host's own capture diag logs new_fps=0 for virtual
outputs on an idle desktop for exactly this reason — so the first version
of this tool ended its measurement on the first idle gap and made a working
mirror look like a 2-frame stall.

Verified on KWin 6.7.3 (home-nobara-1), mirroring HDMI-A-1: 346 frames in
20s at 1920x1080, arriving while input was injected and pausing when the
desktop went quiet, on both the CPU (BGRx/MemFd) and zero-copy (NV12
dmabuf) paths, ownership=External.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 045deaf77a fix(vdisplay/kwin): bind the output-device REGISTRY — KWin 6.7 stopped
advertising per-output globals

Found on-glass while testing monitor enumeration: KWin 6.7.3 advertises
kde_output_device_registry_v2 and NOT one kde_output_device_v2 global per
output. This module only ever bound the globals, so on a current KWin it
saw zero devices.

That is not just an enumeration gap — it silently disabled the whole
in-process output-management path, which exists precisely because
kscreen-doctor wedges. The live 0.19.2 host on the test box logs
"kde_output_management unavailable — kscreen-doctor fallback" on EVERY
session, so every topology apply there has been going through the tool this
module was written to stop using.

Both models are supported now: the per-output globals older KWin sends, and
the registry's `output` events. Devices from the registry arrive a round
later than globals do, so the handshake takes one more barrier — skipped
when nothing is still awaiting its `done`, so the classic path costs
nothing.

Verified on KWin 6.7.3: enumeration now reports HDMI-A-1 1920x1080@60 at
+0,+0 scale 1.35 primary, matching kscreen-doctor exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 bf2f54a5fe feat(vdisplay): mirror a pinned monitor on Mutter, and scope the HDR probe
P3 of design/per-monitor-portal-capture.md. Mutter's mirror is the same
private ScreenCast session the virtual path already drives, one call
different — RecordMonitor instead of RecordVirtual — so it inherits what
makes that path work headlessly: a direct D-Bus API that needs no
interactive approval, unlike the xdg portal a background service could
never answer.

Deliberately not under TOPOLOGY_LOCK: that serializes operations which add
or remove a monitor or apply a monitor configuration, and mirroring does
neither. Nothing is created, no layout changes, and teardown is just Stop —
the SIGSEGV-adjacent ordering the virtual path must observe cannot arise
when no monitor is being removed.

The GNOME HDR probe now honors the pin. Asking "is ANY monitor in BT.2100
mode" was fair while capture took whatever head it was handed; once the
operator names one, an HDR TV on the next connector must not talk the host
into offering PQ formats for the SDR panel it is actually streaming. A pin
that names no live head reports SDR rather than falling back to "any" — the
session is about to fail on that same missing monitor, and an over-claimed
HDR offer would be a second, quieter wrong answer.

⚠️ RecordMonitor's exact signature is read from the interface, not yet
confirmed against a live gnome-shell; no GNOME box was reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 53c772702f feat(host): list-monitors CLI, and two bugs it caught on-glass
An operator configuring an unattended host has to learn the connector
names from somewhere, and "curl the management API before the host is
configured" is not it. `punktfunk-host list-monitors` prints them with
geometry and flags the pinned one.

Running it under a real sway immediately caught two defects the unit tests
could not:

  - the query used the `swaymsg` command helper, which inserts `--`, so
    `-t get_outputs` came back as "Unknown/invalid command '-t'". Queries
    now go through a `swaymsg_query` helper that cannot make that mistake.
  - compositors write the literal "Unknown" rather than leaving make/model
    empty, so the picker label read "Unknown Unknown". One `describe`
    helper now treats that as absent, for all four backends.

Verified on a headless two-output sway: names, modes, positions, scale and
the case-insensitive pin all resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 93c2765db7 feat(vdisplay): mirror a pinned physical monitor on KWin
P2 of design/per-monitor-portal-capture.md. zkde_screencast's
stream_output records an output KWin already has — the connector name IS
the selection, so there is no dialog, no portal and no chooser for a
background service to answer.

It arrives as a VirtualDisplay reporting DisplayOwnership::External, which
already means "someone else's display, merely mirrored: no keep-alive, no
topology, no reuse". So the rule that we must never disable, move or
"restore" a monitor the user is sitting in front of holds by construction
rather than by everyone remembering it — and create() ignores the client's
requested mode for the same reason: a panel runs at the mode its owner set.

Routing lives in vdisplay::open, the one place every session opens a
display, so the pin cannot be honored on one plane and ignored on another.
The host sets the libei anchor from the same pin (pf-vdisplay must not
depend on pf-inject), which is what makes absolute input land on the
mirrored head instead of a same-sized neighbour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 2eeee650b9 fix(inject/libei): absolute coordinates resolve by identity, not by size
libei hands the injector one region per logical monitor with no output
name attached, so picking one meant matching the streamed mode's SIZE —
a coin flip the moment two heads share a mode, and it already resolved
wrong on-glass once (GNOME, a dummy HDMI beside the virtual primary: the
seat cursor never entered the streamed monitor, so neither cursor path
could see it).

The ladder is now mapping_id -> origin -> size -> first. Two outputs can
share a size but never a top-left, and a mirrored head's region is not the
client's size at all, so the size rung cannot find it — this is what makes
per-monitor capture land its input on the monitor it is showing.

An anchor that matches nothing falls back down the ladder rather than
stranding input: the region set is the truth and the anchor is our belief
about it. It warns once per distinct anchor, because the failure this
exists to prevent is a pointer that silently lands elsewhere.

P1 of design/per-monitor-portal-capture.md — mechanism and tests only.
Nothing sets an anchor yet; the pin wires it in P2, and the doc comment
records why it is host-level and not per-session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Opus 5 95e3314d9a feat(vdisplay): enumerate the host's physical monitors
P0 of per-monitor capture (design/per-monitor-portal-capture.md): nothing
in the tree could answer "what heads does this host have?", which both a
monitor pin and a console picker need first.

One read per compositor, each beside the code that already speaks that
dialect: KWin's kde_output_device_v2 (the topology session now also records
geometry + scale), Mutter's DisplayConfig.GetCurrentState, swaymsg
get_outputs, hyprctl monitors. Logical geometry throughout, because x/y is
what identifies a head — two monitors can share a size but never an origin.

PUNKTFUNK_CAPTURE_MONITOR parses here and is reported at startup, loudly
including that it is not yet enforced: a knob that is read but inert has to
say so, or "it didn't work" reads as a bug. GET /display/monitors always
answers 200 with an explained empty list, so a compositor-less host renders
as "nothing to pick", not as a broken console.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehlerandClaude Sonnet 5 3721b6816d chore(release): bump workspace version to 0.20.1
audit / bun-audit (push) Successful in 25s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m32s
audit / cargo-audit (push) Successful in 2m13s
apple / swift (push) Successful in 5m18s
ci / bench (push) Successful in 5m33s
ci / rust-arm64 (push) Successful in 13m8s
android-screenshots / screenshots (push) Successful in 3m13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m11s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m52s
deb / build-publish-host (push) Successful in 10m14s
ci / rust (push) Successful in 34m31s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
decky / build-publish (push) Successful in 28s
arch / build-publish (push) Successful in 12m48s
deb / build-publish (push) Successful in 13m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m48s
android / android (push) Successful in 18m46s
deb / build-publish-client-arm64 (push) Successful in 7m55s
linux-client-screenshots / screenshots (push) Successful in 8m3s
web-screenshots / screenshots (push) Successful in 3m3s
docker / build-push-arm64cross (push) Successful in 5m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13m15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m40s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8m17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
docker / deploy-docs (push) Successful in 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m38s
release / apple (push) Successful in 31m20s
apple / screenshots (push) Successful in 23m22s
windows-host / package (push) Successful in 12m1s
windows-host / winget-source (push) Failing after 12s
flatpak / build-publish (push) Successful in 6m40s
24 commits since v0.20.0. Patch, not minor: entirely fixes and
hardening on top of last release's session⇄game work, nothing new to
opt into. Headline fixes: three Windows install blockers reported
within hours of 0.20.0 (a false conflicting-host detection, a broken
winget log-path switch, missing package-source instructions),
GameStream/Moonlight compat flipped back to opt-in on fresh installs,
three gamescope Game Mode takeover faults that could black out a
Linux machine or strand it on a host restart, a deactivated laptop
panel misdiagnosed as a blind spot instead of a named stall cause,
and the PyroWave high-bitrate latency-creep bundle (clock re-sync
starvation, wire bitrate overshoot, backlog buildup, ABR probe
false-congestion).

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows
virtual-display driver protocol at 6, unchanged from 0.20.0 — no
embedder rebuild required, 0.18/0.19/0.20 hosts and clients mix
freely.

Notes authored ahead of the tag per docs/releases/README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 19:25:47 +02:00
enricobuehler 0e0d5b8b4d chore(api): regenerate openapi.json — the stats description drifted
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m12s
ci / bench (push) Successful in 7m31s
deb / build-publish (push) Successful in 10m0s
decky / build-publish (push) Successful in 27s
deb / build-publish-host (push) Successful in 10m21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
android / android (push) Successful in 12m31s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
windows-host / package (push) Successful in 11m40s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
windows-host / winget-source (push) Skipped
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
ci / rust-arm64 (push) Successful in 13m26s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
docker / deploy-docs (push) Successful in 32s
deb / build-publish-client-arm64 (push) Successful in 10m7s
arch / build-publish (push) Successful in 18m11s
docker / build-push-arm64cross (push) Successful in 4m26s
apple / swift (push) Successful in 6m2s
ci / rust (push) Successful in 28m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m4s
apple / screenshots (push) Successful in 24m37s
`2c69cbda` rewrote StatsSample::mbps's doc comment (goodput → attempted
sealed wire bytes) without regenerating the snapshot, so
`openapi_document_is_complete_and_checked_in` fails on main today. Pure
regeneration, no API surface change: the description and the version stamp
are the only differences.
2026-07-27 19:04:20 +02:00
enricobuehler 6be865f33f fix(host): a host that is stopped hands the box's session back first
Until now the host had no signal handling at all: SIGTERM killed it
outright. That is fine for a host that owns nothing — but a managed
gamescope takeover owns the box's session, and on a mask-fragile display
manager (Nobara's plasmalogin) it has STOPPED that display manager for the
length of the stream. Killed there, the host leaves a box with no graphical
session and nothing left to restart it: the crash-restore state lives in
$XDG_RUNTIME_DIR, which logind removes along with the user manager, so not
even the next host start can heal it. `systemctl --user restart
punktfunk-host` mid-stream — or a package update doing it for you — was
enough to strand a box until someone reached a VT.

So SIGTERM and SIGINT now restore first and exit after. The restore runs on
a blocking thread (it shells out) under a 20s grace, well inside systemd's
TimeoutStopSec, and a host that took nothing over exits immediately.

`restore_takeover_now` is the synchronous sibling of the debounced
disconnect restore, and deliberately ignores the keep-alive policy the
latter honors: `keep_alive=forever` pins a session for the NEXT client,
which means nothing once the host that would serve them is exiting. Both
paths now share one `takeover_live()` predicate instead of repeating the
four-way "is anything ours" test.

Checked on Linux: clippy -D warnings on punktfunk-host + pf-vdisplay, tests,
rustfmt.
2026-07-27 19:04:20 +02:00
enricobuehler f3615f83a5 fix(gamescope): the takeover no longer kills the host it is running in
Field report, Nobara + 0.20.0: switching into Game Mode mid-stream
disconnects the client and the box never lights up again. His journal has
the whole mechanism, ten seconds apart:

  12:34:18.9  freed Steam: stopped the display manager for this stream
  12:34:29.0  systemd[1685]: Stopping punktfunk-host.service

Stopping the display manager ends the user's last login session. The
packaged host is a `systemd --user` unit, so logind then stops
user@1000.service once UserStopDelaySec elapses — 10s by default on Nobara
— and takes the host with it. The stream dies, the scheduled restore never
runs, and the display manager stays down with nothing left to restart it:
a black box that needs a VT to recover. Exactly the two symptoms reported,
and the intermittency is just whether an active Gaming Mode session was
there to trigger the takeover at all.

It never showed on the repro VM because lingering was enabled there for the
earlier sessionless tests (`Linger=yes` — confirmed on the VM today), which
is precisely the thing that breaks the dependency: logind keeps the user
manager alive with no session. Our KDE/GNOME/Arch setup guides already ask
for it; his box, following the KDE route, did not have it.

So the takeover now ensures lingering BEFORE it touches the display
manager: `loginctl enable-linger` directly (allow_active in logind's own
policy — verified working unprivileged on Nobara), else the packaged helper
gains a `linger` verb that enables it for PKEXEC_UID alone, never a
caller-named user. If neither works the takeover is refused with an
actionable error and managed degrades to attach — mirroring the box's own
session is strictly better than a screen that needs a VT. Hosts that cannot
be reached this way (root, a system unit) skip the check: the cgroup test
is a pure `cgroup_under_user_manager` with a unit test, matched against the
real shapes on a Nobara box (`user@1000.service/app.slice/...` vs a
`session-N.scope`).

Also documents the requirement in the gamescope guide's Nobara section.

Checked on Linux: clippy -D warnings, 78 tests, rustfmt; helper verified
with `sh -n`, and the linger enable/disable round-trip run live on the
Nobara VM.
2026-07-27 19:04:20 +02:00
enricobuehler eae1837a3a fix(gamescope): a managed takeover never waits on a dialog nobody can see, nor acts on a months-old sentinel
Two ways the Nobara DM-stop takeover could end a session and leave the box
dark, both found while triaging a 0.20.0 field report (managed round-trip:
"my client disconnects and my monitor doesn't turn back on", intermittent).
0.20.0 is the first release where that box runs the takeover for real —
before it, no package shipped the polkit privilege, so managed always
degraded to attach and never touched the display manager.

Privileged verbs are non-interactive now. `try_stop_display_manager` and
`restore_display_manager` try a plain system-bus `systemctl` first, and
without `--no-ask-password` that asks polkit for INTERACTIVE authorization.
On a box where the host runs inside the user's graphical session, polkit
hands that to the session's agent: a password dialog on the box's own
screen, which during a takeover is off or mid-switch. Nobody sees it,
nobody answers it, and the call blocks on the stream's own thread — the
capture-loss rebuild, or the restore worker — while the rebuild budget
burns. The takeover then lands after the session it was for already ended.
With the flag it fails immediately ("interactive authentication required"),
which is what both callers are already written for: the packaged pkexec
helper (allow_any, no agent needed) takes it from there.

A sentinel with no baseline is not a request. `session_select_requested`
read "no baseline recorded" as "the sentinel appeared during the session" —
but `~/.config/steamos-session-select` is a permanent file, so every box
whose user has ever switched sessions has one. The baseline was recorded
only after a SUCCESSFUL launch, leaving a real window: takeover succeeds,
launch fails, the client retries inside the 5s restore debounce (which the
retry cancels), and the honor gate fires on a months-old write — pushing
the box to the desktop the user never asked for, plus a 120s grace refusing
to relaunch game mode. Now the baseline is taken at takeover too (setting
STOPPED_DM is what arms the gate), and "never baselined" reads as no
request. `SESSION_SELECT_BASELINE` carries both states explicitly, and the
decision is a pure `sentinel_advanced` with a unit test for all six cases.

Checked on Linux: cargo check --all-targets, clippy -D warnings, 77 tests,
rustfmt.
2026-07-27 19:04:20 +02:00
enricobuehlerandClaude Fable 5 ba89b9fcd0 feat(tools): display-disturb learns the bench moves the field sessions needed
ci / rust-arm64 (push) Successful in 9m0s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m3s
android / android (push) Successful in 18m29s
ci / bench (push) Successful in 5m27s
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 19s
arch / build-publish (push) Successful in 19m47s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
ci / rust (push) Failing after 20m23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 28s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 44s
deb / build-publish (push) Successful in 9m55s
deb / build-publish-client-arm64 (push) Successful in 8m33s
deb / build-publish-host (push) Successful in 12m56s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m54s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m10s
apple / swift (push) Successful in 5m28s
apple / screenshots (push) Successful in 23m31s
Three upgrades from the on-glass rounds on .173/.221:

- `ddc-open` timing: the handle-ACQUISITION cost per monitor is reported even
  when it yields no physical handles — the poller's first contact with a
  virtual display, exactly what the DDC-fail-fast driver work changes; the
  old code silently skipped handle-less monitors, which on a streaming host
  in exclusive topology is every monitor there is.
- Monitors are labeled by GDI device name (\\.\DISPLAYn) so a virtual
  display and a panel are tellable apart in the correlation log.
- `extend` mode: one SDC_TOPOLOGY_EXTEND poke — re-activates every
  attachable display, i.e. the exact 'non-managed display re-activated'
  event the exclusive watchdog evicts. One shot, not a loop: trigger a
  reassert round, observe the recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:52:31 +02:00
enricobuehlerandClaude Fable 5 622817954a fix(capture/win-display): a deactivated laptop panel is a named stall suspect, not a blind spot
audit / bun-audit (push) Successful in 14s
ci / web (push) Successful in 52s
windows-drivers / probe-and-proto (push) Successful in 44s
ci / docs-site (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 2m18s
windows-drivers / driver-build (push) Successful in 2m21s
apple / swift (push) Successful in 4m54s
ci / bench (push) Successful in 6m41s
ci / rust-arm64 (push) Successful in 10m4s
decky / build-publish (push) Successful in 42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 26s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
deb / build-publish-host (push) Successful in 10m31s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
ci / rust (push) Failing after 13m9s
deb / build-publish (push) Successful in 12m14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
android / android (push) Successful in 16m5s
arch / build-publish (push) Successful in 17m22s
deb / build-publish-client-arm64 (push) Successful in 9m46s
windows-host / package (push) Successful in 16m38s
windows-host / winget-source (push) Skipped
flatpak / build-publish (push) Successful in 7m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m42s
release / apple (push) Successful in 27m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m34s
docker / deploy-docs (push) Successful in 54s
docker / build-push-arm64cross (push) Successful in 9m53s
apple / screenshots (push) Successful in 23m52s
Field A/B (reporter, Legion 5 Pro hybrid, 2026-07-27): the ~2 s capture-stall
metronome IS the exclusive isolate's own doing — deactivating the laptop panel
leaves a dark-but-connected eDP head whose driver-level servicing disturbs the
capture path (~1.7-2.8 s period); `topology: primary` (panel stays active)
stopped it outright (16.3 stalls/min → 0). Our own .221 logged the identical
signature the day before. The detector then steered AWAY from the cause:
connected_inactive filtered to EXTERNAL physicals only, so the one display
that mattered — on a hybrid laptop, the default config — reported as `none`.

- TargetInventory: new `internal_panel` class bit (eDP/LVDS/embedded).
- connected_inactive_externals → connected_inactive_physicals: internal
  panels join the suspect list (virtual/indirect stay excluded); a nameless
  panel renders as "laptop panel".
- The below-OS METRONOMIC warn names the dark-panel case and its actual
  cures (`topology: primary`, `pnp_disable_monitors`) — the old text's
  "unplug it" prescriptions are impossible for an internal panel.
- ddc_power_off: a zero-ack sweep now says at INFO that the axis did nothing
  (internal panels have no DDC/CI) instead of silently no-op'ing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:04:08 +02:00
enricobuehlerandClaude Fable 5 ab58fd2f0e feat(vdisplay/driver): DDC/CI against the virtual monitor fails fast
In exclusive topology the virtual display is the ONLY monitor on the desktop,
so monitor-control software (the Twinkle Tray / PowerToys PowerDisplay /
Monitorian class) aims its entire DDC traffic — brightness polls,
capabilities-string requests — at OUR monitor. There is no bus and no sink;
the only wrong answer is a slow one (a timeout-shaped failure occupies
win32k's physical-monitor path, serialized per monitor, for its full
duration). Register EvtIddCxMonitorI2CTransmit/Receive and answer every probe
with an immediate STATUS_NOT_SUPPORTED, making the virtual monitor the
cheapest thing on the "bus" to poll. (The receive DDI has no out-arg at the
callback — refusing synchronously refuses the whole transaction.) EDID needs
no equivalent: the OS serves descriptor queries from the blob supplied at
monitor creation without calling the driver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:37:06 +02:00
enricobuehlerandClaude Fable 5 1945052e66 fix(capture/vdisplay): descriptor-following defers while a topology reassert is in flight
The exclusive-topology reassert's forced re-commit transiently bounces the
virtual display's mode; the descriptor poller can sample that EVICTION state
and recreate the ring at a mode the recovery chain is about to undo (field
log 2026-07-27 10:30:44Z: hdr=true → false → recreate → recovery restores
true → second recreate). New pf-win-display::topology_churn latch (deadline
semantics — self-expiring, so a lost release can never wedge descriptor-
following off): the watchdog holds it for interval+3s each fighting round and
releases on "stable again"; poll_display_hdr consumes samples but acts on
nothing while held — which also keeps the negotiated-depth pin-back from
issuing a CCD write mid-eviction, where it would fight the reassert itself.
The deliberate recreate_ring_in_place recovery path is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:37:05 +02:00
enricobuehlerandClaude Fable 5 9e1a686795 feat(tools): display-disturb — deterministic display-stack disturbance generator
The stall-immunity bench needs both stall classes on demand, without a standby
TV or a monitor-tool storm: `ddc` replays the Twinkle-Tray/PowerDisplay-class
DDC/CI traffic (VCP reads, optional capabilities-string requests) through the
win32k I2C path; `modeset` re-commits the current mode with CDS_RESET — the
Level-Two modeset entry that idles the whole adapter with nothing Win32-visible
changing. Every op prints an epoch-stamped took_ms line so host.log stall
reports correlate line-for-line, and the op duration itself measures the
driver's service time per disturbance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:24:20 +02:00
enricobuehlerandClaude Fable 5 19d6f79d2d feat(vdisplay/driver): the frame pump survives MMCSS refusal and outranks GPU contention
Two stall-immunity hardenings for the swap-chain drain thread (branch-2 of the
disturbance-immunity program — failures in OUR delivery leg, as opposed to
adapter-wide display servicing, which no priority survives):

- MMCSS registration fails under the restricted WUDFHost token on some boxes;
  the drain thread then ran UNPRIORITIZED — the whole display's frame pump at
  normal priority, starvable by DDC/HPD-servicing DPC pressure into
  multi-hundred-ms delivery holes. Fall back to TIME_CRITICAL.

- IddCxSetRealtimeGPUPriority (IddCx 1.9) raises the processing device above
  every regular application's GPU priority. Slot availability comes from
  raising the exported IddMinimumVersionRequired 4 → 10, which was overdue
  independently: the drain loop already calls ReleaseAndAcquireBuffer2 (1.10)
  unconditionally, so binding a pre-1.10 framework would dispatch past the
  populated IddFunctions table — with 10 an old framework fails the bind
  cleanly instead. The product floor is already Win11 22H2 (installer
  MinVersion=10.0.22621, whose framework is 1.10), so no supported system
  changes behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:24:10 +02:00
enricobuehlerandClaude Fable 5 94f0207cbd feat(windows/client): logs persist — %LOCALAPPDATA%\punktfunk\logs\client.log
apple / swift (push) Successful in 4m54s
ci / web (push) Successful in 58s
decky / build-publish (push) Successful in 34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
ci / bench (push) Successful in 7m9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 40s
deb / build-publish-client-arm64 (push) Successful in 7m35s
deb / build-publish (push) Successful in 10m19s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m25s
ci / rust-arm64 (push) Successful in 13m57s
deb / build-publish-host (push) Successful in 14m30s
ci / docs-site (push) Successful in 1m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m41s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m32s
android / android (push) Successful in 15m23s
arch / build-publish (push) Successful in 14m1s
flatpak / build-publish (push) Failing after 8m16s
docker / deploy-docs (push) Successful in 11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m25s
release / apple (push) Successful in 29m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m52s
ci / rust (push) Failing after 16m15s
docker / build-push-arm64cross (push) Failing after 4m25s
windows-host / package (push) Successful in 11m35s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m59s
apple / screenshots (push) Successful in 23m44s
The shell is a windows_subsystem binary and spawns punktfunk-session with
CREATE_NO_WINDOW + inherited stderr: a normal GUI/MSIX launch has no
console, so every log line — the shell's and the session's whole
receive/decode/present forensic trail — evaporated exactly when a user
hit something worth reporting. The 2026-07 PyroWave latency-sawtooth
report had to be triaged from host logs alone; the deciding evidence
(clock re-sync vs backlog-flush lines) lives client-side.

- New logfile module: %LOCALAPPDATA%\punktfunk\logs\client.log,
  the host's rotation convention (10 MB cap -> .old at next start, one
  generation), best-effort (no dir -> plain stderr, never a startup
  failure).
- The tracing subscriber tees to stderr AND the file (ANSI off — the
  file is what users send); startup logs the path.
- The spawned session's stderr is piped through the same tee,
  line-buffered — dev-terminal runs keep their interleaved output,
  GUI runs finally keep anything at all.
- The --console path and the punktfunk-console.exe MSIX shim forward
  the same way (a couch launch has no console either).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 2c69cbdab9 docs(host/stats): say what the stats actually measure — three field-triage traps
Three lines that each sent the 2026-07 PyroWave field triage down a wrong
path, made honest:

- The 'client accepts streamed AUs … will stream per-slice' log fires
  before the encoder exists and regardless of backend support (only Linux
  direct-NVENC implements chunked polling) — it now states the client
  capability only.
- StatsSample::mbps was documented as 'transmit goodput'; it is attempted
  sealed wire bytes at seal time — AU bytes + shard framing + FEC parity
  (+ PyroWave window padding), not reduced by socket send drops.
- The per-stage split's 'encode' stage is the poll() duration, which is
  ~0 by construction for synchronous backends (PyroWave encodes inside
  submit → its time shows as 'submit') — documented at the split's
  definition so 'encode 0.00' stops reading as an instrumentation hole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 d8d8c6c43d fix(core/abr): the capacity probe's own overload no longer reads as session congestion
The startup speed-test probe deliberately overloads the link (2 Gb/s for
800 ms) to measure capacity. The report tick is suppressed while it runs
and the window anchors are rebased when it ends — but two residues leak
past the rebase: probe frames still pending in the reassembler age out as
frames_dropped for another ~120 ms (the loss-window fuse), and the burst
can latch flush_in_window (a jump-to-live it caused itself). Either one
reads as a SEVERE window: the controller backs off ×0.7 and — worse —
slow start ends at the first congestion signal, permanently.

That is precisely the 2026-07 field report's Automatic session: 20→14
Mb/s exactly one report tick after the probe, then a purely additive
+6 %/4.5 s crawl that took the whole 4.5-minute match to reach 308 Mb/s
on a link the probe had just measured at ~1.2 Gb/s. Slow start (doubling
per clean window to the probed ceiling, 'seconds instead of minutes')
never got to run.

The first post-probe report window is now discarded outright: no
LossReport (probe residue would also spike the host's adaptive FEC), the
standing-latency detector closes it as not-loss-free (its clean-run just
resets), and the ABR is fed nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 b8b8ac336c feat(core/client): all-intra streams drain the frame channel to the newest AU
The pre-decode FrameChannel is strictly FIFO because H.26x reference
chains forbid mid-stream drops — falling behind is only recoverable via
the coarse jump-to-live thresholds (depth ≥6 sustained, or 400 ms behind
the capture clock) at the cost of a keyframe round-trip.

PyroWave has no reference chains: every AU decodes independently, so a
slow consumer can skip straight to the newest queued AU with zero
recovery cost. The pump now flags the channel all-intra for PyroWave
sessions and pop() drains to the newest, which caps any standing
pre-decode queue at ~1 frame structurally — the 2026-07 field report's
780M client could otherwise ratchet a real multi-frame backlog (and with
it the OSD latency) between those coarse thresholds.

Skips are counted separately from losses (they were delivered, just
superseded) and surfaced at debug on the report tick — the OSD 'lost'
line keeps meaning wire loss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 6d1baa0add fix(pf-encode/pyrowave): the bitrate pin holds on the WIRE, not the raw bitstream
A datagram-aligned PyroWave session inflates the codec bitstream ×1.2–1.3
on its way to the wire — greedy packing of few-hundred-byte atomic block
packets into 1408 B windows zero-pads most window tails, plus the 4-byte
prefixes and FRAG chains. The 2026-07 field report's 1440p60 10-bit
"Automatic" pin of 407 Mb/s put a measured 550 Mb/s on a 1 GbE link;
nothing enforced the pin past the rate controller.

New shared WireBudget (pyrowave_wire.rs, both backends): tracks the real
per-frame AU/bitstream ratio as a ×1024 fixed-point EMA (prior ×1.25,
weight 1/8, clamped ×1.0–×2.0) and deflates the budget handed to
pyrowave's rate control by it, so the windowed AU lands on the configured
rate. Sealed-datagram framing (+4.5%) and FEC parity stay uncompensated —
H.26x sessions carry those on top of the configured bitrate too, and the
pin must mean the same thing for every codec.

Dense (non-chunked) sessions are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 53640b8754 fix(core/clock): re-sync survives loaded links — floor baseline, spaced rounds, bounded staleness
The mid-stream clock re-sync starved on high-bitrate LAN sessions (2026-07
PyroWave-sawtooth field report, RX 9070 XT -> 780M @ 550 Mb/s): every batch
was judged against the CONNECT-TIME RTT, measured before the video data
plane existed, with a 2 ms floor — mid-stream control RTTs on a loaded GbE
link sit above that almost permanently, so batches were rejected for
minutes while the wall clocks drifted apart and the OSD e2e figure ramped
19->150 ms before snapping back on a lucky batch.

Three changes:
- Rounds are spaced 7 ms apart (stamped at send time, so the spacing never
  lands in the RTT). An 8-round batch used to complete inside ONE ~6 ms
  video burst — all rounds sampled the same congestion state; spacing walks
  them across the frame cycle so the min-RTT round finds a quiet gap.
- ResyncGuard replaces the static baseline: the guard band follows the best
  RTT the session has evidenced (connect RTT, then min over every completed
  batch — rejected ones included).
- Rejection streaks are bounded: after 3 consecutive rejections the best
  (min-RTT) batch of the streak is applied anyway. Its queueing bias is at
  most ~half its RTT; unbounded wall-clock drift costs more per minute.

Rejections now log at warn with the streak and floor — the starvation
signature is grep-able in exactly the logs users send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehler 94b7a834cb fix(installer/windows): GameStream is opt-in, not on by default
apple / swift (push) Successful in 3m19s
decky / build-publish (push) Successful in 35s
windows-host / package (push) Successful in 18m44s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m15s
apple / screenshots (push) Successful in 23m24s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m27s
ci / bench (push) Successful in 7m15s
ci / rust-arm64 (push) Successful in 11m47s
deb / build-publish-client-arm64 (push) Successful in 10m21s
android / android (push) Failing after 11m52s
deb / build-publish-host (push) Successful in 11m34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m42s
deb / build-publish (push) Successful in 17m34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8m30s
arch / build-publish (push) Successful in 18m29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13m59s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13m1s
docker / deploy-docs (push) Successful in 35s
ci / rust (push) Successful in 30m15s
docker / build-push-arm64cross (push) Successful in 9m8s
A user found this in their log and had never been offered a choice:

  WARN GameStream/Moonlight compat ENABLED (--gamestream): its pairing runs over
  plain HTTP and its legacy control encryption can reuse GCM nonces
  (security-review #5/#9) — an on-path LAN attacker could MITM pairing or
  recover input. Enable only on a TRUSTED network.

They were right. The `gamestream` task carried no `Flags: unchecked`, so it was
pre-ticked; and since a silent install takes the wizard's own task defaults
(1839d756), a winget install turned it on with no checkbox ever shown. The host
warns that this plane is a security downgrade on every single start, so having
the installer enable it by default cannot be squared with our own advice — least
of all on the path where nobody sees a wizard.

Now unchecked, matching `allowpublicfw`, the other task with a security
consequence. A default install therefore lands `PUNKTFUNK_HOST_CMD=serve`, the
native-only host; Punktfunk's own clients are unaffected, since the native
punktfunk/1 plane is always on. Unattended opt-in is `/MERGETASKS=gamestream`.

Scope, deliberately: this changes FRESH installs only. `GamestreamParam` already
omits the flag entirely unless `FreshHostInstall`, so no existing host has its
choice touched by an upgrade, and anyone relying on Moonlight today keeps it.
`DEFAULT_HOST_CMD` (the service's fallback when host.env carries no
PUNKTFUNK_HOST_CMD line at all) is deliberately NOT flipped here: the installer
always writes the line explicitly, so it does not affect a normal install, and
inverting it would silently disable Moonlight for anyone whose host.env lost that
line — a runtime regression that belongs in its own change, if at all.

Docs corrected everywhere they stated the old default, since three of them now
told users the opposite of what ships: the winget Agreement shown BEFORE install
(it said "enabled by default" and gave the opt-OUT override), the Windows
packaging README, and host.env.example. The `--override` example in the installer
manifest is inverted with it (`/MERGETASKS=gamestream` to add, `!` to remove).

Verified: [Tasks] + [Code] compile with ISCC 6.7.1 on the windows-amd64 runner
(all optional features defined), and the winget catalogue rebuilds and passes
29/29 with the edited manifests.
2026-07-27 12:26:47 +02:00
enricobuehler 4114dfeff7 fix(winget): the Log switch used |LOGPATH|, which winget never substitutes
Second field report on 0.20.0's winget path, and independent of the conflict
abort in 37781a61: an INTERACTIVE install through UniGetUI dies before the
wizard with Inno's "Error creating log file: The filename, directory name, or
volume label syntax is incorrect."

`<LOGPATH>` is winget's own token and the only spelling it replaces — the
schema this manifest declares says so verbatim ("<LOGPATH> token can be included
in the switch value so that winget will replace the token with user provided
path"). We shipped `|LOGPATH|`, which is not a token, so winget passed the
literal string to Inno; `|` is not legal in a Windows filename, and Inno refuses
before doing anything else.

Worse than the conflict abort in two ways: it is not confined to silent installs
— it fires in EVERY mode for any caller that requests a log — and UniGetUI
requests one by default, so a GUI user cannot install at all. Confirmed against
the live source, which is serving the broken switch to everyone right now:
  curl -s https://winget.punktfunk.unom.io/packageManifests/unom.PunktfunkHost
  → "Log": "/LOG=\"|LOGPATH|\""

Guarded by the suite that exists for exactly this class of defect (a wrong
response shape does not fail loudly — winget just says "no package found"). The
new check accepts an absent Log switch, requires <LOGPATH> when present, and
rejects any |TOKEN| spelling. Mutation-verified: restoring |LOGPATH| fails the
check naming the offending value, and the corrected manifest passes 29/29.

⚠ Fixing the template does NOT fix the live catalogue: it is derived from the
manifest trio attached to each release, so the corrected switch only reaches
users when a release carries it.
2026-07-27 10:28:59 +02:00
enricobuehler 37781a610f fix(installer/windows): only a host that will actually RUN is a conflict
docker / deploy-docs (push) Successful in 41s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m50s
apple / swift (push) Successful in 5m41s
ci / bench (push) Successful in 7m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 2m3s
ci / rust-arm64 (push) Successful in 9m51s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 27s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 27s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
deb / build-publish-host (push) Successful in 11m32s
deb / build-publish-client-arm64 (push) Successful in 10m59s
decky / build-publish (push) Successful in 41s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6m19s
android / android (push) Successful in 17m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 5m35s
windows-host / package (push) Successful in 11m21s
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 19m9s
docker / build-push-arm64cross (push) Successful in 5m7s
ci / rust (push) Successful in 22m25s
deb / build-publish (push) Successful in 10m49s
apple / screenshots (push) Successful in 23m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m34s
Field report within hours of 0.20.0 (HitFrostbite): `winget install
unom.PunktfunkHost` fails with "Installer failed with exit code: 1",
0x8A150006, after the hash verifies and the install starts.

That is Setup aborting on purpose. `StreamHostPresent` counted a conflicting
host as present if its SCM service key existed at ANY start type, OR if a bare
Program Files\<Name> directory existed — so a disabled service, or a folder left
behind by an uninstall, read as a live conflict. 3d893013 then made the warning
a SuppressibleMsgBox defaulting to IDNO so unattended installs could not hang on
it; under winget's /VERYSILENT /SUPPRESSMSGBOXES that default aborts
InitializeSetup, and Inno exits "failed to initialize" = code 1. Net effect:
anyone with Sunshine on disk — the exact audience most likely to migrate — could
not install via winget at all, with no visible reason.

The other candidate for exit 1 is excluded rather than assumed: the manifest
declares MinimumOSVersion 10.0.22621.0 matching the script's MinVersion, so
winget refuses an unsupported OS BEFORE downloading, and the report shows the
hash verified and the install starting. The conflict check is also the only
`Result := False` in InitializeSetup.

Detection now keys on the service's `Start` value (0 boot / 1 system /
2 automatic / 3 manual / 4 disabled), counting only 0-2 — a host that comes up
on its own is the only kind that can take the GameStream ports or load a second
virtual-display driver. Directories no longer count at all: they were the
noisiest signal and the one that survives an uninstall. This makes the installer
agree with the tray, which dropped its always-on warning over a merely-INSTALLED
Sunshine in this same release (3e782852) for exactly the same reason. The
message now says "installed and set to start automatically" and offers disabling
the service as a remedy, both of which are what is actually detected.

A conflicting host running as a plain user process is deliberately not detected:
pure Pascal cannot enumerate processes, it is a runtime rather than install-time
condition, and the host already reports it via the `punktfunk::detect` startup
warning, `detect-conflicts`, and /api/v1/local/summary.

Verified by compiling the [Code] section with ISCC 6.7.1 on the windows-amd64
runner (WithWeb defined, as CI does). That caught two defects in my own first
draft that review would not have: a continuation line STARTING with #13#10, which
ISPP reads as a preprocessor directive ("Unknown preprocessor directive", compile
aborted), and `{commonpf}` written inside a `{ }` comment, which closes it early
since Inno comments do not nest. Both are noted at the sites so they are not
reintroduced.
2026-07-27 09:00:34 +02:00
enricobuehler ec9aa415f6 chore(web): override brace-expansion to 5.0.8 — clears GHSA-mh99-v99m-4gvg
audit / bun-audit (push) Successful in 19s
ci / docs-site (push) Successful in 2m18s
audit / cargo-audit (push) Successful in 3m22s
ci / web (push) Successful in 1m6s
apple / swift (push) Successful in 5m10s
ci / bench (push) Successful in 5m49s
ci / rust-arm64 (push) Successful in 9m52s
arch / build-publish (push) Successful in 13m17s
decky / build-publish (push) Successful in 54s
deb / build-publish-client-arm64 (push) Successful in 12m15s
android / android (push) Successful in 18m58s
deb / build-publish-host (push) Successful in 16m33s
windows-host / package (push) Successful in 19m55s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 11m50s
apple / screenshots (push) Successful in 23m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m25s
ci / rust (push) Successful in 33m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 37s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 36s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 28s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 34s
docker / build-push-arm64cross (push) Successful in 5m16s
docker / deploy-docs (push) Successful in 38s
bun-audit has been red since the advisory published, so it was already failing
before v0.20.0 was cut (same failure on 0323158a). High-severity DoS: unbounded
expansion length causing an OOM crash. All three paths are dev tooling —
orval→typedoc→minimatch, @storybook/react-vite→glob→minimatch, and
nitro-v2-vite-plugin→nitropack→archiver→archiver-utils→glob→minimatch — so
nothing shipped to a user was exposed, but the leg gates every lockfile change.

Done as an `overrides` entry, joining the eight already there for exactly this
purpose. `bun update brace-expansion` is NOT the fix and was reverted: for a
package that is only transitive it adds a spurious DIRECT dependency, and it
does not even resolve the advisory — it hoists 5.0.8 for the new direct dep and
leaves `minimatch/brace-expansion` pinned at the vulnerable 5.0.7.

The advisory's affected range is `<= 5.0.7` across ALL majors with 5.0.8 the
only patched version, so the two nested 2.1.2 copies had to move too — a major
bump for consumers declaring ^2.0.1/^2.0.2. Checked rather than assumed: 5.0.8
is dual ESM/CJS with a real `require` branch in its exports map (2.1.2 was
plain CJS), so those consumers still resolve. Proven by building: the nitro
build is itself one of the forced consumers.

Verified: `bun audit` reports no vulnerabilities, both nested entries are gone
from the lock, and the full CI web sequence is green —
`bun install --frozen-lockfile --ignore-scripts`, `bun run build`, `bun run lint`.
2026-07-27 07:32:08 +02:00
enricobuehler 24d2f97eae fix(ci/winget): forward GITHUB_REF_NAME into the remote shell
The catalogue-verify step has failed on every stable tag since it landed,
v0.20.0 included: `bash: line 6: GITHUB_REF_NAME: unbound variable`, twice,
then exit 1.

`env:` populates the RUNNER's environment. appleboy/ssh-action executes its
`script` on the REMOTE host over SSH, which inherits none of it — the action
has a separate `envs:` input naming the variables to forward. Without it the
`set -u` at the top of the script turns the first expansion into a fatal, and
the two `curl`s either side report only the collateral: `Failed writing body`
is curl losing its pipe when the `grep` it was feeding died.

The failure is maximally misleading, because everything it guards ALREADY
WORKED: the catalogue is built, tested and shipped in the preceding steps, and
only the proof-of-serving fails. Confirmed by hand against the live source
during the v0.20.0 release — it serves unom.PunktfunkHost 0.20.0 correctly.

The pattern was already right everywhere else: both "Pull and start docs" steps
(deploy-services.yml, docker.yml) pair `env:` with `envs:`. A sweep of the
other three ssh-action steps found no further instance — the remaining two pass
no variables at all.
2026-07-27 07:31:55 +02:00
enricobuehler c9b8f666cd docs(release): winget needs its source added first — say so
Reported within an hour of the announcement: `winget install unom.PunktfunkHost`
answers "no package found". That is correct behaviour, not a broken package —
Punktfunk is served from its own REST source, and winget only searches sources
it has been told about. The notes said "after adding Punktfunk's package source
once" without ever giving the command, so there was no way to act on it.

Both commands are now spelled out, the `winget source add` one flagged as
needing an Administrator terminal (it does), and the failure mode is stated
explicitly so someone who hits it recognises it rather than filing it as a bug.

The lead-in is untouched, so the Discord embed for v0.20.0 stays accurate and
this needs no re-announcement — the release body is re-synced from this file.
2026-07-27 07:31:37 +02:00
enricobuehler 35b6c835fd docs: on a gamescope session the display outranks the end-game setting
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m19s
decky / build-publish (push) Successful in 33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 26s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
ci / bench (push) Successful in 5m32s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 9m2s
windows-host / package (push) Successful in 20m22s
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 14m46s
docker / build-push-arm64cross (push) Successful in 30s
docker / deploy-docs (push) Successful in 52s
android / android (push) Successful in 16m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m36s
ci / rust (push) Successful in 27m3s
apple / swift (push) Successful in 6m5s
apple / screenshots (push) Successful in 25m23s
deb / build-publish-host (push) Successful in 16m6s
deb / build-publish-client-arm64 (push) Successful in 11m21s
deb / build-publish (push) Successful in 11m28s
On-glass on .41: with `game_on_session_end: keep` — "leave it running, nothing
is ever closed" — a deliberate stop ended the game, and a drop ended it ~14 s
later. Both times the LEASE did the right thing (no `ending the launched game`
line); the game died because the virtual display was torn down, and a nested
launch runs inside that gamescope.

So on a dedicated gamescope session the game's lifetime IS the display's, and
keep-alive decides it — not this setting:

  * a deliberate stop skips the linger by design, so the game goes at once,
  * a drop lingers the keep-alive window, then takes the game with it,
  * only keep-alive Forever actually keeps it — and `always` still ends it at
    the reconnect window, which is the documented precedence and was verified
    (a `forever`-pinned display released at grace expiry, 60.006 s).

That makes the console's promise untrue on the most common Linux setup, so the
card now says so for every option rather than only warning about `always`, and
the docs get the three cases as a table. Worded so a non-gamescope host reads it
and moves on — a desktop-session launch is an ordinary process and none of it
applies.

Not changing behavior here: making `keep` pin the display would pit it against
"a stop must not leave a ghost display", and which wins is a product call rather
than something to decide silently mid-test.

web: codegen + tsc + biome clean, en/de complete. docs-site: build + tsc clean.
2026-07-27 01:43:40 +02:00
enricobuehler 39a9f09f04 fix(mgmt/web): the running-game cover slot says "game" when there is no art
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m46s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 1m1s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
deb / build-publish-client-arm64 (push) Successful in 8m0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 31s
deb / build-publish-host (push) Successful in 10m43s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 33s
ci / docs-site (push) Successful in 1m57s
arch / build-publish (push) Successful in 13m21s
android / android (push) Successful in 17m20s
ci / rust-arm64 (push) Successful in 13m46s
deb / build-publish (push) Successful in 14m47s
ci / rust (push) Successful in 21m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m39s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
ci / bench (push) Successful in 6m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m21s
docker / build-push-arm64cross (push) Successful in 8m21s
docker / deploy-docs (push) Successful in 40s
apple / swift (push) Successful in 5m37s
apple / screenshots (push) Successful in 23m3s
windows-host / package (push) Successful in 18m16s
windows-host / winget-source (push) Skipped
Rendered the Dashboard stories headlessly and looked at them, which is how this
turned up: with no cover the slot was an empty grey rectangle, reading as
something that failed to load rather than something absent.

Plenty of rows will never have art — an operator-typed command has no catalog
entry behind it, a custom entry may carry none, and nothing does until
`/library` has loaded. The slot keeps its fixed size so rows stay aligned when
only some titles have covers; it just holds a gamepad glyph instead of nothing.
2026-07-27 00:47:05 +02:00
enricobuehlerandClaude Opus 5 f57fc85d34 chore(release): bump workspace version to 0.20.0
audit / cargo-audit (push) Successful in 3m41s
ci / rust-arm64 (push) Successful in 13m12s
ci / bench (push) Successful in 6m53s
windows-host / package (push) Successful in 11m47s
ci / web (push) Successful in 1m2s
android-screenshots / screenshots (push) Successful in 4m20s
audit / bun-audit (push) Failing after 32s
ci / docs-site (push) Successful in 1m28s
arch / build-publish (push) Successful in 13m7s
linux-client-screenshots / screenshots (push) Successful in 7m29s
ci / rust (push) Successful in 33m52s
android / android (push) Successful in 18m0s
web-screenshots / screenshots (push) Successful in 3m26s
apple / swift (push) Successful in 5m17s
windows-host / winget-source (push) Failing after 29s
decky / build-publish (push) Successful in 37s
docker / build-push-arm64cross (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 29s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 24s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
docker / deploy-docs (push) Successful in 24s
apple / screenshots (push) Successful in 23m52s
release / apple (push) Successful in 27m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m35s
deb / build-publish-client-arm64 (push) Successful in 7m49s
deb / build-publish-host (push) Successful in 13m51s
deb / build-publish (push) Successful in 15m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m18s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 8m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m16s
flatpak / build-publish (push) Successful in 6m4s
117 commits since v0.19.2. Minor rather than patch: the headline is the
session⇄game lifetime binding on both planes and both platforms — the
stream ends when the game does, and optionally the reverse — plus
library metadata, winget packaging for the Windows host, a 64-bit ARM
Linux client, headless `--pair` enrolment, DRM card selection for the
compositor-less presenter, and the ABR/latency de-escalation work, which
changes runtime behaviour on every session.

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows
virtual-display driver protocol at 6, so 0.18/0.19/0.20 hosts and
clients mix freely and no embedder rebuild is required. The new GameMeta
library fields are optional and flat on the wire.

Notes authored ahead of the tag per docs/releases/README.md, so the
release is born with a body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:33:45 +02:00
enricobuehlerandClaude Fable 5 0323158a3e fix(vdisplay/gamescope): a fresh dedicated launch streams from the first second
audit / bun-audit (push) Failing after 20s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
ci / bench (push) Successful in 6m35s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 7m50s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
arch / build-publish (push) Canceled after 7m36s
deb / build-publish (push) Canceled after 4m26s
ci / web (push) Successful in 1m8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
deb / build-publish-host (push) Canceled after 6m54s
deb / build-publish-client-arm64 (push) Canceled after 6m48s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 40s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
apple / swift (push) Successful in 5m32s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
audit / cargo-audit (push) Successful in 2m54s
ci / docs-site (push) Successful in 1m25s
release / apple (push) Canceled after 1m57s
decky / build-publish (push) Successful in 45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 39s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 6m34s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
A freshly spawned headless gamescope never delivered a single PipeWire
buffer when a game was launched: gamescope only composites (and only on a
composite pushes a capture buffer) when a client paints, and a nested
Steam bootstrap paints nothing until its UI's first frame — far longer
than the 10 s first-frame budget. The native plane's retry ladder then
killed the half-booted Steam on every attempt and started from zero; the
GameStream plane died on its single wait. Reused live displays worked,
which pointed away from the real cause (root-caused on .41 with a raw
pw_stream probe: `sleep infinity` nested → 0 buffers ever, `vkcube`
nested → 60/s immediately).

Every bare spawn now backgrounds the host's own splash client
(`gamescope-splash`, hidden subcommand) beside the nested app: a dark
window with a subtle breathing bar painted at ~2.5 Hz — damage from the
first second, so capture gets its first frame within the budget on both
planes. In `--steam` mode gamescope composites only windows whose appid
is in the root `GAMESCOPECTRL_BASELAYER_APPID` list (live-proven: even a
painting vkcube gets zero composites without it), so the splash declares
itself as the Steam UI (STEAM_GAME=769) and seeds the baselayer iff
unset; Steam's own rewrite at game launch hands composite focus to the
game with no action on our side. PUNKTFUNK_GAMESCOPE_SPLASH=0 is the
escape hatch.

The dedicated Steam launch is also shaped with `-gamepadui` instead of
`-silent`: the nested Steam is Big Picture — the identity gamescope's
`--steam` integration is built around — so the boot shows the gamepad UI
instead of the desktop client window flashing through the stream, and
gamescope's focus rules (game outranks the Steam UI appid) cover what
`-silent` was working around.

On-glass on .41 (Bazzite, RTX 5070 Ti, gamescope c31743d): cold connect +
Balatro launch delivers frames on the first attempt on both a cold and a
torn-down-then-fresh spawn; Big Picture boot → game handover confirmed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 00:28:59 +02:00
enricobuehler 980b399ea8 test(session/gamelease): the live-process test waits out the shim window
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m8s
apple / swift (push) Successful in 5m53s
ci / bench (push) Successful in 5m59s
android / android (push) Successful in 13m13s
arch / build-publish (push) Successful in 13m16s
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 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 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 7m53s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 24s
ci / rust-arm64 (push) Successful in 15m10s
deb / build-publish (push) Successful in 14m12s
deb / build-publish-host (push) Successful in 15m13s
windows-host / package (push) Successful in 19m0s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 28m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m30s
apple / screenshots (push) Successful in 24m16s
Its child is `sh` running a script that `exec`s a binary outside the install
dir, so neither the image path nor the command line carries the directory — the
host's own child is the only signal there is. Which is exactly the case the shim
window now gates: a live child that might still turn out to be a launcher is not
called the game until that window passes.

So "running" legitimately arrives up to SHIM_WINDOW later than it used to, and
the assertion at 1.5 s was asserting the old behavior. Waiting it out is the
point now, not an accident of timing.

The delay is the deliberate cost of the fix: until the window passes there is no
way to tell "the child IS the game" from "the child is about to hand off". It
shows as a few seconds of `launching` in the console, and only for a title whose
store gave us signals.

.25: the ignored live test passes (Child -> Running -> terminate via the
SIGTERM->SIGKILL group ladder -> Exited, session-end action correctly NOT fired),
full suite 305 passed.
2026-07-26 22:33:42 +02:00
enricobuehler d5857abd91 fix(session/gamelease): a launcher handing off is not the game exiting
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m12s
ci / bench (push) Successful in 7m19s
apple / swift (push) Successful in 6m33s
ci / rust-arm64 (push) Successful in 10m0s
decky / build-publish (push) Successful in 20s
deb / build-publish (push) Successful in 9m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
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 11s
arch / build-publish (push) Successful in 13m0s
deb / build-publish-host (push) Successful in 13m49s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m44s
docker / deploy-docs (push) Successful in 25s
android / android (push) Successful in 17m7s
deb / build-publish-client-arm64 (push) Successful in 10m24s
windows-host / package (push) Successful in 19m52s
windows-host / winget-source (push) Skipped
docker / build-push-arm64cross (push) Successful in 4m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m7s
ci / rust (push) Successful in 28m24s
apple / screenshots (push) Successful in 25m43s
On glass (.41), launching any Steam title over the compat plane ended the
session ~7 s later, before the game had started:

  launched app  command=steam steam://rungameid/2379780  pid=12699
  watching the launched game  kind="child"
  the launched game is running  kind="child"  procs=0      <- 3 ms in, zero game procs
  the launched game exited  ->  session ended

Linux launches are `Child` leases: the host spawns the launcher itself. The
watcher counts "my child is alive" as the game running, so the lease left the
waiting phase on its FIRST poll — and the shim reclassification lives in that
phase, so it never got to run. When `steam` handed off and exited, that read as
the game exiting.

The design always called for the shim window ("never fires B from a shim exit")
and the code implements it; the lease had simply moved past where it applies. So
when the store gave us signals to recognize the real game by, a bare live child
no longer counts as the game until that window has passed. With no signals the
child is all we have and still counts immediately — custom commands are
unchanged.

Windows could not reproduce this: it holds no child, so its leases are always
`Matched`, and the whole .173 pass was structurally blind to it. Both planes on
Linux were affected — which is most of the audience for this feature.

Also fixes the live-process fixture, which was CI-red for an unrelated reason:
it copied `/bin/sleep` to a file named `game`, and modern coreutils (uutils on
Ubuntu 25.10+, busybox elsewhere) is a MULTI-CALL binary dispatching on argv[0]
— under any other name it exits instantly, leaving nothing in /proc and looking
exactly like a broken matcher. Verified by A/B: the same test fails identically
on a tree predating these changes.

.25 (Ubuntu 26.04): 305 passed, 0 failed.
2026-07-26 21:24:12 +02:00
enricobuehler 0c261de636 ci(plugin-kit): quote the step name that contains a colon
decky / build-publish (push) Successful in 36s
arch / build-publish (push) Successful in 17m12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
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
apple / screenshots (push) Successful in 26m29s
docker / build-push-arm64cross (push) Successful in 10s
deb / build-publish (push) Successful in 8m55s
docker / deploy-docs (push) Successful in 13s
ci / rust (push) Failing after 17m55s
deb / build-publish-client-arm64 (push) Successful in 7m26s
deb / build-publish-host (push) Successful in 9m41s
apple / swift (push) Successful in 6m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m42s
ci / web (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m23s
android / android (push) Successful in 12m13s
ci / rust-arm64 (push) Successful in 9m33s
ci / bench (push) Successful in 7m23s
`- name: Repair the file: dependency (…)` is not valid YAML — the unquoted
`file: ` reads as a second mapping key — so the workflow has been unparseable
since it was added, which is why re-pushing the tag never triggered a run and a
manual dispatch 500'd. I had put that down to a Gitea tag quirk; it was this.

The failure is invisible from the repo side: nothing validates workflow YAML on
push, and an unparseable workflow simply never runs.
2026-07-26 20:45:58 +02:00
enricobuehler 6e07d063c3 fix(session/gamelease): Windows had no launch reference, so it adopted old games
ci / web (push) Successful in 1m15s
ci / docs-site (push) Successful in 1m2s
ci / bench (push) Successful in 6m37s
windows-host / package (push) Successful in 11m1s
windows-host / winget-source (push) Skipped
ci / rust-arm64 (push) Successful in 11m53s
apple / swift (push) Successful in 7m18s
deb / build-publish (push) Successful in 10m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m16s
ci / rust (push) Failing after 13m29s
docker / build-push-arm64cross (push) Successful in 10s
arch / build-publish (push) Successful in 13m42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / deploy-docs (push) Successful in 25s
decky / build-publish (push) Successful in 25s
apple / screenshots (push) Successful in 26m5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
android / android (push) Successful in 15m6s
deb / build-publish-host (push) Successful in 11m27s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m45s
deb / build-publish-client-arm64 (push) Successful in 8m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m39s
On glass (.173), with a copy of Buckshot Roulette already running: a session
launched the same title, Steam focused the existing instance rather than
starting one, the lease adopted THAT process — and a deliberate stop closed the
player's pre-existing game.

That is the one thing the design says must never happen. The rule was written
and the matcher implements it; the reference instant it filters against just
never arrived. `procscan::launch_stamp` supports Windows, but the wrapper every
launch site calls was gated `#[cfg(target_os = "linux")]` and answered `None`
everywhere else — and `None` doesn't fail, it turns the filter OFF (`find` skips
it), so every process under the install dir became adoptable.

Windows is where this matters most: the host runs as SYSTEM and can see every
process on the box, which is why `procscan/windows.rs` calls rule 1
load-bearing rather than a nicety. Both planes were affected.

No unit test could have caught it — the Linux fixtures pass an explicit
`min_start`, and the Windows live test passes `None` deliberately. So the guard
added here asserts the reference EXISTS wherever processes can be matched, which
is the shape of the failure: silent, and invisible from downstream.

.133: 282 passed (+1), same 4 environmental native::tests failures.
2026-07-26 20:30:20 +02:00
enricobuehlerandClaude Fable 5 dff63b2a29 fix(encode/nvenc): a visible cursor no longer serializes submit — the blend goes stream-ordered
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 2m32s
android / android (push) Canceled after 39s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 34s
ci / rust (push) Canceled after 24s
ci / rust-arm64 (push) Canceled after 24s
ci / bench (push) Canceled after 5s
ci / web (push) Canceled after 24s
ci / docs-site (push) Canceled after 8s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish (push) Canceled after 2s
deb / build-publish-host (push) Canceled after 1s
decky / build-publish (push) Canceled after 4s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 2s
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
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
Report: iPad on a gamescope/NVIDIA 120 fps session capped at ~80 fps with
repeat_fps 0, zero loss, capture 0 µs, ASIC 15 µs — and submit p50 at
10.2 ms, ~81 % of the loop period. Under gamescope the host composites
the live pointer into EVERY frame, and a cursor-bearing frame forced the
CPU-synced submit path: a blocking CUDA copy plus a fence-waited Vulkan
blend, both exposed to the running game's GPU load. The "games hide the
cursor" assumption the gate relied on does not hold under gamescope.

The blend is now stream-ordered end to end. VkSlotBlend exports a
timeline semaphore (VK_KHR_timeline_semaphore + external_semaphore_fd)
into CUDA (cuImportExternalSemaphore, new dlopen entries): the enqueued
copy signals it on the encode thread's copy stream, the blend submission
waits for and advances it on the Vulkan queue, and a CUDA-side wait
orders the encode after the blend on the session's bound IO stream — no
CPU sync anywhere, so cursor frames keep the stream-ordered fast path.
Each ring slot gets its own command buffer + descriptor set (written
once) so several ordered blends can be in flight; cursor-bitmap uploads
and teardown quiesce through the timeline. Drivers without the timeline
export keep the previous CPU-synced blend, and any bring-up or per-frame
failure still degrades to "no cursor", never a dropped frame.

Also: the blocking multi-plane copies (the escalated/pipelined mode and
the non-stream-ordered fallback) now enqueue every plane and pay ONE
stream sync instead of one per plane (NV12 2→1, YUV444 3→1) — each
exposed wait costs scheduling latency under GPU contention, which is
what makes the escalation's blocking copies self-reinforcing.

Verified on the RTX 5070 Ti box (driver 610.43.03): all 12 nvenc_cuda
on-hardware smokes green, including the new
nvenc_cuda_cursor_blend_stream_ordered (6 cursor AUs, all ordered,
across a bitmap-serial flip); host suite 301/301; clippy --all-targets
-D warnings clean; struct layouts of the hand-flattened cuda.h params
asserted in tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:05:27 +02:00
enricobuehler 02b94b828d fix(gamestream): the termination type comes from the version we advertise
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
windows-host / package (push) Successful in 11m8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1s
windows-host / winget-source (push) Skipped
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 3s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
android / android (push) Canceled after 6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m3s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 8s
ci / rust-arm64 (push) Canceled after 10s
ci / rust (push) Canceled after 10s
ci / docs-site (push) Canceled after 6s
ci / bench (push) Canceled after 5s
ci / web (push) Canceled after 9s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish (push) Canceled after 3s
decky / build-publish (push) Canceled after 4s
Still -1 on glass. The message was going out; the client was ignoring it.

Which packet-type table a client reads is decided by exactly one thing —
moonlight-common-c sets `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1,
431)` — and we advertise 7.1.431, so every client reads the ENCRYPTED table,
where termination is 0x0109. We were sending 0x0100.

Deriving that from the nonce scheme was the error: `NonceKind` describes how the
GCM nonce is built and nothing else. The two look like the same "is this
encrypted" axis and are not. The HDR message could never have caught it either,
since 0x010e is identical in both tables — so the only symptom was the silence
this whole chain has been about.

Pinned with a test asserting APP_VERSION still clears 7.1.431, because if it
ever drops below, the correct type silently becomes 0x0100 again and the stream
goes back to ending as an error with nothing failing to explain why.

.133: 281 passed (+3), same 4 environmental native::tests failures. The Linux
box is off the network, so the full Linux suite is owed.
2026-07-26 20:04:54 +02:00
enricobuehler e44f2b1024 fix(gamestream): say "the app quit", not just goodbye
ci / web (push) Successful in 1m2s
android / android (push) Canceled after 7m52s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5m49s
ci / docs-site (push) Canceled after 55s
ci / bench (push) Canceled after 5s
ci / rust (push) Canceled after 5m32s
ci / rust-arm64 (push) Canceled after 2m39s
deb / build-publish (push) Canceled after 7s
deb / build-publish-host (push) Canceled after 3s
deb / build-publish-client-arm64 (push) Canceled after 1s
decky / build-publish (push) Canceled after 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 2s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 3s
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 2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Successful in 19m26s
windows-host / winget-source (push) Skipped
Disconnecting the ENet peer stopped the freeze but the client then reported the
session as error -1: a bare disconnect is indistinguishable from the host
falling over, so Moonlight called it a connection failure. Which, from its side,
was a fair reading — we never said otherwise.

The protocol has a word for this. Send the TERMINATION control message with
`NVST_DISCONN_SERVER_TERMINATED_CLOSED` first, then disconnect; the client maps
that to a graceful termination (given it has seen a frame, true of any real
session) and lands the player back in the app list.

Constants verified against moonlight-common-c `ControlStream.c` rather than
recalled, because every way of getting this wrong is silent — the client ignores
an unparseable control message, which reads on glass as the same freeze:

* the type comes from the table the CLIENT chose, so it is derived from the
  negotiated nonce scheme (plain 0x0100 / encrypted 0x0109) instead of guessed,
* the reason is BIG-endian (the >=6-byte branch; the short branch is a
  little-endian u16, which is GFE's older shape).

`disconnect_later` rather than `disconnect`, so the queued message actually
reaches the wire instead of racing the teardown — which would land us back at -1.

The session's GCM key is cached per tick because ending a session clears the
launch state the key lives in, and this is precisely the message that has to be
sealed after that.
2026-07-26 19:58:25 +02:00
enricobuehler ddc28de32a fix(gamestream): tell the client when the HOST ends the session
ci / rust (push) Failing after 22m10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m15s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 10m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m13s
windows-host / package (push) Failing after 12s
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m2s
ci / bench (push) Successful in 5m41s
ci / rust-arm64 (push) Successful in 9m26s
arch / build-publish (push) Successful in 12m29s
deb / build-publish (push) Successful in 8m57s
decky / build-publish (push) Successful in 32s
apple / swift (push) Successful in 5m57s
android / android (push) Successful in 19m51s
deb / build-publish-client-arm64 (push) Successful in 7m21s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
deb / build-publish-host (push) Successful in 9m34s
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 1m9s
On-glass on .173: a launched game exited, the host tore the session down
correctly — and the client froze on its last frame instead of returning to its
app list.

The host's half was right; it just never said anything. On the native plane
ending a session closes the QUIC connection with APP_EXITED, which every client
renders as "game ended". The compat plane has no such channel: `end_session`
stops the media threads, and stopping a UDP sender is silence, not a signal.
Moonlight holds the ENet control stream for the whole session, so with no word
from us it sat there until its own timeout eventually fired.

The control loop already reacted to the CLIENT disconnecting; it had no path for
the reverse. It now watches for its session being cleared out from under it and
disconnects the peer, which is what Moonlight reads as "the session is over" —
landing the player back in the app list, the same place quitting puts them.

This is the signal every host-side end needed, not just a game exiting: the
management stop and a `/cancel` racing another connection had the same silence.
2026-07-26 19:44:55 +02:00
enricobuehlerandClaude Opus 5 1839d7566b feat(packaging/winget): install the Windows host with winget, from our own source
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 26m56s
apple / screenshots (push) Canceled after 11m30s
windows-host / package (push) Failing after 12s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 6m2s
ci / web (push) Successful in 1m53s
ci / docs-site (push) Successful in 1m29s
ci / rust-arm64 (push) Successful in 9m56s
android / android (push) Successful in 12m33s
ci / bench (push) Successful in 7m17s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
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 11s
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 19s
arch / build-publish (push) Successful in 18m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish (push) Successful in 9m2s
ci / rust (push) Failing after 19m38s
deb / build-publish-client-arm64 (push) Successful in 7m21s
deb / build-publish-host (push) Successful in 9m43s
`winget install unom.PunktfunkHost` after adding the source, and `winget upgrade`
from then on. Windows had no update path at all before this — no self-update, no
package manager — so keeping a host current meant noticing a release and
re-running an installer by hand.

Ships the manifest trio in winget-pkgs' own format (so submitting upstream later
is a copy, not a rewrite) plus a release-time generator that substitutes only
version, URL, hash and release-notes link. Everything reviewable — the silent
switches, the agreements, the installation notes — stays in the checked-in
templates rather than buried in a generator.

Silent installs deliberately take the SAME task defaults the wizard shows: a
per-channel default is a support trap. The disclosures the wizard puts on screen
travel as manifest Agreements instead, shown before install and requiring
acceptance — VB-Audio's bundling grant wants the user to see VB-CABLE's origin
and donationware status, and a silent install shows them nothing otherwise. The
console password can only be pointed at, never printed: it is generated per
install and the notes are fixed at publish time.

Self-hosted rather than the community repo, which gates on Defender/SmartScreen
validation the self-signed installer cert would not clear today. The source is
three endpoints — /information, /manifestSearch, /packageManifests/{id}. The
reference implementation's other twenty are its admin API for mutating a
CosmosDB; a catalogue generated at release time has nothing to mutate.

It runs on unom-1 as a stock bun image with the two .mjs files bind-mounted, the
same shape as the flatpak server, so there is no image to build or pull. The
catalogue is derived from the RELEASES rather than local files: winget resolves
--version and upgrade against the version list, so a source that only knew the
newest release could neither pin an older one nor show an upgrade path from it.
That also leaves no state to drift — re-running the build reproduces it exactly.

NormalizedPackageNameAndPublisher is declared unsupported on purpose. winget
derives it client-side with its own normalization, and a near-miss silently
mis-correlates an installed host; ProductCode is exact and Inno gives us one.

28 checks drive the handler directly, and CI gates on them before anything
reaches the box — a wrong response shape does not fail loudly, it just makes
winget report "no package found".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:09:13 +02:00
enricobuehlerandClaude Opus 5 3d89301336 fix(installer/windows): a silent install can't hang on a prompt, and an upgrade keeps your choices
Three faults that only bite when nobody is watching the wizard — which is how a
package manager always runs us, and how a re-run by hand half-behaves today.

The conflicting-host warning used a plain MsgBox. That ignores /SUPPRESSMSGBOXES
and displays even under /VERYSILENT, so an unattended install onto a box already
running Sunshine/Apollo would sit on a modal dialog nobody can see or click.
SuppressibleMsgBox with an IDNO default aborts instead, which is the honest
answer for a combination the message itself calls unsupported.

The GameStream and Public-firewall choices were re-applied on every run. A silent
run has no wizard, so every task falls back to its script default and the
installer confidently re-asserted it: a user who enabled GameStream had it turned
off again, and one who opened the firewall on Public networks had it quietly
revoked (that task is default-unchecked, so the reset ran the other way). Both
params are now fresh-install-only, keyed off whether host.env already exists.
`service install` already read an absent --gamestream as "keep host.env as-is";
--allow-public-network becomes tri-state the same way, resolving an absent flag
from the marker the previous install recorded. It is strict on the value: a
typo'd =of must not fall through to a marker that may say true, because that
turns a mistyped opt-OUT into leaving Public open.

Also brands the installer "Punktfunk Host" in the strings a user actually sees —
Add/Remove Programs, the Start Menu group, the wizard task text. Paths are
untouched; renaming those would relocate installs and orphan existing config.

Verified on the windows-amd64 runner: ISCC compiles the script (with WithWeb
defined, so the password page parses too), and cargo check + clippy -D warnings
pass for punktfunk-host with nvenc,amf-qsv,qsv.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:09:13 +02:00
enricobuehlerandClaude Opus 5 1ee06defa6 feat(library): descriptive metadata on every entry — platform, year, genres, and friends
apple / swift (push) Successful in 5m38s
windows-host / package (push) Successful in 19m0s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
ci / bench (push) Successful in 5m41s
ci / rust-arm64 (push) Successful in 9m32s
ci / rust (push) Failing after 12m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
android / android (push) Successful in 17m49s
decky / build-publish (push) Successful in 1m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
arch / build-publish (push) Successful in 19m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 39s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
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 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m22s
deb / build-publish (push) Successful in 9m9s
deb / build-publish-client-arm64 (push) Successful in 7m26s
deb / build-publish-host (push) Successful in 9m46s
flatpak / build-publish (push) Failing after 8m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m56s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 11s
apple / screenshots (push) Successful in 25m51s
Emulation-and-beyond libraries need more than a title and a poster. Every
library shape (GameEntry, CustomEntry, CustomInput, ProviderEntryInput)
now carries a shared, flattened GameMeta: platform, description,
developer, publisher, release_year, genres, tags, region, players. All
fields are optional and flat on the wire, so existing library.json files,
provider plugins, and clients keep working unchanged.

- Installed-store scanners (Steam, Lutris, Heroic, Epic, GOG, Xbox) stamp
  platform=PC; custom/provider entries carry whatever was authored.
- GET /library grows a ?platform= filter (case-insensitive) beside
  ?provider=.
- Console: the add/edit form gets a Details section (round-tripping every
  field, since update replaces the entry), the poster tile a platform
  badge (non-PC only — the store badge already implies PC) and the year.
- plugin-kit: ProviderEntry accepts the same fields (new GameMeta schema,
  spread flat); SDK + OpenAPI spec regenerated.
- pf-client-core decodes platform for future client badges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:00:30 +02:00
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
190 changed files with 24416 additions and 6870 deletions
+38
View File
@@ -99,3 +99,41 @@ jobs:
mkdir -p ~/unom-flatpak/site/repo
cd ~/unom-flatpak
docker compose -f compose.production.yml up -d
winget:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync winget source compose + server
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
# Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix).
source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs"
target: "~/unom-winget"
strip_components: 3
overwrite: true
- name: Start winget REST source
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
# ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each
# stable tag (same content/config split as the flatpak repo). Ensure the bind-mount
# source exists so the container starts; it serves 503 until the first catalogue lands.
mkdir -p ~/unom-winget/data
cd ~/unom-winget
docker compose -f compose.production.yml up -d
# Surface a missing catalogue here rather than letting winget report "no package found".
sleep 3
curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it"
+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
+133 -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
@@ -326,3 +359,90 @@ jobs:
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
# winget manifests for the release just attached above. Runs AFTER the attach step so the
# InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a
# manifest published ahead of its artifact is a hard 404 for every client that picks it up.
# Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/`
# alias has nothing it could point at.
- name: Emit + attach winget manifests (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
& scripts/ci/winget-manifest.ps1 `
-Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) {
Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName
}
# Republish the winget REST source on unom-1 once the release above carries its manifests.
#
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
# reads the manifests from the release, so it must not run before they are attached.
winget-source:
needs: package
if: startsWith(gitea.ref, 'refs/tags/v')
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
# so the result cannot drift and re-running any tag reproduces it byte for byte.
- name: Build + test the source catalogue
working-directory: packaging/winget/server
run: |
set -euo pipefail
npm install --no-audit --no-fund
node build-data.mjs --out data/data.json
# A wrong response SHAPE does not fail loudly — winget just reports "no package found".
# Gate on the suite before anything reaches the box.
node test.mjs
# Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the
# flatpak repo's content and config deploy on separate paths.
- name: Ship the catalogue to unom-1
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "packaging/winget/server/data/data.json"
target: "~/unom-winget/data"
strip_components: 4
overwrite: true
# No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the
# one actually being served, and fails the release if it is not.
- name: Verify the served catalogue
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
curl -fsS http://127.0.0.1:3240/healthz
echo
curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' \
| grep -q "${GITHUB_REF_NAME#v}" \
|| { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
echo "winget source serving ${GITHUB_REF_NAME#v}"
# `env:` below populates the RUNNER's environment; this action runs `script` on the
# REMOTE host, which inherits nothing from it. `envs:` is the action's OWN input —
# it must live under `with:` (matching docker.yml/deploy-services.yml's REGISTRY_TOKEN
# forwarding) — naming the variables to forward into the remote shell. A prior fix put
# it as a step-level sibling of `with:`/`env:` instead: that key is not part of the
# step schema, so appleboy/ssh-action never received it as an input and the step kept
# failing ("GITHUB_REF_NAME: unbound variable") on every tag after 24d2f97e too.
envs: GITHUB_REF_NAME
env:
GITHUB_REF_NAME: ${{ gitea.ref_name }}
Generated
+35 -27
View File
@@ -1020,6 +1020,13 @@ dependencies = [
"subtle",
]
[[package]]
name = "display-disturb"
version = "0.21.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
@@ -2194,7 +2201,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.19.2"
version = "0.21.0"
[[package]]
name = "lazy_static"
@@ -2299,7 +2306,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"bindgen",
"cmake",
@@ -2334,7 +2341,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"punktfunk-core",
]
@@ -2823,7 +2830,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2844,7 +2851,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -2868,7 +2875,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2886,7 +2893,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -2907,7 +2914,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -2931,7 +2938,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"ash",
"bindgen",
@@ -2940,7 +2947,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"libc",
@@ -2952,7 +2959,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2966,11 +2973,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.19.2"
version = "0.21.0"
[[package]]
name = "pf-inject"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2999,14 +3006,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -3021,7 +3028,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3048,11 +3055,12 @@ dependencies = [
"wayland-client",
"wayland-scanner",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"x11rb",
]
[[package]]
name = "pf-win-display"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3064,7 +3072,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ash",
@@ -3272,7 +3280,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"android_logger",
"jni",
@@ -3288,7 +3296,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3304,7 +3312,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3319,7 +3327,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3338,7 +3346,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3370,7 +3378,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3454,7 +3462,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3468,7 +3476,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"anyhow",
"ksni",
@@ -3491,7 +3499,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.19.2"
version = "0.21.0"
dependencies = [
"bindgen",
"cmake",
+2 -1
View File
@@ -28,6 +28,7 @@ members = [
"clients/session",
"clients/windows",
"clients/android/native",
"tools/display-disturb",
"tools/latency-probe",
"tools/loss-harness",
]
@@ -48,7 +49,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.19.2"
version = "0.21.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
+698 -9
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.18.0"
"version": "0.20.0"
},
"paths": {
"/api/v1/clients": {
@@ -190,6 +190,38 @@
}
}
},
"/api/v1/display/monitors": {
"get": {
"tags": [
"display"
],
"summary": "Physical monitors",
"description": "The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and\nfor rendering a picker. Read-only: this never creates, moves or disables anything. Note these\nare *not* the managed virtual displays — those are `/display/state`. See\n`design/per-monitor-portal-capture.md` §5.1.",
"operationId": "getDisplayMonitors",
"responses": {
"200": {
"description": "The host's physical monitors",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MonitorsResponse"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/presets": {
"get": {
"tags": [
@@ -665,6 +697,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": [
@@ -913,7 +997,7 @@
"library"
],
"summary": "List the game library",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns.",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
"operationId": "getLibrary",
"parameters": [
{
@@ -924,6 +1008,15 @@
"schema": {
"type": "string"
}
},
{
"name": "platform",
"in": "query",
"description": "Only entries on this platform (case-insensitive, e.g. `PS2`)",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -2226,7 +2319,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 +2373,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 +3436,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.",
@@ -3432,6 +3678,66 @@
}
}
},
"ApiMonitorInfo": {
"type": "object",
"description": "One physical monitor this host has, as the compositor reports it.",
"required": [
"connector",
"description",
"mode",
"x",
"y",
"scale",
"primary",
"enabled",
"managed",
"selected"
],
"properties": {
"connector": {
"type": "string",
"description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes."
},
"description": {
"type": "string",
"description": "Human label for a picker (`make model`, else the connector)."
},
"enabled": {
"type": "boolean",
"description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing."
},
"managed": {
"type": "boolean",
"description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)."
},
"mode": {
"type": "string",
"description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)."
},
"primary": {
"type": "boolean",
"description": "The compositor's primary/focused head."
},
"scale": {
"type": "number",
"format": "double",
"description": "Logical scale factor."
},
"selected": {
"type": "boolean",
"description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor."
},
"x": {
"type": "integer",
"format": "int32",
"description": "Desktop-space top-left — what makes a head identifiable when two share a size."
},
"y": {
"type": "integer",
"format": "int32"
}
}
},
"ApiSelectedGpu": {
"type": "object",
"description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)",
@@ -3807,8 +4113,13 @@
}
},
"CustomEntry": {
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
},
{
"type": "object",
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits.",
"required": [
"id",
"title"
@@ -3817,6 +4128,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",
@@ -3856,10 +4171,18 @@
"type": "string"
}
}
}
],
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits."
},
"CustomInput": {
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept."
},
{
"type": "object",
"description": "Request body to create or replace a custom entry (no `id` — the host owns it).",
"required": [
"title"
],
@@ -3867,6 +4190,10 @@
"art": {
"$ref": "#/components/schemas/Artwork"
},
"detect": {
"$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
},
"launch": {
"oneOf": [
{
@@ -3888,6 +4215,9 @@
"type": "string"
}
}
}
],
"description": "Request body to create or replace a custom entry (no `id` — the host owns it)."
},
"CustomPreset": {
"type": "object",
@@ -3935,6 +4265,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.",
@@ -3986,6 +4343,13 @@
"type": "object",
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
"properties": {
"capture_monitor": {
"type": [
"string",
"null"
],
"description": "**Mirror a physical monitor instead of creating a virtual display**: the connector name\n(`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.\n\nOrthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and\n`#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a\n**host-wide** setting, not per-client — the host-pinned decision of record in\n`design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see\n[`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it."
},
"ddc_power_off": {
"type": "boolean",
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
@@ -4126,6 +4490,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 +4631,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,9 +4865,22 @@
],
"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": {
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata, flattened — see [`GameMeta`]."
},
{
"type": "object",
"description": "One title in the unified library, regardless of which store it came from.",
"required": [
"id",
"store",
@@ -4477,6 +4923,127 @@
"type": "string"
}
}
}
],
"description": "One title in the unified library, regardless of which store it came from."
},
"GameMeta": {
"type": "object",
"description": "Descriptive metadata for a title — everything a richer library UI (details pane, platform\nfilter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to\nabsent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is\n`#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat\nwire shape everywhere.\n\nValues are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)\neach have their own vocabulary and the host has no business normalizing it.",
"properties": {
"description": {
"type": [
"string",
"null"
],
"description": "Short blurb for a details pane."
},
"developer": {
"type": [
"string",
"null"
]
},
"genres": {
"type": "array",
"items": {
"type": "string"
},
"description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)."
},
"platform": {
"type": [
"string",
"null"
],
"description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive).",
"example": "PS2"
},
"players": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Maximum simultaneous (local) players.",
"minimum": 0
},
"publisher": {
"type": [
"string",
"null"
]
},
"region": {
"type": [
"string",
"null"
],
"description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)."
},
"release_year": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Year of first release — the granularity metadata sources reliably agree on.",
"example": 2001,
"minimum": 0
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)."
}
}
},
"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",
@@ -4506,6 +5073,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 +5689,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",
@@ -5238,6 +5819,43 @@
"reject"
]
},
"MonitorsResponse": {
"type": "object",
"description": "The host's physical monitors + which one capture is pinned to.",
"required": [
"monitors"
],
"properties": {
"compositor": {
"type": [
"string",
"null"
],
"description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved."
},
"error": {
"type": [
"string",
"null"
],
"description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"."
},
"monitors": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApiMonitorInfo"
},
"description": "The heads, ordered left-to-right by desktop position."
},
"pinned": {
"type": [
"string",
"null"
],
"description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"."
}
}
},
"NativeClient": {
"type": "object",
"description": "A paired native (punktfunk/1) client.",
@@ -5627,8 +6245,13 @@
}
},
"ProviderEntryInput": {
"allOf": [
{
"$ref": "#/components/schemas/GameMeta",
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
},
{
"type": "object",
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key.",
"required": [
"external_id",
"title"
@@ -5637,6 +6260,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)."
@@ -5662,6 +6289,9 @@
"type": "string"
}
}
}
],
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key."
},
"ProviderRemoved": {
"type": "object",
@@ -5725,7 +6355,8 @@
"audio_streaming",
"pin_pending",
"paired_clients",
"active_sessions"
"active_sessions",
"games"
],
"properties": {
"active_sessions": {
@@ -5738,6 +6369,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 +6545,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`.",
@@ -6080,7 +6769,7 @@
"mbps": {
"type": "number",
"format": "float",
"description": "Transmit goodput (Mb/s)."
"description": "Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes\nplus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned\nmode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops."
},
"packets_dropped": {
"type": "integer",
@@ -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)
+1
View File
@@ -620,6 +620,7 @@ fn mock_library() -> (
store: store.to_string(),
title: title.to_string(),
art: crate::library::Artwork::default(),
platform: None,
};
let games = vec![
game("steam:570", "steam", "Dota 2"),
+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,
+17 -1
View File
@@ -13,8 +13,17 @@
// console behind the couch UI looks like a crash.
#![cfg_attr(windows, windows_subsystem = "windows")]
// The shared client log sink (std-only): a couch launch has no console, so the session's
// stderr would otherwise evaporate — same reasoning as the shell's tee. This shim only
// initializes + forwards; the module's subscriber-side surface stays unused here.
#[cfg(windows)]
#[path = "../logfile.rs"]
#[allow(dead_code)]
mod logfile;
#[cfg(windows)]
fn main() {
logfile::init();
// The session binary ships beside us in the package; fall back to PATH for a dev run.
let session = std::env::current_exe()
.ok()
@@ -27,7 +36,14 @@ fn main() {
if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(_) => std::process::exit(1),
}
+98
View File
@@ -0,0 +1,98 @@
//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`.
//!
//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with
//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every
//! log line (the shell's and, worse, the session's whole receive/decode/present forensic
//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave
//! latency-sawtooth field report had to be triaged from host logs alone because the client
//! side had nowhere to land.
//!
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
/// Rotate at the next start once the file exceeds this (the host's cap).
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
fn log_dir() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
}
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
pub(crate) fn path() -> Option<PathBuf> {
Some(log_dir()?.join("client.log"))
}
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
/// subscriber installs; every later [`tee`] shares the handle.
pub(crate) fn init() {
SINK.get_or_init(|| {
let dir = log_dir()?;
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join("client.log");
if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) {
let old = dir.join("client.log.old");
// Windows `rename` refuses an existing destination — drop the old generation first.
let _ = std::fs::remove_file(&old);
let _ = std::fs::rename(&path, &old);
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()?;
Some(Arc::new(Mutex::new(file)))
});
}
/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved
/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's
/// `with_writer` factory and the session-stderr forwarder both use it.
pub(crate) struct Tee;
/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`).
pub(crate) fn tee() -> Tee {
Tee
}
impl Write for Tee {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let _ = io::stderr().write_all(buf);
if let Some(Some(f)) = SINK.get() {
let _ = f.lock().unwrap().write_all(buf);
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
let _ = io::stderr().flush();
if let Some(Some(f)) = SINK.get() {
let _ = f.lock().unwrap().flush();
}
Ok(())
}
}
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
/// pipe (child exit).
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
let _ = std::thread::Builder::new()
.name("punktfunk-session-log".into())
.spawn(move || {
let mut reader = io::BufReader::new(stderr);
let mut line = String::new();
let mut tee = Tee;
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
let _ = tee.write_all(line.as_bytes());
line.clear();
}
});
}
+21 -1
View File
@@ -26,6 +26,8 @@ mod discovery;
#[cfg(windows)]
mod gpu;
#[cfg(windows)]
mod logfile;
#[cfg(windows)]
mod probe;
#[cfg(windows)]
mod shell_window;
@@ -49,11 +51,20 @@ fn main() {
}
set_app_user_model_id();
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
// a GUI/MSIX launch has no console, so without the file the client side of any field report
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
logfile::init();
tracing_subscriber::fmt()
.with_ansi(false)
.with_writer(logfile::tee)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
if let Some(p) = logfile::path() {
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
}
let args: Vec<String> = std::env::args().collect();
let flag = |name: &str| args.iter().any(|a| a == name);
@@ -88,7 +99,16 @@ fn main() {
if !flag("--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
// (Start-menu tile, Steam shortcut) has no console to inherit either.
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
+7 -1
View File
@@ -169,13 +169,19 @@ fn spawn_with(
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
// Piped through the log tee: dev-terminal runs keep the interleaved stderr they always
// had, and GUI runs — which have no console — finally keep the session's whole
// receive/decode/present log in the client log file.
.stderr(Stdio::piped())
.creation_flags(CREATE_NO_WINDOW);
let mut child = cmd
.spawn()
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
tracing::info!(host = %host_label, "session binary spawned");
if let Some(stderr) = child.stderr.take() {
crate::logfile::forward_child_stderr(stderr);
}
let stdout = child.stdout.take().expect("piped stdout");
// Park the child where the kill handle (and the reader, for the final reap) reach it.
*slot.0.lock().unwrap() = Some(child);
+117 -25
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,14 +529,24 @@ 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)
linux::PortalCapturer::open(
anchored,
want_hdr && !hdr_capture_failed(),
want_metadata_cursor,
policy,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+419
View File
@@ -0,0 +1,419 @@
//! 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 the monitor this host would mirror 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.
///
/// **Scoped to `PUNKTFUNK_CAPTURE_MONITOR` when it is set** (`design/per-monitor-portal-capture.md`
/// §7.4). Without a pin this asks "is ANY monitor in HDR mode", which was a fair heuristic while the
/// capture path took whatever monitor it was handed — but once the operator names the head, an
/// HDR-capable *neighbour* must not talk this host into offering PQ formats for an SDR panel.
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")?;
// `spec.0` is the connector; "color-mode" 1 is META_COLOR_MODE_BT2100.
let heads: Vec<(&str, bool)> = monitors
.iter()
.map(|(spec, _modes, props)| {
let hdr = props
.get("color-mode")
.and_then(|v| u32::try_from(v).ok())
.is_some_and(|mode| mode == 1);
(spec.0.as_str(), hdr)
})
.collect();
Ok(hdr_offer_for(
&heads,
pf_host_config::config().capture_monitor.as_deref(),
))
})
};
match probe() {
Ok(hdr) => hdr,
Err(e) => {
tracing::debug!(error = %format!("{e:#}"), "GNOME HDR colour-mode probe failed — SDR");
false
}
}
}
/// Should this host offer the HDR (10-bit PQ) formats, given each head as `(connector, is_bt2100)`
/// and the `PUNKTFUNK_CAPTURE_MONITOR` pin?
///
/// Pinned: only that head's colour mode counts — an HDR-capable neighbour must not talk the host
/// into offering PQ for the SDR panel it is actually streaming. A pin naming no live head reports
/// SDR rather than falling back to "any": the session is about to fail on that same missing
/// monitor, and an over-claimed HDR offer would be a second, quieter wrong answer.
/// Unpinned: the pre-existing "any monitor is in HDR mode" heuristic, unchanged.
fn hdr_offer_for(heads: &[(&str, bool)], pinned: Option<&str>) -> bool {
match pinned {
Some(want) => heads
.iter()
.find(|(connector, _)| connector.eq_ignore_ascii_case(want))
.is_some_and(|(_, hdr)| *hdr),
None => heads.iter().any(|(_, hdr)| *hdr),
}
}
/// 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);
}
#[cfg(test)]
mod hdr_offer_tests {
use super::hdr_offer_for;
#[test]
fn unpinned_keeps_the_any_monitor_heuristic() {
assert!(hdr_offer_for(&[("DP-1", false), ("HDMI-A-1", true)], None));
assert!(!hdr_offer_for(&[("DP-1", false)], None));
}
/// The regression this exists to prevent: an HDR TV on HDMI while the pinned head is an SDR
/// desk monitor. Before scoping, the host offered PQ formats for a panel that can't show them.
#[test]
fn a_pin_ignores_an_hdr_neighbour() {
let heads = [("DP-1", false), ("HDMI-A-1", true)];
assert!(!hdr_offer_for(&heads, Some("DP-1")));
assert!(hdr_offer_for(&heads, Some("HDMI-A-1")));
}
#[test]
fn a_pin_matches_case_insensitively_like_the_resolver() {
assert!(hdr_offer_for(&[("HDMI-A-1", true)], Some("hdmi-a-1")));
}
#[test]
fn a_pin_naming_no_live_head_reports_sdr() {
assert!(!hdr_offer_for(&[("DP-1", true)], Some("DP-9")));
}
}
+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"
);
}
}
+450 -60
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,46 +90,42 @@ 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());
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,
@@ -120,15 +133,21 @@ impl XFixesCursorSource {
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;
+186 -606
View File
@@ -14,6 +14,15 @@
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget};
// The P010 colour self-test (sweep Phase 5.5) — the `hdr-p010-selftest` subcommand, its f64
// reference math and the f16 uploader. None of it runs in a session; re-exported at the old paths
// (`main.rs` drives the subcommand, `pf-encode`'s qsv live e2e uses the bars helper).
// Explicit `#[path]`, like `idd_push`'s children: this file is itself reached through a `#[path]`
// from `lib.rs`, so a bare `mod selftest;` would resolve to `windows/selftest.rs`.
#[path = "dxgi/selftest.rs"]
mod selftest;
pub use selftest::{hdr_p010_convert_bars_on_luid, hdr_p010_selftest_at};
use anyhow::{bail, Context, Result};
use std::ffi::c_void;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -28,24 +37,31 @@ use windows::Win32::Graphics::Direct3D11::{
ID3D11RenderTargetView, ID3D11SamplerState, ID3D11ShaderResourceView, ID3D11Texture2D,
ID3D11VertexShader, D3D11_BIND_CONSTANT_BUFFER, D3D11_BIND_RENDER_TARGET,
D3D11_BIND_SHADER_RESOURCE, D3D11_BUFFER_DESC, D3D11_COMPARISON_NEVER, D3D11_CPU_ACCESS_READ,
D3D11_CPU_ACCESS_WRITE, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_FILTER_MIN_MAG_MIP_POINT,
D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_MAP_WRITE_DISCARD,
D3D11_RENDER_TARGET_VIEW_DESC, D3D11_RENDER_TARGET_VIEW_DESC_0, D3D11_RTV_DIMENSION_TEXTURE2D,
D3D11_SAMPLER_DESC, D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA, D3D11_TEX2D_RTV,
D3D11_TEXTURE2D_DESC, D3D11_TEXTURE_ADDRESS_CLAMP, D3D11_USAGE_DEFAULT, D3D11_USAGE_DYNAMIC,
D3D11_USAGE_STAGING, D3D11_VIEWPORT,
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_FILTER_MIN_MAG_MIP_POINT, D3D11_MAPPED_SUBRESOURCE,
D3D11_MAP_READ, D3D11_RENDER_TARGET_VIEW_DESC, D3D11_RENDER_TARGET_VIEW_DESC_0,
D3D11_RTV_DIMENSION_TEXTURE2D, D3D11_SAMPLER_DESC, D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA,
D3D11_TEX2D_RTV, D3D11_TEXTURE2D_DESC, D3D11_TEXTURE_ADDRESS_CLAMP, D3D11_USAGE_DEFAULT,
D3D11_USAGE_IMMUTABLE, D3D11_USAGE_STAGING, D3D11_VIEWPORT,
};
use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT, DXGI_FORMAT_P010, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM,
DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
};
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`. If this
/// stays 0 while DDA churns with ACCESS_LOST, the hook is NOT on DXGI's GPU-preference path on this
/// build (so reparenting can't be the cause — look at composition/independent-flip instead). >0 with
/// continuing churn means the hook fires but reparenting isn't the trigger here.
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`.
/// Reported by [`hybrid_hook_hits`] on every IDD-push open, which is the first point at which DXGI
/// has been exercised (factory → `EnumAdapterByLuid` → device). The patch-readback check in
/// [`install_gpu_pref_hook`] proves the BYTES landed; only this counter proves DXGI actually
/// reaches the export on this build — so `0` here means the hook is inert and a
/// reparenting-flavoured symptom (see [`install_gpu_pref_hook`]) has some other cause.
static HYBRID_HOOK_HITS: AtomicU64 = AtomicU64::new(0);
/// See [`HYBRID_HOOK_HITS`] — surfaced in the IDD-push open log so a dead hook is visible in the
/// field instead of being a silent write-only counter.
pub(crate) fn hybrid_hook_hits() -> u64 {
HYBRID_HOOK_HITS.load(Ordering::Relaxed)
}
// kernel32 — declared directly so we don't pull the whole Win32_System_Diagnostics_Debug feature for
// one call. FlushInstructionCache serializes the i-cache after the inline patch: the patch is written
// on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a
@@ -65,18 +81,29 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
if gpu_preference.is_null() {
return 0xC000_000Du32 as i32; // STATUS_INVALID_PARAMETER
}
*gpu_preference = 3; // D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED
// SAFETY: win32u's contract for this export — the caller (DXGI) passes a writable `*mut u32`
// out-param — and the null case has just been rejected above, so this is an in-bounds,
// 4-aligned single-word store into the caller's live local.
unsafe { *gpu_preference = 3 }; // D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED
0 // STATUS_SUCCESS
}
/// The win32u GPU-preference hook (the same technique Apollo applies, reimplemented here from the
/// documented DDI — no GPL source copied). On a HYBRID-GPU box DXGI resolves a GPU preference
/// (registry + power settings + the hybrid-adapter DDI) and REPARENTS outputs onto the chosen render
/// GPU — which constantly invalidates Desktop Duplication (DXGI_ERROR_ACCESS_LOST 0x887A0026, the
/// freeze/churn observed on the RTX 4090 + AMD iGPU box; `SET_RENDER_ADAPTER` is ignored there). Faking
/// a cached preference of UNSPECIFIED makes DXGI skip the resolution, so the output is NOT reparented
/// and DDA stays stable on one adapter (this is what makes Apollo's DDA work on this hardware).
/// Installed once, before the first DXGI factory/enumeration; lasts the process lifetime (like Apollo).
/// GPU, ignoring `SET_RENDER_ADAPTER` (observed on the RTX 4090 + AMD iGPU box). Faking a cached
/// preference of UNSPECIFIED makes DXGI skip that resolution, so an output is NOT reparented and
/// stays on one adapter.
///
/// **Why it is still installed now that DXGI Desktop Duplication is gone:** the IDD-push ring and
/// the driver's swap-chain must live on the SAME adapter, and the host pins that with
/// `SET_RENDER_ADAPTER` at monitor ADD (see `idd_push::open_inner`). A DXGI reparent moves the
/// virtual output off the pinned GPU behind the host's back — which surfaces as the driver's
/// `DRV_STATUS_TEX_FAIL` ("could not open our textures — render-adapter mismatch") and costs a
/// ring rebind. So the hook's job changed from "keep DDA on one adapter" to "keep the VIRTUAL
/// DISPLAY on the adapter we pinned"; the mechanism is unchanged. Installed once from
/// `main.rs`, before the virtual-display setup creates the first DXGI factory; lasts the process
/// lifetime. [`hybrid_hook_hits`] reports whether DXGI ever actually calls it.
pub fn install_gpu_pref_hook() {
use std::sync::Once;
static HOOK: Once = Once::new();
@@ -100,25 +127,38 @@ pub fn install_gpu_pref_hook() {
GetAwarenessFromDpiAwarenessContext, GetThreadDpiAwarenessContext,
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
};
// Per-monitor-v2 DPI awareness — REQUIRED for IDXGIOutput5::DuplicateOutput1 (without it the
// call returns E_ACCESSDENIED forever, forcing the legacy DuplicateOutput path). Matches
// Apollo's startup. SetProcessDpiAwarenessContext fails with E_ACCESS_DENIED if awareness was
// already set (manifest / earlier call) — log the outcome AND the effective awareness so a
// 100% DuplicateOutput1 E_ACCESSDENIED is diagnosable instead of silent.
// Per-monitor-v2 DPI awareness. It was originally set here because
// `IDXGIOutput5::DuplicateOutput1` returns E_ACCESSDENIED without it; DDA is gone, but the
// awareness still matters — an UNAWARE/SYSTEM-aware process gets DPI-VIRTUALIZED window and
// cursor coordinates, while every geometry the host computes comes from CCD in PHYSICAL
// pixels (`source_desktop_rect`, `desktop_bounds`). Mixing the two mis-aims the compose
// kick's `SetCursorPos` and the cursor-blend placement on any scaled display. Set here
// because this is the earliest process-wide hook point. `SetProcessDpiAwarenessContext`
// fails with E_ACCESS_DENIED if awareness was already set (manifest / earlier call) — log
// the outcome AND the effective awareness so a mis-scaled pointer is diagnosable.
match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) {
Ok(()) => tracing::info!("DPI awareness set: PER_MONITOR_AWARE_V2"),
Err(e) => tracing::warn!(error = ?e,
"SetProcessDpiAwarenessContext failed (already set?) — DuplicateOutput1 may E_ACCESSDENIED"),
"SetProcessDpiAwarenessContext failed (already set?) — cursor/desktop coordinates \
may be DPI-virtualized against the host's physical-pixel CCD geometry"),
}
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). DuplicateOutput1 needs 2.
// 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). Physical-pixel coordinates need 2.
let awareness = GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext()).0;
tracing::info!(awareness, "effective DPI awareness (need 2=PER_MONITOR for DuplicateOutput1)");
tracing::info!(
awareness,
"effective DPI awareness (need 2=PER_MONITOR for physical-pixel coordinates)"
);
let Ok(lib) = LoadLibraryA(s!("win32u.dll")) else {
tracing::warn!("GPU-pref hook: win32u.dll not loadable — skipping (DDA may churn on hybrid GPUs)");
tracing::warn!(
"GPU-pref hook: win32u.dll not loadable — skipping (on a hybrid-GPU box DXGI may \
reparent the virtual display off the pinned render adapter TEX_FAIL rebinds)"
);
return;
};
let Some(target) = GetProcAddress(lib, s!("NtGdiDdDDIGetCachedHybridQueryValue")) else {
tracing::warn!("GPU-pref hook: NtGdiDdDDIGetCachedHybridQueryValue not exported — skipping");
tracing::warn!(
"GPU-pref hook: NtGdiDdDDIGetCachedHybridQueryValue not exported — skipping"
);
return;
};
let target = target as usize as *mut u8;
@@ -132,7 +172,14 @@ pub fn install_gpu_pref_hook() {
patch[10] = 0xFF;
patch[11] = 0xE0; // jmp rax
let mut old = PAGE_PROTECTION_FLAGS(0);
if VirtualProtect(target as *const c_void, 12, PAGE_EXECUTE_READWRITE, &mut old).is_err() {
if VirtualProtect(
target as *const c_void,
12,
PAGE_EXECUTE_READWRITE,
&mut old,
)
.is_err()
{
tracing::warn!("GPU-pref hook: VirtualProtect failed — skipping");
return;
}
@@ -150,18 +197,33 @@ pub fn install_gpu_pref_hook() {
std::ptr::copy_nonoverlapping(target, readback.as_mut_ptr(), 12);
if readback == patch {
tracing::info!(
"GPU-pref hook installed + verified (win32u hybrid-query -> UNSPECIFIED): reparenting disabled"
"GPU-pref hook installed + verified (win32u hybrid-query -> UNSPECIFIED): DXGI \
output reparenting disabled. Whether DXGI actually CALLS it shows up as \
hybrid_hook_hits on the IDD-push open line."
);
} else {
tracing::error!(
want = %format!("{patch:02x?}"), got = %format!("{readback:02x?}"),
"GPU-pref hook patch did NOT land — hook is DEAD (DXGI will still reparent → ACCESS_LOST churn)"
"GPU-pref hook patch did NOT land — hook is DEAD (on a hybrid-GPU box DXGI can \
still reparent the virtual display off the pinned render adapter)"
);
}
});
}
/// Compile one HLSL entry point. Returns the bytecode blob's bytes.
///
/// # Safety
/// `entry` and `target` must be valid NUL-terminated ASCII pointers (an `s!()` literal at every
/// call site); `src` is a plain Rust `&str`, read only for the duration of the call.
pub(crate) unsafe fn compile_shader(src: &str, entry: PCSTR, target: PCSTR) -> Result<Vec<u8>> {
// SAFETY: `D3DCompile` reads `src.as_ptr()` for exactly `src.len()` bytes (a live `&str` that
// outlives the synchronous call) plus the two caller-supplied NUL-terminated `PCSTR`s (per the
// contract above); `&mut blob` / `Some(&mut errs)` are live out-params. Both
// `slice::from_raw_parts` calls pair a blob's OWN `GetBufferPointer` with its OWN
// `GetBufferSize` while that blob is still alive, and the slice is copied
// (`to_string` / `to_vec`) before it goes out of scope.
unsafe {
let mut blob: Option<ID3DBlob> = None;
let mut errs: Option<ID3DBlob> = None;
let r = D3DCompile(
@@ -192,6 +254,7 @@ pub(crate) unsafe fn compile_shader(src: &str, entry: PCSTR, target: PCSTR) -> R
let p = blob.GetBufferPointer() as *const u8;
Ok(std::slice::from_raw_parts(p, blob.GetBufferSize()).to_vec())
}
}
/// Fullscreen-triangle vertex shader for the HDR conversion pass (3 verts, no input layout).
pub(crate) const HDR_VS: &str = r"
@@ -316,12 +379,24 @@ pub(crate) struct HdrP010Converter {
ps_y: ID3D11PixelShader,
ps_uv: ID3D11PixelShader,
sampler: ID3D11SamplerState,
/// Constant buffer for the chroma pass (inv_src texel size). 16 bytes.
/// Constant buffer for the chroma pass: `inv_src` = (1/srcW, 1/srcH), 16 bytes. IMMUTABLE and
/// filled at construction — it depends only on the source size, and the converter is already
/// rebuilt whenever the mode changes. It used to be a DYNAMIC buffer that `convert` Mapped,
/// wrote and Unmapped on EVERY HDR frame, inside the ring slot's keyed-mutex hold, with the
/// `Map`'s failure silently ignored (leaving the pass reading a stale/garbage texel size).
cbuf: ID3D11Buffer,
}
impl HdrP010Converter {
pub(crate) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
/// `w`/`h` are the SOURCE dimensions this converter's chroma pass will sample, baked into the
/// immutable constant buffer. Rebuild the converter if they change (the IDD capturer's
/// `recreate_ring` already drops it).
pub(crate) unsafe fn new(device: &ID3D11Device, w: u32, h: u32) -> Result<Self> {
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader` receives
// `s!()` literals (its contract). Each created COM interface owns its own reference, and no
// raw pointer outlives the call that produced it.
unsafe {
// Inline the shared HLSL (D3DCompile has no include handler wired here). The two PS sources
// carry a `#include_common` marker we substitute before compiling.
let y_src = HDR_P010_Y_PS.replace("#include_common", HDR_P010_COMMON);
@@ -349,15 +424,23 @@ impl HdrP010Converter {
};
let mut sampler = None;
device.CreateSamplerState(&sd, Some(&mut sampler))?;
// `inv_src` never changes for a given source size — build the buffer IMMUTABLE with its
// contents, so the chroma pass needs no per-frame Map (and has no unchecked failure).
let inv_src: [f32; 4] = [1.0 / w.max(1) as f32, 1.0 / h.max(1) as f32, 0.0, 0.0];
let cbd = D3D11_BUFFER_DESC {
ByteWidth: 16, // float2 inv_src + float2 pad
Usage: D3D11_USAGE_DYNAMIC,
Usage: D3D11_USAGE_IMMUTABLE,
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
CPUAccessFlags: 0,
..Default::default()
};
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: inv_src.as_ptr().cast(),
SysMemPitch: 0,
SysMemSlicePitch: 0,
};
let mut cbuf = None;
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
device.CreateBuffer(&cbd, Some(&init), Some(&mut cbuf))?;
Ok(Self {
vs: vs.context("p010 vs")?,
ps_y: ps_y.context("p010 y ps")?,
@@ -366,15 +449,24 @@ impl HdrP010Converter {
cbuf: cbuf.context("p010 cbuf")?,
})
}
}
/// Create a per-plane RTV of the P010 texture `dst` with the given single-plane `format`
/// (`R16_UNORM` for plane 0 luma, `R16G16_UNORM` for plane 1 chroma). The plane is selected by the
/// view format (planar-RTV semantics); MipSlice 0.
unsafe fn plane_rtv(
///
/// Called ONCE PER OUT-RING SLOT by the owner of the P010 textures, not per frame — see
/// [`Self::convert`]. Fails when the driver rejects a planar RTV, which is the one hard
/// requirement of this whole path (a D3D11.3+ runtime plus driver support).
pub(crate) unsafe fn plane_rtv(
device: &ID3D11Device,
dst: &ID3D11Texture2D,
format: DXGI_FORMAT,
) -> Result<ID3D11RenderTargetView> {
// SAFETY: one `?`-checked `CreateRenderTargetView` on the live `device` borrow, with a
// fully-initialized `D3D11_RENDER_TARGET_VIEW_DESC` local whose address is taken only for the
// duration of the synchronous call, plus a live `Option` out-param.
unsafe {
let desc = D3D11_RENDER_TARGET_VIEW_DESC {
Format: format,
ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D,
@@ -394,34 +486,31 @@ impl HdrP010Converter {
})?;
rtv.context("p010 plane rtv null")
}
}
/// Convert `src_srv` (FP16 scRGB, WxH) into `dst` (a `DXGI_FORMAT_P010` texture with
/// `BIND_RENDER_TARGET`). Two opaque passes: full-res luma → plane 0, half-res chroma → plane 1.
/// `w`/`h` are the full luma dimensions (must be even). Returns `Err` if a plane RTV can't be
/// created (driver) so the caller can fall back to the R10 path.
/// Convert `src_srv` (FP16 scRGB, WxH) into a `DXGI_FORMAT_P010` texture through the two plane
/// RTVs the CALLER built for it ([`Self::plane_rtv`]): full-res luma → `y_rtv` (plane 0),
/// half-res chroma → `uv_rtv` (plane 1). `w`/`h` are the full luma dimensions (must be even) and
/// must match the `w`/`h` this converter was constructed with.
///
/// Takes the views rather than creating them: two `CreateRenderTargetView`s per frame — plus a
/// Map/Unmap of a never-changing 16-byte constant buffer — was pure per-frame cost inside the
/// ring slot's keyed-mutex hold, i.e. time the DRIVER spent blocked on that slot. Both are
/// lifetime-of-mode facts: the views belong to the out-ring slot (built in `ensure_out_ring`),
/// the buffer to this converter, which is already rebuilt on every mode change.
pub(crate) unsafe fn convert(
&self,
device: &ID3D11Device,
ctx: &ID3D11DeviceContext,
src_srv: &ID3D11ShaderResourceView,
dst: &ID3D11Texture2D,
y_rtv: &ID3D11RenderTargetView,
uv_rtv: &ID3D11RenderTargetView,
w: u32,
h: u32,
) -> Result<()> {
let y_rtv = Self::plane_rtv(device, dst, DXGI_FORMAT_R16_UNORM)?;
let uv_rtv = Self::plane_rtv(device, dst, DXGI_FORMAT_R16G16_UNORM)?;
// Update the chroma constant buffer (inverse source texel size).
let cb: [f32; 4] = [1.0 / w as f32, 1.0 / h as f32, 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 runs on the caller's live `ctx` borrow (the owning capture thread's
// immediate context) over borrowed slices of fully-initialized locals (the viewports) and
// clones of the caller's live SRV/RTVs. No raw pointers and no mapping on this path.
unsafe {
// Shared pipeline state.
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
ctx.VSSetShader(&self.vs, None);
@@ -466,6 +555,7 @@ impl HdrP010Converter {
Ok(())
}
}
}
/// PyroWave LUMA pass PS — full-res, writes Y to a separate `R8_UNORM` texture. BT.709 limited from
/// the 8-bit sRGB (gamma) BGRA slot, BYTE-IDENTICAL to the Linux `rgb2yuv.comp` `lumaY` (so the
@@ -604,6 +694,9 @@ pub(crate) struct BgraToYuvPlanes {
impl BgraToYuvPlanes {
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
// SAFETY: as `HdrP010Converter::new` — `?`-checked D3D11 shader creation on the live
// `device` borrow, with `s!()` literals into `compile_shader` and live out-params.
unsafe {
let (y_src, uv_src) = match (hdr, chroma444) {
(false, false) => (PYRO_Y_PS.to_string(), PYRO_UV_PS.to_string()),
(false, true) => (PYRO_Y_PS.to_string(), PYRO_UV444_PS.to_string()),
@@ -632,6 +725,7 @@ impl BgraToYuvPlanes {
chroma444,
})
}
}
/// Convert `src_srv` (BGRA slot for SDR / scRGB FP16 slot for HDR, WxH) → `y_rtv` (full-res Y
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
@@ -646,6 +740,10 @@ impl BgraToYuvPlanes {
w: u32,
h: u32,
) -> Result<()> {
// SAFETY: D3D11 state-setting plus two `Draw`s on the caller's live immediate-context
// borrow, over borrowed slices of fully-initialized locals (the viewports) and clones of the
// caller's live SRV/RTVs. No raw pointers and no mapping on this path.
unsafe {
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
ctx.VSSetShader(&self.vs, None);
ctx.PSSetShaderResources(0, Some(&[Some(src_srv.clone())]));
@@ -689,540 +787,6 @@ impl BgraToYuvPlanes {
Ok(())
}
}
/// 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)
}
/// 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.
#[cfg(target_os = "windows")]
pub fn hdr_p010_selftest() -> Result<()> {
hdr_p010_selftest_at(64, 64, None)
}
/// [`hdr_p010_selftest`] at an arbitrary even size and (optionally) on a specific GPU vendor
/// (PCI vendor id, e.g. `0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD). The size matters on
/// top of the 64×64 default because the field sessions run at capture 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; the vendor pin matters on dual-GPU boxes where the default
/// adapter is not the one the session encodes on.
/// 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)?;
conv.convert(&device, &context, &src_srv, &p010, w, h)?;
Ok((device, p010))
}
}
#[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)?;
conv.convert(&device, &context, &src_srv, &p010, 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;
sign | half_exp | (half_mant + round)
}
}
use windows::Win32::Graphics::Direct3D11::{
@@ -1242,8 +806,15 @@ use windows::Win32::Graphics::Dxgi::Common::{
/// D3D11 **Video Processor** colour/format converter — runs on the GPU's dedicated VIDEO engine, NOT
/// the 3D engine, so the per-frame RGB→YUV conversion does not contend with a GPU-saturating game (the
/// HDR pixel-shader path and NVENC's internal RGB→YUV both use the 3D/compute engine, which an AAA
/// title pins at ~100%). Output is NV12 (SDR, BT.709 studio-range) or P010 (HDR, BT.2020 PQ
/// studio-range) — NVENC's native YUV inputs, so it encodes them with no further conversion.
/// title pins at ~100%). Output is **always NV12, BT.709 studio-range** — one of NVENC's native YUV
/// inputs, so it encodes with no further conversion.
///
/// It does NOT produce P010/BT.2020 PQ: [`VideoConverter::new`] pins the output colour space to
/// `YCBCR_STUDIO_G22_LEFT_P709` unconditionally, and NVIDIA's video processor cannot do RGB→P010 at
/// all (it renders green) — the HDR path is [`HdrP010Converter`]'s shader instead. The `scrgb_input`
/// arm of `new` is likewise the only part of the HDR story here (an FP16 desktop tone-mapped DOWN to
/// 8-bit BT.709), and it currently has no caller: `idd_push::ensure_converter` builds this converter
/// only on the SDR/BGRA path and always passes `false`.
pub(crate) struct VideoConverter {
vdev: ID3D11VideoDevice,
vctx: ID3D11VideoContext1,
@@ -1264,8 +835,15 @@ impl VideoConverter {
height: u32,
scrgb_input: bool,
) -> Result<Self> {
// SAFETY: the `cast()`s and the `?`-checked video-device factory calls run on the caller's
// live `device`/`context` borrows; `&desc` is a fully-initialized stack
// `D3D11_VIDEO_PROCESSOR_CONTENT_DESC` read only for the duration of the call, and the
// colour-space/frame-format setters take the just-created processor by borrow plus plain
// enum values.
unsafe {
let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?;
let vctx: ID3D11VideoContext1 = context.cast().context("context -> ID3D11VideoContext1")?;
let vctx: ID3D11VideoContext1 =
context.cast().context("context -> ID3D11VideoContext1")?;
let rate = DXGI_RATIONAL {
Numerator: 240,
Denominator: 1,
@@ -1298,8 +876,14 @@ impl VideoConverter {
let out_cs = DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709;
vctx.VideoProcessorSetStreamColorSpace1(&vp, 0, in_cs);
vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs);
// One frame in, one frame out — no interpolation/auto-processing.
// Progressive: one frame in, one frame out — no deinterlace, no frame-rate conversion.
vctx.VideoProcessorSetStreamFrameFormat(&vp, 0, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE);
// …and no vendor "auto processing" either. Its documented DEFAULT is ENABLED, so until
// now the comment above claimed something only this call delivers: denoise, edge
// enhancement and whatever else the driver folds in were free to run inside every SDR
// `VideoProcessorBlt` — on the desktop-capture hot path, altering the pixels we encode
// and costing video-engine time nobody asked for.
vctx.VideoProcessorSetStreamAutoProcessingMode(&vp, 0, false);
Ok(Self {
vdev,
@@ -1308,14 +892,22 @@ impl VideoConverter {
vp,
})
}
}
/// Convert `input` (BGRA or scRGB FP16) → `output` (NV12 or P010) on the video engine. Views are
/// Convert `input` (BGRA, or scRGB FP16 for a converter built with `scrgb_input`) → `output`
/// (NV12, BT.709 studio-range — see the type doc: never P010) on the video engine. Views are
/// created per call (cheap relative to the Blt) so the input texture can vary frame to frame.
pub(crate) unsafe fn convert(
&self,
input: &ID3D11Texture2D,
output: &ID3D11Texture2D,
) -> Result<()> {
// SAFETY: both view creations are `?`-checked calls on `self.vdev` with fully-initialized
// stack descriptors and live out-params. `stream.pInputSurface` is a `ManuallyDrop` of the
// input view just created: `VideoProcessorBlt` only BORROWS it (a COM in-param never transfers
// ownership), and the explicit `into_inner` drop below releases that reference exactly once on
// both the success and the failure path. `slice::from_ref(&stream)` borrows the live local.
unsafe {
let in_desc = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC {
FourCC: 0,
ViewDimension: D3D11_VPIV_DIMENSION_TEXTURE2D,
@@ -1359,16 +951,4 @@ impl VideoConverter {
blt.context("VideoProcessorBlt")
}
}
#[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");
}
}
@@ -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,6 +57,10 @@ pub(super) struct CursorBlendPass {
impl CursorBlendPass {
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
// 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;
@@ -109,6 +113,7 @@ impl CursorBlendPass {
shape: None,
})
}
}
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
unsafe fn ensure_shape(
@@ -116,6 +121,11 @@ impl CursorBlendPass {
device: &ID3D11Device,
ov: &pf_frame::CursorOverlay,
) -> Result<()> {
// 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(());
}
@@ -153,6 +163,7 @@ impl CursorBlendPass {
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).
/// `linear_scale`: 0 = SDR passthrough; non-zero = the frame is FP16 scRGB (HDR
@@ -167,6 +178,13 @@ impl CursorBlendPass {
ov: &pf_frame::CursorOverlay,
linear_scale: f32,
) -> Result<()> {
// 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) {
@@ -178,9 +196,14 @@ impl CursorBlendPass {
{
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);
}
}
let mut rtv: Option<ID3D11RenderTargetView> = None;
device
.CreateRenderTargetView(dst, None, Some(&mut rtv))
@@ -214,3 +237,4 @@ impl CursorBlendPass {
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,81 @@ 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_physicals();
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 servicing is the prime suspect. For a LAPTOP \
PANEL (the exclusive isolate deactivated it the dark-but-connected \
head is itself the disturbance on hybrid laptops): keep it active \
with `topology: primary`, or try the `pnp_disable_monitors` axis. \
For an external display: 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,6 +140,9 @@ impl Capturer for SyntheticNv12Capturer {
/// # Safety
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
// 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) {
@@ -148,6 +151,7 @@ unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
}
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;
// 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!(
+10 -1
View File
@@ -62,6 +62,10 @@ pub struct GameEntry {
pub title: String,
#[serde(default)]
pub art: Artwork,
/// The system the title runs on (`"PC"`, `"PS2"`, …) — free-form display string from the
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
#[serde(default)]
pub platform: Option<String>,
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
@@ -280,7 +284,7 @@ mod tests {
fn game_entry_decodes_the_wire_shape() {
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
let json = r#"[
{"id":"steam:570","store":"steam","title":"Dota 2",
{"id":"steam:570","store":"steam","title":"Dota 2","platform":"PC",
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
"launch":{"kind":"steam_appid","value":"570"}},
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
@@ -288,7 +292,12 @@ mod tests {
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
assert_eq!(games.len(), 2);
assert_eq!(games[0].id, "steam:570");
assert_eq!(games[0].platform.as_deref(), Some("PC"));
assert!(games[1].art.portrait.is_none());
assert!(
games[1].platform.is_none(),
"pre-metadata hosts still parse"
);
}
#[test]
+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
+120 -16
View File
@@ -1613,14 +1613,22 @@ impl Encoder for NvencCudaEncoder {
// `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
// Cursor-bearing frames additionally force the CPU-synced path: the Vulkan blend sits
// between the CUDA copy and the encode, and its cross-API ordering is fence/CPU-
// established, not stream-ordered. Frames without a cursor (games hide it; client-draws
// sessions strip it) keep the stream-ordered fast path untouched.
let ordered = self.stream_ordered
&& self.async_rt.is_none()
&& self.pending.is_empty()
&& captured.cursor.is_none();
let base_ordered =
self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
// Cursor-bearing frames stay on the fast path when the blend itself can be stream-
// ordered: the Vulkan dispatch waits/advances a timeline semaphore CUDA also holds, so
// copy→blend→encode orders entirely on-device (`VkSlotBlend::blend_ref_ordered`). Where
// that isn't available (no timeline export, or the ring fell back to plain CUDA slots)
// a cursor forces the CPU-synced path: the blend's cross-API ordering is then fence/CPU-
// established, sitting between the copy and the encode. That slow path is why cursor
// frames USED to be gated out entirely — under gamescope the compositor re-attaches the
// live pointer to EVERY frame, and the per-frame CPU syncs (exposed to the running
// game's GPU load) capped a 120 fps session near 80 (submit p50 ~10 ms).
let cursor_ordered = base_ordered
&& captured.cursor.is_some()
&& matches!(self.ring[slot].surface, SlotSurface::Vk(_))
&& self.vk_blend.as_ref().is_some_and(|vk| vk.ordered_ready());
let ordered = base_ordered && (captured.cursor.is_none() || cursor_ordered);
let t0 = std::time::Instant::now();
// Copy the captured buffer into this slot's input surface before encoding it.
@@ -1629,21 +1637,26 @@ impl Encoder for NvencCudaEncoder {
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface via the
// SPIR-V compute pass (a dispatch over the cursor's rect — never the compositor's
// dmabuf). Cursor-bearing frames forced `ordered = false` above, so the CUDA copy has
// completed before the Vulkan dispatch and the fence-waited dispatch completes before
// the encode below — the cross-API ordering is CPU-established. Any failure degrades to
// no cursor, never a dropped frame.
// dmabuf). On the `cursor_ordered` path the enqueued copy, the dispatch, and the encode
// are ordered on-device through the timeline semaphore (no CPU sync — see the gate
// above). Otherwise `ordered` is false: the CUDA copy completed before the Vulkan
// dispatch and the fence-waited dispatch completes before the encode below — the
// cross-API ordering is CPU-established. Any failure degrades to no cursor, never a
// dropped frame (a failed ordered blend leaves the copy→encode stream ordering intact).
if let Some(ov) = &captured.cursor {
if let (Some(vk), SlotSurface::Vk(vref)) =
(self.vk_blend.as_mut(), &self.ring[slot].surface)
{
if self.cursor_serial != ov.serial {
// Quiesces any in-flight ordered blend internally before touching the
// staging buffer (bitmap changes are rare — shape flips).
vk.upload_cursor(ov.rgba.as_slice(), ov.w, ov.h);
self.cursor_serial = ov.serial;
}
// surfW = content width; the blend derives plane strides from the slot's luma
// height. Cursor pixels past the content land in cropped padding rows — harmless.
let r = vk.blend_ref(
let r = if cursor_ordered {
vk.blend_ref_ordered(
vref,
slot_fmt_of(self.buffer_fmt),
self.width,
@@ -1651,7 +1664,18 @@ impl Encoder for NvencCudaEncoder {
ov.h,
ov.x,
ov.y,
);
)
} else {
vk.blend_ref(
vref,
slot_fmt_of(self.buffer_fmt),
self.width,
ov.w,
ov.h,
ov.x,
ov.y,
)
};
if let Err(e) = r {
if !self.cursor_blend_warned {
self.cursor_blend_warned = true;
@@ -1686,7 +1710,9 @@ impl Encoder for NvencCudaEncoder {
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
// just filled by the device→device copy — either synchronized (blocking mode) or ordered
// before this encode by the session's IO-stream binding (`ordered` — same stream, see the
// gate above) — and is not overwritten until this slot is reused POOL submits later, by
// gate above; on the `cursor_ordered` path the blend's writes are likewise ordered before
// the encode, via the timeline-semaphore wait `blend_ref_ordered` enqueued on that same
// stream) — and is not overwritten until this slot is reused POOL submits later, by
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
// blocking lock additionally proves the enqueued copy completed).
unsafe {
@@ -1850,7 +1876,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,
@@ -2674,6 +2699,85 @@ mod tests {
);
}
/// ON-HARDWARE (RTX box `.21`): cursor-bearing frames must KEEP the stream-ordered fast
/// path — the gamescope 80-fps-on-a-120-session fix. With the timeline-semaphore blend
/// available, `submit` takes `blend_ref_ordered` (the ticket advances by 2 per frame)
/// instead of the CPU-synced fence-wait blend, and AUs keep flowing — including across a
/// cursor-bitmap change (exercises the upload quiesce) and per-frame position moves.
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_cursor_blend_stream_ordered() {
const W: u32 = 1280;
const H: u32 = 720;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// Respect an explicit operator opt-out (or two-thread mode) rather than fail.
if !stream_ordered_requested() || async_retrieve_requested() {
println!("skipped: stream-ordered submit disabled by env");
return;
}
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
W,
H,
60,
8_000_000,
true,
8,
ChromaFormat::Yuv420,
true, // cursor_blend: bring up the Vulkan slot ring + blend
)
.expect("open NVENC CUDA session");
let cursor = |serial: u64, x: i32, y: i32| pf_frame::CursorOverlay {
x,
y,
w: 32,
h: 32,
rgba: std::sync::Arc::new(vec![0xFF; 32 * 32 * 4]),
serial,
hot_x: 0,
hot_y: 0,
visible: true,
};
let mut aus = 0usize;
for i in 0..6u32 {
let mut frame = nv12_frame(W, H, i);
// Bitmap serial flips at frame 3 (upload quiesce over in-flight ordered blends);
// the position moves every frame (push-constant path).
frame.cursor = Some(cursor(
if i < 3 { 1 } else { 2 },
40 + i as i32 * 9,
60 + i as i32 * 5,
));
enc.submit_indexed(&frame, i).expect("submit cursor frame");
while enc.poll().expect("poll").is_some() {
aus += 1;
}
}
assert_eq!(aus, 6, "every cursor frame must deliver an AU");
assert!(
enc.stream_ordered,
"IO-stream binding must arm on a default-env session"
);
let vk = enc
.vk_blend
.as_ref()
.expect("Vulkan slot blend must come up on an RTX box");
assert!(
vk.ordered_ready(),
"timeline semaphore must export to CUDA on this driver"
);
assert_eq!(
vk.ordered_ticket(),
12,
"all 6 cursor blends must take the ordered path (2 timeline values each)"
);
println!(
"nvenc_cuda cursor stream-ordered: 6 cursor AUs, ticket={}",
vk.ordered_ticket()
);
}
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
+21 -1
View File
@@ -395,6 +395,9 @@ pub struct PyroWaveEncoder {
/// packet to it, so each wire shard carries whole self-delimiting packets. `None` =
/// one packet per AU (the dense MVP shape).
wire_chunk: Option<usize>,
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
/// WIRE, not just the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
wire_budget: crate::pyrowave_wire::WireBudget,
bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>,
frame_count: u64,
@@ -653,6 +656,7 @@ impl PyroWaveEncoder {
chroma444,
frame_budget: budget_for(bitrate, fps),
wire_chunk: None,
wire_budget: crate::pyrowave_wire::WireBudget::new(),
bitstream: Vec::new(),
pending: VecDeque::new(),
frame_count: 0,
@@ -1120,6 +1124,16 @@ impl PyroWaveEncoder {
Ok(self.cpu_img.unwrap().2)
}
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
/// a promise about the wire, not the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
fn rate_budget(&self) -> usize {
match self.wire_chunk {
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
None => self.frame_budget,
}
}
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
@@ -1175,6 +1189,8 @@ impl PyroWaveEncoder {
// paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s
// before anything touches `cmd`; a buffer that completed its one-time submit is INVALID,
// which the next `begin` may implicitly reset.
// Resolved before the closure (which borrows `self` mutably for the recording calls).
let rate_budget = self.rate_budget();
let record_and_submit = (|| -> Result<()> {
dev.begin_command_buffer(
self.cmd,
@@ -1396,7 +1412,7 @@ impl PyroWaveEncoder {
],
};
let rc = pw::pyrowave_rate_control {
maximum_bitstream_size: self.frame_budget,
maximum_bitstream_size: rate_budget,
};
pw::pyrowave_device_set_command_buffer(
self.pw_dev,
@@ -1476,6 +1492,10 @@ impl PyroWaveEncoder {
// single packet, or the datagram-aligned windowed AU (§4.4).
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
if self.wire_chunk.is_some() {
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
self.wire_budget.observe(raw, au.len());
}
self.frame_count += 1;
self.pending.push_back(EncodedFrame {
data: au,
+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");
}
}
+57 -59
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");
}
+86
View File
@@ -81,6 +81,61 @@ pub(crate) fn block_count_32x32(width: u32, height: u32, chroma444: bool) -> u32
count
}
/// Wire-aware deflation of the per-frame rate budget for the datagram-aligned mode.
///
/// [`build_au`]'s windowing inflates the codec bitstream on its way to the wire: greedy packing
/// of pyrowave's few-hundred-byte atomic block packets into `chunk`-sized windows leaves the
/// tail of most windows zero-padded, plus the 4-byte prefixes and FRAG-chain tails. At
/// 1440p/~850 KiB frames that is ×1.21.3 — the 2026-07 field report's "Automatic" 407 Mb/s
/// pin put 550 Mb/s on a 1 GbE link. The pin is a promise about the LINK, so the codec budget
/// must absorb the framing: this tracker measures the real AU/bitstream ratio per frame and
/// deflates the budget handed to pyrowave's rate control by its EMA. Sealed-datagram framing
/// (packet header + AEAD tag) and FEC parity are deliberately NOT compensated — H.26x sessions
/// carry those on top of the configured bitrate too, and the pin must mean the same thing for
/// every codec.
pub(crate) struct WireBudget {
/// EMA of `built AU bytes / packetized bitstream bytes`, ×1024 fixed point.
scale_x1024: u32,
}
impl WireBudget {
/// Startup prior (×1024 ≈ 1.25 — the 1440p field measurement's midpoint); the EMA
/// converges onto the session's real ratio within ~a second of frames.
const PRIOR_X1024: u32 = 1280;
/// EMA weight 1/8: content-driven per-frame wobble smooths out; a mode/bitrate change
/// re-converges in ~16 frames.
const EMA_SHIFT: u32 = 3;
/// Sanity clamp on the applied scale: never inflate the budget (×1.0 floor), never
/// deflate below half (×2.0 cap — tiny explicit bitrates window very coarsely).
const MIN_X1024: u32 = 1024;
const MAX_X1024: u32 = 2048;
pub(crate) fn new() -> WireBudget {
WireBudget {
scale_x1024: Self::PRIOR_X1024,
}
}
/// Record one frame's measured inflation (`bitstream_len` = the packetized codec bytes the
/// rate controller budgeted; `au_len` = the windowed AU that actually reaches the wire).
pub(crate) fn observe(&mut self, bitstream_len: usize, au_len: usize) {
if bitstream_len == 0 {
return;
}
let sample = ((au_len as u64 * 1024) / bitstream_len as u64)
.clamp(Self::MIN_X1024 as u64, Self::MAX_X1024 as u64) as u32;
let ema = self.scale_x1024 as i64;
self.scale_x1024 = (ema + ((sample as i64 - ema) >> Self::EMA_SHIFT)) as u32;
}
/// The rate-control budget that makes the WIRE hit `budget` bytes/frame under the
/// currently-measured inflation.
pub(crate) fn deflate(&self, budget: usize) -> usize {
let scale = self.scale_x1024.clamp(Self::MIN_X1024, Self::MAX_X1024) as u64;
((budget as u64 * 1024) / scale) as usize
}
}
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
/// datagram-aligned AU (a whole number of `chunk`-sized windows).
@@ -259,6 +314,37 @@ mod tests {
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
}
/// The wire-budget tracker: converges its EMA onto the measured AU/bitstream inflation,
/// deflates the budget by exactly that ratio, and clamps runaway samples.
#[test]
fn wire_budget_converges_and_deflates() {
let mut wb = WireBudget::new();
// Prior ≈ ×1.25 (1280/1024): the first deflation is already conservative.
assert_eq!(wb.deflate(1_024_000), 819_200);
// Feed a steady ×1.30 inflation; the EMA must converge onto it.
for _ in 0..64 {
wb.observe(1000, 1300);
}
let b = wb.deflate(1_024_000);
let expect = 1_024_000_u64 * 1000 / 1300;
assert!(
(b as i64 - expect as i64).unsigned_abs() < 8_000,
"budget {b} should approach {expect}"
);
// A dense-ish run (×1.0) walks it back down to no deflation.
for _ in 0..64 {
wb.observe(1000, 1000);
}
assert_eq!(wb.deflate(1_024_000), 1_024_000);
// Garbage samples are clamped: an absurd ratio can at most halve the budget…
for _ in 0..256 {
wb.observe(10, 1000);
}
assert!(wb.deflate(1_024_000) >= 512_000);
// …and a zero-length observation is ignored, never a division by zero.
wb.observe(0, 1000);
}
#[test]
fn stamp_color_bits_sets_range_and_hdr_bits() {
let mut bs = vec![0u8; 16];
-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,
+19 -1
View File
@@ -159,6 +159,9 @@ pub struct PyroWaveEncoder {
frame_budget: usize,
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
wire_chunk: Option<usize>,
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
/// WIRE, not just the raw bitstream (see [`pyrowave_wire::WireBudget`]).
wire_budget: pyrowave_wire::WireBudget,
bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>,
}
@@ -288,6 +291,7 @@ impl PyroWaveEncoder {
hdr16,
frame_budget,
wire_chunk: None,
wire_budget: pyrowave_wire::WireBudget::new(),
bitstream: Vec::new(),
pending: VecDeque::new(),
})
@@ -441,6 +445,16 @@ impl PyroWaveEncoder {
///
/// # Safety
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
/// a promise about the wire, not the raw bitstream (see [`pyrowave_wire::WireBudget`]).
fn rate_budget(&self) -> usize {
match self.wire_chunk {
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
None => self.frame_budget,
}
}
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
// handing null to pyrowave (see the Linux twin).
@@ -616,7 +630,7 @@ impl PyroWaveEncoder {
sync: std::mem::zeroed(),
};
let rc = pw::pyrowave_rate_control {
maximum_bitstream_size: self.frame_budget,
maximum_bitstream_size: self.rate_budget(),
};
pw_check(
pw::pyrowave_encoder_encode_gpu_synchronous(
@@ -664,6 +678,10 @@ impl PyroWaveEncoder {
}
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
if self.wire_chunk.is_some() {
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
self.wire_budget.observe(raw, au.len());
}
self.pending.push_back(EncodedFrame {
data: au,
pts_ns: frame.pts_ns,
+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.
+311 -38
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,27 +608,24 @@ 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 {
// 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 = 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"
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 => {
// Hardware path: NVENC over D3D11. The DXGI capturer switches to its zero-copy
@@ -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,24 +1508,77 @@ 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() {
"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
@@ -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,
+50 -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.
@@ -74,6 +96,14 @@ pub struct HostConfig {
pub perf: bool,
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
pub video_source: Option<String>,
/// `PUNKTFUNK_CAPTURE_MONITOR` — pin capture at a NAMED physical monitor (`DP-1`, `HDMI-A-2`),
/// instead of creating a virtual display or taking whichever head the portal hands back. The
/// point of the knob is an unattended host: a background `systemd --user` service has nobody to
/// answer a chooser dialog, so the monitor has to be config, not a prompt. A name that matches
/// no head is a hard error at session open (never a silent fall-back to a different screen —
/// showing the wrong monitor is worse than showing none). Linux-only today; see
/// `design/per-monitor-portal-capture.md`.
pub capture_monitor: Option<String>,
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
pub compositor: Option<String>,
@@ -93,6 +123,14 @@ pub struct HostConfig {
/// injected pointer. Default OFF: it forces relative mode, which breaks absolute-pointer titles
/// and menus, so it's opt-in per host until validated on-glass.
pub gamescope_grab_cursor: bool,
/// `PUNKTFUNK_GAMESCOPE_SPLASH` — run the host's built-in splash client inside every bare
/// headless gamescope spawn. gamescope only composites (and only then pushes a PipeWire capture
/// buffer) when a client paints, and a dedicated Steam launch paints NOTHING
/// for the whole Steam bootstrap — so without the splash a fresh spawn's capture starves: format
/// negotiated, zero buffers, first-frame timeout, and every retry kills the booting Steam and
/// starts over (the "fresh gamescope output never delivers frames" field failure). Default ON;
/// explicit-off grammar (`=0` disables, the on-glass A/B + emergency escape hatch).
pub gamescope_splash: bool,
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
@@ -128,44 +166,23 @@ 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"),
// Trimmed + emptied-to-None: `PUNKTFUNK_CAPTURE_MONITOR=` in a host.env means "not
// set", not "match the monitor named empty string".
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
compositor: val("PUNKTFUNK_COMPOSITOR"),
gamepad: val("PUNKTFUNK_GAMEPAD"),
vdisplay: val("PUNKTFUNK_VDISPLAY"),
@@ -181,6 +198,9 @@ impl HostConfig {
"1" | "true" | "yes" | "on"
)
}),
// Default ON, explicit-off grammar: the splash is what makes a fresh bare spawn deliver
// its first frames at all; `=0` is the A/B + escape hatch.
gamescope_splash: env_on("PUNKTFUNK_GAMESCOPE_SPLASH").unwrap_or(true),
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
.filter(|s| !s.trim().is_empty()),
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
+249 -16
View File
@@ -20,6 +20,7 @@
//! key events) is enough.
use super::{gs_button_to_evdev, vk_to_evdev, InputInjector};
use crate::AbsoluteAnchor;
use anyhow::{anyhow, Result};
use ashpd::desktop::{
remote_desktop::{
@@ -374,25 +375,98 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<(UnixStream, Opti
}
/// One EI device and its emulation state.
/// Pick the region to map absolute coordinates into: the one whose logical size matches the
/// streamed mode (the session's virtual output). The device advertises one region per logical
/// monitor, and blindly taking `first()` next to a physical monitor put the pointer — and every
/// Pick the region to map absolute coordinates into. The device advertises one region per logical
/// monitor and blindly taking `first()` next to a physical monitor put the pointer — and every
/// click — on whichever output the compositor happened to announce first (on-glass: GNOME with a
/// dummy HDMI beside the virtual primary; the seat cursor never entered the streamed monitor, so
/// neither embedded nor metadata cursor capture could see it). Size is the only key available
/// today: regions carry no output name, and matching the screencast stream's `mapping_id` needs
/// the stream id plumbed across crates (follow-up). Two same-sized monitors stay ambiguous.
fn region_for_mode(
regions: &[reis::event::Region],
/// neither embedded nor metadata cursor capture could see it).
///
/// The ladder, most identifying first:
///
/// 1. **`mapping_id`** from the session's [`AbsoluteAnchor`] — the protocol's own key for
/// correlating a region with a video stream.
/// 2. **origin** from the anchor — two outputs can share a size, never a top-left. This is what
/// makes a *mirrored physical monitor* land correctly (`design/per-monitor-portal-capture.md`
/// §7.2): its region is not the client's size, so the size rung can't find it.
/// 3. **size** — the streamed mode. Correct for a client-sized virtual output, ambiguous the
/// moment two heads share a mode, which is exactly why the rungs above exist.
/// 4. `first()`.
///
/// An anchor that matches nothing falls through rather than failing: the region set is the truth
/// and the anchor is our belief about it. The caller logs that miss ([`anchor_missed`]).
fn region_for_mode<'a>(
regions: &'a [reis::event::Region],
w: f32,
h: f32,
) -> Option<&reis::event::Region> {
anchor: Option<&AbsoluteAnchor>,
) -> Option<&'a reis::event::Region> {
if let Some(a) = anchor {
if let Some(id) = a.mapping_id.as_deref() {
if let Some(r) = regions.iter().find(|r| r.mapping_id.as_deref() == Some(id)) {
return Some(r);
}
}
if let Some((x, y)) = a.origin {
// EI region offsets are unsigned; a compositor places every output at a non-negative
// origin in its own global space, so a negative anchor simply matches nothing.
if x >= 0 && y >= 0 {
if let Some(r) = regions.iter().find(|r| r.x == x as u32 && r.y == y as u32) {
return Some(r);
}
}
}
}
regions
.iter()
.find(|r| r.width as f32 == w && r.height as f32 == h)
.or_else(|| regions.first())
}
/// Report which region absolute coordinates actually landed in, once per distinct answer.
///
/// The ladder above is only *observable* through where the pointer ends up, which on a two-head box
/// is precisely the thing that is hard to see and easy to get wrong — the anchor exists because it
/// already resolved wrong on-glass once, silently. `warn_anchor_miss` covers the anchor naming
/// nothing; this covers the other half, an anchor that matched *something*, by saying which. Once
/// per distinct region so a live session logs one line, not one per motion event.
fn note_abs_region(region: &reis::event::Region, anchor: Option<&AbsoluteAnchor>) {
static LAST: std::sync::Mutex<Option<(u32, u32, u32, u32)>> = std::sync::Mutex::new(None);
let key = (region.x, region.y, region.width, region.height);
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
if *last == Some(key) {
return;
}
*last = Some(key);
tracing::info!(
region = %format!("{}x{}+{}+{}", region.width, region.height, region.x, region.y),
mapping_id = ?region.mapping_id,
anchor_origin = ?anchor.and_then(|a| a.origin),
anchor_mapping_id = ?anchor.and_then(|a| a.mapping_id.clone()),
"libei: absolute input maps into this output"
);
}
/// Did an anchor name an output this region set doesn't have? Drives the one-shot warning — a
/// silently mis-mapped pointer is the failure this whole ladder exists to prevent, so when the
/// anchor can't be honored that has to be visible in the log rather than inferred from "clicks land
/// on the wrong screen".
fn anchor_missed(regions: &[reis::event::Region], anchor: Option<&AbsoluteAnchor>) -> bool {
let Some(a) = anchor else {
return false;
};
if let Some(id) = a.mapping_id.as_deref() {
if regions.iter().any(|r| r.mapping_id.as_deref() == Some(id)) {
return false;
}
}
if let Some((x, y)) = a.origin {
if x >= 0 && y >= 0 && regions.iter().any(|r| r.x == x as u32 && r.y == y as u32) {
return false;
}
}
true
}
struct DeviceSlot {
device: reis::event::Device,
/// The device is resumed (allowed to emit). Devices arrive paused and may pause again.
@@ -430,6 +504,30 @@ struct EiState {
output_hint: Option<(u32, u32)>,
}
/// The anchor whose miss we last warned about, so a persistently unmatchable anchor logs once per
/// *change* instead of once per pointer sample (absolute motion arrives at client frame rate).
static LAST_WARNED_ANCHOR: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// Warn — once per distinct anchor — that the session's anchor names an output this EIS doesn't
/// advertise, so absolute coordinates fell back to size matching. See [`region_for_mode`].
fn warn_anchor_miss(anchor: &AbsoluteAnchor, regions: &[reis::event::Region]) {
let key = format!("{anchor:?}");
let mut last = LAST_WARNED_ANCHOR.lock().unwrap_or_else(|e| e.into_inner());
if last.as_deref() == Some(key.as_str()) {
return;
}
*last = Some(key);
tracing::warn!(
?anchor,
regions = ?regions
.iter()
.map(|r| (r.x, r.y, r.width, r.height, r.mapping_id.clone()))
.collect::<Vec<_>>(),
"libei: the session's absolute-coordinate anchor matches no EIS region — falling back to \
size matching, so the pointer may land on the wrong monitor"
);
}
/// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates
/// into? gamescope advertises a degenerate `(0,0,INT32_MAX,INT32_MAX)` "everything" region on
/// its virtual input device, meaning "absolute coordinates are raw"; normalizing into it
@@ -763,13 +861,23 @@ impl EiState {
// raw client pixels as the last resort.
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
let (x, y) = match region_for_mode(slot.regions(), w, h)
let anchor = crate::absolute_anchor();
if let Some(a) = anchor
.as_ref()
.filter(|a| anchor_missed(slot.regions(), Some(a)))
{
warn_anchor_miss(a, slot.regions());
}
let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref())
.filter(|r| sane_region(r))
{
Some(region) => (
Some(region) => {
note_abs_region(region, anchor.as_ref());
(
region.x as f32 + nx * region.width as f32,
region.y as f32 + ny * region.height as f32,
),
)
}
// Degenerate/absent region: scale into the relay-file output hint
// (correct even when the client streams at a different resolution
// than the session runs); raw client pixels as the last resort.
@@ -847,14 +955,20 @@ impl EiState {
Some(t) if w > 0.0 && h > 0.0 => {
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
// Same region-selection + degenerate fallback ladder as MouseMoveAbs.
let (x, y) = match region_for_mode(slot.regions(), w, h)
// Same region-selection + degenerate fallback ladder as MouseMoveAbs
// (including the session anchor — touch must land on the same monitor the
// pointer does, or a mirrored session's taps go to a different screen).
let anchor = crate::absolute_anchor();
let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref())
.filter(|r| sane_region(r))
{
Some(region) => (
Some(region) => {
note_abs_region(region, anchor.as_ref());
(
region.x as f32 + nx * region.width as f32,
region.y as f32 + ny * region.height as f32,
),
)
}
None => match self.output_hint {
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
None => (ev.x as f32, ev.y as f32),
@@ -913,3 +1027,122 @@ impl EiState {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn region(x: u32, y: u32, w: u32, h: u32, mapping_id: Option<&str>) -> reis::event::Region {
reis::event::Region {
x,
y,
width: w,
height: h,
scale: 1.0,
mapping_id: mapping_id.map(str::to_string),
}
}
/// The case the anchor exists for: two heads at the SAME size, so the size rung is a coin
/// flip. The origin picks the right one.
#[test]
fn the_origin_disambiguates_two_same_size_monitors() {
let regions = [
region(0, 0, 1920, 1080, None),
region(1920, 0, 1920, 1080, None),
];
let anchor = AbsoluteAnchor {
origin: Some((1920, 0)),
mapping_id: None,
};
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!((picked.x, picked.y), (1920, 0));
// Without the anchor the same call takes the first same-sized region — the old behavior,
// preserved deliberately for the client-sized virtual-output path.
let picked = region_for_mode(&regions, 1920.0, 1080.0, None).unwrap();
assert_eq!((picked.x, picked.y), (0, 0));
}
/// `mapping_id` outranks the origin: it is the protocol's own stream↔region correlation, so a
/// stale/rounded origin can't override it.
#[test]
fn mapping_id_outranks_the_origin() {
let regions = [
region(0, 0, 1920, 1080, Some("head-a")),
region(1920, 0, 1920, 1080, Some("head-b")),
];
let anchor = AbsoluteAnchor {
origin: Some((0, 0)),
mapping_id: Some("head-b".into()),
};
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!(picked.mapping_id.as_deref(), Some("head-b"));
}
/// A mirrored monitor's region is NOT the client's streamed size, so the size rung would miss
/// it entirely — the origin is what makes this land.
#[test]
fn the_anchor_finds_a_monitor_the_streamed_size_does_not_match() {
let regions = [
region(0, 0, 1920, 1080, None),
region(1920, 0, 3840, 2160, None),
];
// Client streams 1280x720 of a 4K head parked to the right.
let anchor = AbsoluteAnchor {
origin: Some((1920, 0)),
mapping_id: None,
};
let picked = region_for_mode(&regions, 1280.0, 720.0, Some(&anchor)).unwrap();
assert_eq!((picked.width, picked.height), (3840, 2160));
}
/// An anchor that names nothing present must not strand input: fall back down the ladder
/// (size, then first) — and say so, which `anchor_missed` drives.
#[test]
fn an_unmatched_anchor_falls_back_and_is_reported() {
let regions = [region(0, 0, 1920, 1080, None)];
let anchor = AbsoluteAnchor {
origin: Some((5000, 5000)),
mapping_id: None,
};
assert!(anchor_missed(&regions, Some(&anchor)));
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!((picked.x, picked.y), (0, 0), "fell back to the size match");
// A matched anchor is never reported as missed.
let ok = AbsoluteAnchor {
origin: Some((0, 0)),
mapping_id: None,
};
assert!(!anchor_missed(&regions, Some(&ok)));
assert!(!anchor_missed(&regions, None), "no anchor is not a miss");
}
/// EI region offsets are unsigned; a negative anchor origin can only ever match nothing, and
/// must not be cast into a huge u32 that accidentally matches something.
#[test]
fn a_negative_origin_matches_nothing_rather_than_wrapping() {
let regions = [region(0, 0, 1920, 1080, None)];
let anchor = AbsoluteAnchor {
origin: Some((-1920, 0)),
mapping_id: None,
};
assert!(anchor_missed(&regions, Some(&anchor)));
let picked = region_for_mode(&regions, 1920.0, 1080.0, Some(&anchor)).unwrap();
assert_eq!((picked.x, picked.y), (0, 0));
}
/// An empty anchor is the same as no anchor — so a caller can build one unconditionally and
/// let the setter drop it.
#[test]
fn an_empty_anchor_is_dropped_by_the_setter() {
crate::set_absolute_anchor(Some(AbsoluteAnchor::default()));
assert_eq!(crate::absolute_anchor(), None);
crate::set_absolute_anchor(Some(AbsoluteAnchor {
origin: Some((1920, 0)),
mapping_id: None,
}));
assert_eq!(crate::absolute_anchor().unwrap().origin, Some((1920, 0)));
crate::set_absolute_anchor(None);
assert_eq!(crate::absolute_anchor(), None);
}
}
+58
View File
@@ -107,6 +107,64 @@ pub fn open(_backend: Backend) -> Result<Box<dyn InputInjector>> {
anyhow::bail!("no input-injection backend on this platform")
}
/// Which output the session's **absolute** coordinates belong to, by identity rather than by size.
///
/// libei hands the injector one region per logical monitor and the region set carries no output
/// name, so the backend has to decide which one a normalized client position maps into. Matching on
/// *size* — all it could do before — is a coin flip the moment two heads share a mode, and it
/// resolved wrong on-glass once already (GNOME, a dummy HDMI beside the virtual primary: the seat
/// cursor never entered the streamed monitor). These are the two keys that actually identify a
/// region: the protocol's own `mapping_id`, and the origin (two outputs can share a size; they can
/// never share a top-left).
///
/// Best-effort by design: an anchor that matches no region warns and falls back to the size ladder,
/// because the region set is the truth and the anchor is our belief about it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AbsoluteAnchor {
/// The target output's top-left in the compositor's global logical space.
pub origin: Option<(i32, i32)>,
/// The EI `mapping_id` of the target output, when the capture side knows it — the protocol's
/// blessed way to correlate a region with a video stream, so it wins over the origin.
pub mapping_id: Option<String>,
}
impl AbsoluteAnchor {
/// Nothing to match on — treated as "no anchor" so callers can build one unconditionally.
pub fn is_empty(&self) -> bool {
self.origin.is_none() && self.mapping_id.is_none()
}
}
/// The current absolute-coordinate anchor. A `RwLock` rather than an env var: the injector is
/// host-lifetime and lives behind a channel, so a *session* can only reach it through process
/// state — and process state that is typed and lock-guarded beats the `set_var` pattern the
/// backend-select still uses (security-review 2026-06-28 #7).
static ABSOLUTE_ANCHOR: std::sync::RwLock<Option<AbsoluteAnchor>> = std::sync::RwLock::new(None);
/// Anchor absolute coordinates at a specific output. `None` (the default) keeps the size-matched
/// behavior.
///
/// ⚠️ **This is a HOST-level pin, not per-session state.** The injector is host-lifetime and every
/// concurrent session's input flows through the same one, so an anchor set per session would apply
/// to all of them — the last connect silently re-aiming everyone else's pointer. That is fine for
/// what this exists for (`PUNKTFUNK_CAPTURE_MONITOR`, a host-wide pin — the host-pinned decision of
/// record in `design/per-monitor-portal-capture.md` §5.3) and wrong for anything per-client. A
/// per-session anchor needs the injector to become session-aware first; don't call this from a
/// session path until it is.
pub fn set_absolute_anchor(anchor: Option<AbsoluteAnchor>) {
let anchor = anchor.filter(|a| !a.is_empty());
tracing::debug!(?anchor, "input: absolute-coordinate anchor set");
*ABSOLUTE_ANCHOR.write().unwrap_or_else(|e| e.into_inner()) = anchor;
}
/// The anchor an injector should map absolute coordinates into, if any.
pub fn absolute_anchor() -> Option<AbsoluteAnchor> {
ABSOLUTE_ANCHOR
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
+3
View File
@@ -48,6 +48,9 @@ wayland-backend = "0.3"
# wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums
# (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs).
bitflags = "2"
# The gamescope bare-spawn splash client (gamescope/splash.rs): pure-Rust X11 core protocol (the
# same no-libxcb-link stance as pf-capture's XFixes cursor source), no extension features needed.
x11rb = { version = "0.13", default-features = false }
[target.'cfg(target_os = "windows")'.dependencies]
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
+65 -1
View File
@@ -77,7 +77,7 @@ pub use session::{session_epoch, try_recover_session};
#[path = "vdisplay/routing.rs"]
pub(crate) mod routing;
pub use routing::{
apply_input_env, managed_session_available, restore_managed_session,
apply_input_env, managed_session_available, restore_managed_session, restore_takeover_now,
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
};
#[cfg(target_os = "linux")]
@@ -288,8 +288,35 @@ pub fn rebuild_probe_active() -> bool {
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0
}
/// The monitor this host mirrors instead of creating a virtual display, or `None` for the normal
/// virtual-display path.
///
/// Precedence: **`PUNKTFUNK_CAPTURE_MONITOR` wins over the stored policy.** An appliance pins in
/// `host.env` and must stay pinned there — a console click (or a stale settings file) should not be
/// able to re-aim a machine whose operator declared the answer in its unit's environment. With the
/// env unset, the console's persisted choice applies, which is what makes a picker possible at all:
/// the env is read once at startup, so it could never be what a UI writes.
///
/// Read per `open` rather than cached, so a console change takes effect on the next session instead
/// of at the next host restart.
pub fn capture_monitor() -> Option<String> {
if let Some(env) = pf_host_config::config().capture_monitor.as_deref() {
return Some(env.to_string());
}
policy::prefs().get().capture_monitor
}
/// Open the virtual-display driver for `compositor`.
///
/// A [`capture_monitor`] pin routes to the **mirror** backend instead: the host streams that
/// physical head and creates no virtual display at all. Deliberately resolved here, at the one place
/// every session opens a display, so the pin can't be honored on one plane and ignored on another —
/// it is a host-wide setting (`design/per-monitor-portal-capture.md` §5.3).
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
#[cfg(target_os = "linux")]
if let Some(connector) = capture_monitor() {
return Ok(Box::new(mirror::MirrorDisplay::new(compositor, connector)?));
}
#[cfg(target_os = "linux")]
{
match compositor {
@@ -321,6 +348,18 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
}
}
/// Open the **mirror** backend for a specific monitor, bypassing the `PUNKTFUNK_CAPTURE_MONITOR`
/// pin that [`open`] consults. For tools that name the head explicitly (`punktfunk-host
/// mirror-test`) — the pin can't serve them, since `pf_host_config` parses the environment once at
/// startup, so a tool setting the variable for itself would be reading a snapshot taken before it.
#[cfg(target_os = "linux")]
pub fn open_mirror(compositor: Compositor, connector: &str) -> Result<Box<dyn VirtualDisplay>> {
Ok(Box::new(mirror::MirrorDisplay::new(
compositor,
connector.to_string(),
)?))
}
/// Readiness probe for `compositor`: is it up and able to create a virtual output *right
/// now*? A session-bringup script polls this (via `punktfunk-host probe-compositor`) to gate
/// on actual readiness instead of racing the compositor with a blind sleep.
@@ -359,6 +398,21 @@ pub fn probe(compositor: Compositor) -> Result<()> {
#[path = "vdisplay/policy.rs"]
pub mod policy;
// Read-only physical-monitor enumeration (the heads the compositor ALREADY has — not ours), for
// pinning capture at one of them + the console picker. Platform-neutral facade; the per-backend
// reads live beside the code that already speaks each dialect. See
// `design/per-monitor-portal-capture.md` §5.1.
#[path = "vdisplay/monitors.rs"]
pub mod monitors;
// The monitor-mirror backend: stream a head the compositor ALREADY has (the
// `PUNKTFUNK_CAPTURE_MONITOR` pin) instead of creating one. Implements `VirtualDisplay` so the
// session machinery is unchanged, but reports `DisplayOwnership::External` so none of the
// virtual-display lifecycle policy is applied to someone else's monitor.
#[cfg(target_os = "linux")]
#[path = "vdisplay/mirror.rs"]
mod mirror;
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
// property-tested; the registry executes the side effects its transitions dictate.
#[path = "vdisplay/lifecycle.rs"]
@@ -425,6 +479,16 @@ pub fn effective_topology() -> policy::Topology {
#[path = "vdisplay/linux/gamescope.rs"]
mod gamescope;
/// Entry point for the hidden `gamescope-splash` host subcommand: the tiny X11 client every bare
/// gamescope spawn backgrounds beside its nested app, so the fresh compositor composites — and its
/// PipeWire node delivers frames — from the first second instead of starving the first-frame wait
/// while the nested Steam bootstrap paints nothing (see `vdisplay/linux/gamescope/splash.rs`).
/// Blocks for the session's lifetime; gamescope's reaper tears it down with the session.
#[cfg(target_os = "linux")]
pub fn gamescope_splash_client() -> anyhow::Result<()> {
gamescope::splash_run()
}
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
+8 -3
View File
@@ -181,9 +181,14 @@ pub fn panel_off_except(exclude_gdi: &str) -> u32 {
acked += set_power(m.hmon, &m.device, POWER_OFF);
}
if acked == 0 {
tracing::debug!(
"DDC/CI: no physical panel accepted the DPMS-off command \
(no DDC/CI-capable panel besides the virtual display)"
// INFO, not debug: the user opted into this axis, so "it did nothing" is an answer they
// asked for. The common case is a laptop — internal eDP/LVDS panels have NO DDC/CI at
// all (their brightness/power runs over the driver's own channel), so `ddc_power_off`
// is structurally a no-op for them (reporter feedback 2026-07-27).
tracing::info!(
"DDC/CI: no panel accepted the DPMS-off command — the ddc_power_off axis did \
nothing on this display set (internal eDP/LVDS panels expose no DDC/CI; external \
monitors may have it disabled in the OSD or dropped by a dock/KVM)"
);
}
acked
@@ -21,6 +21,8 @@ use std::time::{Duration, Instant};
#[path = "gamescope/discovery.rs"]
mod discovery;
#[path = "gamescope/splash.rs"]
mod splash;
use discovery::{
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
gamescope_node_present, poll_managed_node, wait_for_node,
@@ -29,6 +31,7 @@ pub(crate) use discovery::{
game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit,
SteamGameWatch,
};
pub(crate) use splash::run as splash_run;
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
@@ -68,14 +71,23 @@ static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(
/// master — live-proven on the Nobara repro VM 2026-07-24).
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// mtime of the `steamos-session-select` sentinel at managed-session launch — the baseline the
/// in-stream "Switch to Desktop" detector compares against. Steam's session-select script writes
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
/// "Switch to Desktop" detector compares against. Steam's session-select script writes
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
/// therefore the one durable trace of the user's switch request.
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<std::time::SystemTime>> =
///
/// Two levels of `Option`, because "no baseline" and "no sentinel" mean opposite things:
/// * **outer `None`** — never baselined (no takeover this host lifetime). Nothing can read as an
/// in-stream request: the sentinel is a permanent file, so any box whose user has EVER switched
/// sessions has one, and comparing against a missing baseline made that ancient write look like
/// a live "Switch to Desktop".
/// * **`Some(None)`** — baselined while no sentinel existed yet; a later one was created inside the
/// session, which IS a request.
/// * **`Some(Some(t))`** — baselined at mtime `t`; anything newer is a request.
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<Option<std::time::SystemTime>>> =
std::sync::Mutex::new(None);
/// When [`honor_session_select_switch`] last ran. While recent, a managed (re)launch is refused —
@@ -1152,8 +1164,8 @@ const DM_HELPER_PATHS: &[&str] = &[
"/usr/lib/punktfunk/pf-dm-helper",
];
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't
/// installed (tarball/old package), pkexec is missing, or polkit denies the action.
/// Run the packaged DM helper (`stop` | `restore` | `linger`) via pkexec. `false` when the helper
/// isn't installed (tarball/old package), pkexec is missing, or polkit denies the action.
fn dm_helper(verb: &str) -> bool {
let Some(helper) = DM_HELPER_PATHS
.iter()
@@ -1169,18 +1181,102 @@ fn dm_helper(verb: &str) -> bool {
.unwrap_or(false)
}
/// `systemctl` on the SYSTEM bus, **never interactively**. Every privileged verb below runs on the
/// stream's own thread — the capture-loss rebuild, or the restore worker — with no way to answer a
/// question. Without `--no-ask-password`, systemctl asks polkit for interactive authorization, and
/// on a box whose desktop session is still alive (the host is a `--user` unit inside it) polkit
/// hands that to the session's agent: a password dialog on the box's OWN screen, which during a
/// managed takeover is off or mid-switch. Nobody sees it, nobody answers it, and the call blocks
/// while the rebuild budget burns — the takeover then lands after the session it was for already
/// ended. `--no-ask-password` turns that into the immediate "interactive authentication required"
/// failure the callers are written for, so the pkexec helper (`allow_any`, no agent needed) takes
/// over instead of a dialog. Field-suspect in the 0.20.0 Nobara report (intermittent disconnect +
/// a screen that never comes back), where the timing is a race against the KDE agent's own death.
fn systemctl_system(args: &[&str]) -> bool {
let mut cmd = Command::new("systemctl");
cmd.arg("--no-ask-password").args(args);
cmd.status().map(|s| s.success()).unwrap_or(false)
}
/// Would stopping the display manager also stop US? A packaged host runs as a `systemd --user`
/// unit, so its lifetime hangs off the user manager — and the DM stop ends the user's last login
/// session. logind then stops `user@<uid>.service` once `UserStopDelaySec` (10 s by default)
/// elapses, taking the host with it: the stream dies mid-takeover, and nothing is left to restart
/// the display manager, so the box stays dark until someone reaches a VT. **Field-proven on 0.20.0**
/// (Nobara, 2026-07-27): DM stopped at 12:34:18.9, the user manager stopped the host at 12:34:29.0
/// — 10.1 s, textbook `UserStopDelaySec`. It never showed on the repro VM because lingering was
/// enabled there for the sessionless tests.
///
/// Lingering (`loginctl enable-linger` — which the KDE/GNOME/Arch setup docs already ask for) is
/// what breaks the dependency: logind keeps the user manager up with no session at all. So ensure
/// it BEFORE touching the DM, and refuse the takeover when it can't be ensured — the caller then
/// degrades to attach, which mirrors the box's own session and never stops the DM.
fn ensure_host_survives_dm_stop() -> bool {
if !host_is_under_user_manager() {
return true; // root / a system unit — the DM stop cannot reach us
}
if linger_enabled() {
return true;
}
// `set-self-linger` is `allow_active` in logind's own policy, so a host started inside the
// user's session can do this itself; a sessionless one (the packaged unit) goes through the
// helper, whose grant is scoped to the calling uid.
let uid = uid_string();
let _ = Command::new("loginctl")
.args(["--no-ask-password", "enable-linger", &uid])
.status();
if linger_enabled() || (dm_helper("linger") && linger_enabled()) {
tracing::info!(
uid,
"enabled lingering for this user — the managed takeover stops the display manager, \
which ends this login session, and without lingering logind would stop the host \
along with it (`loginctl disable-linger` reverts it)"
);
return true;
}
false
}
/// Is this process's lifetime tied to a `systemd --user` manager (i.e. would logind's user-manager
/// stop take us down)? Read from our own cgroup path.
fn host_is_under_user_manager() -> bool {
std::fs::read_to_string("/proc/self/cgroup")
.as_deref()
.map(cgroup_under_user_manager)
.unwrap_or(false)
}
/// [`host_is_under_user_manager`]'s test: does this `/proc/self/cgroup` content sit under a
/// `user@<uid>.service` manager? Pure + unit-tested. A system unit
/// (`/system.slice/punktfunk-host.service`) does not, and neither does a bare process started from
/// a login shell (`/user.slice/user-1000.slice/session-2.scope`) — logind's user-manager stop only
/// reaches units the user manager owns.
fn cgroup_under_user_manager(cgroup: &str) -> bool {
cgroup.contains("user@")
}
/// Our uid as a string — what `loginctl` wants for a user argument.
fn uid_string() -> String {
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
unsafe { libc::getuid() }.to_string()
}
/// Is lingering on for this user (logind keeps the `--user` manager alive with no session)?
fn linger_enabled() -> bool {
Command::new("loginctl")
.args(["show-user", &uid_string(), "-p", "Linger", "--value"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes")
.unwrap_or(false)
}
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
/// attach.
fn try_stop_display_manager(dm: &str) -> bool {
let direct = Command::new("systemctl")
.args(["stop", dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
direct || dm_helper("stop")
systemctl_system(&["stop", dm]) || dm_helper("stop")
}
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
@@ -1189,15 +1285,8 @@ fn try_stop_display_manager(dm: &str) -> bool {
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
/// two steps as root.
fn restore_display_manager(dm: &str) -> bool {
let _ = Command::new("systemctl")
.args(["reset-failed", dm])
.status();
let direct = Command::new("systemctl")
.args(["restart", dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
direct || dm_helper("restore")
let _ = systemctl_system(&["reset-failed", dm]);
systemctl_system(&["restart", dm]) || dm_helper("restore")
}
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
@@ -1223,24 +1312,39 @@ fn session_select_mtime() -> Option<std::time::SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
/// Record the sentinel baseline at managed-session launch, so a LATER write (the user's in-stream
/// "Switch to Desktop") is distinguishable from the switch that led into this session.
/// Record the sentinel baseline, so a LATER write (the user's in-stream "Switch to Desktop") is
/// distinguishable from the switch that led into this session. Taken at **takeover** (the moment
/// [`STOPPED_DM`] is set, which is what arms the honor gate) and again at a successful launch: the
/// switch INTO game mode writes the sentinel on its way in, and that write must never read as a
/// request to go back out. Baselining only at launch left the window in between — a takeover whose
/// launch failed, then a client retry inside the restore debounce — reading a months-old sentinel
/// as a live request and pushing the box to the desktop the user never asked for.
fn record_session_select_baseline() {
*SESSION_SELECT_BASELINE
.lock()
.unwrap_or_else(|e| e.into_inner()) = session_select_mtime();
.unwrap_or_else(|e| e.into_inner()) = Some(session_select_mtime());
}
/// Did a session-select run inside the managed session since its launch (sentinel newer than the
/// recorded baseline, or newly created)? Inside a managed game session the only switch Steam
/// offers is TO the desktop, so an advanced sentinel reads as that request.
/// Did a session-select run inside the managed session since the baseline? Inside a managed game
/// session the only switch Steam offers is TO the desktop, so an advanced sentinel reads as that
/// request.
fn session_select_requested() -> bool {
let baseline = *SESSION_SELECT_BASELINE
.lock()
.unwrap_or_else(|e| e.into_inner());
match (baseline, session_select_mtime()) {
(Some(base), Some(now)) => now > base,
(None, Some(_)) => true, // created during the session
sentinel_advanced(baseline, session_select_mtime())
}
/// [`session_select_requested`]'s decision, as a pure function of the two readings (the
/// unit-testable core). No baseline ⇒ no request: see [`SESSION_SELECT_BASELINE`] for why a
/// missing baseline must not be read as "the sentinel appeared during the session".
fn sentinel_advanced(
baseline: Option<Option<std::time::SystemTime>>,
now: Option<std::time::SystemTime>,
) -> bool {
match (baseline, now) {
(Some(Some(base)), Some(now)) => now > base,
(Some(None), Some(_)) => true, // no sentinel at baseline — created during the session
_ => false,
}
}
@@ -1392,6 +1496,19 @@ fn stop_autologin_sessions() -> Result<()> {
return Ok(());
}
let dm = dm.expect("!is_none_or ⇒ Some");
// The DM stop ends this user's last login session. If our own lifetime hangs off the user
// manager and lingering can't be turned on, that stop kills the host ~10s later — with the
// box's display manager down and nobody left to bring it back. Degrading to attach is
// strictly better than a black screen that needs a VT to recover.
if !ensure_host_survives_dm_stop() {
bail!(
"stopping {dm} ends this user's last login session, and without lingering logind \
would stop the user manager and this host with it about 10s later, leaving \
the box with no display manager and nothing to restore it; enabling lingering \
failed, so the managed takeover is unavailable (run `sudo loginctl enable-linger \
$USER` once, as the setup docs ask, then reconnect)"
);
}
if !try_stop_display_manager(&dm) {
bail!(
"the box's gaming session is driven by {dm}, which does not survive a masked \
@@ -1405,6 +1522,11 @@ fn stop_autologin_sessions() -> Result<()> {
%dm,
"freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)"
);
// Baseline the switch sentinel HERE, not just at a successful launch: setting STOPPED_DM
// is what arms the honor gate, so from this instant an unbaselined sentinel would read as
// an in-stream "Switch to Desktop" — including the write from the switch that just brought
// the box INTO game mode. A successful launch re-baselines (tighter still).
record_session_select_baseline();
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
}
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
@@ -1564,21 +1686,7 @@ fn restore_delay() -> Option<Duration> {
/// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless
/// box). Idempotent / safe to call on every session end.
pub fn schedule_restore_tv_session() {
let nothing_to_restore = STOPPED_AUTOLOGIN
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
&& STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).is_none()
// A managed session that took nothing over (started beside a live desktop — e.g. a client
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
// was ORPHANED forever after disconnect ("closing the app does not end the session",
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
&& MANAGED_SESSION
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none();
if nothing_to_restore {
if !takeover_live() {
return; // nothing was taken over → nothing to restore (also the non-managed path)
}
match restore_delay() {
@@ -1601,6 +1709,48 @@ pub fn schedule_restore_tv_session() {
}
}
/// Is anything of the box's own session ours right now — an autologin unit we stopped, a stopped
/// display manager, a SteamOS target we re-pointed, or a managed session we launched beside a live
/// desktop? The precondition for every restore path.
fn takeover_live() -> bool {
!STOPPED_AUTOLOGIN
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
|| *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
|| STOPPED_DM
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
// A managed session that took nothing over (started beside a live desktop — e.g. a client
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
// was ORPHANED forever after disconnect ("closing the app does not end the session",
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
|| MANAGED_SESSION
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
}
/// Give the box its own session back **now**, synchronously — the host is going away (SIGTERM from
/// `systemctl --user stop`/`restart`, a package update, Ctrl-C) and a live takeover must not
/// outlive it. On a DM-flavor takeover the display manager is STOPPED: nothing else on the box
/// will ever restart it, and the persisted crash-restore state lives in `$XDG_RUNTIME_DIR`, which
/// logind removes along with the user manager — so not even the next host start can heal it. The
/// box would stay dark until someone reached a VT.
///
/// Deliberately ignores the keep-alive policy that [`schedule_restore_tv_session`] honors:
/// `keep_alive=forever` pins a session for the NEXT client, which is meaningless once the host
/// that would serve them is exiting. No-op when nothing was taken over.
pub fn restore_takeover_now() {
if !takeover_live() {
return;
}
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here
tracing::info!("gamescope: host is shutting down — restoring the box's own session first");
do_restore_tv_session();
}
/// Does any DRM connector report a physically `connected` display? Scans
/// `/sys/class/drm/*/status` — only connector nodes (`card0-eDP-1`, `card0-HDMI-A-1`, …) have a
/// `status` file, so the bare `cardN` device dirs and `renderD*` nodes filter themselves out. A
@@ -1963,11 +2113,6 @@ pub fn ei_socket_file() -> std::path::PathBuf {
crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
}
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-silent` inserted so
/// the game is the gamescope focus with no Steam client window to navigate
/// (`design/gamemode-and-dedicated-sessions.md` §5.3). Operator-typed custom commands and non-Steam
/// launches are returned unchanged. Idempotent (never double-inserts `-silent`). Pure + unit-tested.
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
fn is_steam_launch(cmd: &str) -> bool {
@@ -1975,12 +2120,21 @@ fn is_steam_launch(cmd: &str) -> bool {
it.next() == Some("steam") && cmd.contains("steam://")
}
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-gamepadui` inserted
/// so the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built
/// around (it's what SteamOS/Bazzite game mode runs): the boot shows the gamepad UI instead of the
/// desktop Steam client window (field report 2026-07-27: the desktop UI flashing through the
/// stream "looks bad"), and gamescope's focus rules already prefer the game window over the Steam
/// UI appid, which is what the previous `-silent` shaping was working around. Operator-typed
/// custom commands and non-Steam launches are returned unchanged. Idempotent (never
/// double-inserts). Pure + unit-tested.
fn shape_dedicated_command(app: &str) -> String {
let mut it = app.split_whitespace();
if it.next() == Some("steam") {
let rest: Vec<&str> = it.collect();
if !rest.contains(&"-silent") && rest.iter().any(|t| t.starts_with("steam://")) {
return format!("steam -silent {}", rest.join(" "));
if !rest.contains(&"-gamepadui") && rest.iter().any(|t| t.starts_with("steam://")) {
return format!("steam -gamepadui {}", rest.join(" "));
}
}
app.to_string()
@@ -2016,7 +2170,11 @@ fn add_bare_gamescope_args(
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
/// stdout/stderr go to `log` (this spawn's per-instance log, A5). The app is launched through a tiny
/// shell wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`]
/// so the input injector can connect to gamescope's EIS server from outside.
/// so the input injector can connect to gamescope's EIS server from outside — and (unless
/// `PUNKTFUNK_GAMESCOPE_SPLASH=0`) backgrounds the host's splash client first, so the fresh
/// compositor has a painting window from the first second: gamescope pushes capture buffers only
/// when it composites, and a nested Steam bootstrap paints nothing until the gamepad UI's first
/// frame — far longer than any first-frame budget (see `gamescope/splash.rs`).
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result<Child> {
// A non-empty per-session command (set via `set_launch_command`) wins; else the
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
@@ -2033,8 +2191,9 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
// cursor-grab flag below.
let game_launch = app.is_some();
let app = app.unwrap_or_else(|| "sleep infinity".to_string());
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the
// gamescope focus with no Steam client window to navigate.
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-gamepadui` so the nested
// Steam is Big Picture — the identity gamescope's `--steam` mode is built around — instead of
// the desktop client window.
let app = shape_dedicated_command(&app);
let relay = ei_socket_file();
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
@@ -2047,18 +2206,26 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
// cursor, which some FPS titles never signal over the injected pointer — grabbing fixes mouselook.
// Default OFF (it forces relative mode, which would break absolute-pointer games/menus).
let grab_cursor = game_launch && pf_host_config::config().gamescope_grab_cursor;
// The splash client (see `gamescope/splash.rs`): without a painting client gamescope pushes NO
// capture buffers, and a nested Steam bootstrap paints nothing for far longer than any
// first-frame budget — so every bare spawn backgrounds the host's own splash beside the nested
// app. Skipped only via the PUNKTFUNK_GAMESCOPE_SPLASH=0 escape hatch (or if the host can't
// name its own executable, where the old starve-prone behaviour is still better than no spawn).
let splash_exe = pf_host_config::config()
.gamescope_splash
.then(std::env::current_exe)
.and_then(|r| {
r.map_err(|e| tracing::warn!(error = %e, "gamescope: current_exe failed — no splash"))
.ok()
});
let mut cmd = Command::new("gamescope");
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor);
cmd.args([
"sh",
"-c",
&format!(
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
relay.display()
),
"sh",
])
.args(app.split_whitespace())
let script = nested_wrapper_script(&relay, splash_exe.is_some());
cmd.args(["sh", "-c", &script, "sh"]);
if let Some(exe) = &splash_exe {
cmd.arg(exe);
}
cmd.args(app.split_whitespace())
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
@@ -2077,11 +2244,34 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
} else {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
tracing::info!(w, h, hz, steam_mode, %app, log = %log.display(), "spawning gamescope (headless)");
tracing::info!(
w, h, hz, steam_mode,
splash = splash_exe.is_some(),
%app,
log = %log.display(),
"spawning gamescope (headless)"
);
cmd.spawn()
.context("spawn gamescope (is it installed? `apt install gamescope`)")
}
/// The nested-command wrapper script for a bare spawn: relay gamescope's `LIBEI_SOCKET` to the
/// injector's file, optionally background the splash client (`"$1"` is the host executable — passed
/// as an argv so its path never needs shell-escaping), then exec the real app. Pure + unit-tested.
fn nested_wrapper_script(relay: &std::path::Path, with_splash: bool) -> String {
if with_splash {
format!(
"printf %s \"$LIBEI_SOCKET\" > '{}'; \"$1\" gamescope-splash & shift; exec \"$@\"",
relay.display()
)
} else {
format!(
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
relay.display()
)
}
}
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
/// output down.
struct GamescopeProc {
@@ -2104,10 +2294,66 @@ impl Drop for GamescopeProc {
#[cfg(test)]
mod tests {
use super::{
cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under,
dm_survives_masked_unit, is_steam_launch, shape_dedicated_command,
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
display_manager_unit_under, dm_survives_masked_unit, is_steam_launch,
nested_wrapper_script, sentinel_advanced, shape_dedicated_command,
};
#[test]
fn user_manager_lifetime_detection() {
// The packaged host: a `--user` unit, so logind's user-manager stop takes it down with the
// login session the DM stop ends — this is the case that needs lingering.
assert!(cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service\n"
));
assert!(cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/user@1000.service/session.slice/punktfunk-gamescope.service\n"
));
// A system unit outlives every session — the DM stop cannot reach it.
assert!(!cgroup_under_user_manager(
"0::/system.slice/punktfunk-host.service\n"
));
// Started from a login shell: owned by the session scope, not the user manager.
assert!(!cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/session-2.scope\n"
));
}
#[test]
fn session_select_sentinel_needs_a_baseline() {
let t0 = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
let t1 = t0 + std::time::Duration::from_secs(1);
// Never baselined: the sentinel is a permanent file, so a box whose user EVER switched
// sessions has one — that ancient write is not a live "Switch to Desktop" request. This is
// the case that pushed a Nobara box to the desktop after a failed managed launch.
assert!(!sentinel_advanced(None, Some(t0)));
assert!(!sentinel_advanced(None, None));
// Baselined with no sentinel yet, then one appeared inside the session: a real request.
assert!(sentinel_advanced(Some(None), Some(t0)));
assert!(!sentinel_advanced(Some(None), None));
// Baselined at an mtime: only a NEWER one is the user's in-stream switch. The write that
// brought the box into game mode is the baseline itself, so it reads as no request.
assert!(sentinel_advanced(Some(Some(t0)), Some(t1)));
assert!(!sentinel_advanced(Some(Some(t0)), Some(t0)));
assert!(!sentinel_advanced(Some(Some(t1)), Some(t0)));
assert!(!sentinel_advanced(Some(Some(t0)), None));
}
#[test]
fn nested_wrapper_script_shapes() {
let relay = std::path::Path::new("/run/user/1000/pf-ei");
// Plain: relay + exec, no splash machinery.
let plain = nested_wrapper_script(relay, false);
assert!(plain.contains("/run/user/1000/pf-ei"));
assert!(plain.ends_with("exec \"$@\""));
assert!(!plain.contains("gamescope-splash"));
// Splash: `"$1"` is the host exe (an argv, never shell-interpolated), backgrounded and
// shifted away so `exec "$@"` still runs the untouched app tokens.
let splash = nested_wrapper_script(relay, true);
assert!(splash.contains("\"$1\" gamescope-splash &"));
assert!(splash.contains("shift; exec \"$@\""));
}
#[test]
fn display_manager_flavor_detection() {
let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id()));
@@ -2169,15 +2415,15 @@ mod tests {
#[test]
fn dedicated_command_shaping() {
// Steam URI → -silent inserted so the game is the gamescope focus.
// Steam URI → -gamepadui inserted so the nested Steam is Big Picture (not the desktop UI).
assert_eq!(
shape_dedicated_command("steam steam://rungameid/570"),
"steam -silent steam://rungameid/570"
"steam -gamepadui steam://rungameid/570"
);
// Idempotent: an already-silent command is left alone.
// Idempotent: an already-gamepadui command is left alone.
assert_eq!(
shape_dedicated_command("steam -silent steam://rungameid/570"),
"steam -silent steam://rungameid/570"
shape_dedicated_command("steam -gamepadui steam://rungameid/570"),
"steam -gamepadui steam://rungameid/570"
);
// Non-Steam launches and operator custom commands are untouched.
assert_eq!(shape_dedicated_command("vkcube"), "vkcube");
@@ -0,0 +1,159 @@
//! The bare-spawn **splash client** — the reason a fresh headless gamescope delivers frames at all.
//!
//! gamescope only composites (and only on a composite pushes a buffer to its PipeWire capture
//! node) when a client paints. A dedicated Steam launch paints NOTHING for the whole Steam
//! bootstrap (no window maps until the gamepad UI's first frame) — so a fresh
//! spawn's capture negotiated a format, reached `Streaming`, and then starved: zero buffers, the
//! 10 s first-frame timeout, teardown — and every native-plane retry killed the half-booted Steam
//! and started from zero, while the GameStream plane died on its single wait (the "fresh gamescope
//! output never delivers frames" field failure; root-caused on-box 2026-07-27 with a raw pw_stream
//! probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately).
//!
//! This client runs INSIDE the spawned gamescope (the nested-command wrapper backgrounds it before
//! exec'ing the real app) and guarantees damage from the first second: a dark window with a subtle
//! breathing bar painted at ~2.5 Hz. Two gamescope focus facts, both live-proven on c31743d:
//!
//! * In `--steam` mode gamescope composites ONLY windows whose Steam appid is listed in the root
//! window's `GAMESCOPECTRL_BASELAYER_APPID` property (a plain mapped+painting window — even
//! vkcube — gets zero composites). So the splash declares itself as the Steam client UI
//! (`STEAM_GAME=769`) and seeds the baselayer with that appid iff nobody has written it yet.
//! When Steam finishes booting it rewrites the baselayer as part of launching the game, which
//! hands composite focus to the real game window with no action on our side (verified: 2.5 Hz
//! splash cadence → 60 Hz game cadence on the same capture stream, no gap).
//! * In plain (non-`--steam`) mode any mapped painting window composites; the atoms are inert.
//!
//! The splash never exits on its own while the session lives: after the game takes focus it keeps
//! painting unfocused (damage on an unfocused window schedules no composite — harmless), so if the
//! game's window briefly goes away (Steam updater closed, level-load window swap) and gamescope
//! re-focuses the splash, liveness returns instead of a frozen last frame. It dies with the session:
//! gamescope's reaper kills it at teardown, and any X error (the connection dropping) exits cleanly.
use anyhow::{Context, Result};
use std::time::Duration;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
AtomEnum, ChangeGCAux, ConnectionExt, CreateGCAux, CreateWindowAux, PropMode, Rectangle,
WindowClass,
};
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as _;
/// The Steam client UI's appid — the identity the splash borrows so `--steam`-mode gamescope
/// treats it as focusable before (and beneath) any real game.
const STEAM_UI_APPID: u32 = 769;
/// Window/bar palette: near-black background, greys the bar breathes through.
const BG: u32 = 0x0d0d0d;
/// How often the splash repaints (each paint is the damage that makes gamescope push a capture
/// buffer). 400 ms ≈ 2.5 fps — far inside every first-frame budget, invisible in CPU terms.
const TICK: Duration = Duration::from_millis(400);
/// Entry point for the hidden `gamescope-splash` host subcommand. Blocks for the session's
/// lifetime; returns (or errors) only when the X connection is gone or never came up.
pub(crate) fn run() -> Result<()> {
// gamescope execs its nested command only once Xwayland is ready and DISPLAY is set, so the
// first attempt normally lands — the retry covers a slow Xwayland under driver-init load.
let (conn, screen_num) = connect_with_retry()?;
let screen = &conn.setup().roots[screen_num];
let (w, h) = (screen.width_in_pixels, screen.height_in_pixels);
let win = conn.generate_id()?;
conn.create_window(
x11rb::COPY_DEPTH_FROM_PARENT,
win,
screen.root,
0,
0,
w,
h,
0,
WindowClass::INPUT_OUTPUT,
screen.root_visual,
&CreateWindowAux::new().background_pixel(BG),
)?;
conn.change_property8(
PropMode::REPLACE,
win,
AtomEnum::WM_NAME,
AtomEnum::STRING,
b"Punktfunk",
)?;
// STEAM_GAME on the window + GAMESCOPECTRL_BASELAYER_APPID on the root — the pair that makes a
// `--steam` gamescope composite us. Seed the baselayer only while unset: once Steam is up it
// owns that property (its rewrite at game launch is exactly the handover we want).
let steam_game = conn.intern_atom(false, b"STEAM_GAME")?.reply()?.atom;
conn.change_property32(
PropMode::REPLACE,
win,
steam_game,
AtomEnum::CARDINAL,
&[STEAM_UI_APPID],
)?;
let baselayer = conn
.intern_atom(false, b"GAMESCOPECTRL_BASELAYER_APPID")?
.reply()?
.atom;
let existing = conn
.get_property(false, screen.root, baselayer, AtomEnum::CARDINAL, 0, 4)?
.reply()?;
if existing.value_len == 0 {
conn.change_property32(
PropMode::REPLACE,
screen.root,
baselayer,
AtomEnum::CARDINAL,
&[STEAM_UI_APPID],
)?;
}
conn.map_window(win)?;
let gc = conn.generate_id()?;
conn.create_gc(gc, win, &CreateGCAux::new().foreground(BG))?;
conn.flush()?;
tracing::info!(
w,
h,
seeded_baselayer = existing.value_len == 0,
"gamescope splash: mapped"
);
// The breathing bar. Server-side fills generate the damage; the background pixel handles
// exposures, so no event loop is needed. Any request failing = the session (or its Xwayland)
// is gone = a clean exit.
let bar_w = 120i16;
let bar_h = 8i16;
let bar = Rectangle {
x: (w as i16) / 2 - bar_w / 2,
y: (h as i16) / 2 - bar_h / 2,
width: bar_w as u16,
height: bar_h as u16,
};
let mut t: u32 = 0;
loop {
// 8-step triangle pulse through dark greys (0x1a…0x40 per channel).
let phase = (t % 8) as i32;
let tri = if phase < 4 { phase } else { 8 - phase };
let grey = 0x1a + 0x0c * tri as u32;
let colour = (grey << 16) | (grey << 8) | grey;
conn.change_gc(gc, &ChangeGCAux::new().foreground(colour))?;
conn.poly_fill_rectangle(win, gc, &[bar])?;
conn.flush()
.context("gamescope splash: X connection lost")?;
t = t.wrapping_add(1);
std::thread::sleep(TICK);
}
}
/// Connect to the session's `DISPLAY`, retrying briefly — gamescope sets the variable before
/// exec'ing the nested command, but a slow Xwayland under cold driver init gets a grace window.
fn connect_with_retry() -> Result<(RustConnection, usize)> {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
match x11rb::connect(None) {
Ok(ok) => return Ok(ok),
Err(e) if std::time::Instant::now() >= deadline => {
return Err(e).context("gamescope splash: could not connect to the session DISPLAY")
}
Err(_) => std::thread::sleep(Duration::from_millis(200)),
}
}
}
+102 -19
View File
@@ -174,25 +174,12 @@ impl VirtualDisplay for HyprlandDisplay {
set_monitor_rule(&name, mode).with_context(|| format!("set monitor rule for {name}"))?;
// Steer xdph's custom picker at our new output, then run the portal handshake on its own
// thread (it parks to keep the cast alive, like the other backends).
ensure_xdph_config()?;
let sel = selection_file();
std::fs::write(&sel, picker_selection_line(&name))
.with_context(|| format!("write {sel}"))?;
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let hw_cursor = self.hw_cursor;
thread::Builder::new()
.name("punktfunk-hypr-vout".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn hyprland portal thread")?;
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"),
// thread (it parks to keep the cast alive, like the other backends). Serialized: the
// selection is one per-user file, so a concurrent session's write between ours and xdph's
// read would silently capture the wrong output (see `SELECTION_LOCK`).
let (fd, node_id, stop) = {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
select_and_cast(&name, self.hw_cursor)?
};
tracing::info!(
node_id,
@@ -272,6 +259,102 @@ fn hyprctl(args: &[&str]) -> Result<String> {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Serializes **write-the-selection → complete-the-handshake**, process-wide — see the wlroots
/// backend's `SELECTION_LOCK`. The xdph selection is likewise one per-user file, so a concurrent
/// write between ours and xdph's read would silently steer capture at the other session's output.
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
ensure_xdph_config()?;
let sel = selection_file();
std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?;
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
thread::Builder::new()
.name("punktfunk-hypr-cast".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn hyprland portal thread")?;
match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
}
}
/// Record an **existing** Hyprland monitor — the monitor-mirror path
/// (`design/per-monitor-portal-capture.md` L3): the same custom-picker mechanism the virtual-output
/// path uses, pointed at a physical connector, so no GUI picker is involved.
///
/// The keepalive stops the cast only — the monitor is Hyprland's, not ours.
pub(crate) fn stream_existing_output(
connector: &str,
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: Some(fd),
keepalive: Box::new(StopGuard(stop)),
})
}
/// Every head Hyprland reports, for [`crate::monitors::list`].
///
/// `hyprctl -j monitors all` (rather than plain `monitors`) so **disabled** heads are listed too —
/// a picker that silently omits the monitor the operator is trying to name is worse than one that
/// shows it greyed out. Hyprland reports geometry post-transform in logical pixels, which is the
/// space `crate::monitors` documents.
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
let raw = hyprctl(&["-j", "monitors", "all"])?;
let parsed: serde_json::Value =
serde_json::from_str(&raw).context("parse hyprctl -j monitors all")?;
let mut out: Vec<_> = parsed
.as_array()
.context("hyprctl monitors: not an array")?
.iter()
.filter_map(|m| {
let connector = m.get("name")?.as_str()?.to_string();
let num = |k: &str| m.get(k).and_then(|v| v.as_i64()).unwrap_or(0);
// Hyprland's `description` is already a "make model (connector)" string; treat it as
// the make and let the shared helper drop it when it is empty/Unknown.
let description = crate::monitors::describe(
m.get("description").and_then(|v| v.as_str()).unwrap_or(""),
"",
&connector,
);
Some(crate::monitors::PhysicalMonitor {
connector,
description,
width: num("width").max(0) as u32,
height: num("height").max(0) as u32,
// `refreshRate` is Hz as a float.
refresh_mhz: (m.get("refreshRate").and_then(|v| v.as_f64()).unwrap_or(0.0) * 1000.0)
as u32,
x: num("x") as i32,
y: num("y") as i32,
scale: m
.get("scale")
.and_then(|v| v.as_f64())
.filter(|s| *s > 0.0)
.unwrap_or(1.0),
primary: m.get("focused").and_then(|v| v.as_bool()).unwrap_or(false),
enabled: !m.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false),
// Our headless outputs are named `PF-<n>` (see `next_output_name`).
managed: m
.get("name")
.and_then(|v| v.as_str())
.is_some_and(|n| n.starts_with("PF-")),
})
})
.collect();
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
Ok(out)
}
/// Run a `hyprctl` **dispatch** command (`output …`, `keyword …`, `eval …`) that reports success by
/// printing `ok`. hyprctl often exits 0 even when the command is rejected, printing the error to
/// stdout, so treat a known error marker as failure (this is also how [`set_monitor_rule`] tells the
+205 -11
View File
@@ -32,6 +32,7 @@ use std::sync::mpsc::Sender;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use wayland_client::protocol::wl_output::{self, WlOutput};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
@@ -914,6 +915,10 @@ struct State {
node_id: Option<u32>,
failed: Option<String>,
closed: bool,
/// Every `wl_output` KWin advertises, keyed by the proxy, with its connector name once the
/// `name` event arrives. Only the monitor-mirror path ([`stream_existing_output`]) needs these
/// — `stream_output` takes a `wl_output` object, so the connector has to be resolved to one.
outputs: Vec<(WlOutput, Option<String>)>,
}
impl Dispatch<WlRegistry, ()> for State {
@@ -934,6 +939,34 @@ impl Dispatch<WlRegistry, ()> for State {
if interface == Screencast::interface().name {
let v = version.min(MAX_VERSION);
state.screencast = Some(registry.bind::<Screencast, _, _>(name, v, qh, ()));
} else if interface == WlOutput::interface().name {
// v4 is where `wl_output.name` (the connector) arrives; bind at least that when the
// compositor offers it, else bind what it has and let the resolve fail loudly
// rather than mirroring an unidentifiable head.
let v = version.min(WL_OUTPUT_MAX_VERSION);
let out = registry.bind::<WlOutput, _, _>(name, v, qh, ());
state.outputs.push((out, None));
}
}
}
}
/// `wl_output` version we bind at: 4 brings the `name` event carrying the connector
/// (`DP-1`, …) — the only way to tell KWin's outputs apart on this connection.
const WL_OUTPUT_MAX_VERSION: u32 = 4;
impl Dispatch<WlOutput, ()> for State {
fn event(
state: &mut Self,
output: &WlOutput,
event: wl_output::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event {
if let Some(slot) = state.outputs.iter_mut().find(|(o, _)| o == output) {
slot.1 = Some(name);
}
}
}
@@ -988,6 +1021,55 @@ fn virtual_output_thread(
}
}
/// Start recording the existing KWin output named `connector` (the monitor-mirror path), returning
/// its PipeWire node id and the keepalive whose drop stops the recording.
///
/// `hw_cursor` selects the pointer mode exactly as the virtual-output path does: metadata for a
/// cursor-channel session, embedded otherwise, so the cursor behaves the same whichever source a
/// session is on.
pub(crate) fn stream_existing_output(
connector: &str,
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let pointer_mode = if hw_cursor {
POINTER_METADATA
} else {
POINTER_EMBEDDED
};
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let connector_thread = connector.to_string();
thread::Builder::new()
.name("punktfunk-kwin-mirror".into())
.spawn(move || {
if let Err(e) = run_existing(&connector_thread, pointer_mode, &setup_tx, &stop_thread) {
let _ = setup_tx.send(Err(format!("{e:#}")));
}
})
.context("spawn KWin monitor-mirror thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("KWin monitor mirror failed: {e}"),
Err(_) => bail!("timed out recording the KWin output {connector:?}"),
};
Ok(crate::mirror::MirrorStream {
node_id,
// KWin publishes on the user's own PipeWire daemon — no portal remote to carry.
remote_fd: None,
keepalive: Box::new(StopOnDrop(stop)),
})
}
/// Stops the mirror thread (and thus the recording) when the capturer drops it.
struct StopOnDrop(Arc<AtomicBool>);
impl Drop for StopOnDrop {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
/// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm
/// the privileged `zkde_screencast` global is actually advertised. This is exactly what
/// [`run`] needs before it can create a virtual output, so a session-bringup script can poll
@@ -1019,6 +1101,106 @@ pub fn is_available() -> bool {
probe().is_ok()
}
/// Stream an **existing** KWin output — the monitor-mirror path
/// (`design/per-monitor-portal-capture.md` L1). Same privileged global and the same thread/keepalive
/// shape as the virtual-output path; `stream_output` simply takes a `wl_output` instead of minting
/// one, so there is no dialog, no portal, and no chooser: the connector name IS the selection.
///
/// Returns the PipeWire node id. The thread parks until `stop`, holding the Wayland connection that
/// is the cast's lifetime — dropping it stops the recording and leaves the monitor untouched (we
/// never created it, so there is nothing to tear down; §7.1).
fn run_existing(
connector: &str,
pointer_mode: u32,
setup_tx: &Sender<Result<u32, String>>,
stop: &AtomicBool,
) -> Result<()> {
let conn = Connection::connect_to_env()
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut state = State::default();
// Two roundtrips: the first processes the globals (binding screencast + every wl_output), the
// second drains each output's property burst — the `name` event we resolve the connector by.
queue.roundtrip(&mut state).context("registry roundtrip")?;
queue
.roundtrip(&mut state)
.context("wl_output property roundtrip")?;
let screencast = state.screencast.clone().ok_or_else(|| {
anyhow!(
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
)
})?;
// Resolve the connector to a bound wl_output. A miss is a hard error naming what IS there:
// mirroring some other monitor because the requested one is unplugged shows the operator a
// screen they did not ask for, which is worse than a session that refuses with a reason.
let named: Vec<&str> = state
.outputs
.iter()
.filter_map(|(_, n)| n.as_deref())
.collect();
let output = state
.outputs
.iter()
.find(|(_, n)| n.as_deref() == Some(connector))
.or_else(|| {
state.outputs.iter().find(|(_, n)| {
n.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(connector))
})
})
.map(|(o, _)| o.clone())
.ok_or_else(|| {
if named.is_empty() {
anyhow!(
"KWin advertised no named wl_output (needs wl_output v4 for the connector \
name) cannot mirror {connector:?}"
)
} else {
anyhow!(
"KWin has no output named {connector:?} — it has: {}",
named.join(", ")
)
}
})?;
let stream = screencast.stream_output(&output, pointer_mode, &qh, ());
tracing::info!(
connector,
embedded_pointer = pointer_mode != POINTER_METADATA,
"KWin: recording an existing output; awaiting PipeWire node"
);
let node_id = loop {
queue
.blocking_dispatch(&mut state)
.context("wayland dispatch (awaiting created)")?;
if let Some(node) = state.node_id {
break node;
}
if let Some(e) = state.failed.take() {
bail!("stream_output failed: {e}");
}
if state.closed {
bail!("KWin closed the stream before it was created");
}
};
setup_tx
.send(Ok(node_id))
.map_err(|_| anyhow!("monitor-mirror opener went away"))?;
park_until_stopped(&conn, &mut queue, &mut state, stop, connector, node_id)?;
stream.close();
let _ = conn.flush();
Ok(())
}
fn run(
width: u32,
height: u32,
@@ -1080,15 +1262,31 @@ fn run(
.send(Ok(node_id))
.map_err(|_| anyhow!("virtual-output opener went away"))?;
// Keep the connection (and thus the virtual output) alive until told to stop, observing
// `closed`. blocking_dispatch can't be interrupted, so poll the connection fd with a short
// timeout so `stop` is honored within ~200 ms.
park_until_stopped(&conn, &mut queue, &mut state, stop, name, node_id)?;
// Best-effort clean teardown; dropping the connection also makes KWin reclaim the output.
stream.close();
let _ = conn.flush();
Ok(())
}
/// Keep the connection (and thus the stream) alive until told to stop, observing `closed`.
/// `blocking_dispatch` can't be interrupted, so poll the connection fd with a short timeout and
/// honor `stop` within ~200 ms. Shared by the virtual-output and monitor-mirror paths — for a
/// virtual output this connection IS the output's lifetime; for a mirror it is only the
/// recording's, and the monitor itself is untouched either way.
fn park_until_stopped(
conn: &Connection,
queue: &mut wayland_client::EventQueue<State>,
state: &mut State,
stop: &AtomicBool,
output: &str,
node_id: u32,
) -> Result<()> {
while !stop.load(Ordering::Relaxed) {
queue
.dispatch_pending(&mut state)
.context("dispatch_pending")?;
queue.dispatch_pending(state).context("dispatch_pending")?;
if state.closed {
tracing::warn!(output = %name, node_id, "KWin closed the virtual-output stream");
tracing::warn!(output = %output, node_id, "KWin closed the screencast stream");
break;
}
conn.flush().context("wayland flush")?;
@@ -1110,10 +1308,6 @@ fn run(
let _ = guard.read();
} // else: timeout or signal — drop the guard, re-check `stop`
}
// Best-effort clean teardown; dropping the connection also makes KWin reclaim the output.
stream.close();
let _ = conn.flush();
Ok(())
}
@@ -69,6 +69,9 @@ pub mod management {
}
use device::kde_output_device_mode_v2::{Event as ModeEvent, KdeOutputDeviceModeV2 as DeviceMode};
use device::kde_output_device_registry_v2::{
Event as RegistryEvent, KdeOutputDeviceRegistryV2 as DeviceRegistry,
};
use device::kde_output_device_v2::{Event as DeviceEvent, KdeOutputDeviceV2 as OutputDevice};
use management::kde_mode_list_v2::KdeModeListV2 as ModeList;
use management::kde_output_configuration_v2::{
@@ -82,10 +85,16 @@ use management::kde_output_management_v2::KdeOutputManagementV2 as OutputManagem
/// always in range on any KWin that advertises the globals.
const MGMT_MAX: u32 = 22;
const DEVICE_MAX: u32 = 24;
/// `kde_output_device_registry_v2` — the newer way KWin hands out output devices (verified live on
/// KWin 6.7.3, which advertises this at v23 and NO per-output `kde_output_device_v2` globals).
const DEVICE_REGISTRY_MAX: u32 = 24;
/// The opcode of `kde_output_device_v2.mode` (0-based event index) — the event that creates a child
/// `kde_output_device_mode_v2`. Kept in sync with the vendored `kde-output-device-v2.xml`.
const DEVICE_MODE_EVENT_OPCODE: u16 = 2;
/// The opcode of `kde_output_device_registry_v2.output` — the event that creates a child
/// `kde_output_device_v2`. `finished` is event 0, `output` is event 1.
const REGISTRY_OUTPUT_EVENT_OPCODE: u16 = 1;
/// Overall budget for one enumerate-then-apply operation. Generous next to a healthy roundtrip (a
/// few ms); it exists only so a wedged compositor can't pin the session's stream thread.
@@ -131,6 +140,14 @@ struct DeviceState {
name: Option<String>,
uuid: Option<String>,
enabled: bool,
/// Top-left in the compositor's global logical space, from the `geometry` event — the key that
/// makes a head identifiable when two of them share a size (`monitors::PhysicalMonitor`).
position: (i32, i32),
/// `make` / `model` from the same event, for the picker label.
make: Option<String>,
model: Option<String>,
/// Logical scale from the `scale` event. `None` ⇒ the protocol's documented default of 1.
scale: Option<f64>,
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
priority: Option<u32>,
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
@@ -148,6 +165,9 @@ struct DeviceState {
struct State {
management: Option<OutputManagement>,
mgmt_name_version: Option<(u32, u32)>,
/// The `kde_output_device_registry_v2` on KWin ≥ 6.7, held so it keeps announcing outputs for
/// the life of the session (dropping it would end the announcements).
device_registry: Option<DeviceRegistry>,
devices: HashMap<ObjectId, DeviceState>,
/// mode object id → `(width, height, refresh_mHz)`.
mode_dims: HashMap<ObjectId, (u32, u32, u32)>,
@@ -185,6 +205,17 @@ impl Dispatch<WlRegistry, ()> for State {
let dev = registry.bind::<OutputDevice, _, _>(name, v, qh, name);
let id = dev.id();
state.devices.entry(id).or_default().proxy = Some(dev);
} else if interface == DeviceRegistry::interface().name {
// KWin ≥ 6.7 (Plasma 6.7.3 verified) no longer advertises ONE
// `kde_output_device_v2` global per output — it advertises this registry and
// hands the devices out through its `output` events instead. Binding it is what
// makes this whole module work on a current KWin: without it the device list
// comes back EMPTY, which reads as "no outputs" and silently degrades every
// topology apply to the `kscreen-doctor` shell-out this module exists to avoid.
// Both models are kept — the per-output globals are still what older KWin sends.
let v = version.min(DEVICE_REGISTRY_MAX);
state.device_registry =
Some(registry.bind::<DeviceRegistry, _, _>(name, v, qh, ()));
}
}
wl_registry::Event::GlobalRemove { .. } => {}
@@ -193,6 +224,30 @@ impl Dispatch<WlRegistry, ()> for State {
}
}
/// The device registry hands out one `kde_output_device_v2` per output via its `output` event
/// (a `new_id`, so the child is created by the `event_created_child!` binding below). Devices that
/// arrive this way have no global `name` number — the newest-wins supersede tie-break uses 0 for
/// them, which is fine: that tie-break only matters for the per-output-global model.
impl Dispatch<DeviceRegistry, ()> for State {
fn event(
state: &mut Self,
_: &DeviceRegistry,
event: RegistryEvent,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let RegistryEvent::Output { output } = event {
let id = output.id();
state.devices.entry(id).or_default().proxy = Some(output);
}
}
event_created_child!(State, DeviceRegistry, [
REGISTRY_OUTPUT_EVENT_OPCODE => (OutputDevice, 0u32),
]);
}
// Management has no events.
impl Dispatch<OutputManagement, ()> for State {
fn event(
@@ -237,6 +292,15 @@ impl Dispatch<OutputDevice, u32> for State {
match event {
DeviceEvent::Name { name } => entry.name = Some(name),
DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid),
DeviceEvent::Geometry {
x, y, make, model, ..
} => {
entry.position = (x, y);
entry.make = Some(make);
entry.model = Some(model);
}
// `fixed` decodes to f64 in wayland-rs.
DeviceEvent::Scale { factor } => entry.scale = Some(factor),
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
@@ -349,6 +413,15 @@ impl Session {
if !s.sync_barrier(deadline) {
return None;
}
// Phase 3 (KWin ≥ 6.7, the registry model): the devices themselves only arrive as the
// registry's `output` events during phase 2, so their property bursts are one round further
// out than they are for per-output globals. One more barrier — skipped when no device is
// still waiting for its `done`, so the classic path costs nothing.
if s.state.device_registry.is_some() && s.state.devices.values().any(|d| !d.seen_done) {
if !s.sync_barrier(deadline) {
return None;
}
}
Some(s)
}
@@ -455,6 +528,54 @@ fn mode_spec(dims: (u32, u32, u32)) -> String {
/// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
/// Every head KWin reports, for [`crate::monitors::list`].
///
/// Reuses the same bounded enumerate-only session the topology path opens — no configuration is
/// built and nothing is applied, so this is a pure read. A device that never completed its `done`
/// burst is skipped rather than reported half-read (its geometry would be a guess, and geometry is
/// exactly what callers key on).
pub(crate) fn list_monitors() -> anyhow::Result<Vec<crate::monitors::PhysicalMonitor>> {
let session = Session::open().ok_or_else(|| {
anyhow::anyhow!(
"KWin did not answer kde_output_management_v2 (not a KWin session, the protocol is \
not advertised to this client, or the compositor is wedged)"
)
})?;
let mut out: Vec<_> = session
.state
.devices
.values()
.filter(|d| d.seen_done)
.filter_map(|d| {
let connector = d.name.clone()?;
let dims = session.current_dims(d);
Some(crate::monitors::PhysicalMonitor {
managed: connector.starts_with(MANAGED_PREFIX),
description: crate::monitors::describe(
d.make.as_deref().unwrap_or(""),
d.model.as_deref().unwrap_or(""),
&connector,
),
// A disabled output has no current mode — report 0s rather than inventing one.
width: dims.map(|d| d.0).unwrap_or(0),
height: dims.map(|d| d.1).unwrap_or(0),
refresh_mhz: dims.map(|d| d.2).unwrap_or(0),
x: d.position.0,
y: d.position.1,
scale: d.scale.filter(|s| *s > 0.0).unwrap_or(1.0),
// KWin ranks outputs; 1 is the primary.
primary: d.priority == Some(1),
enabled: d.enabled,
connector,
})
})
.collect();
// The device globals arrive in bind order, which is not stable across runs; sort by desktop
// position so a picker (and a log line) reads left-to-right the way the desk looks.
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
Ok(out)
}
/// Make the streamed output (name starts with `our_prefix`, current size `our_w`×`our_h`) the
/// primary — and, for `Exclusive`, disable every other enabled output — over `kde_output_management_v2`.
/// See the module docs for why this is done in-process instead of via `kscreen-doctor`.
@@ -817,4 +938,12 @@ mod tests {
fn mode_event_opcode_is_two() {
assert_eq!(DEVICE_MODE_EVENT_OPCODE, 2);
}
/// Same hazard for the registry's `output` event (`finished` is 0, `output` is 1): the
/// `event_created_child!` binding hardcodes the opcode, so a reorder in the vendored XML would
/// bind the child to the wrong event and produce outputs with no properties.
#[test]
fn registry_output_event_opcode_is_one() {
assert_eq!(REGISTRY_OUTPUT_EVENT_OPCODE, 1);
}
}
@@ -376,6 +376,190 @@ fn session_thread(
});
}
/// Record an **existing** monitor by connector — the monitor-mirror path
/// (`design/per-monitor-portal-capture.md` L2). Returns the PipeWire node id and the keepalive
/// whose drop stops the recording.
///
/// Same private ScreenCast API as the virtual path, one call different: `RecordMonitor` instead of
/// `RecordVirtual`. So it inherits what makes that path work headlessly — Mutter's *direct* D-Bus
/// API needs no interactive approval, unlike the xdg portal a background service could never answer.
///
/// Deliberately **not** under [`TOPOLOGY_LOCK`]: that lock serializes operations which add/remove a
/// monitor or apply a monitor configuration, and mirroring does neither. It creates nothing, changes
/// no layout, and leaves the head exactly as its owner set it.
pub(crate) fn stream_existing_output(
connector: &str,
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let connector_thread = connector.to_string();
thread::Builder::new()
.name("punktfunk-mutter-mirror".into())
.spawn(move || mirror_thread(setup_tx, stop_thread, connector_thread, hw_cursor))
.context("spawn Mutter monitor-mirror thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("Mutter monitor mirror failed: {e}"),
Err(_) => bail!("timed out recording the Mutter output {connector:?}"),
};
Ok(crate::mirror::MirrorStream {
node_id,
// Mutter's RecordMonitor node lives on the user's PipeWire daemon (like RecordVirtual).
remote_fd: None,
keepalive: Box::new(MirrorStop(stop)),
})
}
/// Stops the mirror thread — and with it the recording — when the capturer drops it.
struct MirrorStop(Arc<AtomicBool>);
impl Drop for MirrorStop {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
/// Owns the D-Bus connection behind a mirrored monitor's cast: connect, hand back the node id,
/// park until stopped, then `Stop` the session.
fn mirror_thread(
setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>,
connector: String,
hw_cursor: bool,
) {
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
return;
}
};
rt.block_on(async move {
let session = match connect_monitor(&connector, hw_cursor).await {
Ok(s) => s,
Err(e) => {
let _ = setup_tx.send(Err(format!("{e:#}")));
return;
}
};
let _ = setup_tx.send(Ok(session.node_id));
while !stop.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(200)).await;
}
// Stop the cast. Nothing else to undo: no virtual output was added, so there is no monitor
// removal for Mutter to rebuild around — the SIGSEGV-adjacent teardown ordering the virtual
// path has to observe simply doesn't arise here.
let _ = session.rd_session.call_method("Stop", &()).await;
});
}
/// The `RecordMonitor` handshake: RemoteDesktop session → ScreenCast session anchored to it →
/// record `connector` → node id on `PipeWireStreamAdded` after `Start`.
async fn connect_monitor(connector: &str, hw_cursor: bool) -> Result<MutterSession> {
let conn = zbus::Connection::session()
.await
.context("connect session D-Bus")?;
let rd = zbus::Proxy::new(
&conn,
BUS_RD,
"/org/gnome/Mutter/RemoteDesktop",
"org.gnome.Mutter.RemoteDesktop",
)
.await
.context("RemoteDesktop proxy (is gnome-shell running?)")?;
let rd_path: OwnedObjectPath = rd
.call("CreateSession", &())
.await
.context("RemoteDesktop.CreateSession")?;
let rd_session = zbus::Proxy::new(
&conn,
BUS_RD,
rd_path,
"org.gnome.Mutter.RemoteDesktop.Session",
)
.await?;
let session_id: String = rd_session
.get_property("SessionId")
.await
.context("read SessionId")?;
let sc = zbus::Proxy::new(
&conn,
BUS_SC,
"/org/gnome/Mutter/ScreenCast",
"org.gnome.Mutter.ScreenCast",
)
.await
.context("ScreenCast proxy")?;
let mut props: HashMap<&str, Value> = HashMap::new();
props.insert("remote-desktop-session-id", Value::from(session_id));
let sc_path: OwnedObjectPath = sc
.call("CreateSession", &(props,))
.await
.context("ScreenCast.CreateSession")?;
let sc_session = zbus::Proxy::new(
&conn,
BUS_SC,
sc_path,
"org.gnome.Mutter.ScreenCast.Session",
)
.await?;
// `RecordMonitor(connector, properties) -> stream_path`. Only `cursor-mode` is set: the mode
// belongs to the monitor's owner, not to us.
let mut rec: HashMap<&str, Value> = HashMap::new();
rec.insert(
"cursor-mode",
Value::from(if hw_cursor {
CURSOR_METADATA
} else {
CURSOR_EMBEDDED
}),
);
let stream_path: OwnedObjectPath = sc_session
.call("RecordMonitor", &(connector, rec))
.await
.with_context(|| format!("Session.RecordMonitor({connector:?})"))?;
let stream = zbus::Proxy::new(
&conn,
BUS_SC,
stream_path,
"org.gnome.Mutter.ScreenCast.Stream",
)
.await?;
let mut added = stream
.receive_signal("PipeWireStreamAdded")
.await
.context("subscribe PipeWireStreamAdded")?;
rd_session
.call_method("Start", &())
.await
.context("RemoteDesktop.Session.Start")?;
let msg = tokio::time::timeout(Duration::from_secs(10), added.next())
.await
.map_err(|_| anyhow!("PipeWireStreamAdded did not arrive within 10s"))?
.ok_or_else(|| anyhow!("signal stream ended before PipeWireStreamAdded"))?;
let (node_id,): (u32,) = msg
.body()
.deserialize()
.context("PipeWireStreamAdded body")?;
tracing::info!(connector, node_id, "mutter: recording an existing monitor");
Ok(MutterSession {
rd_session,
_sc_session: sc_session,
_conn: conn,
node_id,
})
}
/// The live session objects (held for the stream's lifetime) + the PipeWire node id.
struct MutterSession {
rd_session: zbus::Proxy<'static>,
@@ -811,6 +995,56 @@ fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
.map(|l| l.2)
}
/// Every head Mutter reports, for [`crate::monitors::list`].
///
/// A pure `GetCurrentState` read on its own short-lived connection + runtime — no session, no
/// `ApplyMonitorsConfig`, so it never touches the topology and never contends [`TOPOLOGY_LOCK`].
/// Geometry comes from the **logical** monitors (`state.2`), which is the coordinate space that
/// matters (see `crate::monitors`); a monitor absent from every logical monitor is disabled, and is
/// reported as such at the origin rather than dropped.
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build tokio runtime (monitor enumeration)")?;
let state = rt.block_on(async {
let dc = display_config().await?;
get_state(&dc).await
})?;
let mut out: Vec<_> = state
.1
.iter()
.map(|(spec, _modes, _props)| {
let (connector, vendor, product, _serial) = spec;
// The logical monitor carrying this connector: its (x, y), scale and primary flag.
let logical = state
.2
.iter()
.find(|l| l.5.iter().any(|s| &s.0 == connector));
let (w, h, refresh) = current_mode_full(&state, connector)
.map(|(_id, w, h, hz)| (w.max(0) as u32, h.max(0) as u32, (hz * 1000.0) as u32))
.unwrap_or((0, 0, 0));
crate::monitors::PhysicalMonitor {
connector: connector.clone(),
description: crate::monitors::describe(vendor, product, connector),
width: w,
height: h,
refresh_mhz: refresh,
x: logical.map(|l| l.0).unwrap_or(0),
y: logical.map(|l| l.1).unwrap_or(0),
scale: logical.map(|l| l.2).filter(|s| *s > 0.0).unwrap_or(1.0),
primary: logical.map(|l| l.4).unwrap_or(false),
enabled: logical.is_some(),
// Mutter names a `RecordVirtual` monitor indistinguishably from a physical one —
// no prefix to key on, and the connector is minted per session. See the field doc.
managed: false,
}
})
.collect();
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
Ok(out)
}
/// Read the virtual output's current scale and, when the user changed it (GNOME Settings
/// mid-stream), persist it under the client's `scale_key` so the next connect reapplies it.
/// Best-effort: read failures (teardown races, shell restart) are silently skipped.
+129 -24
View File
@@ -111,25 +111,13 @@ impl VirtualDisplay for WlrootsDisplay {
swaymsg(&["output", &name, "enable"])
.with_context(|| format!("swaymsg output {name} enable"))?;
// Steer xdpw's headless output chooser at our new output, then run the portal
// handshake on its own thread (it parks to keep the cast alive, like the other backends).
ensure_xdpw_config()?;
let chooser = chooser_file();
std::fs::write(&chooser, format!("Monitor: {name}\n"))
.with_context(|| format!("write {chooser}"))?;
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let hw_cursor = self.hw_cursor;
thread::Builder::new()
.name("punktfunk-wlr-vout".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn wlroots portal thread")?;
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"),
// Steer xdpw's headless output chooser at our new output, then run the portal handshake on
// its own thread (it parks to keep the cast alive, like the other backends). Serialized:
// the chooser is one per-user file, so a concurrent session's write between ours and xdpw's
// read would silently capture the wrong output (see `SELECTION_LOCK`).
let (fd, node_id, stop) = {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
select_and_cast(&name, self.hw_cursor)?
};
tracing::info!(
node_id,
@@ -207,20 +195,29 @@ fn swaymsg(args: &[&str]) -> Result<String> {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Current output names from `swaymsg -t get_outputs` (JSON).
fn output_names() -> Result<Vec<String>> {
/// Run a swaymsg **query** (`-t <kind> --raw`) and parse its JSON.
///
/// ⚠️ Deliberately NOT [`swaymsg`]: that helper inserts `--` so its arguments are read as a sway
/// *command*, which is right for `create_output` and wrong for a query — `-t` after `--` comes back
/// as `Unknown/invalid command '-t'` (caught on-glass writing the monitor enumeration).
fn swaymsg_query(kind: &str) -> Result<serde_json::Value> {
let out = Command::new("swaymsg")
.args(["-t", "get_outputs", "--raw"])
.args(["-t", kind, "--raw"])
.output()
.context("run swaymsg (is sway installed?)")?;
if !out.status.success() {
bail!(
"swaymsg -t get_outputs failed: {}",
"swaymsg -t {kind} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let raw = String::from_utf8_lossy(&out.stdout).into_owned();
let outputs: serde_json::Value = serde_json::from_str(&raw).context("parse get_outputs")?;
serde_json::from_str(&raw).with_context(|| format!("parse {kind}"))
}
/// Current output names from `swaymsg -t get_outputs` (JSON).
fn output_names() -> Result<Vec<String>> {
let outputs = swaymsg_query("get_outputs")?;
Ok(outputs
.as_array()
.context("get_outputs: not an array")?
@@ -229,6 +226,114 @@ fn output_names() -> Result<Vec<String>> {
.collect())
}
/// Serializes **write-the-chooser → complete-the-handshake**, process-wide.
///
/// The chooser is a single per-user file: whoever writes last before xdpw reads wins. Two sessions
/// starting at once (or a mirror starting beside a virtual output) would otherwise race, and the
/// loser doesn't fail — it silently captures the *other* session's output. Held across the portal
/// handshake, not just the write, because the read happens inside it.
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd +
/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
ensure_xdpw_config()?;
let chooser = chooser_file();
std::fs::write(&chooser, format!("Monitor: {output}\n"))
.with_context(|| format!("write {chooser}"))?;
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
thread::Builder::new()
.name("punktfunk-wlr-cast".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn wlroots portal thread")?;
match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
}
}
/// Record an **existing** sway output — the monitor-mirror path
/// (`design/per-monitor-portal-capture.md` L3). Same chooser mechanism the virtual-output path
/// uses, pointed at a physical connector instead of a headless one we created, so it inherits the
/// "no GUI picker" property a background service needs.
///
/// The keepalive stops the cast and nothing else: sway keeps the monitor, because we never made it.
pub(crate) fn stream_existing_output(
connector: &str,
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: Some(fd),
keepalive: Box::new(StopGuard(stop)),
})
}
/// Every head sway reports, for [`crate::monitors::list`].
///
/// `swaymsg -t get_outputs` reports `rect` in the logical coordinate space (post-scale,
/// post-transform) — what `crate::monitors` documents. An inactive output has no `current_mode`, so
/// its mode reads as zeros rather than a guess.
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
let parsed = swaymsg_query("get_outputs")?;
let mut out: Vec<_> = parsed
.as_array()
.context("get_outputs: not an array")?
.iter()
.filter_map(|o| {
let connector = o.get("name")?.as_str()?.to_string();
let rect = |k: &str| {
o.get("rect")
.and_then(|r| r.get(k))
.and_then(|v| v.as_i64())
.unwrap_or(0)
};
let mode = |k: &str| {
o.get("current_mode")
.and_then(|m| m.get(k))
.and_then(|v| v.as_i64())
.unwrap_or(0)
};
let str_field = |k: &str| o.get(k).and_then(|v| v.as_str()).unwrap_or("").trim();
Some(crate::monitors::PhysicalMonitor {
description: crate::monitors::describe(
str_field("make"),
str_field("model"),
&connector,
),
width: mode("width").max(0) as u32,
height: mode("height").max(0) as u32,
// sway reports `refresh` in mHz already.
refresh_mhz: mode("refresh").max(0) as u32,
x: rect("x") as i32,
y: rect("y") as i32,
scale: o
.get("scale")
.and_then(|v| v.as_f64())
.filter(|s| *s > 0.0)
.unwrap_or(1.0),
primary: o
.get("primary")
.and_then(|v| v.as_bool())
.or_else(|| o.get("focused").and_then(|v| v.as_bool()))
.unwrap_or(false),
enabled: o.get("active").and_then(|v| v.as_bool()).unwrap_or(true),
// Sway auto-names headless outputs `HEADLESS-N` and that is what `create` adds. A
// sway started with its own headless output would match too — hence best-effort.
managed: connector.starts_with("HEADLESS-"),
connector,
})
})
.collect();
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
Ok(out)
}
/// Wait for the output `create_output` added (the name not in `before` — HEADLESS-N).
fn wait_new_output(before: &[String], timeout: Duration) -> Result<String> {
let deadline = Instant::now() + timeout;
+269
View File
@@ -0,0 +1,269 @@
//! The monitor-mirror display backend: stream a **physical** head the compositor already has,
//! instead of creating a virtual one. See `design/per-monitor-portal-capture.md`.
//!
//! It implements [`VirtualDisplay`] so it drops into the session machinery unchanged — but it
//! creates nothing, and that difference is load-bearing:
//!
//! * [`DisplayOwnership::External`] ("someone else's display, merely mirrored: no keep-alive, no
//! topology, no reuse") is exactly the contract for a head we don't own, so the registry's
//! pooling/linger/exclusive machinery passes it through. §7.1 of the design doc — never disable,
//! re-position or "restore" a monitor the user is sitting in front of — holds *by construction*
//! here rather than by everyone remembering it.
//! * `create()` ignores the requested [`Mode`]. A panel runs at the mode the user set; the client
//! scales. Reconfiguring someone's physical display to match a phone would be the same class of
//! rudeness as blanking it (§7.3).
//!
//! Selection is a **host-level pin** (`PUNKTFUNK_CAPTURE_MONITOR`), not a per-session choice —
//! the host-pinned decision of record (§5.3), which is also what makes the input anchor below
//! sound: one pin for the whole host, so the shared injector can't be re-aimed per session.
use super::backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
use super::monitors;
use crate::{Compositor, Mode};
use anyhow::{bail, Context, Result};
/// What a backend hands back when it starts recording an existing head.
///
/// `remote_fd` is the split: KWin and Mutter publish the node on the user's own PipeWire daemon
/// (nothing to carry), while the portal-based backends (sway/xdpw, Hyprland/xdph) hand back a
/// sandboxed remote fd that the capturer must connect through — the same distinction their
/// *virtual*-output paths already make.
pub(crate) struct MirrorStream {
pub node_id: u32,
pub remote_fd: Option<std::os::fd::OwnedFd>,
/// Dropping this ends the recording. It never owns the monitor — we did not create it.
pub keepalive: Box<dyn Send>,
}
/// Streams an existing monitor, named by connector.
pub struct MirrorDisplay {
compositor: Compositor,
connector: String,
hw_cursor: bool,
}
impl MirrorDisplay {
pub fn new(compositor: Compositor, connector: String) -> Result<Self> {
Ok(MirrorDisplay {
compositor,
connector,
hw_cursor: false,
})
}
}
impl VirtualDisplay for MirrorDisplay {
fn name(&self) -> &'static str {
"mirror"
}
fn set_hw_cursor(&mut self, on: bool) {
self.hw_cursor = on;
}
fn hw_cursor(&self) -> bool {
self.hw_cursor
}
fn create(&mut self, _mode: Mode) -> Result<VirtualOutput> {
// Resolve the pin against the live head list FIRST: it yields the geometry the input anchor
// needs, and it turns "that monitor is gone" into one clear error before any compositor
// call. `resolve` refuses to substitute a different head (see its doc).
let monitors = monitors::list(self.compositor)
.with_context(|| format!("enumerate monitors to mirror {:?}", self.connector))?;
let target = monitors::resolve(&monitors, &self.connector)?;
check_mirrorable(target, self.compositor)?;
let origin = (target.x, target.y);
let dims = (target.width, target.height, refresh_hz(target.refresh_mhz));
let stream = match self.compositor {
#[cfg(target_os = "linux")]
Compositor::Kwin => {
crate::kwin::stream_existing_output(&target.connector, self.hw_cursor)?
}
#[cfg(target_os = "linux")]
Compositor::Mutter => {
crate::mutter::stream_existing_output(&target.connector, self.hw_cursor)?
}
#[cfg(target_os = "linux")]
Compositor::Wlroots => {
crate::wlroots::stream_existing_output(&target.connector, self.hw_cursor)?
}
#[cfg(target_os = "linux")]
Compositor::Hyprland => {
crate::hyprland::stream_existing_output(&target.connector, self.hw_cursor)?
}
// gamescope is nested — it has no physical heads of its own, and `monitors::list`
// already returned an empty set, so `resolve` failed before we got here. This arm
// exists for exhaustiveness, not as a reachable path.
other => bail!(
"mirroring an existing monitor is not supported on the {} backend",
other.id()
),
};
// NOTE: aiming absolute input at this head is the HOST's job, not ours — this crate must
// not depend on pf-inject (see the crate doc: "never on capture/inject"). The host sets the
// anchor from the same pin at startup; §7.2 of the design doc explains why it is host-level
// rather than set here per session.
tracing::info!(
connector = %target.connector,
mode = %target.mode_label(),
at = %format!("+{}+{}", origin.0, origin.1),
node_id = stream.node_id,
"mirroring an existing monitor (no virtual display created)"
);
// The keepalive IS the recording: dropping it stops the cast and leaves the monitor exactly
// as it was (we created nothing, so there is nothing to restore).
let mut out = VirtualOutput::owned(stream.node_id, Some(dims), stream.keepalive);
// Portal-based backends (sway/xdpw, Hyprland/xdph) publish on a sandboxed remote the
// capturer must connect through; KWin/Mutter use the user's own daemon.
out.remote_fd = stream.remote_fd;
// Never pooled, never lingered, never made primary/exclusive: we don't own this head.
out.ownership = DisplayOwnership::External;
Ok(out)
}
}
/// Can this head be mirrored at all? Each rejection is something a compositor would otherwise
/// answer with silence or a black stream, so it is caught here where the reason can be stated.
fn check_mirrorable(target: &monitors::PhysicalMonitor, compositor: Compositor) -> Result<()> {
if !target.enabled {
bail!(
"monitor {:?} is disabled — enable it before streaming it",
target.connector
);
}
if target.managed {
// `managed` is only *conclusive* where the name is ours by construction: KWin's
// `Virtual-punktfunk` prefix, Hyprland's `PF-N`. Sway names EVERY headless output
// `HEADLESS-N` — its own included — so refusing there would block a legitimate setup
// (a headless sway box whose outputs are all HEADLESS-N is exactly the remote case this
// feature serves). Warn and continue; a user who really did pin our own virtual display
// sees a mirror of it and the log says why.
if names_ours_conclusively(compositor) {
bail!(
"monitor {:?} is one of punktfunk's own virtual displays, not a physical head — \
clear the streamed-screen setting to use the normal virtual-display path",
target.connector
);
}
tracing::warn!(
connector = %target.connector,
"the pinned monitor looks like a headless output — on this compositor that name is \
ambiguous (it may be one of punktfunk's own virtual displays), mirroring it anyway"
);
}
if target.width == 0 || target.height == 0 {
bail!(
"monitor {:?} reports no current mode ({}x{}) — it is not driving a signal",
target.connector,
target.width,
target.height
);
}
Ok(())
}
/// Does this compositor's `managed` flag mean "ours, for certain"? KWin outputs carry the
/// `Virtual-punktfunk` prefix we chose, and Hyprland's are `PF-N` — both ours by construction.
/// Sway's `HEADLESS-N` is sway's own generic naming, so it is a hint, not proof.
fn names_ours_conclusively(compositor: Compositor) -> bool {
matches!(compositor, Compositor::Kwin | Compositor::Hyprland)
}
/// mHz → whole Hz for [`VirtualOutput::preferred_mode`], never 0 (the negotiation treats 0 as
/// "unset", and a head that didn't report a refresh is still running at *some* rate — 60 is the
/// same assumption KWin's own virtual outputs are born with).
fn refresh_hz(mhz: u32) -> u32 {
let hz = (mhz as f64 / 1000.0).round() as u32;
if hz == 0 {
60
} else {
hz
}
}
#[cfg(test)]
mod tests {
use super::*;
fn head(connector: &str) -> monitors::PhysicalMonitor {
monitors::PhysicalMonitor {
connector: connector.into(),
description: "ACME 27".into(),
width: 2560,
height: 1440,
refresh_mhz: 144_000,
x: 1920,
y: 0,
scale: 1.0,
primary: false,
enabled: true,
managed: false,
}
}
#[test]
fn a_real_enabled_head_is_mirrorable() {
assert!(check_mirrorable(&head("DP-2"), Compositor::Kwin).is_ok());
}
/// Streaming a dark head would be a black rectangle with no explanation — say why instead.
#[test]
fn a_disabled_head_is_refused_with_the_reason() {
let mut m = head("DP-2");
m.enabled = false;
let err = check_mirrorable(&m, Compositor::Kwin)
.unwrap_err()
.to_string();
assert!(err.contains("disabled"), "{err}");
}
/// Mirroring our OWN virtual display is a loop with extra steps; catch the misconfiguration
/// rather than streaming something confusing.
#[test]
fn one_of_our_own_virtual_displays_is_refused() {
let mut m = head("Virtual-punktfunk-1");
m.managed = true;
let err = check_mirrorable(&m, Compositor::Kwin)
.unwrap_err()
.to_string();
assert!(err.contains("virtual displays"), "{err}");
}
/// Sway names every headless output `HEADLESS-N`, its own included, so the `managed` hint is
/// NOT proof there — refusing would block a headless sway box, which is exactly the remote
/// setup this feature serves. (Found on-glass: a two-output headless sway had both heads
/// flagged, and the KWin-strength rule would have refused to mirror either.)
#[test]
fn a_headless_sway_output_is_mirrored_despite_the_ambiguous_name() {
let mut m = head("HEADLESS-2");
m.managed = true;
assert!(check_mirrorable(&m, Compositor::Wlroots).is_ok());
// Hyprland's `PF-N` IS our naming, so it stays conclusive.
assert!(check_mirrorable(&m, Compositor::Hyprland).is_err());
}
/// A head listed but not driving a mode (enabled yet modeless) would negotiate a 0x0 stream.
#[test]
fn a_head_with_no_current_mode_is_refused() {
let mut m = head("DP-2");
m.width = 0;
m.height = 0;
let err = check_mirrorable(&m, Compositor::Kwin)
.unwrap_err()
.to_string();
assert!(err.contains("no current mode"), "{err}");
}
#[test]
fn refresh_rounds_and_never_reports_zero() {
assert_eq!(refresh_hz(60000), 60);
assert_eq!(refresh_hz(59940), 60);
assert_eq!(refresh_hz(119_920), 120);
// An unreported refresh must not become a 0 Hz preferred mode.
assert_eq!(refresh_hz(0), 60);
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Physical-monitor enumeration — "what heads does this host actually have?".
//!
//! This is the read-only counterpart to the rest of this crate: everything else here *creates* and
//! owns virtual outputs, while this module only *reports* the heads the compositor already has, so
//! an operator can pin capture at one of them (`PUNKTFUNK_CAPTURE_MONITOR`) and the console can
//! render a picker. See `design/per-monitor-portal-capture.md` §5.1.
//!
//! It lives in pf-vdisplay because monitor enumeration is a **per-compositor** question and this
//! crate already speaks every one of those dialects — KWin's `kde_output_device_v2`, Mutter's
//! `DisplayConfig.GetCurrentState`, `swaymsg -t get_outputs`, `hyprctl -j monitors`. Each backend's
//! implementation sits beside the code that already talks to it; this module is the shared type and
//! the dispatch.
//!
//! **The geometry is the point.** `x`/`y` are what make a head *identifiable*: two monitors can
//! share a size (and then a size-keyed match is a coin flip — see `pf-inject`'s absolute-coordinate
//! region selection), but they can never share an origin in the compositor's global space.
use crate::Compositor;
use anyhow::{bail, Result};
/// One head as the compositor currently reports it. Logical (post-scale) geometry throughout —
/// the same coordinate space libei regions and compositor layout use, *not* pixels.
#[derive(Clone, Debug, PartialEq)]
pub struct PhysicalMonitor {
/// Connector name — `DP-1`, `HDMI-A-2`, `eDP-1`. The id `PUNKTFUNK_CAPTURE_MONITOR` names.
pub connector: String,
/// Human label for a picker (`make model`, else the connector). Never used for matching.
pub description: String,
/// Current mode, in pixels.
pub width: u32,
pub height: u32,
/// Refresh in mHz (60000 = 60 Hz). 0 when the backend doesn't report it.
pub refresh_mhz: u32,
/// Top-left in the compositor's global logical space — the identity key (see the module doc).
pub x: i32,
pub y: i32,
/// Logical scale factor (1.0 when unreported).
pub scale: f64,
/// The compositor's primary/focused head, when it says.
pub primary: bool,
/// Enabled (driven). A disabled head is still listed, so "why can't I pick it?" has an answer.
pub enabled: bool,
/// **Best-effort**: this output is one WE created (a managed virtual display), not a real head.
/// Only KWin can say so reliably (managed outputs carry a name prefix); the other backends
/// name virtual outputs indistinguishably from physical ones, so this stays `false` there
/// rather than guessing. Callers use it to grey out nonsense choices, never to filter blindly.
pub managed: bool,
}
/// Build the picker label from a compositor's make/model, falling back to the connector.
///
/// Compositors fill unknown fields with the literal string `"Unknown"` rather than leaving them
/// empty (seen on-glass: sway reports `"Unknown Unknown"` for a headless output), so treat that as
/// absent too — a picker row reading "Unknown Unknown" is worse than one reading "DP-1".
pub(crate) fn describe(make: &str, model: &str, connector: &str) -> String {
let known = |s: &str| {
let s = s.trim();
!s.is_empty() && !s.eq_ignore_ascii_case("unknown")
};
let label = [make, model]
.iter()
.map(|s| s.trim())
.filter(|s| known(s))
.collect::<Vec<_>>()
.join(" ");
if label.is_empty() {
connector.to_string()
} else {
label
}
}
impl PhysicalMonitor {
/// `1920x1080@60` — for logs and pickers.
pub fn mode_label(&self) -> String {
if self.refresh_mhz == 0 {
format!("{}x{}", self.width, self.height)
} else {
format!(
"{}x{}@{}",
self.width,
self.height,
(self.refresh_mhz as f64 / 1000.0).round() as u32
)
}
}
}
/// Every head `compositor` reports, in the compositor's own order.
///
/// Errors when the backend can't be reached (compositor not running, IPC unavailable) — that is
/// deliberately distinct from `Ok(vec![])`, which means "reached it, it has no heads" (a headless
/// session). Callers that only want to *offer* a picker can treat both as "nothing to show";
/// callers resolving a pinned monitor must not (see [`resolve`]).
pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
match compositor {
#[cfg(target_os = "linux")]
Compositor::Kwin => crate::kwin_output_mgmt::list_monitors(),
#[cfg(target_os = "linux")]
Compositor::Mutter => crate::mutter::list_monitors(),
#[cfg(target_os = "linux")]
Compositor::Wlroots => crate::wlroots::list_monitors(),
#[cfg(target_os = "linux")]
Compositor::Hyprland => crate::hyprland::list_monitors(),
// gamescope is nested: it has no physical heads of its own, and mirroring the desktop it
// runs on is a question for the compositor UNDER it. Not an error — just nothing to offer.
#[cfg(target_os = "linux")]
Compositor::Gamescope => Ok(Vec::new()),
#[cfg(not(target_os = "linux"))]
_ => bail!("physical-monitor enumeration is implemented for the Linux backends only"),
}
}
/// Resolve a configured monitor name against `monitors`, exactly then case-insensitively.
///
/// **A miss is a hard error carrying the available names**, never a silent fall-back to some other
/// head: an operator who pinned `DP-2` and gets `DP-1` streamed has been shown the wrong screen,
/// which is worse than a host that refuses to start a session and says why
/// (`design/per-monitor-portal-capture.md` §5.2).
pub fn resolve<'a>(monitors: &'a [PhysicalMonitor], want: &str) -> Result<&'a PhysicalMonitor> {
if let Some(m) = monitors.iter().find(|m| m.connector == want) {
return Ok(m);
}
if let Some(m) = monitors
.iter()
.find(|m| m.connector.eq_ignore_ascii_case(want))
{
return Ok(m);
}
let available = monitors
.iter()
.map(|m| m.connector.as_str())
.collect::<Vec<_>>()
.join(", ");
if available.is_empty() {
bail!("no monitor named {want:?} — this compositor reports no monitors at all");
}
bail!("no monitor named {want:?} — this host has: {available}");
}
#[cfg(test)]
mod tests {
use super::*;
fn mon(connector: &str) -> PhysicalMonitor {
PhysicalMonitor {
connector: connector.into(),
description: String::new(),
width: 1920,
height: 1080,
refresh_mhz: 60000,
x: 0,
y: 0,
scale: 1.0,
primary: false,
enabled: true,
managed: false,
}
}
#[test]
fn resolve_matches_exactly_then_case_insensitively() {
let ms = [mon("DP-1"), mon("HDMI-A-2")];
assert_eq!(resolve(&ms, "DP-1").unwrap().connector, "DP-1");
assert_eq!(resolve(&ms, "hdmi-a-2").unwrap().connector, "HDMI-A-2");
}
#[test]
fn resolve_prefers_the_exact_match_over_a_case_fold() {
// Connector names are case-sensitive to the compositor; if both spellings exist, the
// exact one wins rather than whichever the fold happened to reach first.
let ms = [mon("dp-1"), mon("DP-1")];
assert_eq!(resolve(&ms, "DP-1").unwrap().connector, "DP-1");
}
#[test]
fn a_miss_lists_what_is_available() {
let ms = [mon("DP-1"), mon("HDMI-A-2")];
let err = resolve(&ms, "DP-9").unwrap_err().to_string();
assert!(err.contains("DP-1") && err.contains("HDMI-A-2"), "{err}");
}
#[test]
fn a_miss_with_no_monitors_says_so() {
let err = resolve(&[], "DP-1").unwrap_err().to_string();
assert!(err.contains("no monitors at all"), "{err}");
}
/// Compositors write the literal "Unknown" instead of leaving make/model empty (sway does it
/// for headless outputs), so a picker must not end up showing "Unknown Unknown".
#[test]
fn describe_falls_back_to_the_connector_for_empty_or_unknown_fields() {
assert_eq!(describe("ACME", "U2720Q", "DP-1"), "ACME U2720Q");
assert_eq!(describe("Unknown", "Unknown", "HEADLESS-1"), "HEADLESS-1");
assert_eq!(describe("", "", "DP-1"), "DP-1");
// A half-known pair keeps the half that means something.
assert_eq!(describe("Unknown", "U2720Q", "DP-1"), "U2720Q");
assert_eq!(describe(" ", "unknown", "DP-2"), "DP-2");
}
#[test]
fn mode_label_drops_an_unknown_refresh() {
let mut m = mon("DP-1");
assert_eq!(m.mode_label(), "1920x1080@60");
m.refresh_mhz = 0;
assert_eq!(m.mode_label(), "1920x1080");
}
}
+32 -4
View File
@@ -246,6 +246,16 @@ pub struct DisplayPolicy {
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
#[serde(default)]
pub pnp_disable_monitors: bool,
/// **Mirror a physical monitor instead of creating a virtual display**: the connector name
/// (`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.
///
/// Orthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and
/// `#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a
/// **host-wide** setting, not per-client — the host-pinned decision of record in
/// `design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see
/// [`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it.
#[serde(default)]
pub capture_monitor: Option<String>,
}
fn one() -> u32 {
@@ -271,6 +281,7 @@ impl Default for DisplayPolicy {
game_session: GameSession::default(),
ddc_power_off: false,
pnp_disable_monitors: false,
capture_monitor: None,
}
}
}
@@ -316,6 +327,12 @@ impl DisplayPolicy {
pub fn sanitized(mut self) -> Self {
self.version = 1;
self.max_displays = self.max_displays.clamp(1, 16);
// A picker that clears its selection sends `""`; that means "no pin", not "match the
// monitor named empty string" — same normalization the env knob does.
self.capture_monitor = self
.capture_monitor
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
self
}
}
@@ -332,6 +349,7 @@ impl EffectivePolicy {
game_session: GameSession,
ddc_power_off: bool,
pnp_disable_monitors: bool,
capture_monitor: Option<String>,
) -> DisplayPolicy {
DisplayPolicy {
version: 1,
@@ -345,10 +363,13 @@ impl EffectivePolicy {
positions,
},
max_displays: self.max_displays,
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them).
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them). Dropping any of
// them here would mean "saving a display arrangement silently cleared my setting" —
// for `capture_monitor` that would swap the streamed screen out from under the operator.
game_session,
ddc_power_off,
pnp_disable_monitors,
capture_monitor,
}
}
}
@@ -791,12 +812,19 @@ mod tests {
let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 0, y: 0 });
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true);
// The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through
// the transform.
let p = eff.with_manual_layout(
positions,
GameSession::Dedicated,
true,
true,
Some("DP-2".into()),
);
// The orthogonal axes (game-session, DDC power-off, PnP disable, capture-monitor pin) are
// preserved through the transform — arranging displays must not clear an unrelated setting.
assert_eq!(p.game_session, GameSession::Dedicated);
assert!(p.ddc_power_off);
assert!(p.pnp_disable_monitors);
assert_eq!(p.capture_monitor.as_deref(), Some("DP-2"));
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
assert_eq!(p.preset, Preset::Custom);
// …every other behavior axis is preserved verbatim…
@@ -268,6 +268,18 @@ pub fn restore_takeover_on_startup() {
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_on_startup() {}
/// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks
/// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown
/// path — a takeover that outlives the host leaves the box with no display manager and nobody left
/// to restart it. No-op when nothing was taken over.
#[cfg(target_os = "linux")]
pub fn restore_takeover_now() {
gamescope::restore_takeover_now();
}
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_now() {}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
+159 -1
View File
@@ -163,8 +163,15 @@ pub struct SessionEnv {
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket;
/// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the
/// session env (unlike sway's `SWAYSOCK`). `None` for every other compositor.
/// session env. `None` for every other compositor.
pub hyprland_signature: Option<String>,
/// `SWAYSOCK` of the live sway instance (`Some` only for a sway [`ActiveKind::DesktopWlroots`]).
/// `swaymsg` needs it, and it was the LAST session variable the host could not derive: a
/// `systemd --user` host that never inherited the login shell's environment had no sway IPC at
/// all, so output enumeration and the chooser both failed. Derived from the detected compositor
/// PID like the Hyprland signature above. `None` on river (wlroots, but no sway IPC) and every
/// other compositor.
pub sway_socket: Option<String>,
}
/// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it.
@@ -316,6 +323,12 @@ pub fn detect_active_session() -> ActiveSession {
ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid),
_ => None,
};
// Same idea for sway's IPC socket: without it `swaymsg` has nothing to talk to, and a
// `systemd --user` host never inherited it.
let sway_socket = match kind {
ActiveKind::DesktopWlroots => find_sway_socket(&xdg_runtime_dir, uid, winning_pid),
_ => None,
};
ActiveSession {
kind,
env: SessionEnv {
@@ -324,6 +337,7 @@ pub fn detect_active_session() -> ActiveSession {
dbus_session_bus_address: dbus,
xdg_current_desktop,
hyprland_signature,
sway_socket,
},
compositor_pid: winning_pid,
}
@@ -361,6 +375,46 @@ fn find_hypr_signature(runtime: &str, uid: u32) -> Option<String> {
cands.into_iter().next().map(|(_, n)| n)
}
/// Find the live sway IPC socket (`SWAYSOCK`) for our uid. Trust a valid inherited value first (the
/// host launched inside the session); then the exact `sway-ipc.<uid>.<pid>.sock` for the compositor
/// PID detection already picked — a name sway builds from those two numbers, so it is an identity
/// match rather than a guess; then the newest-mtime `sway-ipc.<uid>.*.sock` we own, for the case
/// where the socket name does not match the PID we saw (sway re-exec, a wrapper process).
///
/// `None` on river: it is the other [`ActiveKind::DesktopWlroots`] compositor and has no sway IPC —
/// which is the honest answer, since the wlroots backend drives sway through `swaymsg`.
#[cfg(target_os = "linux")]
fn find_sway_socket(runtime: &str, uid: u32, pid: Option<u32>) -> Option<String> {
use std::os::unix::fs::MetadataExt;
if let Ok(s) = std::env::var("SWAYSOCK") {
if !s.is_empty() && std::path::Path::new(&s).exists() {
return Some(s);
}
}
if let Some(pid) = pid {
let exact = std::path::Path::new(runtime).join(format!("sway-ipc.{uid}.{pid}.sock"));
if exact.exists() {
return Some(exact.to_string_lossy().into_owned());
}
}
let prefix = format!("sway-ipc.{uid}.");
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
for e in std::fs::read_dir(runtime).ok()?.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.starts_with(&prefix) || !name.ends_with(".sock") {
continue;
}
let Ok(md) = e.metadata() else { continue };
if md.uid() != uid {
continue;
}
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
cands.push((mtime, e.path().to_string_lossy().into_owned()));
}
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
cands.into_iter().next().map(|(_, p)| p)
}
#[cfg(not(target_os = "linux"))]
pub fn detect_active_session() -> ActiveSession {
ActiveSession::none()
@@ -426,6 +480,15 @@ pub fn apply_session_env(active: &ActiveSession) {
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
}
// sway: same treatment, and for the same reason — `swaymsg` (output enumeration, the capture
// chooser) is unreachable without it, so a systemd `--user` host that never inherited the login
// environment had no sway backend at all. Cleared when nothing sway-shaped is live, so a
// sway→Hyprland switch can't leave `swaymsg` aimed at a dead socket. `wlroots::is_available()`
// keys off this variable, so setting it here is also what makes the backend visible at all.
match &e.sway_socket {
Some(sock) => std::env::set_var("SWAYSOCK", sock),
None => std::env::remove_var("SWAYSOCK"),
}
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
// connect's retarget, and the availability probes read them: after a gnome-shell crash
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
@@ -564,3 +627,98 @@ pub fn settle_desktop_portal(chosen: Compositor) {
#[cfg(not(target_os = "linux"))]
pub fn settle_desktop_portal(_chosen: Compositor) {}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
/// A scratch runtime dir with the sway-ipc sockets named in `pids`, plus the uid the names are
/// built from. Removed on drop.
struct FakeRuntime {
dir: std::path::PathBuf,
uid: u32,
}
impl FakeRuntime {
fn new(tag: &str, pids: &[u32]) -> FakeRuntime {
// SAFETY: parameterless POSIX call, returns the calling process's uid; touches no memory.
let uid = unsafe { libc::getuid() };
let dir =
std::env::temp_dir().join(format!("pf-swaysock-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for pid in pids {
std::fs::write(dir.join(format!("sway-ipc.{uid}.{pid}.sock")), b"").unwrap();
}
FakeRuntime { dir, uid }
}
fn path(&self) -> &str {
self.dir.to_str().unwrap()
}
}
impl Drop for FakeRuntime {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// Run `f` with `SWAYSOCK` unset, so the "trust what we inherited" rung can't decide the test.
/// Serialized on the crate's env lock — these tests mutate process-global state.
fn without_inherited_swaysock<R>(f: impl FnOnce() -> R) -> R {
crate::with_env_lock(|| {
let prev = std::env::var_os("SWAYSOCK");
std::env::remove_var("SWAYSOCK");
let out = f();
if let Some(p) = prev {
std::env::set_var("SWAYSOCK", p);
}
out
})
}
/// The point of deriving it: the socket that belongs to the compositor detection actually found,
/// not merely *a* sway socket. A stale socket from a previous sway (crash, re-login) sitting in
/// the same runtime dir must not win.
#[test]
fn the_socket_matching_the_detected_pid_wins() {
let rt = FakeRuntime::new("exact", &[4242, 9999]);
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(4242)));
assert_eq!(
got,
Some(format!("{}/sway-ipc.{}.4242.sock", rt.path(), rt.uid))
);
}
/// sway re-exec (or a wrapper) can leave the socket named for a PID we didn't see. One socket in
/// the dir is still unambiguous — better to hand `swaymsg` the real thing than nothing.
#[test]
fn an_unmatched_pid_falls_back_to_the_socket_that_is_there() {
let rt = FakeRuntime::new("fallback", &[777]);
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(12345)));
assert_eq!(
got,
Some(format!("{}/sway-ipc.{}.777.sock", rt.path(), rt.uid))
);
}
/// river is the other wlroots desktop and ships no sway IPC. Reporting `None` is what keeps
/// `apply_session_env` from exporting a `SWAYSOCK` that points at nothing — an exported lie
/// would make `wlroots::is_available()` claim a backend that cannot answer.
#[test]
fn no_sway_ipc_socket_reports_none() {
let rt = FakeRuntime::new("none", &[]);
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(1)));
assert_eq!(got, None);
}
/// Someone else's socket in a shared runtime dir is not ours to talk to.
#[test]
fn another_uids_socket_is_ignored() {
let rt = FakeRuntime::new("otheruid", &[]);
let other = rt.uid.wrapping_add(1);
std::fs::write(rt.dir.join(format!("sway-ipc.{other}.500.sock")), b"").unwrap();
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(500)));
assert_eq!(got, None);
}
}
@@ -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,140 @@ 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"
);
// Close the churn window early — descriptor-following resumes now
// instead of waiting out the hold's self-expiry.
pf_win_display::topology_churn::release();
}
fighting = 0;
continue;
}
fighting += 1;
// Announce the churn BEFORE evicting: every descriptor the capturer's poller
// samples from here until "stable again" (or self-expiry) is potentially the
// TRANSIENT eviction state — acting on it would recreate the ring at a mode
// the recovery chain is about to undo (the field hdr=true→false→true double
// recreate). Window = watchdog interval + recovery/debounce margin, refreshed
// every fighting round.
pf_win_display::topology_churn::hold(interval + Duration::from_secs(3));
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 +1155,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 +1576,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 +1592,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
+18 -6
View File
@@ -156,15 +156,27 @@ pub fn summarize(events: &[DisplayEvent]) -> String {
out.join(", ")
}
/// The prime suspects for link-probe disturbances, from the cached inventory: external physical
/// displays that are CONNECTED but not part of the desktop (standby TV / input-switched monitor).
/// Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
pub fn connected_inactive_externals() -> Vec<String> {
/// The prime suspects for link-probe/dark-head disturbances, from the cached inventory: PHYSICAL
/// displays — external connectors AND internal panels — that are CONNECTED but not part of the
/// desktop. External = the classic standby TV / input-switched monitor; internal = the laptop
/// panel the exclusive isolate deactivated, whose driver-level servicing produces the identical
/// ~2 s metronome on hybrid laptops (field A/B 2026-07-27: reporter's Legion, exclusive
/// 16.3 stalls/min vs primary 0 — the old external-only filter reported `none` there and steered
/// the diagnosis AWAY from the real cause for hours). Virtual/indirect targets stay excluded
/// (precision rule). Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
pub fn connected_inactive_physicals() -> Vec<String> {
let st = state().lock().unwrap();
st.inventory
.iter()
.filter(|t| t.external_physical && !t.active)
.map(|t| format!("{} ({})", t.friendly, t.tech))
.filter(|t| (t.external_physical || t.internal_panel) && !t.active)
.map(|t| {
let name = if t.friendly.is_empty() && t.internal_panel {
"laptop panel"
} else {
&t.friendly
};
format!("{} ({})", name, t.tech)
})
.collect()
}
+3
View File
@@ -16,6 +16,9 @@ pub mod display_events;
mod input_desktop;
#[cfg(target_os = "windows")]
pub mod monitor_devnode;
/// Cross-crate "topology churn in flight" latch (pure std — no Windows surface, so unconditionally
/// compiled and unit-tested on every platform).
pub mod topology_churn;
#[cfg(target_os = "windows")]
pub mod win_display;
@@ -0,0 +1,74 @@
//! Cross-crate "topology churn in flight" latch (stall-immunity program): the exclusive-topology
//! reassert watchdog (pf-vdisplay's manager) announces that it is evicting/restoring displays, and
//! the IDD-push capturer's descriptor follower (pf-capture) defers acting on descriptor changes
//! while the window is open.
//!
//! Why: the reassert's forced re-commit transiently bounces the virtual display's mode — the
//! descriptor poller can sample the EVICTION state (field log 2026-07-27 10:30:44Z: `hdr=true` →
//! `hdr=false` → recreate → recovery restores `hdr=true` → second recreate). Every descriptor
//! sampled inside the window is potentially that transient, and acting on it recreates the ring at
//! a mode the recovery chain is about to undo. The deliberate recovery rebuild
//! (`recreate_ring_in_place`, keyed off the reassert generation) is NOT affected — only the
//! passive descriptor-following is.
//!
//! Deadline semantics, not a flag: a `hold()` self-expires, so a holder that dies mid-churn (or a
//! release lost to a teardown race) can never wedge descriptor-following off forever. `release()`
//! just expires the deadline early on the watchdog's "stable again" observation.
use std::sync::{
atomic::{AtomicU64, Ordering},
OnceLock,
};
use std::time::{Duration, Instant};
/// Deadline as milliseconds on the process-local [`clock`]; `0` = no hold.
static HOLD_UNTIL_MS: AtomicU64 = AtomicU64::new(0);
/// Process-local monotonic epoch (an `Instant` cannot live in an atomic).
fn clock() -> u64 {
static EPOCH: OnceLock<Instant> = OnceLock::new();
EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
}
/// Open (or extend) the churn window for `dur` from now. `fetch_max` so overlapping holders — a
/// reassert round racing a slot transition — never shorten each other's window.
pub fn hold(dur: Duration) {
let until = clock().saturating_add(dur.as_millis() as u64);
HOLD_UNTIL_MS.fetch_max(until, Ordering::Relaxed);
}
/// Expire the window now (the watchdog observed a stable topology again).
pub fn release() {
HOLD_UNTIL_MS.store(0, Ordering::Relaxed);
}
/// Is a churn window open? Cheap enough for a per-frame path (one atomic load + a monotonic read).
#[must_use]
pub fn held() -> bool {
clock() < HOLD_UNTIL_MS.load(Ordering::Relaxed)
}
#[cfg(test)]
mod tests {
use super::*;
/// The latch's contract end-to-end: closed at rest, open after `hold`, extended by the longer
/// of two overlapping holds, closed by `release`, and self-expiring without one.
#[test]
fn hold_release_expire() {
release();
assert!(!held());
hold(Duration::from_secs(60));
assert!(held());
// A shorter overlapping hold must not shorten the window.
hold(Duration::from_millis(1));
assert!(held());
release();
assert!(!held());
// Self-expiry: a millisecond-scale hold lapses on its own.
hold(Duration::from_millis(30));
assert!(held());
std::thread::sleep(Duration::from_millis(60));
assert!(!held());
}
}
+65 -6
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();
@@ -800,15 +844,21 @@ pub unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option<u32> {
}
/// One CONNECTED display target from a full (`QDC_ALL_PATHS`) CCD sweep — the disturbance-
/// attribution inventory. `external_physical` is the load-bearing bit: a standby TV/monitor on a
/// real connector is the prime suspect for the periodic link-probe stutter class, while internal
/// panels and indirect/virtual targets (our own IDD included) are not.
/// attribution inventory. `external_physical` and `internal_panel` are the load-bearing bits: a
/// standby TV/monitor on a real connector is the classic suspect for the periodic link-probe
/// stutter class, and a laptop panel the exclusive isolate DEACTIVATED is the hybrid-laptop
/// variant of the same disturbance (field A/B 2026-07-27: dark-but-connected eDP head on the
/// iGPU → ~2 s stall metronome; `topology: primary` → zero) — only indirect/virtual targets
/// (our own IDD included) can never be suspects.
pub struct TargetInventory {
pub target_id: u32,
/// Whether any active path drives this target (part of the desktop right now).
pub active: bool,
/// External physical connector (HDMI/DP/DVI/…): candidate for standby link-probe churn.
pub external_physical: bool,
/// Internal panel (eDP/LVDS/embedded): candidate for dark-head servicing churn when
/// connected-but-inactive (the exclusive isolate on a laptop creates exactly that state).
pub internal_panel: bool,
/// Short connector label for logs (`"HDMI"`, `"DisplayPort"`, `"internal-panel"`, …).
pub tech: &'static str,
/// The monitor's friendly name (`"LG TV SSCR2"`); empty when the EDID carries none.
@@ -909,6 +959,7 @@ pub unsafe fn target_inventory() -> Vec<TargetInventory> {
target_id: t.id,
active: active.contains(&key),
external_physical,
internal_panel: tech == "internal-panel",
tech,
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
monitor_device_path: utf16z_str(&req.monitorDevicePath),
@@ -1045,6 +1096,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
+158 -13
View File
@@ -262,6 +262,15 @@ unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what)
}
/// Block until everything enqueued on THIS THREAD's copy stream completed — the shared tail of
/// the multi-plane blocking copies (stream FIFO: one sync after the last enqueue covers every
/// plane, where the per-plane `copy_blocking` paid one exposed CPU wait EACH — and under a
/// game's GPU load each exposed wait eats scheduling latency). The shared context must be
/// current.
unsafe fn sync_copy_stream() -> Result<()> {
ck(cuStreamSynchronize(copy_stream()), "cuStreamSynchronize")
}
/// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device`
/// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> {
@@ -985,18 +994,25 @@ pub fn copy_nv12_to_device(
Height: h / 2,
..Default::default()
};
// SAFETY: two unsafe `copy_issue` device→device copies; the caller must have the shared
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
// enqueue. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
// `(w/2)*2`×`h/2`, each within its planes; `sync: false` shifts the source-lifetime obligation
// to the caller (documented above). Wrappers → live table.
// SAFETY: two unsafe `copy_async` device→device enqueues + an optional stream sync; the
// caller must have the shared context current (documented). `&y`/`&uv` are live local
// `CUDA_MEMCPY2D`s outliving each enqueue. All four device pointers are valid:
// `src.ptr`/`src_uv_ptr` come from a live NV12 `DeviceBuffer` (its `.uv` presence was
// checked via `ok_or_else`), `y_dst`/`uv_dst` are the caller's live NVENC surface planes;
// the luma copy is `w`×`h`, the chroma copy `(w/2)*2`×`h/2`, each within its planes. With
// `sync` the single trailing stream sync covers both enqueues (FIFO) before we return — the
// same completion guarantee as the old per-plane blocking copies at half the exposed waits;
// `sync: false` shifts the source-lifetime obligation to the caller (documented above).
// Wrappers → live table.
unsafe {
copy_issue(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)", sync)?;
copy_issue(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)", sync)
copy_async(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
copy_async(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")?;
if sync {
sync_copy_stream()?;
}
}
Ok(())
}
/// Copy our imported stacked-YUV444 [`DeviceBuffer`] into NVENC's three-plane CUDA surface
/// (`av_hwframe_get_buffer`'s `data[0..3]` + `linesize[0..3]` for a `yuv444p` frames context).
@@ -1024,13 +1040,19 @@ pub fn copy_yuv444_to_device(
Height: h,
..Default::default()
};
// SAFETY: unsafe `copy_issue` device→device copy; the caller must have the shared
// SAFETY: unsafe `copy_async` device→device enqueue; the caller must have the shared
// context current (documented). `&copy` is a live local outliving the enqueue;
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
// both; `sync: false` shifts the source-lifetime obligation to the caller (documented
// above). Wrapper → live table.
unsafe { copy_issue(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? };
// both. Completion is the trailing stream sync below (`sync`) or the caller's
// stream-ordering obligation (`sync: false`, documented above). Wrapper → live table.
unsafe { copy_async(&copy, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? };
}
if sync {
// SAFETY: one stream sync after the last enqueue covers all three planes (FIFO) — the
// same completion guarantee as the old per-plane blocking copies at a third of the
// exposed waits. Context current per the caller's contract. Wrapper → live table.
unsafe { sync_copy_stream()? };
}
Ok(())
}
@@ -1169,6 +1191,101 @@ impl Drop for ExternalDmabuf {
}
}
/// A Vulkan **timeline** semaphore imported as a CUDA external semaphore — the cross-API ordering
/// primitive for the stream-ordered cursor blend (`vkslot.rs`): CUDA [`signal`](Self::signal)s a
/// value on this thread's copy stream once the input copy is enqueued, the Vulkan blend waits for
/// and then advances the timeline on its queue, and CUDA [`wait`](Self::wait)s that advanced
/// value before the encode — all ordering on-device, no CPU sync anywhere. One imported handle
/// per [`VkSlotBlend`](super::vkslot::VkSlotBlend); values are monotonic for its lifetime.
pub struct ExternalSemaphore {
sem: CUexternalSemaphore,
}
// SAFETY: `CUexternalSemaphore` is an opaque driver handle with no thread affinity (the driver
// API allows use from any thread with the context current). It is uniquely owned here, used from
// the encode thread but moved with its `VkSlotBlend`, and destroyed exactly once in `Drop` —
// `Send` (not `Sync`) matches that single-thread-at-a-time use, like `ExternalDmabuf`.
unsafe impl Send for ExternalSemaphore {}
impl ExternalSemaphore {
/// Import a Vulkan timeline semaphore exported as an OPAQUE_FD (`vkGetSemaphoreFdKHR`). The
/// fd is handed over: the driver owns it on success, we close it on failure. The shared
/// context must be current.
pub fn import_owned_timeline_fd(fd: i32) -> Result<ExternalSemaphore> {
let mut desc = CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC {
type_: CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD,
..Default::default()
};
desc.handle[0] = fd as u32 as u64; // union member `int fd` (little-endian low bytes)
let mut sem: CUexternalSemaphore = std::ptr::null_mut();
// SAFETY: `cuImportExternalSemaphore` reads `&desc`, a live local `#[repr(C)]`
// `CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC` (layout-asserted below) outliving the synchronous
// call: `type_` is TIMELINE_SEMAPHORE_FD and `handle[0]` holds the fd in the union's
// `int fd` low bytes. `&mut sem` is a live null-init out-param the driver writes the
// imported handle into. Distinct locals → no aliasing. Wrapper → live table (caller holds
// the context current).
let r = unsafe { cuImportExternalSemaphore(&mut sem, &desc) };
if r != 0 {
// SAFETY: import failed (`r != 0`), so the driver did NOT take ownership of `fd`; we
// still own it and close it exactly once here. `libc::close` acts on the integer alone.
unsafe { libc::close(fd) };
bail!("cuImportExternalSemaphore failed ({r}) — timeline-semaphore fd export/import unsupported?");
}
Ok(ExternalSemaphore { sem })
}
/// Enqueue a signal that sets the timeline to `value` once all prior work on THIS THREAD's
/// copy stream (the stream `copy_stream_handle` exposes) completes. No CPU wait.
pub fn signal(&self, value: u64) -> Result<()> {
let params = CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS {
value,
..Default::default()
};
// SAFETY: `self.sem` is the live imported handle (this struct only exists after a
// successful import; destroyed only in `Drop`). `&self.sem`/`&params` are live locals the
// synchronous enqueue reads (count 1); the driver retains no pointer into Rust memory.
// The stream is this thread's live copy stream. Wrapper → live table (context current).
unsafe {
ck(
cuSignalExternalSemaphoresAsync(&self.sem, &params, 1, copy_stream()),
"cuSignalExternalSemaphoresAsync",
)
}
}
/// Enqueue a wait: work enqueued on THIS THREAD's copy stream after this call runs only once
/// the timeline reaches `value`. No CPU wait.
pub fn wait(&self, value: u64) -> Result<()> {
let params = CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS {
value,
..Default::default()
};
// SAFETY: same contract as `signal` — live handle, live locals across the synchronous
// enqueue, this thread's live copy stream. Wrapper → live table (context current).
unsafe {
ck(
cuWaitExternalSemaphoresAsync(&self.sem, &params, 1, copy_stream()),
"cuWaitExternalSemaphoresAsync",
)
}
}
}
impl Drop for ExternalSemaphore {
fn drop(&mut self) {
// SAFETY: `self.sem` is the valid imported handle this struct exclusively owns, destroyed
// exactly once here. The shared context is made current first because drop may run off
// the import thread (`VkSlotBlend` teardown quiesces the GPU before dropping, so no
// enqueued signal/wait still references the semaphore). Result ignored (best-effort).
unsafe {
if let Some(c) = CONTEXT.get() {
let _ = cuCtxSetCurrent(c.0);
}
let _ = cuDestroyExternalSemaphore(self.sem);
}
}
}
/// Copy a pitched span starting at `src_ptr` (e.g. an [`ExternalDmabuf`] mapping at the chunk
/// offset) into `dst`. The shared context must be current on this thread.
pub fn copy_pitched_to_buffer(
@@ -1243,3 +1360,31 @@ pub fn copy_pitched_nv12_to_buffer(
copy_blocking(&uv, "cuMemcpy2DAsync_v2(ext->dev nv12 UV)")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::mem::{offset_of, size_of};
/// The external-semaphore param structs are hand-flattened from cuda.h unions — assert the
/// layout against the C definitions so a transcription slip fails in CI, not in the driver.
#[test]
fn external_semaphore_struct_layouts_match_cuda_h() {
// CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC: type(4)+pad(4)+union(16)+flags(4)+reserved(64) = 96.
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC>(), 96);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC, handle), 8);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC, flags), 24);
// CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS: params{fence(8)+nvSciSync(8)+keyedMutex(8)+
// reserved[12](48)} = 72, flags at 72, reserved[16] → 144 total.
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS>(), 144);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS, value), 0);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS, flags), 72);
// CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS: params{fence(8)+nvSciSync(8)+keyedMutex(16,
// tail-padded)+reserved[10](40)} = 72, flags at 72, reserved[16] → 144 total.
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS>(), 144);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS, value), 0);
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS, flags), 72);
}
}
+117
View File
@@ -20,6 +20,7 @@ pub type CUdeviceptr = u64;
pub type CUgraphicsResource = *mut c_void;
pub type CUarray = *mut c_void;
pub type CUexternalMemory = *mut c_void; // opaque CUextMemory_st*
pub type CUexternalSemaphore = *mut c_void; // opaque CUextSemaphore_st*
/// `CUmemorytype` (cuda.h): HOST=1, DEVICE=2, ARRAY=3, UNIFIED=4.
pub const CU_MEMORYTYPE_DEVICE: c_uint = 2;
@@ -84,6 +85,60 @@ pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
/// `CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC` (cuda.h, 64-bit layout). Same union-flattening as the
/// memory desc above: `handle` is a union whose largest member is the win32 two-pointer struct
/// (16 bytes, align 8); for the fd-carrying types only the first 4 bytes (the `int fd`) are read.
/// No `size` field — a semaphore has none.
#[repr(C)]
#[derive(Default)]
pub struct CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC {
pub type_: c_uint, // CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD = 9
pub(crate) _pad: u32,
pub handle: [u64; 2], // union { int fd; {void*,void*} win32; const void* nvSciSyncObj }
pub flags: c_uint,
pub(crate) reserved: [c_uint; 16],
pub(crate) _pad2: u32,
}
/// `CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS` (cuda.h, 64-bit layout), flattened: `params` nests
/// `fence.value` (the only member we set — the timeline value to signal), the `nvSciSync` union,
/// `keyedMutex.key`, then 12 reserved words; `flags` + 16 reserved words follow. 144 bytes total
/// (layout-asserted in `super`'s tests).
#[repr(C)]
#[derive(Default)]
pub struct CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS {
pub value: u64, // params.fence.value — the timeline value this signal sets
pub(crate) _nv_sci_sync: u64,
pub(crate) _keyed_mutex_key: u64,
pub(crate) _params_reserved: [c_uint; 12],
pub flags: c_uint,
pub(crate) reserved: [c_uint; 16],
pub(crate) _pad: u32,
}
/// `CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS` (cuda.h, 64-bit layout), flattened like the signal
/// params. The C `keyedMutex` member is `{ u64 key; u32 timeoutMs; }` — size 16 with tail
/// padding, hence the explicit pad word before the 10 reserved words. 144 bytes total.
#[repr(C)]
#[derive(Default)]
pub struct CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS {
pub value: u64, // params.fence.value — wait until the timeline reaches this value
pub(crate) _nv_sci_sync: u64,
pub(crate) _keyed_mutex_key: u64,
pub(crate) _keyed_mutex_timeout: c_uint,
pub(crate) _keyed_mutex_pad: u32,
pub(crate) _params_reserved: [c_uint; 10],
pub flags: c_uint,
pub(crate) reserved: [c_uint; 16],
pub(crate) _pad: u32,
}
/// `CUexternalSemaphoreHandleType` (cuda.h): a Vulkan **timeline** semaphore exported as an
/// OPAQUE_FD (`vkGetSemaphoreFdKHR`). Needs driver ≥ 460 (CUDA 11.2) — far below the NVENC 12.1
/// floor this backend already requires, so import failure means "driver refused", not "too old
/// to try".
pub const CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD: c_uint = 9;
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
@@ -139,6 +194,23 @@ pub(crate) struct CudaApi {
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
) -> CUresult,
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
cuImportExternalSemaphore: unsafe extern "C" fn(
*mut CUexternalSemaphore,
*const CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC,
) -> CUresult,
cuDestroyExternalSemaphore: unsafe extern "C" fn(CUexternalSemaphore) -> CUresult,
cuSignalExternalSemaphoresAsync: unsafe extern "C" fn(
*const CUexternalSemaphore,
*const CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS,
c_uint,
CUstream,
) -> CUresult,
cuWaitExternalSemaphoresAsync: unsafe extern "C" fn(
*const CUexternalSemaphore,
*const CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS,
c_uint,
CUstream,
) -> CUresult,
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
@@ -206,6 +278,14 @@ pub(crate) fn cuda_api() -> Option<&'static CudaApi> {
.get(b"cuExternalMemoryGetMappedBuffer\0")
.ok()?,
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
// External-semaphore interop (the stream-ordered cursor blend): all four are
// CUDA 10.0 entry points, far older than anything else this table requires.
cuImportExternalSemaphore: *lib.get(b"cuImportExternalSemaphore\0").ok()?,
cuDestroyExternalSemaphore: *lib.get(b"cuDestroyExternalSemaphore\0").ok()?,
cuSignalExternalSemaphoresAsync: *lib
.get(b"cuSignalExternalSemaphoresAsync\0")
.ok()?,
cuWaitExternalSemaphoresAsync: *lib.get(b"cuWaitExternalSemaphoresAsync\0").ok()?,
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
@@ -381,6 +461,43 @@ pub(crate) unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUres
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuImportExternalSemaphore(
ext_sem_out: *mut CUexternalSemaphore,
sem_handle_desc: *const CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuImportExternalSemaphore)(ext_sem_out, sem_handle_desc),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuDestroyExternalSemaphore(ext_sem: CUexternalSemaphore) -> CUresult {
match cuda_api() {
Some(a) => (a.cuDestroyExternalSemaphore)(ext_sem),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuSignalExternalSemaphoresAsync(
ext_sems: *const CUexternalSemaphore,
params: *const CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS,
count: c_uint,
stream: CUstream,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuSignalExternalSemaphoresAsync)(ext_sems, params, count, stream),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuWaitExternalSemaphoresAsync(
ext_sems: *const CUexternalSemaphore,
params: *const CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS,
count: c_uint,
stream: CUstream,
) -> CUresult {
match cuda_api() {
Some(a) => (a.cuWaitExternalSemaphoresAsync)(ext_sems, params, count, stream),
None => CU_ERROR_NOT_LOADED,
}
}
pub(crate) unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
match cuda_api() {
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
+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 {
+515 -161
View File
@@ -12,11 +12,24 @@
//!
//! The direct-SDK NVENC encoder allocates its input ring through [`VkSlotBlend::alloc_slot`]
//! instead of `cuMemAllocPitch`: same contiguous layouts (`InputSurface` docs), but the memory is
//! Vulkan external memory both APIs address. Per cursor-bearing frame the encoder CPU-syncs its
//! CUDA copy, then [`VkSlotBlend::blend`] dispatches the compute blend over the cursor's
//! rectangle and fence-waits — the same coherence ceremony [`super::vulkan::VkBridge`] ships for
//! its CSC (fence-ordered cross-API access on NVIDIA, no queue-family transfer needed). Frames
//! without a cursor never touch Vulkan, keeping the stream-ordered fast path intact.
//! Vulkan external memory both APIs address.
//!
//! Cursor-bearing frames blend one of two ways:
//!
//! * **Stream-ordered** ([`blend_ref_ordered`](VkSlotBlend::blend_ref_ordered), the fast path
//! when the driver exports a timeline semaphore to CUDA): the encoder enqueues its CUDA copy
//! with no CPU sync, CUDA signals the shared timeline on the copy stream, the blend submission
//! waits for and then advances it on the Vulkan queue, and CUDA waits the advanced value
//! before the encode — the whole copy→blend→encode chain orders on-device, so a visible
//! cursor costs the submit path nothing. (Before this, EVERY cursor frame took the CPU-synced
//! path below; under gamescope — which composites the pointer into every frame — that
//! serialized submit behind the game's GPU load and capped a 120 fps session near 80.)
//! * **CPU-synced** ([`blend_ref`](VkSlotBlend::blend_ref), the fallback when timeline bring-up
//! failed): the encoder blocks on its CUDA copy, the blend fence-waits — the same coherence
//! ceremony [`super::vulkan::VkBridge`] ships for its CSC (fence-ordered cross-API access on
//! NVIDIA, no queue-family transfer needed).
//!
//! Frames without a cursor never touch Vulkan at all.
//!
//! Falls back cleanly: if bring-up fails the encoder allocates plain CUDA surfaces and composite
//! mode degrades to no cursor (warned once) — never a failed session.
@@ -87,12 +100,36 @@ pub struct VkSlotRef {
}
/// One allocated slot's backing objects, freed together in reverse order (CUDA mapping first).
/// Each slot carries its own command buffer + descriptor set so ordered blends can be in flight
/// on several slots at once (the shared-single-set design raced the next recording against a
/// still-executing submission); the set's bindings are written once here — the slot buffer never
/// changes and the cursor staging buffer is shared.
struct SlotAlloc {
buffer: vk::Buffer,
memory: vk::DeviceMemory,
/// CUDA's import of the exported OPAQUE_FD — must drop BEFORE the Vulkan memory is freed.
cuda: cuda::ExternalDmabuf,
size: u64,
cmd: vk::CommandBuffer,
desc: vk::DescriptorSet,
}
/// The cross-API ordering state for stream-ordered blends: one Vulkan timeline semaphore,
/// exported as an OPAQUE_FD and imported into CUDA. `None` when the driver lacks timeline
/// semaphores or the export/import failed — blends then stay CPU-synced ([`VkSlotBlend::blend_ref`]).
struct Timeline {
sem: vk::Semaphore,
/// `vkWaitSemaphoresKHR` — the CPU-side quiesce for cursor-bitmap uploads and teardown
/// (the 1.1 device gets the entry point from `VK_KHR_timeline_semaphore`).
ts: ash::khr::timeline_semaphore::Device,
/// CUDA's import; its `signal`/`wait` enqueue on the encode thread's copy stream.
cuda: cuda::ExternalSemaphore,
/// Last timeline value handed out (monotonic for the device's lifetime, never reused —
/// each ordered blend consumes two: copy-done, then blend-done).
ticket: u64,
/// Last blend-done value an ACCEPTED `vkQueueSubmit` will signal — what upload/teardown
/// quiesce on. Only advanced on submit success: a value from a failed submit would never
/// be signaled and a quiesce on it would time out.
last_blend: u64,
}
/// 28-byte push-constant block matching `cursor_blend.comp`'s `Push`.
@@ -114,14 +151,12 @@ pub struct VkSlotBlend {
ext_fd: ash::khr::external_memory_fd::Device,
queue: vk::Queue,
cmd_pool: vk::CommandPool,
cmd: vk::CommandBuffer,
fence: vk::Fence,
mem_props: vk::PhysicalDeviceMemoryProperties,
shader: vk::ShaderModule,
desc_layout: vk::DescriptorSetLayout,
pipe_layout: vk::PipelineLayout,
desc_pool: vk::DescriptorPool,
desc_set: vk::DescriptorSet,
/// One pipeline per [`SlotFormat`], indexed by `mode()` (spec constant).
pipelines: [vk::Pipeline; 3],
/// Host-visible cursor bitmap staging (CURSOR_MAX²·4, tight rows), persistently mapped.
@@ -129,6 +164,8 @@ pub struct VkSlotBlend {
cur_mem: vk::DeviceMemory,
cur_map: *mut u8,
slots: Vec<SlotAlloc>,
/// Stream-ordered blend support (`None` = CPU-synced blends only). See [`Timeline`].
timeline: Option<Timeline>,
}
// SAFETY: raw Vulkan handles + a persistently-mapped pointer, all uniquely owned by this struct
@@ -182,14 +219,44 @@ impl VkSlotBlend {
let qci = [vk::DeviceQueueCreateInfo::default()
.queue_family_index(qf)
.queue_priorities(&prio)];
let exts = [ash::khr::external_memory_fd::NAME.as_ptr()];
let device = match instance.create_device(
phys,
&vk::DeviceCreateInfo::default()
// Timeline-semaphore export to CUDA (the stream-ordered blend) is optional: probe the
// device extensions + feature bit and enable them only where present, so a driver
// without them still gets a working (CPU-synced) blend device. NVIDIA has shipped
// both extensions since ~2019; the probe is for exotic/legacy stacks.
let want_timeline = {
let have_exts = instance
.enumerate_device_extension_properties(phys)
.map(|props| {
let has = |name: &std::ffi::CStr| {
props
.iter()
.any(|p| p.extension_name_as_c_str().is_ok_and(|n| n == name))
};
has(ash::khr::timeline_semaphore::NAME)
&& has(ash::khr::external_semaphore_fd::NAME)
})
.unwrap_or(false);
have_exts && {
let mut tl = vk::PhysicalDeviceTimelineSemaphoreFeatures::default();
let mut f2 = vk::PhysicalDeviceFeatures2::default().push_next(&mut tl);
instance.get_physical_device_features2(phys, &mut f2);
tl.timeline_semaphore == vk::TRUE
}
};
let mut exts = vec![ash::khr::external_memory_fd::NAME.as_ptr()];
if want_timeline {
exts.push(ash::khr::timeline_semaphore::NAME.as_ptr());
exts.push(ash::khr::external_semaphore_fd::NAME.as_ptr());
}
let mut tl_enable =
vk::PhysicalDeviceTimelineSemaphoreFeatures::default().timeline_semaphore(true);
let mut dci = vk::DeviceCreateInfo::default()
.queue_create_infos(&qci)
.enabled_extension_names(&exts),
None,
) {
.enabled_extension_names(&exts);
if want_timeline {
dci = dci.push_next(&mut tl_enable);
}
let device = match instance.create_device(phys, &dci, None) {
Ok(d) => d,
Err(e) => {
instance.destroy_instance(None);
@@ -207,30 +274,93 @@ impl VkSlotBlend {
ext_fd,
queue,
cmd_pool: vk::CommandPool::null(),
cmd: vk::CommandBuffer::null(),
fence: vk::Fence::null(),
mem_props,
shader: vk::ShaderModule::null(),
desc_layout: vk::DescriptorSetLayout::null(),
pipe_layout: vk::PipelineLayout::null(),
desc_pool: vk::DescriptorPool::null(),
desc_set: vk::DescriptorSet::null(),
pipelines: [vk::Pipeline::null(); 3],
cur_buf: vk::Buffer::null(),
cur_mem: vk::DeviceMemory::null(),
cur_map: std::ptr::null_mut(),
slots: Vec::new(),
timeline: None,
};
me.init_objects(qf).inspect_err(|_| {
// `Drop` runs the same teardown and tolerates the nulls left by a partial init.
})?;
if want_timeline {
// Non-fatal: any failure (export refused, CUDA import refused) leaves
// `timeline` None and blends CPU-synced — never a failed bring-up.
if let Err(e) = me.init_timeline() {
tracing::info!(
error = %format!("{e:#}"),
"cursor blend timeline export unavailable — blends stay CPU-synced"
);
}
}
tracing::info!(
stream_ordered = me.timeline.is_some(),
"Vulkan slot blend ready (exportable NVENC inputs + SPIR-V cursor blend)"
);
Ok(me)
}
}
/// Bring up the [`Timeline`]: create an exportable timeline semaphore, export its OPAQUE_FD,
/// import it into CUDA. Only called when the device was created with the timeline extensions.
fn init_timeline(&mut self) -> Result<()> {
// SAFETY: ash calls on the live `self.device` with builder infos from locals outliving
// each synchronous call. The semaphore is destroyed on every failure path after its
// creation; the exported fd is owned by `import_owned_timeline_fd` (the driver takes it
// on success, it closes it on failure). The shared CUDA context is current: the encoder
// brings this device up from its encode thread with the context established.
unsafe {
let mut type_ci = vk::SemaphoreTypeCreateInfo::default()
.semaphore_type(vk::SemaphoreType::TIMELINE)
.initial_value(0);
let mut export = vk::ExportSemaphoreCreateInfo::default()
.handle_types(vk::ExternalSemaphoreHandleTypeFlags::OPAQUE_FD);
let sem = self
.device
.create_semaphore(
&vk::SemaphoreCreateInfo::default()
.push_next(&mut type_ci)
.push_next(&mut export),
None,
)
.context("create timeline semaphore")?;
let sem_fd = ash::khr::external_semaphore_fd::Device::new(&self.instance, &self.device);
let fd = match sem_fd.get_semaphore_fd(
&vk::SemaphoreGetFdInfoKHR::default()
.semaphore(sem)
.handle_type(vk::ExternalSemaphoreHandleTypeFlags::OPAQUE_FD),
) {
Ok(f) => f,
Err(e) => {
self.device.destroy_semaphore(sem, None);
return Err(e).context("vkGetSemaphoreFdKHR(timeline)");
}
};
let cuda_sem = match cuda::ExternalSemaphore::import_owned_timeline_fd(fd) {
Ok(c) => c,
Err(e) => {
self.device.destroy_semaphore(sem, None);
return Err(e);
}
};
self.timeline = Some(Timeline {
sem,
ts: ash::khr::timeline_semaphore::Device::new(&self.instance, &self.device),
cuda: cuda_sem,
ticket: 0,
last_blend: 0,
});
Ok(())
}
}
/// The non-device objects: command machinery, cursor staging, descriptor + pipelines.
fn init_objects(&mut self, qf: u32) -> Result<()> {
// SAFETY: same contract as `new` — ash calls on the live `self.device` with builder infos
@@ -246,14 +376,6 @@ impl VkSlotBlend {
None,
)
.context("create command pool")?;
self.cmd = d
.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(self.cmd_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1),
)
.context("allocate command buffer")?[0];
self.fence = d
.create_fence(&vk::FenceCreateInfo::default(), None)
.context("create fence")?;
@@ -320,25 +442,21 @@ impl VkSlotBlend {
None,
)
.context("create pipeline layout")?;
// Per-slot sets (one per ring slot, written once at `alloc_slot`; freed by
// `free_slots` on ring rebuilds, hence FREE_DESCRIPTOR_SET). 64 is comfortably
// above any ring depth (the encoder's POOL is 8).
let pool_sizes = [vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::STORAGE_BUFFER)
.descriptor_count(2)];
.descriptor_count(128)];
self.desc_pool = d
.create_descriptor_pool(
&vk::DescriptorPoolCreateInfo::default()
.max_sets(1)
.flags(vk::DescriptorPoolCreateFlags::FREE_DESCRIPTOR_SET)
.max_sets(64)
.pool_sizes(&pool_sizes),
None,
)
.context("create descriptor pool")?;
let dls = [self.desc_layout];
self.desc_set = d
.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(self.desc_pool)
.set_layouts(&dls),
)
.context("allocate descriptor set")?[0];
// The shader + one pipeline per MODE (spec constant 0).
if CURSOR_SPV.len() % 4 != 0 {
@@ -465,6 +583,59 @@ impl VkSlotBlend {
return Err(e).context("cuImportExternalMemory(slot OPAQUE_FD)");
}
};
// The slot's own descriptor set + command buffer (see `SlotAlloc`). Bindings are
// written once here: binding 0 is this slot's buffer (immutable for its lifetime),
// binding 1 the shared cursor staging buffer.
let dls = [self.desc_layout];
let desc = match d.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(self.desc_pool)
.set_layouts(&dls),
) {
Ok(s) => s[0],
Err(e) => {
drop(ext); // CUDA's view of the memory goes first
d.free_memory(memory, None);
d.destroy_buffer(buffer, None);
return Err(e).context("allocate slot descriptor set");
}
};
let surf_info = [vk::DescriptorBufferInfo::default()
.buffer(buffer)
.offset(0)
.range(size)];
let cur_info = [vk::DescriptorBufferInfo::default()
.buffer(self.cur_buf)
.offset(0)
.range((CURSOR_MAX * CURSOR_MAX * 4) as u64)];
let writes = [
vk::WriteDescriptorSet::default()
.dst_set(desc)
.dst_binding(0)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&surf_info),
vk::WriteDescriptorSet::default()
.dst_set(desc)
.dst_binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&cur_info),
];
d.update_descriptor_sets(&writes, &[]);
let cmd = match d.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(self.cmd_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1),
) {
Ok(c) => c[0],
Err(e) => {
let _ = d.free_descriptor_sets(self.desc_pool, &[desc]);
drop(ext); // CUDA's view of the memory goes first
d.free_memory(memory, None);
d.destroy_buffer(buffer, None);
return Err(e).context("allocate slot command buffer");
}
};
let r = VkSlotRef {
ptr: ext.ptr,
pitch: pitch as usize,
@@ -475,7 +646,8 @@ impl VkSlotBlend {
buffer,
memory,
cuda: ext,
size,
cmd,
desc,
});
Ok(r)
}
@@ -485,12 +657,26 @@ impl VkSlotBlend {
/// first (field order in [`SlotAlloc`] frees `cuda` via its own `Drop` before we free the VK
/// objects explicitly here).
pub fn free_slots(&mut self) {
// Ordered blends return with their work still on the queue — quiesce before freeing the
// buffers/sets it references. `device_wait_idle` (not a timeline wait) so a submission
// whose CUDA copy-done signal never fired is still covered; this tiny device idles in
// microseconds outside that pathological case. CPU-synced blends need nothing (they
// fence-wait before returning), so a `None` timeline skips it.
if self.timeline.is_some() && !self.slots.is_empty() {
// SAFETY: single-threaded owner; no other thread touches this device or its queue.
unsafe {
let _ = self.device.device_wait_idle();
}
}
for s in self.slots.drain(..) {
drop(s.cuda); // CUDA's view of the memory goes first
// SAFETY: `buffer`/`memory` were created in `alloc_slot`, are uniquely owned by the
// drained `SlotAlloc`, and are destroyed exactly once here. No queue work can be
// in flight: every `blend` fence-waits before returning.
// SAFETY: `buffer`/`memory`/`cmd`/`desc` were created in `alloc_slot`, are uniquely
// owned by the drained `SlotAlloc`, and are destroyed exactly once here. No queue
// work is in flight: CPU-synced blends fence-wait before returning, and ordered
// blends were quiesced by the `device_wait_idle` above.
unsafe {
let _ = self.device.free_descriptor_sets(self.desc_pool, &[s.desc]);
self.device.free_command_buffers(self.cmd_pool, &[s.cmd]);
self.device.destroy_buffer(s.buffer, None);
self.device.free_memory(s.memory, None);
}
@@ -498,21 +684,188 @@ impl VkSlotBlend {
}
/// Upload the cursor RGBA (`cw*ch*4`, tight rows) into the mapped staging buffer. Call only
/// when the bitmap changes; position moves are push constants.
/// when the bitmap changes; position moves are push constants. Quiesces any in-flight
/// ordered blend first (bitmap changes are rare — pointer-shape flips — so the occasional
/// bounded CPU wait here is the trade that keeps the per-frame path sync-free).
pub fn upload_cursor(&mut self, rgba: &[u8], cw: u32, ch: u32) {
if let Some(t) = &self.timeline {
if t.last_blend > 0 {
let sems = [t.sem];
let values = [t.last_blend];
// SAFETY: `t.sem` is the live timeline semaphore; the wait info's arrays are
// locals outliving the synchronous call. `last_blend` only holds values an
// accepted submit will signal, so the wait terminates (the timeout is the
// backstop for a wedged queue — then we proceed and risk one torn cursor
// bitmap, never UB: the staging buffer stays alive regardless).
let r = unsafe {
t.ts.wait_semaphores(
&vk::SemaphoreWaitInfo::default()
.semaphores(&sems)
.values(&values),
1_000_000_000,
)
};
if let Err(e) = r {
tracing::warn!(
error = ?e,
"cursor upload quiesce failed — proceeding (a torn cursor bitmap for \
one frame at worst)"
);
}
}
}
let cw = cw.min(CURSOR_MAX);
let ch = ch.min(CURSOR_MAX);
let len = (cw * ch * 4) as usize;
let len = len.min(rgba.len());
// SAFETY: `cur_map` is the live persistent mapping of the CURSOR_MAX²·4 host-coherent
// allocation (created in `init_objects`, unmapped only in `Drop`); `len` is clamped to
// both the source slice and the buffer capacity. No blend is in flight (every `blend`
// fence-waits before returning), so no GPU read races this host write.
// both the source slice and the buffer capacity. No blend reads race this host write:
// CPU-synced blends fence-wait before returning, and ordered blends were quiesced via
// the timeline wait above.
unsafe {
std::ptr::copy_nonoverlapping(rgba.as_ptr(), self.cur_map, len);
}
}
/// Stream-ordered blends available: the timeline semaphore was exported to CUDA at bring-up,
/// so [`blend_ref_ordered`](Self::blend_ref_ordered) can order copy→blend→encode on-device.
pub fn ordered_ready(&self) -> bool {
self.timeline.is_some()
}
/// Last timeline value handed out (0 = no ordered blend submitted yet) — test observability
/// for "did the ordered path actually run".
pub fn ordered_ticket(&self) -> u64 {
self.timeline.as_ref().map_or(0, |t| t.ticket)
}
/// Push constants + dispatch geometry for one blend (must match cursor_blend.comp): ARGB =
/// per cursor px; NV12/YUV444 = per word-aligned 4-px span × (2-row blocks | rows). `None`
/// when the clamped cursor rect is empty (nothing to dispatch).
fn blend_geometry(
slot: &VkSlotRef,
fmt: SlotFormat,
surf_w: u32,
cw: u32,
ch: u32,
ox: i32,
oy: i32,
) -> Option<(Push, u32, u32)> {
let cw = cw.min(CURSOR_MAX);
let ch = ch.min(CURSOR_MAX);
if cw == 0 || ch == 0 {
return None;
}
let push = Push {
pitch: slot.pitch as u32,
surf_w,
surf_h: slot.height,
cur_w: cw,
cur_h: ch,
ox,
oy,
};
let (gx, gy) = match fmt {
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)),
_ => {
let x0 = (ox >> 2) << 2;
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
let rows = match fmt {
SlotFormat::Nv12 => ch.div_ceil(2),
_ => ch,
};
(spans.div_ceil(8), rows.div_ceil(8))
}
};
Some((push, gx, gy))
}
/// Record the blend (barriers + bind + push constants + dispatch) into `id`'s own command
/// buffer and return it, ready for submission.
///
/// # Safety
/// The slot's previous submission must have completed: sync blends fence-wait before
/// returning; ordered blends rely on the encoder's ring-reuse discipline (a slot is reused
/// only after its encode — GPU-ordered after its blend — was polled).
unsafe fn record_blend(
&self,
id: usize,
fmt: SlotFormat,
push: &Push,
gx: u32,
gy: u32,
) -> Result<vk::CommandBuffer> {
let alloc = self
.slots
.get(id)
.ok_or_else(|| anyhow!("bad slot id {id}"))?;
let d = &self.device;
let cmd = alloc.cmd;
d.begin_command_buffer(
cmd,
&vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)
.context("begin blend cmd")?;
// CUDA wrote the frame into this memory outside Vulkan's view — make it visible to
// the shader (external-memory coherence ceremony; NVIDIA honors this with the
// fence/semaphore ordering alone, the barrier is the spec-shaped belt-and-braces).
let acquire = [vk::MemoryBarrier::default()
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
.dst_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::SHADER_WRITE)];
d.cmd_pipeline_barrier(
cmd,
vk::PipelineStageFlags::TOP_OF_PIPE,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::DependencyFlags::empty(),
&acquire,
&[],
&[],
);
d.cmd_bind_pipeline(
cmd,
vk::PipelineBindPoint::COMPUTE,
self.pipelines[fmt.mode() as usize],
);
d.cmd_bind_descriptor_sets(
cmd,
vk::PipelineBindPoint::COMPUTE,
self.pipe_layout,
0,
&[alloc.desc],
&[],
);
let bytes = std::slice::from_raw_parts(
(push as *const Push) as *const u8,
std::mem::size_of::<Push>(),
);
d.cmd_push_constants(
cmd,
self.pipe_layout,
vk::ShaderStageFlags::COMPUTE,
0,
bytes,
);
d.cmd_dispatch(cmd, gx.max(1), gy.max(1), 1);
// Release the shader's writes so the downstream CUDA/NVENC reads (fence- or
// semaphore-ordered) see them.
let release = [vk::MemoryBarrier::default()
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
.dst_access_mask(vk::AccessFlags::MEMORY_READ)];
d.cmd_pipeline_barrier(
cmd,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
vk::DependencyFlags::empty(),
&release,
&[],
&[],
);
d.end_command_buffer(cmd).context("end blend cmd")?;
Ok(cmd)
}
/// Blend the uploaded cursor into `slot` at `(ox, oy)`: record, submit, fence-wait. The
/// caller has CPU-synced its CUDA frame copy first; the fence wait makes the shader's writes
/// visible to the subsequent NVENC encode (the `VkBridge` precedent: fence-ordered cross-API
@@ -528,129 +881,20 @@ impl VkSlotBlend {
ox: i32,
oy: i32,
) -> Result<()> {
let alloc = self
.slots
.get(slot.id)
.ok_or_else(|| anyhow!("bad slot id {}", slot.id))?;
let cw = cw.min(CURSOR_MAX);
let ch = ch.min(CURSOR_MAX);
if cw == 0 || ch == 0 {
let Some((push, gx, gy)) = Self::blend_geometry(slot, fmt, surf_w, cw, ch, ox, oy) else {
return Ok(());
}
let push = Push {
pitch: slot.pitch as u32,
surf_w,
surf_h: slot.height,
cur_w: cw,
cur_h: ch,
ox,
oy,
};
// Dispatch geometry (must match cursor_blend.comp): ARGB = per cursor px; NV12/YUV444 =
// per word-aligned 4-px span × (2-row blocks | rows).
let (gx, gy) = match fmt {
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)),
_ => {
let x0 = (ox >> 2) << 2;
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
let rows = match fmt {
SlotFormat::Nv12 => ch.div_ceil(2),
_ => ch,
};
(spans.div_ceil(8), rows.div_ceil(8))
}
};
// SAFETY: single-threaded record/submit/wait on handles this struct owns. The descriptor
// update is safe because no prior submission is in flight (every blend fence-waits and
// the fence is reset before reuse). Buffer infos and barrier structs are locals outliving
// their synchronous calls. The dispatch's shader accesses stay in-bounds by the shader's
// own guards (surfW/surfH/curW/curH from `push`) against the slot allocation sized in
// `alloc_slot` for exactly that geometry.
// SAFETY: single-threaded record/submit/wait on handles this struct owns. The slot's
// previous submission completed (`record_blend`'s contract: this path fence-waits every
// blend, and an earlier ORDERED blend on this slot finished before the encoder reused it
// — ring-reuse discipline). Submit-info arrays are locals outliving the synchronous
// calls. The dispatch's shader accesses stay in-bounds by the shader's own guards
// (surfW/surfH/curW/curH from `push`) against the slot allocation sized in `alloc_slot`
// for exactly that geometry.
unsafe {
let d = &self.device;
let surf_info = [vk::DescriptorBufferInfo::default()
.buffer(alloc.buffer)
.offset(0)
.range(alloc.size)];
let cur_info = [vk::DescriptorBufferInfo::default()
.buffer(self.cur_buf)
.offset(0)
.range((CURSOR_MAX * CURSOR_MAX * 4) as u64)];
let writes = [
vk::WriteDescriptorSet::default()
.dst_set(self.desc_set)
.dst_binding(0)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&surf_info),
vk::WriteDescriptorSet::default()
.dst_set(self.desc_set)
.dst_binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
.buffer_info(&cur_info),
];
d.update_descriptor_sets(&writes, &[]);
d.begin_command_buffer(
self.cmd,
&vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)
.context("begin blend cmd")?;
// CUDA wrote the frame into this memory outside Vulkan's view — make it visible to
// the shader (external-memory coherence ceremony; NVIDIA honors this with the fence
// ordering alone, the barrier is the spec-shaped belt-and-braces).
let acquire = [vk::MemoryBarrier::default()
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
.dst_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::SHADER_WRITE)];
d.cmd_pipeline_barrier(
self.cmd,
vk::PipelineStageFlags::TOP_OF_PIPE,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::DependencyFlags::empty(),
&acquire,
&[],
&[],
);
d.cmd_bind_pipeline(
self.cmd,
vk::PipelineBindPoint::COMPUTE,
self.pipelines[fmt.mode() as usize],
);
d.cmd_bind_descriptor_sets(
self.cmd,
vk::PipelineBindPoint::COMPUTE,
self.pipe_layout,
0,
&[self.desc_set],
&[],
);
let bytes = std::slice::from_raw_parts(
(&push as *const Push) as *const u8,
std::mem::size_of::<Push>(),
);
d.cmd_push_constants(
self.cmd,
self.pipe_layout,
vk::ShaderStageFlags::COMPUTE,
0,
bytes,
);
d.cmd_dispatch(self.cmd, gx.max(1), gy.max(1), 1);
// Release the shader's writes so the post-fence CUDA/NVENC reads see them.
let release = [vk::MemoryBarrier::default()
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
.dst_access_mask(vk::AccessFlags::MEMORY_READ)];
d.cmd_pipeline_barrier(
self.cmd,
vk::PipelineStageFlags::COMPUTE_SHADER,
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
vk::DependencyFlags::empty(),
&release,
&[],
&[],
);
d.end_command_buffer(self.cmd).context("end blend cmd")?;
let cmds = [self.cmd];
let cmd = self.record_blend(slot.id, fmt, &push, gx, gy)?;
let cmds = [cmd];
let submit = [vk::SubmitInfo::default().command_buffers(&cmds)];
d.queue_submit(self.queue, &submit, self.fence)
.context("submit blend")?;
@@ -660,11 +904,121 @@ impl VkSlotBlend {
}
Ok(())
}
/// Stream-ordered blend — no CPU sync anywhere (the fix for cursor frames serializing the
/// NVENC submit path). The caller has ENQUEUED (not synced) its CUDA frame copy on this
/// thread's copy stream; this method then
/// 1. enqueues a CUDA signal of `copy_done` on that stream (fires once the copy completes),
/// 2. submits the blend waiting `copy_done` and signaling `blend_done` on the Vulkan queue,
/// 3. enqueues a CUDA wait of `blend_done` — so later stream work (the encode, via the
/// session's IO-stream binding) orders after the blend's writes.
///
/// Timeline values are fresh per call and never reused. Failure leaves no dangling waiter:
/// the CUDA wait is enqueued only after the submit was accepted, and an orphaned CUDA
/// signal is legal (timeline signals may skip values — a later, larger signal satisfies
/// any waiter).
#[allow(clippy::too_many_arguments)] // same unpacked kernel args as `blend_ref`
pub fn blend_ref_ordered(
&mut self,
slot: &VkSlotRef,
fmt: SlotFormat,
surf_w: u32,
cw: u32,
ch: u32,
ox: i32,
oy: i32,
) -> Result<()> {
let Some((push, gx, gy)) = Self::blend_geometry(slot, fmt, surf_w, cw, ch, ox, oy) else {
return Ok(());
};
let (copy_done, blend_done, sem) = {
let t = self
.timeline
.as_mut()
.ok_or_else(|| anyhow!("ordered blend without timeline support"))?;
t.ticket += 2;
(t.ticket - 1, t.ticket, t.sem)
};
// Signal FIRST: if this fails nothing was submitted and the fresh values are simply
// skipped. (The reverse order could wedge the queue — a submitted blend waiting on a
// copy-done signal that never got enqueued.)
self.timeline
.as_ref()
.expect("checked above")
.cuda
.signal(copy_done)
.context("cuSignalExternalSemaphoresAsync (copy done)")?;
// SAFETY: single-threaded record/submit on handles this struct owns. The slot's previous
// submission completed (`record_blend`'s contract — ring-reuse discipline, see above).
// All submit-info arrays and the timeline-submit chain are locals outliving the
// synchronous `queue_submit`. No fence: completion is observed through the timeline
// (the encode polls it via the CUDA wait below; teardown quiesces via
// `device_wait_idle`).
unsafe {
let cmd = self.record_blend(slot.id, fmt, &push, gx, gy)?;
let cmds = [cmd];
let wait_sems = [sem];
let wait_vals = [copy_done];
let wait_stages = [vk::PipelineStageFlags::COMPUTE_SHADER];
let sig_sems = [sem];
let sig_vals = [blend_done];
let mut tsi = vk::TimelineSemaphoreSubmitInfo::default()
.wait_semaphore_values(&wait_vals)
.signal_semaphore_values(&sig_vals);
let submit = [vk::SubmitInfo::default()
.command_buffers(&cmds)
.wait_semaphores(&wait_sems)
.wait_dst_stage_mask(&wait_stages)
.signal_semaphores(&sig_sems)
.push_next(&mut tsi)];
self.device
.queue_submit(self.queue, &submit, vk::Fence::null())
.context("submit ordered blend")?;
}
let t = self.timeline.as_mut().expect("checked above");
// Only now: `blend_done` WILL be signaled, so quiesces may rely on it.
t.last_blend = blend_done;
if let Err(e) = t.cuda.wait(blend_done) {
// The blend is in flight but the encode would no longer order after it — restore
// the ordering with a bounded CPU wait (the CPU-synced path's cost, once). The
// frame still composites correctly.
let sems = [t.sem];
let values = [blend_done];
// SAFETY: live timeline semaphore; wait-info arrays are locals outliving the
// synchronous call; `blend_done` was accepted for signaling just above, so the
// wait terminates (timeout backstops a wedged queue).
let r = unsafe {
t.ts.wait_semaphores(
&vk::SemaphoreWaitInfo::default()
.semaphores(&sems)
.values(&values),
1_000_000_000,
)
};
tracing::warn!(
error = %format!("{e:#}"),
cpu_wait = ?r,
"ordered cursor blend: CUDA-side wait enqueue failed — ordering restored with \
a CPU wait this frame"
);
}
Ok(())
}
}
impl Drop for VkSlotBlend {
fn drop(&mut self) {
self.free_slots();
if let Some(t) = self.timeline.take() {
drop(t.cuda); // CUDA's import of the semaphore goes first (mirrors the slot order)
// SAFETY: `sem` was created in `init_timeline`, is uniquely owned, and is destroyed
// exactly once here. No submission still references it: ordered blends only exist
// alongside allocated slots, and `free_slots` just quiesced (device_wait_idle) any
// in-flight work before this point.
unsafe {
self.device.destroy_semaphore(t.sem, None);
}
}
// SAFETY: every handle below was created in `new`/`init_objects` (or is null from a
// partial init — Vulkan destroy/free calls are defined no-ops on null handles) and is
// uniquely owned; each is destroyed exactly once here, pipelines/layouts/pools before the
+2 -2
View File
@@ -52,8 +52,8 @@ pub(crate) struct Negotiated {
/// Host clock minus client clock (ns); `0` = no skew handshake (old host / synced clocks).
pub(crate) clock_offset_ns: i64,
/// Min RTT of the connect-time skew handshake (ns); `None` = the host never answered —
/// mid-stream re-syncs are pointless then and stay off. The re-sync acceptance guard
/// compares each batch against this baseline ([`accept_resync`]).
/// mid-stream re-syncs are pointless then and stay off. Seeds the re-sync admission
/// guard's session-floor baseline ([`ResyncGuard`]).
pub(crate) clock_rtt_ns: Option<u64>,
/// Resolved encode bit depth: `8`, or `10` for a Main10 / HDR session.
pub(crate) bit_depth: u8,
@@ -248,7 +248,16 @@ pub(crate) struct EncodeLatAcc {
/// reference-chain break the loss counters never saw). A transient burst fills it briefly and drains on
/// its own, so a clump never costs a keyframe.
///
/// **All-intra exception** ([`set_all_intra`], PyroWave): every AU is independently decodable, so
/// the reference-chain reasoning above does not apply — a consumer that falls behind can skip
/// straight to the newest queued AU with zero recovery cost (no keyframe round-trip, no corrupt
/// dependents). [`pop`] then drains to the newest instead of returning the oldest, which caps any
/// standing queue at ~1 frame structurally; the 2026-07 field report's 780M client otherwise
/// ratcheted a genuine multi-frame backlog between the coarse jump-to-live thresholds.
///
/// [`clear`]: FrameChannel::clear
/// [`set_all_intra`]: FrameChannel::set_all_intra
/// [`pop`]: FrameChannel::pop
pub(crate) struct FrameChannel {
inner: Mutex<FrameQueue>,
ready: Condvar,
@@ -259,6 +268,11 @@ struct FrameQueue {
/// Set when the pump exits so a blocked [`FrameChannel::pop`] reports the stream ended
/// ([`PunktfunkError::Closed`]) rather than a spurious timeout (the old mpsc did this on sender drop).
closed: bool,
/// Every AU decodes independently (PyroWave): [`FrameChannel::pop`] drains to the newest.
all_intra: bool,
/// AUs skipped by the all-intra drain since the last [`FrameChannel::take_skipped`] — NOT
/// losses (the wire delivered them); the pump surfaces them at debug on its report tick.
skipped_total: u64,
}
/// Outcome of [`FrameChannel::pop`] — mirrors the old `recv_timeout` results so `next_frame`'s
@@ -275,11 +289,25 @@ impl FrameChannel {
inner: Mutex::new(FrameQueue {
q: VecDeque::new(),
closed: false,
all_intra: false,
skipped_total: 0,
}),
ready: Condvar::new(),
}
}
/// Pump side, once at session start: mark the stream all-intra (every AU independently
/// decodable) — [`Self::pop`] then drains to the newest queued AU instead of strict FIFO.
pub(crate) fn set_all_intra(&self, all_intra: bool) {
self.inner.lock().unwrap().all_intra = all_intra;
}
/// Pump side: AUs skipped by the all-intra drain since the last call (reset on read).
pub(crate) fn take_skipped(&self) -> u64 {
let mut st = self.inner.lock().unwrap();
std::mem::take(&mut st.skipped_total)
}
/// Pump side: append a completed AU and wake a blocked consumer. Enforces the memory backstop
/// ([`FRAME_QUEUE_HARD_CAP`]) by dropping the oldest (see its doc — a jump-to-live keyframe is
/// already in flight by the time this can bite).
@@ -312,12 +340,21 @@ impl FrameChannel {
self.ready.notify_all();
}
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive.
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive. On an
/// all-intra stream ([`Self::set_all_intra`]) a multi-deep queue drains to the NEWEST AU
/// instead — the skipped ones are already superseded and decode independently, so showing
/// them only adds latency.
pub(crate) fn pop(&self, timeout: Duration) -> FramePop {
let mut st = self.inner.lock().unwrap();
if st.q.is_empty() && !st.closed {
st = self.ready.wait_timeout(st, timeout).unwrap().0;
}
if st.all_intra && st.q.len() > 1 {
st.skipped_total += (st.q.len() - 1) as u64;
let newest = st.q.pop_back().expect("len > 1");
st.q.clear();
return FramePop::Frame(newest);
}
if let Some(f) = st.q.pop_front() {
FramePop::Frame(f)
} else if st.closed {
@@ -364,6 +401,25 @@ mod frame_channel_tests {
assert_eq!(ch.depth(), 0);
}
/// The all-intra exception: a multi-deep queue drains to the NEWEST AU (every frame
/// decodes independently — older queued ones are superseded), the skips are counted
/// separately from losses, and a single-deep queue behaves exactly like FIFO.
#[test]
fn all_intra_drains_to_newest_and_counts_skips() {
let ch = FrameChannel::new();
ch.set_all_intra(true);
for i in 1..=3 {
ch.push(frame(i));
}
assert_eq!(popped(&ch), Some(3));
assert_eq!(ch.depth(), 0);
assert_eq!(ch.take_skipped(), 2);
assert_eq!(ch.take_skipped(), 0); // reset on read
ch.push(frame(4));
assert_eq!(popped(&ch), Some(4)); // depth 1 = plain FIFO, no skip accounting
assert_eq!(ch.take_skipped(), 0);
}
#[test]
fn empty_pop_times_out_not_closed() {
let ch = FrameChannel::new();
+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
+2 -2
View File
@@ -11,8 +11,8 @@ use crate::abr::BitrateController;
use crate::config::Role;
use crate::packet::FLAG_PROBE;
use crate::quic::{
accept_resync, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho,
ClockResync, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
ResyncStep, SetBitrate, Start, Welcome,
};
use crate::session::Session;
@@ -52,6 +52,14 @@ impl ControlTask {
// the read arm below; only when the host answered the connect-time handshake — an
// old host would just eat the probes.
let mut resync = ClockResync::new();
let mut resync_guard = clock_rtt_ns.map(ResyncGuard::new);
// Inter-round spacing: without it the whole 8-round batch completes inside one
// ~6 ms video burst and every round samples the same congestion state — a batch
// that starts mid-burst is then wholly congested and gets rejected. 7 ms staggers
// the rounds across the ~16.7 ms frame cycle so the min-RTT round almost always
// lands in a quiet inter-burst gap, even at PyroWave-class bitrates.
const RESYNC_ROUND_SPACING: std::time::Duration = std::time::Duration::from_millis(7);
let mut staged_round: Option<tokio::time::Instant> = None;
let mut resync_tick = tokio::time::interval_at(
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
CLOCK_RESYNC_INTERVAL,
@@ -72,6 +80,7 @@ impl ControlTask {
if clock_rtt_ns.is_none() {
continue; // no connect-time handshake — host can't answer
}
staged_round = None; // a new batch abandons any staged round
resync.begin(wall_clock_ns()).encode()
}
CtrlRequest::ClipControl(c) => c.encode(),
@@ -83,11 +92,21 @@ impl ControlTask {
}
}
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
staged_round = None; // a new batch abandons any staged round
let probe = resync.begin(wall_clock_ns());
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
break;
}
}
_ = async { tokio::time::sleep_until(staged_round.unwrap()).await },
if staged_round.is_some() => {
staged_round = None;
// Stamped at send time so the inter-round spacing stays out of the RTT.
let probe = resync.next_probe(wall_clock_ns());
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
break;
}
}
msg = ctrl_recv.read_msg() => {
let Ok(msg) = msg else { break }; // stream closed
if let Ok(ack) = Reconfigured::decode(&msg) {
@@ -132,16 +151,36 @@ impl ControlTask {
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
} else if let Ok(echo) = ClockEcho::decode(&msg) {
match resync.on_echo(&echo, wall_clock_ns()) {
ResyncStep::Probe(p) => {
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() {
break;
}
ResyncStep::MoreRounds => {
staged_round = Some(
tokio::time::Instant::now() + RESYNC_ROUND_SPACING,
);
}
ResyncStep::Done { offset_ns, rtt_ns } => {
// Never let a congested window bias the offset (frames read
// late exactly then) — keep the old estimate and let the next
// periodic batch try again.
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
let Some(guard) = resync_guard.as_mut() else {
continue; // no connect handshake — batches never start
};
let (apply, best_of_streak) = match guard.admit(offset_ns, rtt_ns)
{
ResyncAdmit::Fresh => (Some((offset_ns, rtt_ns)), false),
ResyncAdmit::BestOfStreak { offset_ns, rtt_ns } => {
(Some((offset_ns, rtt_ns)), true)
}
ResyncAdmit::Rejected { streak } => {
// warn, not debug: repeated rejections are exactly the
// stale-offset starvation signature the 2026-07
// PyroWave-sawtooth report had to be diagnosed without.
tracing::warn!(
rtt_us = rtt_ns / 1000,
floor_us = guard.floor_rtt_ns() / 1000,
streak,
"clock re-sync batch rejected — RTT above the \
session floor (congested window)"
);
(None, false)
}
};
if let Some((offset_ns, rtt_ns)) = apply {
// info, not debug: ≤1/min, and it is THE forensic
// trail for a stale-offset (stepped/slewed wall clock)
// latency plateau — the 2026-07 two-pair investigation
@@ -149,16 +188,11 @@ impl ControlTask {
tracing::info!(
offset_ns,
rtt_us = rtt_ns / 1000,
best_of_streak,
"mid-stream clock re-sync applied"
);
clock_offset.store(offset_ns, Ordering::Relaxed);
clock_gen.fetch_add(1, Ordering::Relaxed);
} else {
tracing::info!(
rtt_us = rtt_ns / 1000,
"clock re-sync batch discarded — RTT above the \
connect-time baseline (congested window)"
);
}
}
ResyncStep::Idle => {}
+45 -3
View File
@@ -87,6 +87,10 @@ impl DataPump {
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard
// per-frame CBR — controller and capacity probe stay off (0 = permanently off).
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
// All-intra streams have no reference chains: the frame channel drains to the newest
// AU instead of strict FIFO (see `FrameChannel::set_all_intra`), so a slow consumer
// caps its standing queue at ~1 frame with zero recovery cost.
frames.set_all_intra(negotiated_codec == crate::quic::CODEC_PYROWAVE);
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
resolved_bitrate_kbps
} else {
@@ -114,6 +118,15 @@ impl DataPump {
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
// finished one left the ABR window anchored before the burst.
let mut was_probing = false;
// Set when a probe ends: the FIRST post-probe report window is discarded outright (no
// LossReport, no standing-latency close, no ABR feed). The `last_*` rebase below cannot
// fully clean it — probe frames still pending in the reassembler age out as
// `frames_dropped` for another LOSS_WINDOW (~120 ms) AFTER the rebase, and the burst may
// have latched `flush_in_window` — and either reads as SEVERE congestion. The 2026-07
// field report's Automatic session backed off 20→14 Mb/s one second in (exactly one
// report tick after its capacity probe) and, with slow start dead from that first
// "congestion", crawled additively for the entire match.
let mut discard_abr_window = false;
let mut probe_watchdog: Option<Instant> = None;
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
let mut flush_in_window = false;
@@ -193,6 +206,8 @@ impl DataPump {
last_dropped = st.frames_dropped;
last_bytes = st.bytes_received;
last_report = Instant::now();
discard_abr_window = true;
flush_in_window = false;
}
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
@@ -289,6 +304,14 @@ impl DataPump {
resync_wanted = false;
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
}
// All-intra drain-to-newest skips are NOT losses (the wire delivered them) —
// surface them at debug so a slow consumer is visible without alarming the
// OSD loss counters.
let skipped = frames.take_skipped();
if skipped > 0 {
tracing::debug!(skipped, "all-intra frame channel drained to newest");
}
let discard = std::mem::take(&mut discard_abr_window);
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
let loss_ppm = window_loss_ppm(
st.fec_recovered_shards.wrapping_sub(last_recovered),
@@ -296,14 +319,26 @@ impl DataPump {
st.packets_received.wrapping_sub(last_received),
window_dropped,
);
if discard {
// Probe-tail residue (see `discard_abr_window`): a LossReport from this
// window would also spike the host's adaptive FEC off deliberate overload.
tracing::debug!(
loss_ppm,
window_dropped,
"discarding the first post-probe ABR window (probe-tail residue)"
);
} else {
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
}
// Standing-latency bleed: close the detector's window with this report's loss
// verdict and run its escalation ladder — re-sync first (free; a stale offset
// from a stepped wall clock produces exactly this signature and the applied
// re-sync rebases the floor), then a bounded flush+keyframe (drains a real
// sub-threshold standing backlog the jump-to-live thresholds tolerate), then a
// loud disarm (the path latency itself changed; nothing local fixes that).
match standing_lat.on_window(loss_ppm == 0 && window_dropped == 0) {
// A discard window closes the detector as NOT-loss-free: its clean-run resets
// (conservative) and no action can fire off probe residue.
match standing_lat.on_window(!discard && loss_ppm == 0 && window_dropped == 0) {
StandingLatAction::None => {}
StandingLatAction::Resync { above_ms } => {
tracing::info!(
@@ -386,7 +421,12 @@ impl DataPump {
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
/ window_ms) as u32;
if let Some(kbps) = abr.on_window(
// A discard window feeds the controller NOTHING — its signals are probe-tail
// residue, and one "congestion" verdict here ends slow start for good.
let verdict = if discard {
None
} else {
abr.on_window(
Instant::now(),
window_dropped,
loss_ppm,
@@ -395,7 +435,9 @@ impl DataPump {
encode_mean_us,
actual_kbps,
flush_in_window,
) {
)
};
if let Some(kbps) = verdict {
// Log the window's signals alongside the decision so an on-glass session can
// tell a decode-/encode-driven re-target (the new signals — elevated with
// loss/OWD flat) from a network-driven one.
+155 -17
View File
@@ -82,8 +82,12 @@ pub fn wall_clock_ns() -> u64 {
pub enum ResyncStep {
/// Nothing — the echo was stale (a previous batch) or no batch is in flight.
Idle,
/// Send this next-round probe and keep feeding echoes.
Probe(ClockProbe),
/// The round was recorded and the batch wants another: wait the inter-round spacing, then
/// stamp + send [`ClockResync::next_probe`]. Spacing the rounds makes the batch sample
/// several phases of the periodic video-burst cycle instead of completing inside one burst
/// — at high bitrates the whole 8-round batch otherwise fits in a single ~6 ms burst and
/// every round reads the same congested (or same quiet) instant.
MoreRounds,
/// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`].
Done { offset_ns: i64, rtt_ns: u64 },
}
@@ -118,6 +122,13 @@ impl ClockResync {
/// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`.
pub fn begin(&mut self, now_ns: u64) -> ClockProbe {
self.samples.clear();
self.next_probe(now_ns)
}
/// Stamp + arm the next round's probe at `now_ns` — send it immediately. Called after
/// [`ResyncStep::MoreRounds`] once the caller's inter-round spacing has elapsed; stamping
/// at send time keeps that spacing out of the measured RTT.
pub fn next_probe(&mut self, now_ns: u64) -> ClockProbe {
self.pending_t1 = Some(now_ns);
ClockProbe { t1_ns: now_ns }
}
@@ -129,11 +140,12 @@ impl ClockResync {
}
self.samples
.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns));
if self.samples.len() < Self::ROUNDS {
self.pending_t1 = Some(now_ns);
return ResyncStep::Probe(ClockProbe { t1_ns: now_ns });
}
// No probe in flight until the driver arms the next round (or a batch restarts) — a
// duplicate of this round's echo must not double-record.
self.pending_t1 = None;
if self.samples.len() < Self::ROUNDS {
return ResyncStep::MoreRounds;
}
match clock_offset_ns(&self.samples) {
Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns },
None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected
@@ -147,12 +159,97 @@ impl Default for ClockResync {
}
}
/// Acceptance guard for a re-sync batch: apply the new offset only when its min RTT is
/// comparable to the connect-time RTT — `≤ max(2 ms, 1.5 × connect RTT)`. A congested window
/// biases the offset by its queueing delay, and frames already read late exactly then; better
/// to keep the old estimate and let the next batch try again.
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
/// Acceptance predicate for a re-sync batch: its min RTT must be comparable to the best RTT
/// this session has evidenced — `≤ max(2 ms, 1.5 × floor)`. A congested window biases the
/// offset by its queueing delay, and frames already read late exactly then; better to keep the
/// old estimate and let the next batch try again.
pub fn accept_resync(batch_rtt_ns: u64, floor_rtt_ns: u64) -> bool {
batch_rtt_ns <= (floor_rtt_ns + floor_rtt_ns / 2).max(2_000_000)
}
/// Admission decision for a completed re-sync batch (see [`ResyncGuard::admit`]).
#[derive(Debug, PartialEq, Eq)]
pub enum ResyncAdmit {
/// Batch RTT is within the guard band of the session floor: apply this batch's offset.
Fresh,
/// Batch rejected (congested window) — keep the previous offset; `streak` counts the
/// consecutive rejections since the last applied batch.
Rejected { streak: u32 },
/// The rejection streak hit [`ResyncGuard::MAX_REJECTED_STREAK`]: apply the best (min-RTT)
/// batch of the streak instead of drifting further. Carries that batch's estimate.
BestOfStreak { offset_ns: i64, rtt_ns: u64 },
}
/// Admission control for mid-stream re-sync batches. Two fixes over the original static
/// `≤ max(2 ms, 1.5 × connect RTT)` guard (2026-07 PyroWave-sawtooth field report):
///
/// - **The baseline is the session floor, not the connect-time RTT.** The connect handshake
/// runs before the video data plane exists; comparing loaded mid-stream batches against that
/// idle figure rejected essentially every batch of a high-bitrate LAN session, and the
/// offset went stale while the wall clocks drifted apart — the OSD latency ramped for
/// minutes and snapped back only when a lucky batch landed. The floor now folds in every
/// completed batch's min RTT (rejected ones included: their min-RTT round is still floor
/// evidence), so the baseline tracks what this path can actually do under load.
/// - **Staleness is bounded.** After [`Self::MAX_REJECTED_STREAK`] consecutive rejections the
/// best batch of the streak is applied anyway: its queueing bias is at most ~half its RTT
/// (a few ms), while unbounded wall-clock drift is worth that many ms *per minute* on a
/// slewing clock. A bounded bias beats an unbounded drift.
pub struct ResyncGuard {
/// Best RTT this session has evidenced: connect-time RTT, then min over every batch.
floor_rtt_ns: u64,
rejected_streak: u32,
/// Min-RTT batch among the current rejection streak.
best_pending: Option<(i64, u64)>,
}
impl ResyncGuard {
/// Consecutive rejected batches tolerated before the best of them is applied anyway.
pub const MAX_REJECTED_STREAK: u32 = 3;
pub fn new(connect_rtt_ns: u64) -> ResyncGuard {
ResyncGuard {
floor_rtt_ns: connect_rtt_ns,
rejected_streak: 0,
best_pending: None,
}
}
/// The current baseline the guard compares batches against (log/debug surface).
pub fn floor_rtt_ns(&self) -> u64 {
self.floor_rtt_ns
}
/// Judge a completed batch. The caller applies the offset on [`ResyncAdmit::Fresh`] (this
/// batch's) or [`ResyncAdmit::BestOfStreak`] (the carried one) and keeps the old offset on
/// [`ResyncAdmit::Rejected`].
pub fn admit(&mut self, offset_ns: i64, rtt_ns: u64) -> ResyncAdmit {
// Judge against the floor as evidenced BEFORE this batch, then fold this batch in —
// comparing a batch against a floor that already includes it would accept everything.
let fresh = accept_resync(rtt_ns, self.floor_rtt_ns);
self.floor_rtt_ns = self.floor_rtt_ns.min(rtt_ns);
if fresh {
self.rejected_streak = 0;
self.best_pending = None;
return ResyncAdmit::Fresh;
}
let best = match self.best_pending {
Some((o, r)) if r <= rtt_ns => (o, r),
_ => (offset_ns, rtt_ns),
};
self.best_pending = Some(best);
self.rejected_streak += 1;
if self.rejected_streak >= Self::MAX_REJECTED_STREAK {
self.rejected_streak = 0;
self.best_pending = None;
return ResyncAdmit::BestOfStreak {
offset_ns: best.0,
rtt_ns: best.1,
};
}
ResyncAdmit::Rejected {
streak: self.rejected_streak,
}
}
}
#[cfg(test)]
@@ -218,9 +315,14 @@ mod tests {
let echo = echo_for(probe.t1_ns, one_way);
let t4 = t4_for(&echo, one_way);
match rs.on_echo(&echo, t4) {
ResyncStep::Probe(p) => {
ResyncStep::MoreRounds => {
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
probe = p;
// A duplicate of the just-consumed echo must not double-record: no probe
// is in flight until the driver arms the next round.
assert_eq!(rs.on_echo(&echo, t4), ResyncStep::Idle);
// The driver stamps the next probe at SEND time (after its inter-round
// spacing), so the spacing never lands in the measured RTT.
probe = rs.next_probe(t4 + 7_000_000);
}
ResyncStep::Done { offset_ns, rtt_ns } => {
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
@@ -243,10 +345,46 @@ mod tests {
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
ResyncStep::Idle
);
assert!(matches!(
assert_eq!(
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
ResyncStep::Probe(_)
));
ResyncStep::MoreRounds
);
}
/// The guard's two field-report fixes: the baseline tracks the SESSION floor (a batch
/// better than the stale connect figure re-anchors it), and a rejection streak is bounded
/// — the best batch of the streak is applied rather than letting the offset go stale
/// while the wall clocks drift apart.
#[test]
fn resync_guard_floor_tracking_and_bounded_streak() {
// Connect measured 400 µs idle; the 2 ms floor of accept_resync governs early on.
let mut g = ResyncGuard::new(400_000);
assert_eq!(g.admit(10, 1_500_000), ResyncAdmit::Fresh);
// A better batch lowers the floor evidence.
assert_eq!(g.admit(11, 300_000), ResyncAdmit::Fresh);
assert_eq!(g.floor_rtt_ns(), 300_000);
// Loaded stretch: batches at 46 ms all exceed max(2 ms, 1.5 × 300 µs).
assert_eq!(g.admit(100, 6_000_000), ResyncAdmit::Rejected { streak: 1 });
// The best (min-RTT) batch of the streak is remembered…
assert_eq!(g.admit(200, 4_000_000), ResyncAdmit::Rejected { streak: 2 });
// …and applied when the streak hits the cap — offset 200 (the 4 ms batch), not 300.
assert_eq!(
g.admit(300, 5_000_000),
ResyncAdmit::BestOfStreak {
offset_ns: 200,
rtt_ns: 4_000_000
}
);
// The streak reset: the next congested batch starts a new one.
assert_eq!(g.admit(400, 5_000_000), ResyncAdmit::Rejected { streak: 1 });
// A quiet batch clears it and applies normally.
assert_eq!(g.admit(500, 350_000), ResyncAdmit::Fresh);
// A batch that IS the new floor is always fresh (compared against the pre-batch floor).
let mut g2 = ResyncGuard::new(10_000_000);
assert_eq!(g2.admit(1, 8_000_000), ResyncAdmit::Fresh);
assert_eq!(g2.floor_rtt_ns(), 8_000_000);
}
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not
+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)")
}
+252
View File
@@ -475,3 +475,255 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
println!("dualsense-windows-test: done (devnode removed)");
Ok(())
}
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
///
/// Opens the display backend exactly as a session would (so a `PUNKTFUNK_CAPTURE_MONITOR` pin
/// routes to the mirror backend), attaches a capturer to whatever PipeWire node comes back, and
/// reports the frames it actually receives. What it proves that a unit test cannot: the compositor
/// accepted the record request for a NAMED head, and that head is producing pixels at its own size.
///
/// `--monitor <CONNECTOR>` pins for this run (else `PUNKTFUNK_CAPTURE_MONITOR`); `--seconds N`.
#[cfg(target_os = "linux")]
pub fn mirror_test(args: &[String]) -> Result<()> {
use std::time::{Duration, Instant};
let arg = |name: &str| {
args.iter()
.skip_while(|a| a.as_str() != name)
.nth(1)
.cloned()
};
let secs: u64 = arg("--seconds").and_then(|s| s.parse().ok()).unwrap_or(5);
// `--monitor` cannot work by setting PUNKTFUNK_CAPTURE_MONITOR here: pf_host_config parses the
// environment ONCE and startup already read it, so this process would still see the old
// snapshot. An explicit connector therefore goes through `open_mirror` below; only the unset
// case falls back to the pin (and to `open`, which is the production routing).
let explicit = arg("--monitor");
let want = explicit
.clone()
.or_else(crate::vdisplay::capture_monitor)
.context(
"no monitor named — pass --monitor <CONNECTOR> or set PUNKTFUNK_CAPTURE_MONITOR",
)?;
let compositor = crate::vdisplay::detect()?;
let monitors = crate::vdisplay::monitors::list(compositor)?;
let target = crate::vdisplay::monitors::resolve(&monitors, &want)?;
println!(
"mirror-test: {compositor:?} {} ({}) at +{},+{}",
target.connector,
target.mode_label(),
target.x,
target.y
);
// No `--monitor` ⇒ exercise the PRODUCTION routing (`open` consulting the pin), which is the
// more valuable path to prove; an explicit connector takes the direct opener.
let mut vd = match &explicit {
Some(connector) => crate::vdisplay::open_mirror(compositor, connector)?,
None => crate::vdisplay::open(compositor)?,
};
// The mode is ignored by the mirror backend (a panel runs at the mode its owner set); pass the
// head's own so this also behaves if the pin is ever unset mid-test.
let mode = crate::vdisplay::Mode {
width: target.width,
height: target.height,
refresh_hz: 60,
};
let vout = vd.create(mode).context("open the mirror display")?;
println!(
"mirror-test: node_id={} preferred={:?} ownership={:?}",
vout.node_id, vout.preferred_mode, vout.ownership
);
// Default to the GPU (dmabuf zero-copy) path a real session uses; `--cpu` forces the mmap
// path, which is worth having as a switch — the two negotiate different PipeWire buffer types.
let gpu = !args.iter().any(|a| a == "--cpu");
let fmt = pf_frame::OutputFormat::resolve(false, gpu);
println!(
"mirror-test: capture path = {}",
if gpu { "gpu/dmabuf" } else { "cpu/mmap" }
);
let mut cap = crate::capture::capture_virtual_output(
vout,
fmt,
crate::session_plan::CaptureBackend::resolve(),
)
.context("attach a capturer to the mirrored monitor")?;
cap.set_active(true);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut frames, mut first) = (0u32, None);
let mut idle = 0u32;
let mut dims = (0u32, 0u32);
while Instant::now() < deadline {
match cap.next_frame_within(Duration::from_secs(5)) {
Ok(f) => {
if first.is_none() {
first = Some(Instant::now());
println!(
"mirror-test: FIRST FRAME {}x{} {:?}",
f.width, f.height, f.format
);
}
dims = (f.width, f.height);
frames += 1;
}
// A timeout is NOT fatal here: compositor screencast is damage-driven, so a static
// desktop legitimately produces nothing for seconds at a time (the host's own capture
// diag logs `new_fps=0` for virtual outputs on an idle desktop for the same reason).
// Keep waiting until the deadline instead of ending the measurement on the first gap.
Err(e) => {
idle += 1;
if idle == 1 {
println!("mirror-test: (idle — no damage yet: {e:#})");
}
}
}
}
match first {
Some(_) => println!(
"mirror-test: OK — {frames} frames in {secs}s at {}x{} ({:.1} fps over the whole run, \
{idle} idle gaps). Compositor capture is damage-driven: a static desktop produces \
nothing, so judge this by whether frames track what is happening on screen.",
dims.0,
dims.1,
frames as f64 / secs as f64
),
None => {
anyhow::bail!("no frames arrived in {secs}s — the cast started but produced nothing")
}
}
Ok(())
}
/// Aim absolute input at a named monitor and prove where it landed — the on-glass gate for the
/// input-region ladder (`design/per-monitor-portal-capture.md` §7.2), without needing a client.
///
/// The ladder exists for one case a unit test can only simulate: **two heads of the same size**,
/// where matching a libei region by the streamed mode is a coin flip and the pointer silently ends
/// up on the wrong screen. This drives the real thing — the compositor's own EIS regions, the real
/// anchor, the real resolver — and prints the region absolute coordinates actually mapped into, so
/// "it went to the right monitor" is something you read rather than infer.
///
/// `--monitor <CONNECTOR>` anchors at that head's origin (default: the `PUNKTFUNK_CAPTURE_MONITOR` /
/// policy pin); `--none` deliberately runs UNANCHORED, which is the A/B that makes the anchored run
/// mean something on a same-size pair. It then walks the corners and centre of a `--width`×`--height`
/// client surface so an observer can watch the pointer.
///
/// Read the answer from the log line `libei: absolute input maps into this output`.
#[cfg(target_os = "linux")]
pub fn anchor_test(args: &[String]) -> Result<()> {
use punktfunk_core::input::{InputEvent, InputKind};
use std::time::Duration;
let arg = |name: &str| {
args.iter()
.skip_while(|a| a.as_str() != name)
.nth(1)
.cloned()
};
let unanchored = args.iter().any(|a| a == "--none");
let w: u32 = arg("--width").and_then(|s| s.parse().ok()).unwrap_or(1920);
let h: u32 = arg("--height").and_then(|s| s.parse().ok()).unwrap_or(1080);
let compositor = crate::vdisplay::detect()?;
let monitors = crate::vdisplay::monitors::list(compositor)?;
println!(
"anchor-test: {compositor:?} has {} monitor(s):",
monitors.len()
);
for m in &monitors {
println!(
" {:<12} {:>13} at +{},+{}",
m.connector,
m.mode_label(),
m.x,
m.y
);
}
// Two heads at the same size is the case the ladder exists for; say so when the rig is right,
// and say so when it is NOT — a green run on a single-head box proves nothing about it.
let same_size = monitors.iter().enumerate().any(|(i, a)| {
monitors
.iter()
.skip(i + 1)
.any(|b| a.width == b.width && a.height == b.height)
});
println!(
"anchor-test: two same-size heads present: {} {}",
same_size,
if same_size {
"— this run exercises the case the ladder exists for"
} else {
"— WEAK RIG: size matching would have picked correctly anyway"
}
);
if unanchored {
crate::inject::set_absolute_anchor(None);
println!("anchor-test: UNANCHORED (--none) — the size/first rungs decide");
} else {
let want = arg("--monitor")
.or_else(crate::vdisplay::capture_monitor)
.context("no monitor named — pass --monitor <CONNECTOR>, or --none for the A/B")?;
let m = crate::vdisplay::monitors::resolve(&monitors, &want)?;
crate::inject::set_absolute_anchor(Some(crate::inject::AbsoluteAnchor {
origin: Some((m.x, m.y)),
mapping_id: None,
}));
println!(
"anchor-test: anchored at {} +{},+{} ({})",
m.connector,
m.x,
m.y,
m.mode_label()
);
}
let backend = crate::inject::default_backend();
if backend != crate::inject::Backend::Libei {
// The ladder is libei's; on any other backend this command would report nothing about it.
// Say so rather than emitting a green run that means nothing (sway injects via WlrVirtual,
// which is why the sway box cannot serve as this rig — set PUNKTFUNK_INPUT_BACKEND=libei on
// a compositor that speaks EI).
anyhow::bail!(
"input backend is {backend:?}, not libei — the absolute-region ladder only exists on \
the libei backend; set PUNKTFUNK_INPUT_BACKEND=libei"
);
}
let mut inj = crate::inject::open(backend)?;
// libei establishes its portal/EIS session + device resume asynchronously; events before then
// are dropped (and it is the resume that publishes the regions we are testing).
std::thread::sleep(Duration::from_secs(4));
let flags = (w << 16) | (h & 0xffff);
let pts = [
(w as i32 / 2, h as i32 / 2),
(60, 60),
(w as i32 - 60, 60),
(w as i32 - 60, h as i32 - 60),
(60, h as i32 - 60),
(w as i32 / 2, h as i32 / 2),
];
println!("anchor-test: walking {w}x{h} — centre, four corners, centre (1s apart)");
for (x, y) in pts {
let e = InputEvent {
kind: InputKind::MouseMoveAbs,
_pad: [0; 3],
code: 0,
x,
y,
flags,
};
if let Err(err) = inj.inject(&e) {
tracing::warn!(error = %format!("{err:#}"), "anchor-test: inject failed");
}
std::thread::sleep(Duration::from_secs(1));
}
println!(
"anchor-test: done — read the `libei: absolute input maps into this output` line above \
for the region that was chosen"
);
Ok(())
}
+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
@@ -78,6 +78,10 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
// One-shot latch for the HDR-mode control message (0x010e); re-armed on Disconnect.
let mut hdr_sent = false;
let mut peer: Option<PeerID> = None;
// The session's GCM key, remembered from the last tick it was live. Ending a session
// clears `launch` — and the key lives there — so without this copy the one message that
// has to go out *because* the session ended could no longer be sealed.
let mut last_key: Option<[u8; 16]> = None;
loop {
loop {
match host.service() {
@@ -154,6 +158,56 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
}
}
}
// A session can also end from the HOST side: the launched game exited, the operator
// stopped it, or `/cancel` arrived on another connection. Tearing the media threads
// down is *silence*, not a signal — Moonlight holds this control stream for the
// whole session, so with no word from us it sits on its last frame until its own
// timeout eventually fires. The client froze instead of ending (on-glass, .173).
//
// Disconnecting the peer is how it learns. Moonlight treats a control-stream
// disconnect as the session being over and returns to its app list — the same place
// it lands when the user quits, which is exactly where "the game exited" should
// leave them.
//
// Merely dropping the peer is not enough either: the client reports that as a
// connection error (`-1` on glass, .173), because an ENet disconnect on its own is
// indistinguishable from the host falling over. The protocol has a word for this —
// a TERMINATION control message — so send that first and let the disconnect follow.
//
// `end_session` clears `launch`, so a tracked peer with no launch behind it *is*
// the ended session. Clearing `peer` first makes this fire once; the real
// `Disconnect` event that follows then takes the non-session-peer branch, which is
// why this arm repeats that branch's per-connection cleanup.
if let Some(pid) = peer {
let ended = state
.launch
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none();
if ended {
// Only sealable once the client's scheme is known; without it, fall through
// to the bare disconnect rather than send a packet it can't read.
if let (Some(scheme), Some(key)) = (detected, last_key) {
let pt = termination_plaintext();
let wire = encrypt_control(&key, &scheme, host_seq, &pt);
host_seq = host_seq.wrapping_add(1);
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
{
tracing::warn!(error = ?e, "control: termination send failed");
}
}
tracing::info!("control: the session ended — telling the client");
// `disconnect_later` flushes the queued termination first; a plain
// `disconnect` would race it off the wire and land us back at "-1".
host.peer_mut(pid).disconnect_later(0);
peer = None;
detected = None;
decrypt_fails = 0;
hdr_sent = false;
pads = GamepadManager::new();
pointer = super::pen::GsPointer::new();
}
}
// Service the pads' force-feedback protocol every tick (games block inside
// EVIOCSFF until answered) and relay mixed rumble levels to the client.
//
@@ -171,6 +225,10 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
// use the native punktfunk/1 plane (correct per-direction nonces + seq-as-AAD).
if let (Some(pid), Some(scheme)) = (peer, detected) {
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
// Remember it for the teardown message (see `last_key`).
if key.is_some() {
last_key = key;
}
if let Some(key) = key {
let mut out: Vec<Vec<u8>> = Vec::new();
// One-shot HDR-mode signal (type 0x010e / Sunshine `IDX_HDR_MODE`) once the
@@ -547,6 +605,42 @@ fn hdr_mode_plaintext(enabled: bool, m: &HdrMeta) -> Vec<u8> {
pt
}
/// Build the host→client TERMINATION control plaintext — "the session is over, on purpose".
///
/// Without it, ending a session host-side is invisible on the wire: the media threads simply stop,
/// which the client cannot tell from a host that fell over, so it either sits on its last frame or
/// (once we disconnect the peer) reports a connection error. This is the message that makes it a
/// clean end, and the client returns to its app list.
///
/// Verified against moonlight-common-c `ControlStream.c` (master, 2026-07-26):
///
/// * **Type `0x0109`**, from `packetTypesGen7Enc[IDX_TERMINATION]`. Which table the client reads is
/// decided by ONE thing — the version we advertise:
/// `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and
/// [`super::APP_VERSION`] is exactly `7.1.431`. So every client is on the encrypted table, where
/// termination is `0x0109`; the plain table's `0x0100` would be ignored.
///
/// This is *not* the same axis as [`NonceKind`], which only describes how the GCM nonce is built.
/// Deriving the type from the nonce scheme looks reasonable and is wrong — it sent `0x0100` to a
/// client reading the encrypted table, which ignored it silently and reported the session as `-1`
/// (on glass, .173). The HDR message could never have caught this: `0x010e` in both tables.
/// * **Payload** is a big-endian `u32` reason (the ≥6-byte "extended" branch; the short branch is a
/// little-endian `u16`, which is GFE's older shape).
/// * **`0x80030023`** is `NVST_DISCONN_SERVER_TERMINATED_CLOSED`, which the client maps to
/// `ML_ERROR_GRACEFUL_TERMINATION` *provided it has seen a frame* — true for any real session,
/// and the reason this reads as "the app quit" rather than an error.
fn termination_plaintext() -> Vec<u8> {
/// `packetTypesGen7Enc[IDX_TERMINATION]` — see the version gate above.
const TERMINATION: u16 = 0x0109;
/// `NVST_DISCONN_SERVER_TERMINATED_CLOSED` — a deliberate, graceful host-side end.
const GRACEFUL: u32 = 0x8003_0023;
let mut pt = Vec::with_capacity(8);
pt.extend_from_slice(&TERMINATION.to_le_bytes());
pt.extend_from_slice(&4u16.to_le_bytes()); // length = the reason that follows
pt.extend_from_slice(&GRACEFUL.to_be_bytes()); // big-endian: the extended branch
pt
}
/// Seal a host→client control message, mirroring the client's `detected` scheme with the
/// direction flipped: V2 nonces use marker `H?` (host-originated) instead of `C?`; legacy
/// nonces keep their construction with our own independent `seq` counter. Wire layout matches
@@ -648,6 +742,41 @@ mod tests {
assert_eq!(decode_rfi_range(&rfi_msg(9, 4)), None); // last < first
}
/// The termination plaintext is what turns "the host went quiet" into "the app quit" on the
/// client. Getting the type from the wrong table, or the reason's byte order wrong, is silently
/// ignored by the client — which reads on glass as a frozen stream or a `-1` error, not as a
/// failed test. Pinned against moonlight-common-c `ControlStream.c`.
#[test]
fn termination_plaintext_wire_layout() {
let pt = super::termination_plaintext();
assert_eq!(pt.len(), 8);
// The ENCRYPTED table's entry — which is the one every client reads, see below.
assert_eq!(&pt[0..2], &0x0109u16.to_le_bytes());
assert_eq!(&pt[2..4], &4u16.to_le_bytes());
// The reason is BIG-endian: the client's >=6-byte "extended" branch.
assert_eq!(&pt[4..8], &0x8003_0023u32.to_be_bytes());
}
/// The termination type above is only correct because of the version we advertise:
/// moonlight-common-c sets `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and that
/// alone decides whether the client reads `packetTypesGen7Enc` (termination `0x0109`) or
/// `packetTypesGen7` (`0x0100`). Drop below that version and the message silently stops being
/// understood — the stream would end as an error again, with nothing failing here to say why.
#[test]
fn advertised_version_keeps_the_client_on_the_encrypted_packet_table() {
let q: Vec<i32> = super::super::APP_VERSION
.split('.')
.map(|p| p.parse().unwrap_or(0))
.collect();
let at_least = q[0] > 7 || (q[0] == 7 && (q[1] > 1 || (q[1] == 1 && q[2] >= 431)));
assert!(
at_least,
"APP_VERSION {} is below 7.1.431, so clients read the PLAIN packet table and \
termination must become 0x0100",
super::super::APP_VERSION
);
}
/// The HDR-mode plaintext must match what moonlight-common-c parses: `[u16 type=0x010e]
/// [u16 length=27][u8 enable][SS_HDR_METADATA]`, 31 bytes, all little-endian, primaries R,G,B.
#[test]
@@ -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
+405 -56
View File
@@ -33,15 +33,40 @@ pub struct StreamConfig {
pub hdr: bool,
}
/// A pooled capturer plus the three properties reuse must match on — its HDR-ness, its
/// metadata-cursor mode (both fixed at PipeWire-negotiation time) and **which screen it is
/// actually capturing**: the `capture_monitor` pin, or `None` for the portal's own pick. A
/// mismatch on any of them needs a fresh screencast session (see `AppState::video_cap`).
///
/// The pin belongs in the key because it is a *live* setting — the console can re-aim the host
/// between two GameStream connects (`design/per-monitor-portal-capture.md` §7.3). Without it the
/// second connect would silently keep streaming the previous screen, which is the exact failure
/// the pin exists to prevent.
pub type PooledCapturer = (Box<dyn Capturer>, bool, bool, Option<String>);
/// 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 +80,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 +132,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 +163,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 +210,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 +252,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 +270,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 +394,47 @@ 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
};
// Which screen this stream must show. The host-wide pin (§5.3) applies to the compat plane too:
// the portal chooser cannot name a head, so a pinned host MIRRORS it here the same way the
// virtual source does via `vdisplay::open`. Without this a Moonlight client on a pinned host
// would silently get whichever monitor the portal handed back — "showing the wrong monitor is
// worse than showing none" is the rule the whole feature is built on.
#[cfg(target_os = "linux")]
let pinned = crate::vdisplay::capture_monitor();
#[cfg(not(target_os = "linux"))]
let pinned: Option<String> = None;
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, ref was_pin))
if was_hdr == cfg.hdr && was_meta == metadata_cursor && *was_pin == pinned =>
{
Some(c)
}
Some((c, was_hdr, was_meta, was_pin)) => {
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,
was_monitor = was_pin.as_deref().unwrap_or("<portal's pick>"),
want_monitor = pinned.as_deref().unwrap_or("<portal's pick>"),
"video source: pooled capturer depth/cursor-mode/monitor mismatch — opening a \
fresh screencast session"
);
drop(c);
None
@@ -284,9 +446,28 @@ fn run(
tracing::info!("video source: reusing capturer");
c
}
#[cfg(target_os = "linux")]
None if pf_host_config::config().video_source.as_deref() == Some("portal")
&& pinned.is_some() =>
{
let connector = pinned.as_deref().expect("guarded by the match arm");
tracing::info!(
hdr = cfg.hdr,
metadata_cursor,
monitor = connector,
"video source: mirroring the pinned monitor (portal source, host pin)"
);
open_gs_mirror_source(connector, cfg, metadata_cursor)
.with_context(|| format!("mirror the pinned monitor {connector:?}"))?
}
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 +479,7 @@ fn run(
let result = stream_body(
&mut capturer,
None,
metadata_cursor,
&sock,
cfg,
running,
@@ -308,10 +490,132 @@ 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` and the
// monitor pin ride along as the other two reuse keys, beside HDR-ness — see `PooledCapturer`.)
if result.is_ok() && capturer.is_alive() {
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr, metadata_cursor, pinned));
} 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
}
/// Open a capturer on the **pinned physical monitor** for the compat plane's portal source
/// (`design/per-monitor-portal-capture.md` §5.3). The pin is host-wide, so it has to be honored on
/// every plane that captures a screen — and the portal source is the one that otherwise takes
/// "whichever head the portal hands back".
///
/// Deliberately *not* the `open_gs_virtual_source` path: this source launches nothing and creates no
/// virtual output, so it needs neither the game-lifetime machinery nor the registry (a mirror is
/// [`DisplayOwnership::External`](crate::vdisplay::DisplayOwnership) and would pass straight through
/// it anyway). A missing monitor fails the stream loudly rather than falling back to another screen.
#[cfg(target_os = "linux")]
fn open_gs_mirror_source(
connector: &str,
cfg: StreamConfig,
metadata_cursor: bool,
) -> Result<Box<dyn Capturer>> {
// Follow the live session first, exactly as the virtual source does — a mirror host that
// switched Desktop↔Game since startup must be enumerated against the compositor that is up now.
let active = crate::vdisplay::detect_active_session();
crate::vdisplay::observe_session_instance(&active);
crate::vdisplay::apply_session_env(&active);
let compositor = crate::vdisplay::compositor_for_kind(active.kind)
.map(Ok)
.unwrap_or_else(crate::vdisplay::detect)
.context("detect compositor")?;
crate::vdisplay::apply_input_env(compositor, false);
let mut vd = crate::vdisplay::open_mirror(compositor, connector)?;
// Cursor mode is the session's negotiated one: metadata where this encode path composites
// `frame.cursor`, otherwise let the compositor embed it (§7.5 — one resolver, per-backend
// expression).
vd.set_hw_cursor(metadata_cursor);
// The mirror backend ignores the requested mode by design (§7.3 — a panel runs at the mode its
// owner set, and the client scales); pass the client's anyway so the argument stays honest.
let vout = vd
.create(punktfunk_core::Mode {
width: cfg.width,
height: cfg.height,
refresh_hz: cfg.fps,
})
.context("start mirroring the pinned monitor")?;
crate::capture::capture_virtual_output(
vout,
pf_frame::OutputFormat::resolve(cfg.hdr, crate::zerocopy::enabled()),
crate::session_plan::CaptureBackend::resolve(),
)
.context("attach a capturer to the mirrored monitor")
}
/// 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 +627,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 +659,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 +675,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 +708,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 +722,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 +948,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 +987,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 +1161,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 +1477,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]
+65
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)]
@@ -83,6 +85,57 @@ pub struct LaunchSpec {
pub value: String,
}
/// Descriptive metadata for a title — everything a richer library UI (details pane, platform
/// filter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to
/// absent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is
/// `#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat
/// wire shape everywhere.
///
/// Values are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)
/// each have their own vocabulary and the host has no business normalizing it.
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct GameMeta {
/// The system the title runs on — `"PS2"`, `"Xbox 360"`, `"SNES"`, … Installed-store
/// scanners stamp `"PC"`; `GET /library?platform=` filters on it (case-insensitive).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(example = "PS2")]
pub platform: Option<String>,
/// Short blurb for a details pane.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub developer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
/// Year of first release — the granularity metadata sources reliably agree on.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(example = 2001)]
pub release_year: Option<u16>,
/// Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub genres: Vec<String>,
/// Free-form organizational labels (`"co-op"`, `"kids"`, `"finished"`, …).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Release region — emulation-relevant (`"NTSC-U"`, `"PAL"`, `"NTSC-J"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
/// Maximum simultaneous (local) players.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub players: Option<u8>,
}
impl GameMeta {
/// The one field an installed-store scanner can assert about its own titles: they run on this
/// host, i.e. on a PC. Everything else stays absent (the launchers' local files don't carry it).
pub(crate) fn pc() -> Self {
GameMeta {
platform: Some("PC".into()),
..Default::default()
}
}
}
/// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry {
@@ -102,6 +155,18 @@ 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,
/// Descriptive metadata, flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
}
/// A store that contributes titles to the library. The trait is the extension point for future
@@ -28,6 +28,18 @@ 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,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
}
/// Request body to create or replace a custom entry (no `id` — the host owns it).
@@ -41,6 +53,13 @@ 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,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced
/// wholesale on update, like `art`: an edit must round-trip every field it wants kept.
#[serde(flatten)]
pub meta: GameMeta,
}
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
@@ -57,10 +76,30 @@ 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,
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
#[serde(flatten)]
pub meta: GameMeta,
}
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 +107,8 @@ impl From<CustomEntry> for GameEntry {
art: c.art,
launch: c.launch,
provider: c.provider,
detect,
meta: c.meta,
}
}
}
@@ -157,6 +198,8 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
prep: input.prep,
provider: None,
external_id: None,
detect: input.detect,
meta: input.meta,
};
entries.push(entry.clone());
save_custom(&entries)?;
@@ -178,6 +221,8 @@ 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;
slot.meta = input.meta;
let updated = slot.clone();
save_custom(&entries)?;
emit_changed("manual");
@@ -272,6 +317,8 @@ fn reconcile_entries(
prep: input.prep,
provider: Some(provider.to_string()),
external_id: Some(input.external_id),
detect: input.detect,
meta: input.meta,
});
}
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
@@ -349,6 +396,8 @@ mod tests {
prep: Vec::new(),
provider: None,
external_id: None,
detect: DetectHint::default(),
meta: GameMeta::default(),
}
}
@@ -359,6 +408,8 @@ mod tests {
art: Artwork::default(),
launch: None,
prep: Vec::new(),
detect: DetectHint::default(),
meta: GameMeta::default(),
}
}
@@ -372,8 +423,43 @@ mod tests {
let mut e = manual("def456", "Synced");
e.provider = Some("romm".into());
e.external_id = Some("rom-1".into());
e.meta.platform = Some("PS2".into());
let g: GameEntry = e.into();
assert_eq!(g.provider.as_deref(), Some("romm"));
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
}
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
/// pre-metadata `library.json` / payload still parses (all-optional).
#[test]
fn meta_is_flat_and_optional_on_the_wire() {
let mut e = manual("abc123", "Shadow of the Colossus");
e.meta = GameMeta {
platform: Some("PS2".into()),
release_year: Some(2005),
genres: vec!["Adventure".into()],
..Default::default()
};
let v = serde_json::to_value(&e).unwrap();
assert_eq!(v["platform"], "PS2");
assert_eq!(v["release_year"], 2005);
assert_eq!(v["genres"][0], "Adventure");
assert!(v.get("meta").is_none(), "flattened, not nested");
assert!(v.get("description").is_none(), "absent fields are omitted");
assert!(v.get("tags").is_none(), "empty lists are omitted");
// A pre-metadata entry (what an existing library.json holds) still deserializes.
let old = r#"{"id":"abc","title":"Old"}"#;
let e: CustomEntry = serde_json::from_str(old).unwrap();
assert!(e.meta.platform.is_none());
// And a provider payload can carry the fields flat, next to `title` (RFC §8 shape).
let payload = r#"{"external_id":"rom-1","title":"OoT","platform":"N64",
"region":"NTSC-U","players":1,"tags":["kids"]}"#;
let p: ProviderEntryInput = serde_json::from_str(payload).unwrap();
assert_eq!(p.meta.platform.as_deref(), Some("N64"));
assert_eq!(p.meta.players, Some(1));
}
/// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow,
+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);
}
}
+12
View File
@@ -88,8 +88,19 @@ 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,
meta: GameMeta::pc(),
id: format!("epic:{app_name}"),
store: "epic".into(),
title,
@@ -98,6 +109,7 @@ fn epic_entry(
kind: "epic".into(),
value,
}),
detect,
})
}
+5
View File
@@ -52,8 +52,12 @@ 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,
meta: GameMeta::pc(),
id,
store: "gog".into(),
title,
@@ -62,6 +66,7 @@ fn gog_games() -> Vec<GameEntry> {
kind: "gog".into(),
value: format!("{exe}\t{args}\t{workdir}"),
}),
detect,
});
}
out
+12 -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())
@@ -107,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
};
games.push(GameEntry {
provider: None,
meta: GameMeta::pc(),
id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(),
title,
@@ -115,6 +118,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)
+96 -43
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")
_ => (
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)]
+25 -5
View File
@@ -55,22 +55,36 @@ 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,
meta: GameMeta::pc(),
id: format!("lutris:{id}"),
store: "lutris".into(),
title: name,
@@ -79,6 +93,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)

Some files were not shown because too many files have changed in this diff Show More