Compare commits

...
Author SHA1 Message Date
enricobuehler b385f0a031 Merge pull request 'Rust edition 2024: the whole tree, with the env-mutation class made visible (WP20)' (#177) from worktree-edition-2024 into main
android / android (push) In progress
docker / builders-arm64cross (push) Blocked by required conditions
docker / deploy-docs (push) Blocked by required conditions
arch / build-publish (push) In progress
windows-host / canary-manifest (push) Blocked by required conditions
windows-host / winget-source (push) Blocked by required conditions
ci / rust (push) Failing after 56s
apple / swift (push) Successful in 1m36s
release / apple (push) In progress
ci / web (push) Successful in 1m9s
ci / bun-nix (push) In progress
ci / docs-site (push) Successful in 1m15s
windows-drivers / driver-build (push) In progress
windows-host / package (push) In progress
deb / build-publish-client-arm64 (push) Failing after 2m11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
flatpak / build-publish (push) In progress
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
nix / flake (push) In progress
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Waiting to run
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Waiting to run
docker / apps (., web/Dockerfile, punktfunk-web) (push) Waiting to run
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m28s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Waiting to run
apple / screenshots (push) Successful in 5m59s
ci / rust-arm64 (push) Failing after 8m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) In progress
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) In progress
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m5s
windows / build (aarch64-pc-windows-msvc) (push) Waiting to run
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m43s
deb / build-publish (push) In progress
windows-drivers / probe-and-proto (push) Successful in 23s
deb / build-publish-host (push) Successful in 7m58s
decky / build-publish (push) Successful in 36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) In progress
Reviewed-on: #177
2026-08-12 15:38:22 +00:00
enricobuehler 7528fd48a9 Merge pull request 'A stats tier picked between streams reached nothing until the app was restarted' (#178) from worktree-console-stats-tier-relatch into main
android / android (push) Canceled after 21s
apple / swift (push) Canceled after 21s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 26s
ci / rust (push) Canceled after 32s
ci / rust-arm64 (push) Canceled after 12s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 1s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #178
2026-08-12 15:38:03 +00:00
enricobuehler c68e0be688 Merge remote-tracking branch 'origin/main' into worktree-edition-2024
ci / bun-nix (pull_request) Successful in 24s
windows-drivers / probe-and-proto (pull_request) Successful in 34s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m45s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m4s
windows-drivers / driver-build (pull_request) Successful in 2m23s
ci / rust (pull_request) Failing after 4m6s
ci / rust-arm64 (pull_request) Failing after 5m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m54s
nix / flake (pull_request) Successful in 13m36s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 54s
android / android (pull_request) Successful in 15m6s
2026-08-12 17:27:27 +02:00
enricobuehler 71c1970b93 fix(client/console): a stats tier picked between streams reached nothing until a restart
ci / bun-nix (pull_request) Successful in 31s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 59s
android / android (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 4m18s
ci / rust-arm64 (pull_request) Successful in 5m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 4m17s
ci / docs-site (pull_request) Successful in 4m32s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m15s
Field report: "no matter what I select the stats overlay is stuck showing as
detailed" on the Deck, cured by restarting the client app.

The console (Gaming Mode, and therefore Decky) builds its window and its run loop
ONCE and streams every session through them, and the loop took its stats tier from
the settings snapshot read at process start. Its own settings screen writes the
chosen tier to the file and redraws its row, so the choice looked taken while every
stream kept the tier the process happened to start on — Detailed for anyone who had
been on Detailed. Only a restart re-read it. The desktop shells were never affected:
they spawn a session process per stream, which resolves settings for itself.

The tier now rides `SessionParams` per launch, so browse mode adopts what THIS launch
resolved and the start-of-process value only seeds the loop until the first stream.
Two things fall out of resolving per launch rather than per process: a profile bound
to a host can finally move the tier in console mode (part of the documented P4 gap),
and the adoption sits in the `Start` arm rather than `StreamState::new`, so the
codec-fallback retry can't snap the overlay back and undo an in-stream cycle.

The `--stats` rule (a floor that lifts Off to Normal and demotes nothing) was written
out three times and is now one tested helper. The rest of the console's latched
presentation tier — touch and mouse model, shortcut inhibit, match-window, render
scale — is unchanged and still needs the models rebuilt per launch.

Gate: clippy --all-targets -D warnings, plain build, and tests for pf-client-core,
pf-presenter and punktfunk-client-session, all green in pf-lxcheck2 (linux/amd64);
clippy proven non-vacuous by touching the four edited files. cargo fmt --all --check
clean.
2026-08-12 17:26:33 +02:00
enricobuehler f373dffb5e chore: migrate the main workspace and pf-vkhdr-layer to edition 2024 (WP20)
The safety half of the rust-safety programme's §8.4: `std::env::set_var`/`remove_var` are
`unsafe fn` in edition 2024, converting the class of bug the programme found the hard way
(the 972af299 environ data race lived in a file with ZERO occurrences of the word
`unsafe`) from invisible to counted and compiler-enforced.

Manifests: [workspace.package] edition 2021→2024, rust-version 1.82→1.85 (the pinned
toolchain is 1.96.0, so no toolchain bump — only the declared floor rises); the 13 crates
pinning `edition = "2021"` literally now inherit it (Trap 1: the root bump alone reaches
only `edition.workspace = true` crates and would have left pf-encode/pf-capture/pf-inject
et al. on 2021 while reading as complete); pf-driver-proto's stale rust-version 1.82 pin
now inherits; pf-vkhdr-layer (a separate workspace, inherits nothing) bumped to 2024. The
four vendored crates (fec-rs, cros-codecs, usbip-sim, the patched ndk) stay on 2021
deliberately — upstream code stays pristine. The excluded usbip-poc standalone PoC is
untouched.

Mechanical, done textually across ALL cfg branches so no platform's half is left behind
(Trap 3 — 44% of the host's unsafe is Windows-only and a one-platform `cargo fix` misses
it): 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 in abi.rs); 12 bare extern blocks →
`unsafe extern`; `gen` is a reserved keyword, so pf-vdisplay's generation stamps
(registry.rs, windows/manager.rs) and the WinUI shell's animation counters rename
gen → generation (internal identifiers only, no serde/wire surface); two
match-ergonomics patterns take the compiler's suggested reference form.

env mutation: every `set_var`/`remove_var` site (20 files) now sits in an `unsafe` block
whose SAFETY comment states the real serialization argument (pf-vdisplay's ENV_LOCK,
CONFIG_DIR_TEST_LOCK, ART_ROOTS_LOCK, vkdecode's gpu_lock, the `--test-threads=1`
contracts of the hardware spikes, or single-threaded startup). Two genuine hazards
surfaced en route — exactly the WP3b-class finds this migration exists to make visible —
and are fixed here:
- windows/service.rs spawned the network-profile warner thread BEFORE `load_host_env()`,
  so a child-spawning thread (child spawn snapshots the env block) was live while
  `set_var` ran in a loop; the load now precedes the spawn.
- pf-console-ui's `fake_home()` re-set HOME outside its OnceLock on EVERY call, so two
  parallel tests could race the write; the set now happens exactly once inside
  `get_or_init`.

cbindgen (Trap 2): 0.29.4 parses `#[unsafe(no_mangle)]` — verified empirically; the
header regenerates byte-identical. The ci.yml drift check could never catch "failed to
regenerate" (build.rs demotes a cbindgen failure to a warning and writes nothing, leaving
the checked-in header untouched and the diff clean), so the step now first asserts the
"punktfunk-core: wrote" line and the absence of "cbindgen failed" (sh -e safe: no `!`
pipeline, no tee-masked exit).

rustfmt: style_edition pinned to 2021 at the root — edition 2024 would otherwise flip the
style edition and reformat ~370 untouched files inside this same commit, burying the
migration diff. The drivers workspace pins its already-current 2024 style. Adopting the
2024 style tree-wide is its own future one-line-plus-reformat commit.

Census: the primary metric moves UP BY DESIGN — 2435 → 2453 operations, unsafe blocks
1534 → 1577, and env_set_var is now a counted category (45 ops). The newly counted env
sites are a truer number, not a regression; baseline snapshot saved as punktfunk-planning
design/rust-safety-census-baseline-2026-08-12-edition-2024.txt. Gate C's env ratchet is
now compiler-enforced (the hygiene-script header says so); the two shrunk file counts
(nvenc_cuda 49→2 via the test helpers, shell/tests 2→1) are lowered in the same commit
per the gate's own rule.

Drop order (the semantic change most likely to bite this codebase): the migration lint
`-W tail-expr-drop-order` reports zero findings on the macOS-visible halves of
pf-encode / pf-zerocopy / pf-capture / pf-frame; the Linux and Windows halves run the
same lint on the gate boxes. The four #[ignore]d alloc/drop-cycle tests on the hardware
boxes remain owed, as before this change.
2026-08-12 16:12:35 +02:00
enricobuehler 28b6633058 Merge pull request 'feat(display): edid_lock policy axis — pin AMD connector EDID emulation while streaming' (#176) from worktree-edid-lock-toggle into main
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / bun-audit (web) (push) Successful in 23s
audit / docs-site-audit (push) Successful in 22s
audit / pnpm-audit (push) Successful in 13s
apple / swift (push) Successful in 1m36s
audit / license-gate (push) Successful in 4m52s
ci / bun-nix (push) Successful in 23s
audit / miri (push) Successful in 5m50s
apple / screenshots (push) Successful in 5m56s
ci / web (push) Successful in 3m49s
ci / rust-arm64 (push) Successful in 4m6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
ci / docs-site (push) Successful in 3m50s
audit / c-abi-asan (push) Successful in 5m55s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 2m56s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m54s
audit / cargo-audit (push) Successful in 36s
deb / build-publish-host (push) Successful in 8m38s
release / apple (push) Successful in 9m55s
deb / build-publish (push) Successful in 6m27s
docker / deploy-docs (push) Successful in 37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 14m53s
docker / builders-arm64cross (push) Skipped
android / android (push) Successful in 15m34s
ci / rust (push) Failing after 14m34s
flatpak / build-publish (push) Successful in 20m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m1s
nix / flake (push) Failing after 20m46s
arch / build-publish (push) Successful in 21m46s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m45s
windows-host / package (push) Successful in 38m25s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 29s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m20s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m20s
2026-08-12 13:12:11 +00:00
enricobuehler c58217e403 Merge pull request 'fix(client): stop the double-arm race re-freezing RFI-healed streams' (#174) from worktree-gate-double-arm-fix into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Canceled after 0s
audit / bun-audit (sdk) (push) Canceled after 0s
audit / bun-audit (web) (push) Canceled after 0s
audit / docs-site-audit (push) Canceled after 0s
audit / pnpm-audit (push) Canceled after 0s
audit / license-gate (push) Canceled after 0s
audit / miri (push) Canceled after 0s
audit / c-abi-asan (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
nix / flake (push) Canceled after 0s
release / apple (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 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-12 13:11:51 +00:00
enricobuehler 23f9b1130e Merge pull request 'Black-stream fixes: CCD restore snapshot pruning, iOS silent-black recovery, devnode journal retention' (#175) from worktree-black-stream-fixes into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 24s
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
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-12 13:11:29 +00:00
enricobuehler c2c71f0ac5 Merge pull request 'fix(vdisplay/driver,pf-frame): no punktfunk process holds REALTIME GPU priority by default' (#173) from worktree-rt-gpu-priority-defaults into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (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 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 24s
windows-drivers / driver-build (push) Successful in 1m54s
2026-08-12 13:11:02 +00:00
enricobuehler 6a506a8fa9 fix(vdisplay/driver,pf-frame): no punktfunk process holds REALTIME GPU priority by default
windows-drivers / probe-and-proto (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 56s
ci / docs-site (pull_request) Successful in 1m16s
ci / web (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m31s
windows-drivers / driver-build (pull_request) Successful in 1m47s
android / android (pull_request) Successful in 4m40s
ci / rust (pull_request) Successful in 9m54s
apple / swift (pull_request) Failing after 13m27s
apple / screenshots (pull_request) Skipped
The RX 9070 XT field A/B (2026-08-11/12 logs) convicted BOTH of our REALTIME
GPU-scheduling levers of generating the metronomic capture-stall class the
stall program has chased for weeks — compose-silence holes of 150-800 ms in
which ETW shows NO process presenting while the GPU stays responsive:

- the vdisplay driver's IddCxSetRealtimeGPUPriority raise beat at ~1.75-1.78 s
  (PFVD_NO_RT_GPU=1 alone removed that metronome: ~0.35 stalls/s metronomic ->
  10 sparse aperiodic over 3.9 min);
- the host auto-gate's HIGH->REALTIME upgrade (pf-frame dxgi.rs, T2.3) beat at
  ~3.58 s in the AV1 sessions where it promoted (vram_pct=1, 12:59:26); pinning
  PUNKTFUNK_GPU_PRIORITY_CLASS=high removed that residual too (13:45 session:
  zero metronomic, stall rate at the clean-run baseline).

Neither period matches any punktfunk clock: the full periodic-actor census
(driver: event-paced drain + 16 ms E_PENDING wait, 33 ms cursor poll, 3 s
watchdog reap; host: 250 ms descriptor poll, 5/50/100 ms probes + ~2 s scanline
retarget, 2 s VRAM gate, 2 s exclusive re-assert, 3.33 s pinger, 1 s stats,
~1 Hz phase-lock, fps/2 LTR marks) has nothing in the 1.69-2.29 s band, and
every host-side actor ran unchanged in the A/B that killed the fast metronome.
The periodicity is emergent from holding an unreachable-priority queue against
the WDDM scheduler on this AMD family (the period even differs by which of our
processes holds REALTIME); it is not a punktfunk cadence being amplified, so
there is nothing punktfunk-periodic to fix - the fix is to stop holding
REALTIME by default, which is also canonical parity (no shipping IDD raises
it, and HIGH was the class that delivered the original Sunshine-parity encode
win).

- Driver: PFVD_NO_RT_GPU (default-ON, opt-OUT) becomes the PFVD_RT_GPU ladder,
  default OFF on every vendor: unset = no raise (canonical IDD behavior);
  =thread = SetGPUThreadPriority(+7), a graduated in-band middle rung for field
  A/B (not default: unmeasured here, and the host measured the same call as "no
  help" for its own starvation case); anything else = the old REALTIME DDI.
  PFVD_NO_RT_GPU stays recognized and WINS over the opt-in, so the field boxes
  that carry it through the default-ON era keep meaning OFF. Both directions
  remain A/B-able without a rebuild (machine env + device restart). The CPU
  half of the original branch-2 hardening (MMCSS / TIME_CRITICAL) is untouched
  - it addressed the delivery holes that were actually observed.
- Host: PUNKTFUNK_GPU_PRIORITY_CLASS default auto -> high. `auto` (the gated
  REALTIME upgrade) stays available as an explicit opt-in, `realtime` still
  pins; unrecognized values now land on the HIGH default instead of silently
  opting into the gate - a typo must not buy the hazard. The VRAM/HAGS gate
  machinery is unchanged for `auto`; it guards the NVENC-hang hazard but cannot
  see this one.
- stall.rs: the no-OS-event METRONOMIC warning now carries rt_gpu_driver /
  rt_gpu_host fields (the machine-env state of both levers) and names clearing
  them as the FIRST cure, ahead of the display-hardware suspects - a field log
  self-answers the triage question this program just spent a week on.

No console policy axis for the driver knob: the lever is default-safe now, the
driver reads config at WUDFHost scope where machine env already matches the
device-restart lifecycle, and a policy axis would need pf-driver-proto churn
(or a device-key registry write) for an experimental lever that only exists to
be A/B-ed. If the `thread` rung ever proves out as a default-worthy raise,
that is the moment to revisit.
2026-08-12 13:57:20 +02:00
enricobuehler f4e39a442b feat(display): edid_lock policy axis — pin AMD connector EDID emulation while streaming
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m45s
apple / swift (pull_request) Successful in 1m45s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 1m27s
ci / bun-nix (pull_request) Successful in 34s
android / android (pull_request) Successful in 5m52s
ci / rust (pull_request) Failing after 7m55s
ci / docs-site (pull_request) Successful in 7m54s
ci / rust-arm64 (pull_request) Successful in 9m13s
nix / flake (pull_request) Successful in 14m26s
Productizes the adl-emul probe (the prior commit) as the display-policy axis its
PR promised: the ADL FFI moves to pf_win_display::adl_emul (one surface shared by
the probe tool and the host, so a reporter's probe and the console's toggle
exercise byte-identical driver calls), and an EXPERIMENTAL edid_lock axis joins
ddc_power_off/pnp_disable_monitors — orthogonal to presets, off by default.

At the first Exclusive isolate the host pins each occupied AMD connector's live
EDID + ADL_EMUL_MODE_ALWAYS (the software HPD dummy) BEFORE the physicals
deactivate; last-member teardown unlocks. Pinned emulation outlives the process,
so a crash journal (edid-lock-active.json) unlocks on the next host start,
mirroring the pnp_disable_monitors recovery. Inert without an AMD driver.

The console shows the toggle ONLY when the GPU inventory lists an AMD adapter —
the lever exists nowhere else, and a toggle that can never act is the 'saved and
then did nothing' trap the enforced-axes list exists to prevent.
2026-08-12 08:47:42 +02:00
enricobuehler a0577cb86e feat(tools/display-disturb): adl-emul — AMD connector-emulation probe (software HPD dummy)
The standby-sink stall program's §3 dead-end list marked ADL EmulationMode
'likely Pro-gated' on field hearsay, with 'probe once, log rc' as the owed
falsification — never run. Three RX 9070 XT field cases later (ASUS
VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI), this is that
probe, shippable to reporters: read-only caps/board-layout/connection-state
walk by default, --lock pins the live EDID + ADL_EMUL_MODE_ALWAYS on
occupied connectors (the software HPD-holding dummy), --unlock restores.
Every call prints the bench's epoch_ms correlation line with the decoded
ADL rc — ADL_ERR_NOT_SUPPORTED(-8) vs ADL_OK on consumer Adrenalin is the
Pro-gating answer, and a --lock run during a stream with the sink asleep
is the direct A/B for the metronomic stall class.

atiadlxx.dll is bound dynamically (absent = clean exit 2), structs mirror
adl_structures.h verbatim, and the probe touches only connectors the
board-layout walk enumerated. Gates: check/clippy -D warnings (msvc
cross-target) + fmt clean; native stub unaffected.
2026-08-12 08:16:33 +02:00
enricobuehler 2a62fe7857 fix(client): stop the double-arm race re-freezing RFI-healed streams
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 1m42s
ci / web (pull_request) Successful in 1m51s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 6m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 13m7s
ci / rust (pull_request) Successful in 14m4s
Every unrecoverable loss armed the client's freeze gate twice: instantly at
frame-index-gap detection (which fires the RFI), and ~120 ms later when the
reassembler ages the lost frame into frames_dropped and poll() re-armed
unconditionally. An LTR-RFI recovery anchor lands in ~60 ms — between the two
signals — so the stale climb re-froze a bit-exact-healed stream, the host
swallowed the re-ask as an RFI echo, and the picture stayed frozen until the
overdue backstop extracted a full IDR: the field 'H265 freezes on every loss,
AV1 fine' signature on AMD hosts (AMF is the only LTR-RFI backend; the slower
IDR path usually lands after the climb and dodged the race).

The gap-arm now pre-credits the expected climb (ReanchorGate::arm_expecting_drops;
credit expires after DROP_CREDIT_WINDOW so a straggler-filled gap can't mask a
later real loss), and poll() consumes credited climbs instead of re-arming.
Plumbed through every embedder: pf-client-core's session pump, Android's
sync/async loops (note_frame_index now returns the gap width), and the Swift
client via new ABI exports punktfunk_connection_note_frame_index_ex +
punktfunk_reanchor_gate_arm_expecting_drops (additive; the bool ABI stays).
2026-08-12 08:11:35 +02:00
98 changed files with 2284 additions and 773 deletions
+11 -1
View File
@@ -187,7 +187,17 @@ jobs:
- name: Verify generated header is committed & up to date
run: |
cargo build -p punktfunk-core --locked
cargo build -p punktfunk-core --locked >/tmp/core-build.log 2>&1 \
|| { cat /tmp/core-build.log; exit 1; }
cat /tmp/core-build.log
# build.rs demotes a cbindgen failure to a warning and then writes NOTHING — the
# checked-in header stays untouched and the drift check below stays green while the
# header is silently stale. So first assert the regeneration actually happened.
# (cargo replays build-script warnings from cache, so this holds on cached builds too.)
grep -q "punktfunk-core: wrote" /tmp/core-build.log
if grep -q "cbindgen failed" /tmp/core-build.log; then
echo "cbindgen failed to parse the ABI surface — header NOT regenerated" && exit 1
fi
git config --global --add safe.directory "$PWD"
git diff --exit-code include/punktfunk_core.h \
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
Generated
+1
View File
@@ -1116,6 +1116,7 @@ dependencies = [
name = "display-disturb"
version = "0.27.0"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
+2 -2
View File
@@ -66,8 +66,8 @@ ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.27.0"
edition = "2021"
rust-version = "1.82"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
authors = ["unom"]
repository = "https://git.unom.io/unom/punktfunk"
+4
View File
@@ -4753,6 +4753,10 @@
"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."
},
"edid_lock": {
"type": "boolean",
"description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off."
},
"game_session": {
"$ref": "#/components/schemas/GameSession",
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
@@ -43,10 +43,12 @@ struct OutputReady {
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
enum DecodeEvent {
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
Au(Frame, bool),
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
/// reassembler's later `frames_dropped` climb for the loss (the feeder already fired the RFI
/// request).
Au(Frame, u32),
/// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
@@ -603,7 +605,11 @@ fn feeder_loop(
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
// counting AUs.
let au_first = frame.part.is_none_or(|p| p.first);
let gap = au_first && client.note_frame_index(frame.frame_index);
let gap = if au_first {
client.note_frame_index(frame.frame_index)
} else {
0
};
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
// HUD-only `received` point + host/network split stay gated on the overlay.
@@ -691,9 +697,12 @@ fn dispatch_event(
match ev {
DecodeEvent::Au(f, gap) => {
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
// fold `on_decoded` (keyed by the pts the codec will echo).
if gap {
gate.arm(Instant::now());
// fold `on_decoded` (keyed by the pts the codec will echo). Credited arm: the gap width
// pre-covers the reassembler's ~120 ms-later `frames_dropped` climb for the same loss,
// so a fast RFI anchor that heals in between isn't re-frozen by it (the double-arm
// race — see `ReanchorGate::arm_expecting_drops`).
if gap > 0 {
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
}
// One entry per AU (parts share the pts): the completing delivery carries it.
if f.complete {
@@ -222,8 +222,13 @@ pub(super) fn run_sync(
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
// arms the freeze gate so the decoder's concealment is held off the screen until the
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
if client.note_frame_index(frame.frame_index) {
gate.arm(Instant::now());
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
// `frames_dropped` climb for the same loss, so a fast RFI anchor that heals in
// between isn't re-frozen by it (the double-arm race — see
// `ReanchorGate::arm_expecting_drops`).
let gap = client.note_frame_index(frame.frame_index);
if gap > 0 {
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
}
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
+3 -3
View File
@@ -200,7 +200,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
///
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
_env: JNIEnv,
_this: JObject,
@@ -214,7 +214,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -245,7 +245,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
///
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
_env: JNIEnv,
_this: JObject,
+2 -2
View File
@@ -60,7 +60,7 @@ const TAG_HID_RAW: u8 = 0x05;
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
/// back to the controller holding that wire `pad` index (multi-pad rumble). Run from a Kotlin
/// poll thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv,
_this: JObject,
@@ -99,7 +99,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
env: JNIEnv,
_this: JObject,
+3 -3
View File
@@ -54,7 +54,7 @@ mod probe;
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn JNI_OnLoad(
_vm: *mut jni::sys::JavaVM,
_reserved: *mut std::ffi::c_void,
@@ -74,7 +74,7 @@ pub extern "system" fn JNI_OnLoad(
/// `NativeBridge.abiVersion(): Int` — the core's C-ABI version. A non-error return is the
/// scaffold's proof that `System.loadLibrary` found the `.so`, the JNI symbol resolved, and the
/// linked `punktfunk-core` is the one we expect.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
_env: JNIEnv,
_this: JObject,
@@ -83,7 +83,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
}
/// `NativeBridge.coreVersion(): String` — the crate version, proving JNI string marshaling works.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
+1 -1
View File
@@ -14,7 +14,7 @@ use std::time::Duration;
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
mut env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -40,7 +40,7 @@ fn client(handle: jlong) -> Option<&'static SessionHandle> {
}
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
_env: JNIEnv,
_this: JObject,
@@ -53,7 +53,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupport
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
/// clipboard-related happens on either side until an `enabled: true` crosses.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
_env: JNIEnv,
_this: JObject,
@@ -68,7 +68,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
/// counter, newest wins.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
_env: JNIEnv,
_this: JObject,
@@ -88,7 +88,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferTe
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or 1.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
_env: JNIEnv,
_this: JObject,
@@ -106,7 +106,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchTe
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
/// clipboard's current text (the host is pasting our offer).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
mut env: JNIEnv,
_this: JObject,
@@ -125,7 +125,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeTe
}
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
_env: JNIEnv,
_this: JObject,
@@ -144,7 +144,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
/// can never split a UTF-8 sequence.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
env: JNIEnv,
_this: JObject,
+10 -10
View File
@@ -36,7 +36,7 @@ fn note_error(e: &punktfunk_core::error::PunktfunkError) {
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
/// handle / `""` fingerprint.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -51,7 +51,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastErr
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -74,7 +74,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket
/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
_env: JNIEnv,
_this: JObject,
@@ -120,7 +120,7 @@ fn force_parts_sysprop() -> bool {
/// budget: the normal path passes a short value, the no-PIN "request access" path a long one (≥ the
/// host's approval-park window) so a slow operator approval lands on this same parked connection
/// rather than timing the client out first. Returns an opaque handle, or 0 on failure.
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>,
@@ -322,7 +322,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
/// # Safety contract
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
_env: JNIEnv,
_this: JObject,
@@ -344,7 +344,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
/// # Safety contract
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
_env: JNIEnv,
_this: JObject,
@@ -363,7 +363,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQ
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
/// connect. `""` on a `0` handle.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -388,7 +388,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
/// handle. Cheap (one atomic load); safe on the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
_env: JNIEnv,
_this: JObject,
@@ -413,7 +413,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for
/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one
/// atomic load); safe on the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
_env: JNIEnv,
_this: JObject,
@@ -433,7 +433,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
mut env: JNIEnv<'local>,
+18 -18
View File
@@ -35,7 +35,7 @@ fn send_event(handle: jlong, kind: InputKind, code: u32, x: i32, y: i32, flags:
}
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
_env: JNIEnv,
_this: JObject,
@@ -51,7 +51,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
_env: JNIEnv,
_this: JObject,
@@ -68,7 +68,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
_env: JNIEnv,
_this: JObject,
@@ -86,7 +86,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
/// `NativeBridge.nativeSendScroll(handle, axis, delta)` — one scroll step. `axis`: 0=vertical,
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
_env: JNIEnv,
_this: JObject,
@@ -103,7 +103,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
/// (libei touchscreen / wlroots / SendInput).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
_env: JNIEnv,
_this: JObject,
@@ -128,7 +128,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
_env: JNIEnv,
_this: JObject,
@@ -151,7 +151,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
_env: JNIEnv,
_this: JObject,
@@ -168,7 +168,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSu
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
/// Kotlin side splits stylus pointers out of the touch path onto the pen plane
/// (design/pen-tablet-input.md §7). `0` handle → false.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
_env: JNIEnv,
_this: JObject,
@@ -197,7 +197,7 @@ const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
/// normalized 0..1; `distance`/`tilt_deg`/`azimuth_deg`/`roll_deg` < 0 = unknown. Call only
/// against a [`nativeHostSupportsPen`] host; the client heartbeats the last sample ≤100 ms
/// while in range (Kotlin side — see `StylusStream`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
env: JNIEnv,
_this: JObject,
@@ -264,7 +264,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
mut env: JNIEnv,
_this: JObject,
@@ -296,7 +296,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
_env: JNIEnv,
_this: JObject,
@@ -318,7 +318,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
/// (32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
_env: JNIEnv,
_this: JObject,
@@ -343,7 +343,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
_env: JNIEnv,
_this: JObject,
@@ -373,7 +373,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
///
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
/// the `Auto` rule inside the predicate itself.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
_env: JNIEnv,
_this: JObject,
@@ -399,7 +399,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionRe
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
_env: JNIEnv,
_this: JObject,
@@ -415,7 +415,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
/// own report rate (~250500 Hz) — the direct-buffer read avoids a JNI array copy per report.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
env: JNIEnv,
_this: JObject,
@@ -455,7 +455,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
/// the capture diffs, the host holds per-slot state.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
_env: JNIEnv,
_this: JObject,
@@ -485,7 +485,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
/// controller's report rate.
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
_env: JNIEnv,
+17 -17
View File
@@ -19,7 +19,7 @@ use super::{jni_guard, lock_recover, SessionHandle};
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
/// No-op if already started.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
mut env: JNIEnv,
_this: JObject,
@@ -91,7 +91,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -116,7 +116,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -140,7 +140,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecL
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
/// device).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -161,7 +161,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecode
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
/// session). No-op on `0`.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
_env: JNIEnv,
_this: JObject,
@@ -210,7 +210,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
/// Poll ~1 Hz from the UI; each call
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
/// the host build too (Kotlin only ever calls it on device).
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
env: JNIEnv,
_this: JObject,
@@ -312,7 +312,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
/// build too. Cheap; safe on the UI thread.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
env: JNIEnv,
_this: JObject,
@@ -346,7 +346,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
/// pure `jni` + an atomic store, so it links on the host build too.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
_env: JNIEnv,
_this: JObject,
@@ -373,7 +373,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
/// streaming.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
_env: JNIEnv,
_this: JObject,
@@ -398,7 +398,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
/// closing the session). No-op on `0`.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
_env: JNIEnv,
_this: JObject,
@@ -422,7 +422,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
/// the rest of the session streaming.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
_env: JNIEnv,
_this: JObject,
@@ -457,7 +457,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
_env: JNIEnv,
_this: JObject,
@@ -484,7 +484,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
/// app-side fix worth blocking a session on.
#[no_mangle]
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
_env: JNIEnv,
@@ -530,7 +530,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
/// never reveal that the client handed the renderer a descriptor something else was already
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
#[no_mangle]
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
_env: JNIEnv,
@@ -553,7 +553,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSel
///
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
/// `UsbDeviceConnection` as soon as this returns and not before.
#[no_mangle]
#[unsafe(no_mangle)]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
_env: JNIEnv,
@@ -594,7 +594,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
/// no captured audio leaves the process.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
_env: JNIEnv,
_this: JObject,
@@ -617,7 +617,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
#[cfg(target_os = "android")]
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
_env: JNIEnv,
_this: JObject,
+2 -2
View File
@@ -23,7 +23,7 @@ const PROBE_RESULT_LEN: usize = 6;
/// **briefly pausing video**. Non-blocking: poll
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] until its `done` element is 1.
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
_env: JNIEnv,
_this: JObject,
@@ -54,7 +54,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
///
/// Layout (doubles so one array carries both the counts and the percentages):
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
+1 -1
View File
@@ -10,7 +10,7 @@ use jni::JNIEnv;
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
/// Returns true if at least one datagram went out.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
mut env: JNIEnv<'local>,
_this: JObject<'local>,
@@ -774,12 +774,22 @@ public final class PunktfunkConnection {
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
/// after close.
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
noteFrameIndexGapWidth(frameIndex) > 0
}
/// Like `noteFrameIndexGap`, but reports the gap's WIDTH how many frames this arrival revealed
/// as missing (0 = none). The post-loss re-anchor gate arms with the width
/// (`ReanchorGate.arm(expectingDrops:)`) so the reassembler's later `framesDropped` climb for
/// the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm race).
/// Same core side effect as `noteFrameIndex` (the throttled RFI request); call it for every
/// received AU. Returns 0 after close.
public func noteFrameIndexGapWidth(_ frameIndex: UInt32) -> UInt32 {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return false }
var gap = false
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
return gap
guard let h = handle, !closeRequested else { return 0 }
var width: UInt32 = 0
_ = punktfunk_connection_note_frame_index_ex(h, frameIndex, &width)
return width
}
/// Cumulative access units the hostclient reassembler dropped as unrecoverable (FEC couldn't
@@ -55,6 +55,16 @@ final class ReanchorGate: @unchecked Sendable {
lock.unlock()
}
/// `arm()` for a loss detected as a frame-index gap of a known width
/// (`PunktfunkConnection.noteFrameIndexGapWidth`). Pre-credits the reassembler's later
/// `framesDropped` climb for the same lost frames, so `poll` doesn't re-freeze a stream an
/// RFI anchor already healed (the double-arm race the Rust gate's docs tell the story).
func arm(expectingDrops: UInt64) {
lock.lock()
punktfunk_reanchor_gate_arm_expecting_drops(ptr, expectingDrops)
lock.unlock()
}
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
/// `decoderKeyframe: false` VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
@@ -923,7 +923,11 @@ public final class Stage2Pipeline {
// recovery above stays the backstop for when the recovery frame itself is lost.
// The same gap is the earliest, most precise signal to ARM the display freeze
// the following concealed frames are withheld until a clean re-anchor.
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
// between isn't re-frozen by it (the double-arm race).
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
if gapWidth > 0 { reanchorGate.arm(expectingDrops: UInt64(gapWidth)) }
onFrame?(au)
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
format = f // refreshed on every IDR (mode changes included)
@@ -100,7 +100,11 @@ final class StreamPump {
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
// recovery above stays the backstop for when the recovery frame itself is lost.
// The same gap is the earliest, most precise signal to ARM the display freeze.
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
// between isn't re-frozen by it (the double-arm race).
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
if gapWidth > 0 { gate.arm(expectingDrops: UInt64(gapWidth)) }
onFrame?(au)
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
if let f = idrFormat {
+3 -1
View File
@@ -885,7 +885,9 @@ pub fn run() -> glib::ExitCode {
] {
if let Ok(v) = std::env::var(var) {
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
std::env::remove_var(var);
// SAFETY: top of `run()`, before GTK init or any other thread exists in this
// process — nothing reads the environment concurrently.
unsafe { std::env::remove_var(var) };
}
}
// Headless paths (no GTK window).
+4 -1
View File
@@ -135,7 +135,10 @@ mod tests {
let home = std::env::temp_dir().join(format!("pf-spawn-test-{}", std::process::id()));
let cfg = home.join(".config/punktfunk");
std::fs::create_dir_all(&cfg).unwrap();
std::env::set_var("HOME", &home);
// SAFETY: the only env-mutating test in this binary (see the doc above — one test, one
// `HOME`, deliberately not split). Parallel tests may `getenv` concurrently; glibc keeps
// replaced environ storage alive, and every reader tolerates either value.
unsafe { std::env::set_var("HOME", &home) };
// A device whose owner has set a bitrate, and a host bound to a profile that raises it
// further — the two layers the spec has to carry.
+15 -11
View File
@@ -13,7 +13,7 @@
//! (portrait paths starting with `/` load from disk), the GPU-only dev path.
use crate::session_main::{
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos,
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, stats_tier, window_pos,
};
use pf_client_core::gamepad::is_steam_deck;
use pf_client_core::{discovery, library, trust, wol};
@@ -141,11 +141,18 @@ pub fn run(target: Option<&str>) -> u8 {
let json_status = arg_flag("--json-status");
let settings_at_start = trust::Settings::load();
// The console's window and its input models are built ONCE, from the global defaults, and
// live across every launch — so the presentation-tier fields below (stats tier, touch and
// mouse model, shortcut inhibit, match-window, render scale) are latched here and a per-host
// profile cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec,
// audio, pad) is re-resolved per launch and does honor the binding. Closing that gap means
// rebuilding the presenter's models per launch — profiles P4 territory, not P0.
// live across every launch — so the presentation-tier fields below (touch and mouse model,
// shortcut inhibit, match-window, render scale) are latched here and a per-host profile
// cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec, audio,
// pad) is re-resolved per launch and does honor the binding. Closing the rest of that gap
// means rebuilding the presenter's models per launch — profiles P4 territory, not P0.
//
// ⚠ The STATS TIER used to be latched here too, and that was a bug people hit: the console's
// own settings screen writes the tier to the file and redraws its row, so the choice looked
// taken while every stream kept the tier the process started on — "no matter what I select
// the overlay is stuck on Detailed", cured only by restarting the app. It now rides
// `SessionParams` per launch (`stats_verbosity`), so the value below only seeds the loop
// until the first stream. Anything else moved off this snapshot has to travel the same way.
let latched_mouse = settings_at_start.mouse_mode();
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
@@ -162,11 +169,8 @@ pub fn run(target: Option<&str>) -> u8 {
),
fullscreen: fullscreen_mode(),
window_pos: window_pos(),
// `--stats` forces the overlay visible without demoting a richer chosen tier.
stats_verbosity: match settings_at_start.stats_verbosity() {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
// Seeds the loop only — every launch carries its own freshly resolved tier.
stats_verbosity: stats_tier(&settings_at_start),
touch_mode: settings_at_start.touch_mode(),
mouse_mode: settings_at_start.mouse_mode(),
invert_scroll: settings_at_start.invert_scroll,
+74 -10
View File
@@ -116,6 +116,28 @@ mod session_main {
std::env::args().any(|a| a == flag)
}
/// The stats-overlay tier a session starts on: the resolved setting, except that
/// `--stats` (tooling/debug runs) forces the overlay VISIBLE without demoting an
/// explicitly chosen richer tier.
///
/// One helper because three callers need the identical rule — both run modes' presenter
/// options and the per-launch [`session_params`] — and a fourth reading of it would be
/// the bug this is here to prevent.
pub(crate) fn stats_tier(settings: &trust::Settings) -> trust::StatsVerbosity {
stats_tier_with(settings.stats_verbosity(), arg_flag("--stats"))
}
/// [`stats_tier`]'s rule, with argv lifted out so it is testable.
pub(crate) fn stats_tier_with(
chosen: trust::StatsVerbosity,
stats_flag: bool,
) -> trust::StatsVerbosity {
match chosen {
trust::StatsVerbosity::Off if stats_flag => trust::StatsVerbosity::Normal,
v => v,
}
}
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
/// "auto" policy keys off this.
@@ -420,6 +442,12 @@ mod session_main {
connect_timeout: connect_timeout(),
force_software,
profile,
// Presentation-tier, carried per launch rather than read once by the run loop:
// the console streams many sessions through ONE loop, so this is the only way a
// tier the user picked between streams (or one a host's profile carries) reaches
// the overlay before the app is restarted. Single mode passes the same value its
// presenter options already hold, so it changes nothing there.
stats_verbosity: stats_tier(settings),
// Phase-locked capture (design/phase-locked-capture.md, Apple/Android parity):
// advertised only when the presenter has real on-glass latch stamps
// (VK_KHR_present_wait) — without them there is no latch grid to report. The
@@ -509,8 +537,13 @@ mod session_main {
const TOKEN: &str = "video_decode";
match std::env::var("RADV_PERFTEST") {
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
// SAFETY: called at the very top of `run()`, before this process creates any
// thread — the Vulkan loader, SDL, and the session runtime all start later.
Ok(v) if !v.is_empty() => unsafe {
std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}"))
},
// SAFETY: as above — single-threaded startup.
_ => unsafe { std::env::set_var("RADV_PERFTEST", TOKEN) },
}
tracing::info!(
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
@@ -804,7 +837,10 @@ mod session_main {
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
] {
if std::env::var_os(var).is_none() && !value.is_empty() {
std::env::set_var(var, value);
// SAFETY: still the single-threaded startup stretch of `run()` — the
// early-exit probes above return out of the process, and everything that
// spawns threads (the session, the console, SDL) only starts below.
unsafe { std::env::set_var(var, value) };
}
}
}
@@ -818,7 +854,9 @@ mod session_main {
] {
if let Ok(v) = std::env::var(var) {
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
std::env::remove_var(var);
// SAFETY: as the settings block above — single-threaded startup, before SDL
// (the reader of these variables) or any other thread exists.
unsafe { std::env::remove_var(var) };
}
}
@@ -926,12 +964,7 @@ mod session_main {
window_title: format!("Punktfunk · {title}"),
fullscreen,
window_pos: window_pos(),
// `--stats` forces the overlay visible (tooling/debug runs) without
// demoting an explicitly chosen richer tier.
stats_verbosity: match settings.stats_verbosity() {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
stats_verbosity: stats_tier(&settings),
touch_mode: settings.touch_mode(),
mouse_mode: settings.mouse_mode(),
invert_scroll: settings.invert_scroll,
@@ -1000,6 +1033,37 @@ mod session_main {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use trust::StatsVerbosity as V;
/// `--stats` is a floor, never a ceiling: it lifts Off to Normal and leaves every
/// richer chosen tier alone. Both run modes' presenter options AND the per-launch
/// params read this one rule, which is the point of having it.
#[test]
fn the_stats_flag_lifts_off_and_demotes_nothing() {
assert_eq!(stats_tier_with(V::Off, true), V::Normal);
assert_eq!(stats_tier_with(V::Off, false), V::Off);
for chosen in [V::Compact, V::Normal, V::Detailed] {
assert_eq!(stats_tier_with(chosen, true), chosen);
assert_eq!(stats_tier_with(chosen, false), chosen);
}
}
/// The console reads the file ONCE for its window, so a tier changed between streams
/// can only reach the overlay by riding the launch. Guards the wiring the field exists
/// for: whatever settings a launch resolved is what the params carry.
#[test]
fn a_launch_carries_the_tier_its_settings_resolved() {
let mut s = trust::Settings::default();
for chosen in [V::Off, V::Compact, V::Normal, V::Detailed] {
s.set_stats_verbosity(chosen);
assert_eq!(stats_tier_with(s.stats_verbosity(), false), chosen);
}
}
}
}
#[cfg(any(target_os = "linux", windows))]
+1 -1
View File
@@ -3,7 +3,7 @@ name = "punktfunk-client-windows"
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary"
version.workspace = true
edition.workspace = true
# Not workspace-inherited (1.82): windows-reactor at the pinned rev declares rust-version 1.95+
# Not workspace-inherited (1.85): windows-reactor at the pinned rev declares rust-version 1.95+
# and edition 2024. rust-toolchain.toml pins 1.96, so this records reality rather than raising it.
rust-version = "1.96"
license.workspace = true
+10 -10
View File
@@ -318,10 +318,10 @@ fn edit_editor(
if !addr.is_empty() {
h.addr = addr;
}
if let Ok(p) = port_draft.borrow().trim().parse::<u16>() {
if p != 0 {
h.port = p;
}
if let Ok(p) = port_draft.borrow().trim().parse::<u16>()
&& p != 0
{
h.port = p;
}
let mac = mac_draft.borrow().trim().to_string();
h.mac = if mac.is_empty() {
@@ -1094,12 +1094,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.close_button_text("Cancel")
.is_open(pending.is_some())
.on_closed(move |r: ContentDialogResult| {
if r == ContentDialogResult::Primary {
if let Some((fp, _)) = &pending {
let mut known = KnownHosts::load();
known.remove_by_fp(fp);
let _ = known.save();
}
if r == ContentDialogResult::Primary
&& let Some((fp, _)) = &pending
{
let mut known = KnownHosts::load();
known.remove_by_fp(fp);
let _ = known.save();
}
sf.call(None); // re-renders the page; the row is gone on the next load
})
+40 -37
View File
@@ -515,35 +515,37 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
move || {
std::thread::Builder::new()
.name("pf-probe".into())
.spawn(move || loop {
// A spawned session/browse child is running: the shell is hidden
// (nobody sees the pips) and one of these hosts is mid-stream —
// probing it is pure noise. Sleep through and sweep after it ends.
if shared.session.lock().unwrap().is_running() {
std::thread::sleep(Duration::from_secs(12));
continue;
}
let handles: Vec<_> = KnownHosts::load()
.hosts
.into_iter()
.filter(|h| !h.addr.is_empty())
.map(|h| {
std::thread::spawn(move || {
(
h.fp_hex,
NativeClient::probe(
&h.addr,
h.port,
Duration::from_millis(2500),
),
)
.spawn(move || {
loop {
// A spawned session/browse child is running: the shell is hidden
// (nobody sees the pips) and one of these hosts is mid-stream —
// probing it is pure noise. Sleep through and sweep after it ends.
if shared.session.lock().unwrap().is_running() {
std::thread::sleep(Duration::from_secs(12));
continue;
}
let handles: Vec<_> = KnownHosts::load()
.hosts
.into_iter()
.filter(|h| !h.addr.is_empty())
.map(|h| {
std::thread::spawn(move || {
(
h.fp_hex,
NativeClient::probe(
&h.addr,
h.port,
Duration::from_millis(2500),
),
)
})
})
})
.collect();
let map: HashMap<String, bool> =
handles.into_iter().filter_map(|h| h.join().ok()).collect();
set_probed.call(map);
std::thread::sleep(Duration::from_secs(12));
.collect();
let map: HashMap<String, bool> =
handles.into_iter().filter_map(|h| h.join().ok()).collect();
set_probed.call(map);
std::thread::sleep(Duration::from_secs(12));
}
})
.ok();
}
@@ -560,14 +562,15 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let anim_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
let (anim, set_anim) = cx.use_async_state((Option::<Screen>::None, 1.0f64));
cx.use_effect(screen.clone(), {
let (s, set_anim, gen) = (screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
let (s, set_anim, generation) =
(screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
move || {
use std::sync::atomic::Ordering::SeqCst;
let mine = gen.fetch_add(1, SeqCst) + 1;
let mine = generation.fetch_add(1, SeqCst) + 1;
std::thread::spawn(move || {
const STEPS: u32 = 14;
for i in 0..=STEPS {
if gen.load(SeqCst) != mine {
if generation.load(SeqCst) != mine {
return; // a newer navigation superseded this tween
}
let p = f64::from(i) / f64::from(STEPS);
@@ -593,18 +596,18 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let nav_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
let (nav_anim, set_nav_anim) = cx.use_async_state((String::new(), 1.0f64));
cx.use_effect(settings_nav.clone(), {
let (s, set_nav_anim, gen) = (
let (s, set_nav_anim, generation) = (
settings_nav.clone(),
set_nav_anim.clone(),
nav_gen.borrow().clone(),
);
move || {
use std::sync::atomic::Ordering::SeqCst;
let mine = gen.fetch_add(1, SeqCst) + 1;
let mine = generation.fetch_add(1, SeqCst) + 1;
std::thread::spawn(move || {
const STEPS: u32 = 14;
for i in 0..=STEPS {
if gen.load(SeqCst) != mine {
if generation.load(SeqCst) != mine {
return; // a newer section switch superseded this tween
}
let p = f64::from(i) / f64::from(STEPS);
@@ -628,10 +631,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let add_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
let (add_anim, set_add_anim) = cx.use_async_state(0.0f64);
cx.use_effect(show_add, {
let (set_add_anim, gen) = (set_add_anim.clone(), add_gen.borrow().clone());
let (set_add_anim, generation) = (set_add_anim.clone(), add_gen.borrow().clone());
move || {
use std::sync::atomic::Ordering::SeqCst;
let mine = gen.fetch_add(1, SeqCst) + 1;
let mine = generation.fetch_add(1, SeqCst) + 1;
if !show_add {
set_add_anim.call(0.0);
return;
@@ -639,7 +642,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
std::thread::spawn(move || {
const STEPS: u32 = 12;
for i in 0..=STEPS {
if gen.load(SeqCst) != mine {
if generation.load(SeqCst) != mine {
return; // reopened/closed mid-tween — a newer run owns the value
}
let p = f64::from(i) / f64::from(STEPS);
+5 -4
View File
@@ -82,10 +82,11 @@ fn main() {
// where the user's hosts already are. A hand-off that finds nobody falls through and this
// process becomes the shell that opens it, so the link is never simply lost.
let link = deeplink::positional_url(&args);
if let Some(url) = &link {
if !deeplink::claim_primary() && deeplink::forward_to_primary(url) {
return;
}
if let Some(url) = &link
&& !deeplink::claim_primary()
&& deeplink::forward_to_primary(url)
{
return;
}
if flag("--discover") {
+1 -1
View File
@@ -7,7 +7,7 @@
[package]
name = "pf-capture"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host frame capture: Linux PipeWire portal + Windows IDD direct-push capturers behind one Capturer trait."
+1 -1
View File
@@ -64,7 +64,7 @@ pub(crate) fn hybrid_hook_hits() -> u64 {
// on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a
// different core), so the "same-thread, no flush needed" assumption was wrong.
#[link(name = "kernel32")]
extern "system" {
unsafe extern "system" {
fn FlushInstructionCache(h: *mut c_void, base: *const c_void, size: usize) -> i32;
fn GetCurrentProcess() -> *mut c_void;
}
@@ -533,14 +533,47 @@ impl StallWatch {
suspects)"
);
} else {
// The two REALTIME GPU-priority opt-ins, as configured in THIS process's
// environment (machine env; the WUDFHost driver process resolves the PFVD pair
// the same way, so this read mirrors what the driver decided — modulo a machine
// env edited after either process started, which a restart heals). The RX 9070
// XT field A/B (2026-08-12) convicted EXACTLY this warning's signature twice
// over: the driver's swap-chain REALTIME raise beat at ~1.8 s, the host
// auto-gate's REALTIME upgrade at ~3.6 s — so a log carrying this warning must
// say whether either lever is engaged before anyone chases display hardware.
let rt_gpu_driver = if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
"off (PFVD_NO_RT_GPU)"
} else {
match std::env::var_os("PFVD_RT_GPU") {
None => "off (default)",
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
Some(_) => "REALTIME (PFVD_RT_GPU)",
}
};
let rt_gpu_host = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok()
.as_deref()
{
Some("off") => "off",
Some("normal") => "normal",
Some("realtime") => "REALTIME (pinned)",
Some("auto") => "auto (gated REALTIME upgrade)",
_ => "high (default)",
};
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
os_correlated = correlated,
connected_inactive = %suspects,
rt_gpu_driver,
rt_gpu_host,
verdicts = %verdict_tally,
classes = %class_tally,
"capture stalls are METRONOMIC with NO coinciding OS display event — \
the disturbance is BELOW Windows: the GPU driver servicing a \
the disturbance is BELOW Windows. FIRST: if rt_gpu_driver or \
rt_gpu_host shows a REALTIME opt-in, clear it (unset PFVD_RT_GPU / \
set PUNKTFUNK_GPU_PRIORITY_CLASS=high) a punktfunk process holding \
REALTIME GPU priority is the field-proven amplifier of exactly this \
signature on AMD. Otherwise: 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 \
+4 -4
View File
@@ -164,10 +164,10 @@ fn read_appid(conn: &RustConnection, root: Window, atom: Atom) -> Option<u32> {
.ok()?
.reply()
.ok()?;
// Bound rather than returned inline: the iterator borrows `reply`, and as a tail
// expression its temporary would outlive it.
let id = reply.value32()?.next();
id
// Inline is sound since edition 2024: tail-expression temporaries now drop BEFORE the
// block's locals, so the iterator borrowing `reply` no longer outlives it (the 2021 rule
// forced a `let` binding here).
reply.value32()?.next()
}
/// The whole decision, separated from X so it can be tested: an overlay is up exactly when
+19 -1
View File
@@ -104,6 +104,19 @@ pub struct SessionParams {
/// above; it rides along so the stats overlay can answer "which profile am I on?" without
/// re-reading any store (design/client-settings-profiles.md §5.2).
pub profile: Option<String>,
/// The stats-overlay tier THIS launch resolved to — the globals, or the profile bound to
/// this host. Presentation-tier, like [`profile`](Self::profile): the session controller
/// never reads it, it rides along so the presenter can adopt it when a browse-mode launch
/// starts.
///
/// That adoption is the whole point. The console (Gaming Mode / Decky) builds its window
/// and its run loop ONCE and streams many sessions through them, so a tier taken only from
/// the loop's start-of-process options could never change again — a user picking a tier in
/// the console's settings screen saw the row move, the file updated, and every stream keep
/// the old overlay until the app was restarted. Carrying it per launch is what lets the
/// choice land on the next stream, and it makes a profile's `stats_verbosity` reach the
/// console too. The in-stream cycle chord still wins for the rest of the stream it moved.
pub stats_verbosity: crate::trust::StatsVerbosity,
/// Advertise `quic::CLIENT_CAP_PHASE_LOCK`: this embedder's presenter has REAL on-glass
/// latch stamps (`VK_KHR_present_wait`) and will feed [`latch_grid`](Self::latch_grid),
/// so the pump sends the ~1 Hz `PhaseReport`s the host phase-locks its capture tick to
@@ -885,7 +898,12 @@ fn pump(
Some(exp) => {
if let Some(gap) = index_gap(exp, frame.frame_index) {
let now = Instant::now();
gate.arm(now);
// Credited arm: the reassembler books these same lost frames into
// `frames_dropped` up to ~120 ms from now; the credit keeps that
// delayed climb from re-freezing a stream the RFI anchor healed in
// between (the double-arm race — see
// `ReanchorGate::arm_expecting_drops`).
gate.arm_expecting_drops(now, u64::from(gap));
next_expected_index = Some(frame.frame_index.wrapping_add(1));
// The gap carries the PRECISE lost range — [first missing, newest
// received - 1] — so this is the one recovery signal that can drive true
@@ -2241,7 +2241,7 @@ mod parity {
// `desc.Height` rows at `RowPitch` and the chroma plane follows at byte
// offset `RowPitch * desc.Height`, so `total` below is exactly the mapped
// extent and every sub-slice read is inside it. `Unmap` pairs the `Map`.
let out = unsafe {
unsafe {
let src: ID3D11Resource = pool.cast().expect("pool -> resource");
let dst: ID3D11Resource = staging.cast().expect("staging -> resource");
self.ctx
@@ -2268,8 +2268,7 @@ mod parity {
}
self.ctx.Unmap(&staging, 0);
out
};
out
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
[package]
name = "pf-clipboard"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host shared clipboard: per-OS session-clipboard backends behind one HostClipboard + the QUIC clipboard-plane coordinator."
+8 -4
View File
@@ -382,13 +382,13 @@ impl SettingsScreen {
}
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
}
};
}
RowId::NoProfiles => {
return match msg {
ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
}
};
}
_ => {}
}
@@ -1054,12 +1054,16 @@ mod tests {
fn fake_home() {
use std::sync::OnceLock;
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
let dir = HOME.get_or_init(|| {
HOME.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("pf-settings-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// SAFETY: runs at most once, inside `get_or_init` — concurrent `fake_home` callers
// block until it returns, and nothing else in this binary mutates `HOME`. (The old
// set after the closure ran on EVERY call, so two parallel tests could race the
// write; setting once under the OnceLock is what makes this sound.)
unsafe { std::env::set_var("HOME", &dir) };
dir
});
std::env::set_var("HOME", dir);
}
/// Render the screen once so its strip and list carry real geometry, then hand back a
+7 -4
View File
@@ -49,13 +49,16 @@ fn motion_matches_the_shared_vectors() {
fn fake_home() {
use std::sync::OnceLock;
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
let dir = HOME.get_or_init(|| {
HOME.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("pf-console-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("HOME", &dir);
dir.clone()
// SAFETY: runs at most once, inside `get_or_init` — concurrent `fake_home` callers
// block until it returns, and nothing else in this binary mutates `HOME`. (The old
// re-set after the closure ran on EVERY call, so two parallel tests could race the
// write; setting once under the OnceLock is what makes this sound.)
unsafe { std::env::set_var("HOME", &dir) };
dir
});
std::env::set_var("HOME", dir);
}
fn hosts() -> Vec<HostRow> {
+2 -2
View File
@@ -9,8 +9,8 @@
[package]
name = "pf-driver-proto"
version = "0.0.1"
edition = "2021"
rust-version = "1.82"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display (control IOCTLs + IDD-push frame transport)."
publish = false
+1 -1
View File
@@ -6,7 +6,7 @@
[package]
name = "pf-encode"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host video encode: NVENC/VAAPI/AMF/QSV/Vulkan-Video/PyroWave/openh264 backends behind one Encoder trait."
+74 -58
View File
@@ -1652,7 +1652,7 @@ impl NvencCudaEncoder {
return Err(nvenc_status::call_err(
"register_resource (CUDADEVICEPTR)",
e,
))
));
}
}
self.ring.push(RingSlot {
@@ -2779,6 +2779,20 @@ mod tests {
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
use pf_zerocopy::cuda::DeviceBuffer;
/// Env knob for the `#[ignore]`d hardware spikes, which every caller's doc says to run ALONE
/// with `--test-threads=1` (they mutate process env and own the GPU).
fn set_env(key: &str, val: impl AsRef<std::ffi::OsStr>) {
// SAFETY: only reached from the manually-run `--test-threads=1` hardware tests, so no
// other thread exists in this process to read or write the environment concurrently.
unsafe { std::env::set_var(key, val) };
}
/// [`set_env`]'s companion; the same single-threaded-run contract.
fn remove_env(key: &str) {
// SAFETY: as `set_env` — single-threaded manual test run, no concurrent env access.
unsafe { std::env::remove_var(key) };
}
/// The 10-bit input mapping is load-bearing in a way a smoke test can't reach: pick the wrong
/// NVENC format for a packed 2:10:10:10 capture and the encoder reads the words as 8-bit
/// `ARGB` — a picture that decodes, looks *almost* right, and is silently 8-bit with the
@@ -3315,8 +3329,8 @@ mod tests {
// Isolate the split variable: sub-frame off, and open explicitly split-DISABLED so the
// switch below is a real change rather than a no-op.
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
set_env("PUNKTFUNK_SPLIT_ENCODE", "0");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
@@ -3428,8 +3442,8 @@ mod tests {
}
enc.flush().ok();
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
/// ON-HARDWARE — **spike S1b**, the other half of S1: an in-place `splitEncodeMode` change that
@@ -3468,7 +3482,7 @@ mod tests {
const SETTLE: u32 = 16;
let two = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
// Separate buffers rotated per frame, so identical content can't let the encoder
// skip-code everything and erase the difference we are trying to measure.
@@ -3482,7 +3496,7 @@ mod tests {
// Returns (early-half p50 µs, late-half p50 µs, median bytes/AU).
let run_leg = |open_split: &str, switch_to: Option<u32>| -> (u128, u128, usize) {
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", open_split);
set_env("PUNKTFUNK_SPLIT_ENCODE", open_split);
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
@@ -3554,9 +3568,15 @@ mod tests {
println!("S1b @ {W}x{H}@60 HEVC 8-bit, {} Mbps CBR:", BPS / 1_000_000);
println!(" (early = first half of the measured window, late = second half)");
println!(" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU");
println!(" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU");
println!(" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU");
println!(
" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU"
);
println!(
" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU"
);
println!(
" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU"
);
if c_early > c_late + c_late / 8 {
println!(
" ⇒ leg C SETTLES ({c_early} → {c_late} us): the in-place switch is not \
@@ -3583,8 +3603,8 @@ mod tests {
}
);
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
let _ = (a_bytes, b_bytes, c_bytes);
}
@@ -3618,8 +3638,8 @@ mod tests {
// Open split-DISABLED, and leave sub-frame at its Linux default (ON where the GPU
// advertises SUBFRAME_READBACK) — that is the fleet shape the arbitration starts from.
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
set_env("PUNKTFUNK_SPLIT_ENCODE", "0");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
@@ -3664,7 +3684,7 @@ mod tests {
"S1c SKIPPED: sub-frame is off at open on this GPU/driver, so there is no pair to \
flip the arbitration reduces to S1a's plain split switch here."
);
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
return;
}
@@ -3715,7 +3735,7 @@ mod tests {
}
enc.flush().ok();
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
}
/// ON-HARDWARE — **the D5 confirm** (design §2 defect D5), the one claim in that list that was
@@ -3755,12 +3775,12 @@ mod tests {
// produced a spurious "D5 REFUTED" on the first run of this test.
let run = |split: Option<&str>, subframe: Option<&str>| -> (u128, bool) {
match split {
Some(v) => std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", v),
None => std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"),
Some(v) => set_env("PUNKTFUNK_SPLIT_ENCODE", v),
None => remove_env("PUNKTFUNK_SPLIT_ENCODE"),
}
match subframe {
Some(v) => std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", v),
None => std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"),
Some(v) => set_env("PUNKTFUNK_NVENC_SUBFRAME", v),
None => remove_env("PUNKTFUNK_NVENC_SUBFRAME"),
}
let mut enc = NvencCudaEncoder::open(
Codec::H265,
@@ -3841,8 +3861,8 @@ mod tests {
}
);
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
/// ON-HARDWARE — **what is the real split ceiling on this GPU?** Feeds WP1.1: we want to use
@@ -3870,13 +3890,13 @@ mod tests {
const WARMUP: u32 = 8;
const MEASURED: u32 = 24;
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
// → (requested mode, mode actually opened, p50 µs, engines the driver reports)
let run = |split: &str| -> (u32, u128, i32) {
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
@@ -3940,11 +3960,7 @@ mod tests {
};
println!(
" req {label} → opened_mode={opened:<2} {} {us:>6} us/frame{vs} [engines={engines}]",
if honoured {
"HONOURED"
} else {
"FELL BACK"
}
if honoured { "HONOURED" } else { "FELL BACK" }
);
}
println!(
@@ -3952,8 +3968,8 @@ mod tests {
HONOURED but no faster than DISABLE was accepted and did nothing."
);
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
/// ON-HARDWARE — the live split arbitration end to end (WP3). Opens a 4K session that the
@@ -3975,9 +3991,9 @@ mod tests {
const H: u32 = 2160;
let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
std::env::set_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
set_env("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
@@ -4045,8 +4061,8 @@ mod tests {
max_forced_split_mode(enc_engines)
);
std::env::remove_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
// The verdict cache is process-global: leaving this session's result in it would steer
// every later test that opens the same config with the split env unset (the D5 legs do
// exactly that).
@@ -4086,14 +4102,14 @@ mod tests {
})
.unwrap_or((3840, 2160, 60));
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// 10-bit input: the packed 2:10:10:10 PQ path is how a Main10 session is actually fed here
// (`bit_depth`/`hdr` are DERIVED from the input format, never trusted from the args).
let frames: Vec<CapturedFrame> = (0..4).map(|i| rgb10_frame(w, h, i)).collect();
let run = |split: &str| -> (u128, u8, usize) {
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::X2Rgb10,
@@ -4157,8 +4173,8 @@ mod tests {
}
);
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
/// ON-HARDWARE — **THE BITS/FRAME CURVE**, the measurement this whole programme has been blind
@@ -4190,7 +4206,7 @@ mod tests {
})
.unwrap_or((3840, 2160, 60));
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// Sweep CONTENT DETAIL, not nominal bitrate. Pure noise is incompressible, so a low
// bitrate target simply overshoots (measured: 719 KB/AU against a 104 KB quota) and every
@@ -4207,7 +4223,7 @@ mod tests {
let frames: Vec<CapturedFrame> =
(0..4).map(|i| noise_nv12_frame(w, h, i, block)).collect();
let run = |split: &str| -> (u128, usize) {
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
@@ -4250,8 +4266,8 @@ mod tests {
);
}
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_SPLIT_ENCODE");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
/// A pre-session RFI request and nonsense ranges all correctly decline (→ caller forces IDR).
@@ -4658,12 +4674,12 @@ mod tests {
struct EnvGuard;
impl Drop for EnvGuard {
fn drop(&mut self) {
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_NVENC_SLICES");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
}
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "4");
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "1");
set_env("PUNKTFUNK_NVENC_SLICES", "4");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "1");
let _guard = EnvGuard;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
@@ -4770,8 +4786,8 @@ mod tests {
const W: u32 = 1920;
const H: u32 = 1080;
// Defaults under test — make sure another test's knobs aren't leaking in.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_NVENC_SLICES");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
@@ -4866,8 +4882,8 @@ mod tests {
const W: u32 = 1920;
const H: u32 = 1080;
// The ceiling under test is the negotiated one, not the operator override.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_NVENC_SLICES");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
Codec::H265,
@@ -4911,16 +4927,16 @@ mod tests {
struct EnvGuard;
impl Drop for EnvGuard {
fn drop(&mut self) {
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
remove_env("PUNKTFUNK_NVENC_SLICES");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
}
}
let _guard = EnvGuard;
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
// Escape 1: explicit single slice — no boundaries to cut, chunked poll disarmed.
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "1");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
set_env("PUNKTFUNK_NVENC_SLICES", "1");
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
let mut enc = open_h265();
let frame = nv12_frame(W, H, 0);
enc.submit_indexed(&frame, 0).expect("submit");
@@ -4943,8 +4959,8 @@ mod tests {
// Escape 2: sub-frame readback vetoed — slices stay (default 4) but chunked poll
// disarms and the plain poll path carries the session.
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
remove_env("PUNKTFUNK_NVENC_SLICES");
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
let mut enc = open_h265();
let frame = nv12_frame(W, H, 0);
enc.submit_indexed(&frame, 0).expect("submit");
+4 -4
View File
@@ -425,11 +425,11 @@ pub fn run_from_args(args: &[String]) -> Result<()> {
/// the priority intent (it arrives explicitly in `Hello`) and the worker path itself (nothing here
/// spawns a worker, and a stale value in a core dump is just noise).
fn sanitize_env() {
// Single-threaded — this runs before anything in this process creates a thread, which is the
// one situation where mutating the environment is sound (the `getenv` race the house rule
// about `set_var` is about needs a second thread).
for k in ["PYROWAVE_QUEUE_PRIORITY", "PUNKTFUNK_ENCODE_WORKER"] {
std::env::remove_var(k);
// SAFETY: single-threaded — this runs before anything in this process creates a thread,
// which is the one situation where mutating the environment is sound (the `getenv` race
// `remove_var`'s contract is about needs a second thread).
unsafe { std::env::remove_var(k) };
}
}
+14 -5
View File
@@ -1616,7 +1616,9 @@ impl Encoder for NvencD3d11Encoder {
let frame = match &captured.payload {
FramePayload::D3d11(f) => f,
FramePayload::Cpu(_) => {
bail!("NVENC D3D11 encoder needs a GPU texture frame (use the software encoder for CPU frames)")
bail!(
"NVENC D3D11 encoder needs a GPU texture frame (use the software encoder for CPU frames)"
)
}
};
// The capturer recreates its D3D11 device on a desktop switch (secure/Winlogon) and may come
@@ -2882,8 +2884,12 @@ mod tests {
let two = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
// Isolate the split variable exactly as the Linux spike does.
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
// SAFETY: this `#[ignore]`d hardware spike is run alone (manual RTX-box run, one test),
// so no other thread exists to read or write the environment concurrently.
unsafe {
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
}
// SAFETY: (test-only) the same straight-line D3D11/DXGI setup as `nvenc_reconfigure_no_idr`.
unsafe {
@@ -3007,8 +3013,11 @@ mod tests {
enc.flush().ok();
}
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
// SAFETY: as the set above — single-threaded manual test run, no concurrent env access.
unsafe {
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
}
}
/// ON-GLASS (RTX box): the measurement gating the AYUV 4:4:4 work — encodes the probe
+1 -1
View File
@@ -6,7 +6,7 @@
[package]
name = "pf-frame"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host shared frame/format vocabulary: CapturedFrame, PixelFormat, HDR metadata, thread QoS, and the Windows DXGI capture identity."
+42 -24
View File
@@ -155,18 +155,26 @@ enum PrioMode {
Off,
/// A fixed class the operator pinned (`normal`=2 / `high`=4 / `realtime`=5).
Static(i32),
/// The default: HIGH immediately, then upgrade to REALTIME when it is safe — HAGS off, or
/// Opt-in (`auto`): HIGH immediately, then upgrade to REALTIME when it is safe — HAGS off, or
/// HAGS on with comfortable VRAM headroom (with a monitor that downgrades the moment VRAM
/// tightens). REALTIME is the proven ceiling-raiser (it is how our brief encode preempts a
/// saturating game), but REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC
/// hang the gate takes the win everywhere it cannot hit the hazard.
/// tightens). REALTIME is the T2.3 ceiling-raiser (a higher-priority context preempts at
/// pixel granularity), but it carries TWO field-proven hazards: REALTIME + NVIDIA + HAGS +
/// near-full VRAM is a documented NVENC hang (the VRAM gate covers that one), and on AMD the
/// upgrade itself produced a metronomic content-starving stall class (~3.6 s period, RX 9070
/// XT, 2026-08-12 A/B: pinning `high` removed it) that no VRAM gate can see — which is why
/// `auto` is no longer the default.
Auto,
}
/// Resolve `PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default **auto**).
/// Resolve `PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default **high**).
/// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4,
/// REALTIME 5. `realtime` pins REALTIME statically (no gate — the operator owns the hazard);
/// `high` restores the pre-T2.3 static default.
/// `auto` is the T2.3 gated-REALTIME mode, opt-in since the 2026-08-12 field A/B convicted the
/// REALTIME upgrade of its own metronomic stall class on AMD (see [`PrioMode::Auto`]) — HIGH is
/// the Sunshine/Apollo-parity lever that delivered the original decisive win, and the default
/// must not hold REALTIME anywhere (the same inversion as the vdisplay driver's `PFVD_RT_GPU`
/// ladder, which fixed the faster ~1.8 s metronome the same day). Unrecognized values read as
/// the default, not as `auto` — a typo must not opt a box into the hazard.
fn configured_gpu_priority_mode() -> PrioMode {
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
.ok()
@@ -174,9 +182,10 @@ fn configured_gpu_priority_mode() -> PrioMode {
{
Some("off") => PrioMode::Off,
Some("normal") => PrioMode::Static(2),
Some("high") => PrioMode::Static(4),
Some("realtime") => PrioMode::Static(5),
_ => PrioMode::Auto,
Some("auto") => PrioMode::Auto,
// `high`, unset, and anything unrecognized all land on the HIGH default.
_ => PrioMode::Static(4),
}
}
@@ -275,14 +284,17 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
/// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but
/// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling
/// priority class (the strong cross-process lever — far more effective than `SetGPUThreadPriority`
/// alone, which we measured as no help) lets our brief encode preempt the game. Default is the
/// T2.3 `auto` mode: HIGH immediately here, then [`auto_priority_gate`] upgrades to REALTIME
/// where the NVIDIA+HAGS+full-VRAM NVENC-hang hazard cannot bite (and a monitor downgrades when
/// it could). Runs once per process; best-effort.
/// `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime|auto` (default auto; `high` = the
/// pre-gate static behavior; `realtime` = pinned, operator owns the hazard). Best-effort:
/// silently no-ops under a UAC-filtered token (the process will not hold SE_INC_BASE_PRIORITY,
/// so the D3DKMT call is a no-op).
/// alone, which we measured as no help) lets our brief encode preempt the game. Default is a
/// static HIGH — the class that delivered that win. The T2.3 `auto` mode (HIGH here, then
/// [`auto_priority_gate`] upgrades to REALTIME behind the NVENC-hang VRAM gate) is opt-in since
/// the 2026-08-12 field A/B: on AMD the REALTIME upgrade generated its own metronomic
/// content-starving stall class (~3.6 s period) that the VRAM gate cannot see, and pinning HIGH
/// removed it. Runs once per process; best-effort.
/// `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime|auto` (default high; `auto` = the
/// gated-REALTIME upgrade, operator opts into the AMD stall hazard for the extra ceiling;
/// `realtime` = pinned, operator owns every hazard). Best-effort: silently no-ops under a
/// UAC-filtered token (the process will not hold SE_INC_BASE_PRIORITY, so the D3DKMT call is a
/// no-op).
fn elevate_process_gpu_priority() {
use std::sync::Once;
static ONCE: Once = Once::new();
@@ -316,17 +328,23 @@ fn elevate_process_gpu_priority() {
});
}
// --- REALTIME auto-gate (gpu-contention §5.C / latency plan T2.3) --------------------------------
// --- REALTIME auto-gate (gpu-contention §5.C / latency plan T2.3) — OPT-IN since 2026-08-12 ------
//
// REALTIME GPU scheduling priority is the genuine cross-process ceiling-raiser under a saturating
// game (a higher-priority context preempts at pixel granularity — the Async-TimeWarp mechanism),
// and our SYSTEM service uniquely holds the SE_INC_BASE_PRIORITY it needs. The one documented
// hazard: REALTIME + NVIDIA + HAGS-on + near-full VRAM can hang NVENC. So: probe HAGS once via
// D3DKMT; HAGS off ⇒ REALTIME unconditionally; HAGS on ⇒ REALTIME gated on LOCAL-segment VRAM
// headroom, with a monitor thread that downgrades to HIGH the moment usage crosses
// [`VRAM_DOWNGRADE_PCT`] of the OS budget and restores REALTIME after it has stayed under
// [`VRAM_RESTORE_PCT`] for [`VRAM_RESTORE_TICKS`] consecutive polls (hysteresis against flapping
// on the boundary of the hazard window).
// and our SYSTEM service uniquely holds the SE_INC_BASE_PRIORITY it needs. Two field-proven
// hazards bound it. (1) REALTIME + NVIDIA + HAGS-on + near-full VRAM can hang NVENC — the VRAM
// gate below exists for that one: probe HAGS once via D3DKMT; HAGS off ⇒ REALTIME
// unconditionally; HAGS on ⇒ REALTIME gated on LOCAL-segment VRAM headroom, with a monitor
// thread that downgrades to HIGH the moment usage crosses [`VRAM_DOWNGRADE_PCT`] of the OS
// budget and restores REALTIME after it has stayed under [`VRAM_RESTORE_PCT`] for
// [`VRAM_RESTORE_TICKS`] consecutive polls (hysteresis against flapping on the boundary of the
// hazard window). (2) On AMD (RX 9070 XT A/B), a punktfunk process holding REALTIME generated a
// metronomic content-starving stall class — every ~3.6 s ALL processes' presents paused
// 150800 ms with the GPU responsive — that no VRAM gate can see, and the vdisplay driver's
// REALTIME swap-chain raise produced the same pathology on its own ~1.8 s beat. That second
// hazard is why the whole gate now runs only under an explicit `auto`, and the default stays a
// static HIGH.
/// Downgrade REALTIME→HIGH when local VRAM usage exceeds this share of the OS budget.
const VRAM_DOWNGRADE_PCT: u64 = 92;
+4 -4
View File
@@ -21,11 +21,11 @@ mod imp {
type Bool = i32;
#[link(name = "winmm")]
extern "system" {
unsafe extern "system" {
fn timeBeginPeriod(uPeriod: u32) -> u32;
}
#[link(name = "kernel32")]
extern "system" {
unsafe extern "system" {
fn GetCurrentProcess() -> Handle;
fn SetPriorityClass(hProcess: Handle, dwPriorityClass: u32) -> Bool;
fn SetThreadExecutionState(esFlags: u32) -> u32;
@@ -46,11 +46,11 @@ mod imp {
simple_reason: *const u16,
}
#[link(name = "dwmapi")]
extern "system" {
unsafe extern "system" {
fn DwmEnableMMCSS(fEnableMMCSS: Bool) -> i32; // HRESULT
}
#[link(name = "avrt")]
extern "system" {
unsafe extern "system" {
fn AvSetMmThreadCharacteristicsW(TaskName: *const u16, TaskIndex: *mut u32) -> Handle;
}
+1 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "pf-gpu"
version.workspace = true
edition = "2021"
edition.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host GPU vendor/adapter enumeration, selection preference, and active-session accounting."
publish = false
+28 -28
View File
@@ -169,7 +169,7 @@ mod kmt {
}
#[link(name = "gdi32")]
extern "system" {
unsafe extern "system" {
fn D3DKMTOpenAdapterFromLuid(arg: *mut OpenAdapterFromLuid) -> i32;
fn D3DKMTQueryAdapterInfo(arg: *mut QueryAdapterInfo) -> i32;
fn D3DKMTCloseAdapter(arg: *mut CloseAdapter) -> i32;
@@ -500,12 +500,12 @@ pub fn pick(
env_substr: Option<&str>,
) -> Option<(usize, PickSource)> {
let mut preference_missing = false;
if pref.mode == GpuMode::Manual {
if let Some(want) = &pref.gpu {
match find_preferred(gpus, want) {
Some(i) => return Some((i, PickSource::Preference)),
None => preference_missing = true,
}
if pref.mode == GpuMode::Manual
&& let Some(want) = &pref.gpu
{
match find_preferred(gpus, want) {
Some(i) => return Some((i, PickSource::Preference)),
None => preference_missing = true,
}
}
if let Some(sub) = env_substr.filter(|s| !s.is_empty()) {
@@ -560,17 +560,17 @@ pub fn selected_gpu() -> Option<SelectedGpu> {
let gpus = enumerate();
let pref = prefs().get();
let mut preference_missing = false;
if pref.mode == GpuMode::Manual {
if let Some(want) = &pref.gpu {
match find_preferred(&gpus, want) {
Some(i) => {
return Some(SelectedGpu {
info: gpus.into_iter().nth(i)?,
source: PickSource::Preference,
})
}
None => preference_missing = true,
if pref.mode == GpuMode::Manual
&& let Some(want) = &pref.gpu
{
match find_preferred(&gpus, want) {
Some(i) => {
return Some(SelectedGpu {
info: gpus.into_iter().nth(i)?,
source: PickSource::Preference,
});
}
None => preference_missing = true,
}
}
let source = if preference_missing {
@@ -578,13 +578,13 @@ pub fn selected_gpu() -> Option<SelectedGpu> {
} else {
PickSource::Auto
};
if linux_nvidia_present() {
if let Some(i) = gpus.iter().position(|g| g.vendor_id == VENDOR_NVIDIA) {
return Some(SelectedGpu {
info: gpus.into_iter().nth(i)?,
source,
});
}
if linux_nvidia_present()
&& let Some(i) = gpus.iter().position(|g| g.vendor_id == VENDOR_NVIDIA)
{
return Some(SelectedGpu {
info: gpus.into_iter().nth(i)?,
source,
});
}
let node = linux_render_node();
let i = gpus
@@ -620,10 +620,10 @@ pub fn manual_selection() -> Option<GpuInfo> {
/// (a deliberate live env read — see `config.rs` module docs) > `/dev/dri/renderD128`.
#[cfg(target_os = "linux")]
pub fn linux_render_node() -> PathBuf {
if let Some(g) = manual_selection() {
if let Some(node) = g.handle.render_node {
return node;
}
if let Some(g) = manual_selection()
&& let Some(node) = g.handle.render_node
{
return node;
}
std::env::var("PUNKTFUNK_RENDER_NODE")
.ok()
+1 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "pf-host-config"
version.workspace = true
edition = "2021"
edition.workspace = true
license = "MIT OR Apache-2.0"
description = "Process-wide punktfunk host configuration (env-parsed HostConfig behind a OnceLock)."
publish = false
+10 -10
View File
@@ -84,17 +84,17 @@ impl AudioOutputMode {
/// first (it is the more restrictive promise — "do not touch my devices" must not be overridden
/// by a stale `PUNKTFUNK_HOST_AUDIO` in the same `host.env`).
fn from_env() -> AudioOutputMode {
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE") {
if !raw.trim().is_empty() {
if let Some(m) = AudioOutputMode::parse(&raw) {
return m;
}
// Never silently fall through to a different routing than the operator asked for.
eprintln!(
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
client_only/host_and_client/follow_default using client_only"
);
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE")
&& !raw.trim().is_empty()
{
if let Some(m) = AudioOutputMode::parse(&raw) {
return m;
}
// Never silently fall through to a different routing than the operator asked for.
eprintln!(
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
client_only/host_and_client/follow_default using client_only"
);
}
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
return AudioOutputMode::FollowDefault;
+1 -1
View File
@@ -7,7 +7,7 @@
[package]
name = "pf-inject"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host input injection: per-OS keyboard/mouse injectors + the virtual-gamepad HID backends behind one InputInjector trait."
+1 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "pf-paths"
version.workspace = true
edition = "2021"
edition.workspace = true
license = "MIT OR Apache-2.0"
description = "Host config-directory resolution + owner-private file/dir creation (0600/0700 or SYSTEM/Admins DACL)."
publish = false
+13
View File
@@ -1280,6 +1280,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
opts.render_scale_max_dim,
);
}
// Adopt the tier this launch RESOLVED (globals or the host's
// profile) instead of keeping the one the process started on.
// `opts.stats_verbosity` only ever seeds the loop: the console
// outlives every stream, so without this a settings change
// reached the file and the settings row and nothing else until
// the app was restarted.
//
// Deliberately HERE and not in `StreamState::new`: the
// codec-fallback retry rebuilds the state from a clone of these
// params mid-stream, and doing it there would snap the overlay
// back every time a session fell down the codec ladder, undoing
// a cycle the user had just made with the chord.
stats_verbosity = params.stats_verbosity;
// A live pump here would be DETACHED by the assignment
// below — `StreamState` has no `Drop`, so its thread
// would keep decoding onto the shared Vulkan device that
+4 -1
View File
@@ -398,7 +398,10 @@ mod linux_main {
}
// One libc symbol, declared directly — not worth a libc dependency in a root helper.
extern "C" {
// (Edition 2024 spells extern blocks `unsafe extern`, which the `unsafe_code` lint now
// counts — the same one-named-exemption rule as `effective_uid` below applies.)
#[allow(unsafe_code)]
unsafe extern "C" {
#[link_name = "geteuid"]
fn libc_geteuid() -> u32;
}
+1 -1
View File
@@ -8,7 +8,7 @@
[package]
name = "pf-vdisplay"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host virtual-display orchestration: per-compositor Linux backends + the Windows IddCx driver backend behind one VirtualDisplay trait."
+26 -3
View File
@@ -267,6 +267,16 @@ pub struct DisplayPolicy {
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
#[serde(default)]
pub pnp_disable_monitors: bool,
/// **EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the
/// software equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at
/// the first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its
/// live-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks
/// on its next start. Targets the standby-sink stall class at its SOURCE: with emulation
/// pinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD
/// driver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like
/// `game_session`); `#[serde(default)]` = off.
#[serde(default)]
pub edid_lock: 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.
///
@@ -318,6 +328,7 @@ impl Default for DisplayPolicy {
game_session: GameSession::default(),
ddc_power_off: false,
pnp_disable_monitors: false,
edid_lock: false,
capture_monitor: None,
}
}
@@ -454,6 +465,7 @@ impl EffectivePolicy {
game_session: GameSession,
ddc_power_off: bool,
pnp_disable_monitors: bool,
edid_lock: bool,
capture_monitor: Option<String>,
) -> DisplayPolicy {
DisplayPolicy {
@@ -474,6 +486,7 @@ impl EffectivePolicy {
game_session,
ddc_power_off,
pnp_disable_monitors,
edid_lock,
capture_monitor,
}
}
@@ -739,6 +752,13 @@ impl DisplayPolicyStore {
self.get().pnp_disable_monitors
}
/// The experimental AMD connector-EDID-emulation axis — orthogonal to the preset (like
/// [`Self::game_session`]), read directly off the stored policy (default off when
/// unconfigured).
pub fn edid_lock(&self) -> bool {
self.get().edid_lock
}
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
/// write succeeds, so a full disk can't leave memory and file disagreeing — and the whole
/// transaction runs under [`Self::write`], so neither can two concurrent PUTs.
@@ -1318,13 +1338,16 @@ mod tests {
GameSession::Dedicated,
true,
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.
// The orthogonal axes (game-session, DDC power-off, PnP disable, EDID lock,
// 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!(p.edid_lock);
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);
@@ -1405,7 +1428,7 @@ mod tests {
let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
assert_eq!(
keys.len(),
12,
13,
"a display-policy axis was added or removed: {keys:?} — wire it into the mgmt PUT's \
per-axis merge (and into `EffectivePolicy` if it is a behavior axis) before bumping this"
);
+130 -118
View File
@@ -16,7 +16,7 @@
//! lands — a runtime gate on `remote_fd.is_some()`.
//!
//! The ownership split: the session's capturer no longer owns the real keepalive — the registry does.
//! [`acquire`] hands the session a `VirtualOutput` whose `keepalive` is a lightweight, gen-stamped
//! [`acquire`] hands the session a `VirtualOutput` whose `keepalive` is a lightweight, generation-stamped
//! `DisplayLease` (mirrors the Windows `MonitorLease`); dropping it releases the registry refcount,
//! and the lifecycle machine decides linger / teardown. `capture_virtual_output`'s signature is
//! unchanged — it just holds a lease instead of the real keepalive.
@@ -85,7 +85,7 @@ fn topology_str() -> String {
/// with the quit application code — a user "stop", not a network drop), the display is torn down
/// **immediately**, skipping the keep-alive linger. A bare disconnect leaves it `false` → normal linger.
///
/// `supersedes`: the pool gen of a display this acquire REPLACES (a mid-stream mode switch creates
/// `supersedes`: the pool generation of a display this acquire REPLACES (a mid-stream mode switch creates
/// the new display before retiring the old — create-before-drop). The replacement inherits group
/// topology ownership: without this, the dying predecessor counts as a live sibling and the new
/// display "extends" behind it, losing a Primary/Exclusive topology on every resize. `None`
@@ -151,7 +151,7 @@ pub fn snapshot() -> Snapshot {
.into_iter()
.enumerate()
.map(|(idx, i)| DisplayInfo {
slot: i.gen,
slot: i.generation,
backend: i.backend.to_string(),
mode: i.mode,
state: i.state.to_string(),
@@ -185,7 +185,7 @@ pub fn snapshot() -> Snapshot {
/// released.
pub fn release(slot: Option<u64>) -> usize {
#[cfg(target_os = "windows")]
// Windows slots (Stage W1): `slot` selects one kept monitor by its gen stamp
// Windows slots (Stage W1): `slot` selects one kept monitor by its generation stamp
// ([`DisplayInfo::slot`]); `None` releases every kept one.
let released = super::manager::force_release(slot);
#[cfg(target_os = "linux")]
@@ -211,12 +211,12 @@ pub fn release(slot: Option<u64>) -> usize {
/// Tear down a **reused-but-dead** pool entry by its generation stamp (A2). Called by the pipeline
/// builder when the first frame fails on a display [`acquire`] handed back as REUSED — so the retry
/// loop's next `acquire` creates fresh instead of re-wedging on the same corpse. No-op off Linux / if
/// the entry is already gone (idempotent — the subsequent stale-gen lease drop no-ops too).
pub fn mark_failed(gen: u64) {
/// the entry is already gone (idempotent — the subsequent stale-generation lease drop no-ops too).
pub fn mark_failed(generation: u64) {
#[cfg(target_os = "linux")]
linux::mark_failed(gen);
linux::mark_failed(generation);
#[cfg(not(target_os = "linux"))]
let _ = gen;
let _ = generation;
}
/// Force-release a **superseded** kept display by its generation stamp
@@ -225,13 +225,13 @@ pub fn mark_failed(gen: u64) {
/// keep-alive policy every resize would accumulate kept monitors at stale modes. The mode-switch
/// arm calls this once the new pipeline is up and the old capturer is dropped. Only a KEPT
/// (lingering/pinned) entry is released — an Active one is refused, like `/display/release` — and
/// a gen that's already gone (immediate teardown) is a no-op. No-op off Linux (Windows
/// a generation that's already gone (immediate teardown) is a no-op. No-op off Linux (Windows
/// reconfigures the same monitor in place — nothing is superseded).
pub fn retire(gen: u64) {
pub fn retire(generation: u64) {
#[cfg(target_os = "linux")]
linux::retire(gen);
linux::retire(generation);
#[cfg(not(target_os = "linux"))]
let _ = gen;
let _ = generation;
}
/// Invalidate every kept display of `backend` — its compositor instance is gone (a Game↔Desktop switch
@@ -320,9 +320,9 @@ mod pool {
/// The session epoch at creation (A4). Reuse requires an epoch match; the linger timer reaps
/// entries whose epoch is stale (their compositor instance was replaced under them).
pub(super) epoch: u64,
/// Generation stamp: a `DisplayLease` only releases if its gen still matches (a stale lease
/// Generation stamp: a `DisplayLease` only releases if its generation still matches (a stale lease
/// — its entry was reused + re-stamped — is a no-op).
pub(super) gen: u64,
pub(super) generation: u64,
/// The out-of-band-cursor mode this display was CREATED with (Phase B): metadata-pointer
/// (cursor-channel session) vs compositor-embedded. Reuse requires an exact match — a kept
/// embedded display has no cursor metadata for a channel session to forward, and a kept
@@ -344,15 +344,15 @@ mod pool {
/// gamescope **spawn** is an independent nested session per client (no shared desktop), so each
/// gamescope display is its OWN group — never auto-rowed against, or topology-/restore-grouped with,
/// another gamescope session.
pub(super) fn group_key(backend: &str, gen: u64) -> String {
pub(super) fn group_key(backend: &str, generation: u64) -> String {
if backend == "gamescope" {
format!("gamescope#{gen}")
format!("gamescope#{generation}")
} else {
backend.to_string()
}
}
/// Is the pooled entry `(e_backend, e_gen)` a member of the group the display `(backend, gen)`
/// Is the pooled entry `(e_backend, e_gen)` a member of the group the display `(backend, generation)`
/// belongs to — the ONE definition of membership, shared by the restore hand-off, the
/// first-in-group probe and the layout collection.
///
@@ -370,10 +370,10 @@ mod pool {
e_backend: &str,
e_gen: u64,
backend: &str,
gen: u64,
generation: u64,
supersedes: Option<u64>,
) -> bool {
Some(e_gen) != supersedes && group_key(e_backend, e_gen) == group_key(backend, gen)
Some(e_gen) != supersedes && group_key(e_backend, e_gen) == group_key(backend, generation)
}
/// Hand off a torn-down display's topology restore (design §6.1 — per-group restore): if a
@@ -382,20 +382,20 @@ mod pool {
/// reclaimed display's keepalive, so the physical is re-enabled while our output still exists —
/// the compositor never sees zero outputs). `None` in → `None` out.
///
/// `backend`+`gen` identify the DEPARTING display, and both are needed: keyed on the backend name
/// `backend`+`generation` identify the DEPARTING display, and both are needed: keyed on the backend name
/// alone, one gamescope spawn's restore floated onto an unrelated client's spawn — where it would
/// run when THAT session ended and never when its own did.
pub(super) fn hand_off_restore(
remaining: &mut [Entry],
backend: &'static str,
gen: u64,
generation: u64,
restore: Option<Restore>,
) -> Option<Restore> {
let action = restore?;
// At most one restore per group, so any surviving sibling has `None` to receive it.
match remaining
.iter_mut()
.find(|e| in_group(e.backend, e.gen, backend, gen, None))
.find(|e| in_group(e.backend, e.generation, backend, generation, None))
{
Some(sibling) => {
sibling.topology_restore = Some(action);
@@ -441,8 +441,9 @@ mod pool {
&& !matches!(entries[i].life, lifecycle::State::Active { .. });
if entries[i].life.poll_expiry(now) || dead_epoch {
let mut e = entries.remove(i);
let (backend, gen) = (e.backend, e.gen);
if let Some(r) = hand_off_restore(entries, backend, gen, e.topology_restore.take())
let (backend, generation) = (e.backend, e.generation);
if let Some(r) =
hand_off_restore(entries, backend, generation, e.topology_restore.take())
{
restores.push(r);
}
@@ -470,7 +471,7 @@ mod pool {
/// One live/kept display, flattened out of the pool under the lock — so the group + arrangement
/// math (which calls the layout engine) runs OUTSIDE the lock.
pub(super) struct Row {
pub(super) gen: u64,
pub(super) generation: u64,
pub(super) backend: &'static str,
pub(super) mode: Mode,
pub(super) identity_slot: Option<u32>,
@@ -480,7 +481,7 @@ mod pool {
}
/// The desktop position for a display just appended to its group (design §6.2): the group's
/// `existing` members (each with its acquire `gen`) plus `new` last, ordered by `gen`, arranged by
/// `existing` members (each with its acquire `generation`) plus `new` last, ordered by `generation`, arranged by
/// the pure [`layout`](crate::layout) engine, taking the new member's placement. Pure — so the
/// append-in-acquire-order + auto-row/manual arrangement is unit-tested independent of the
/// pool/global.
@@ -503,10 +504,10 @@ mod pool {
/// is dropped. Pure over its `known`/`next` state — the caller owns the process-lifetime copy.
///
/// Ids used to be the index into the sorted key list, which meant a new group could RENUMBER an
/// untouched one: with one KWin desktop at group 1, a gamescope spawn at gen 3 sorts ahead of
/// untouched one: with one KWin desktop at group 1, a gamescope spawn at generation 3 sorts ahead of
/// `"kwin"` and silently moved the unchanged desktop to group 2 on the next `/display/state` poll.
/// A monotonic counter, remembered per key, cannot do that. Pruning to the live keys is what keeps
/// the map bounded: the per-spawn keys (`gamescope#<gen>`, one per dedicated session) would
/// the map bounded: the per-spawn keys (`gamescope#<generation>`, one per dedicated session) would
/// otherwise accumulate for the host's lifetime — an id is retired with its group.
pub(super) fn assign_group_ids(
known: &mut std::collections::BTreeMap<String, u32>,
@@ -523,7 +524,7 @@ mod pool {
}
/// Group the flattened rows into the mgmt `/display/state` view (design §6.1/§6.2) by
/// [`group_key`], ordered by acquire (`gen`), with each member's position from the pure
/// [`group_key`], ordered by acquire (`generation`), with each member's position from the pure
/// [`layout`](crate::layout) engine. `ids` maps each group key to its reported group id (see
/// [`group_ids`]). Pure — no I/O, no global — so the grouping / ordering / position assignment is
/// unit-tested against synthetic rows.
@@ -535,20 +536,23 @@ mod pool {
) -> Vec<DisplayInfo> {
use crate::layout::{self, Member};
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
let mut keys: Vec<String> = rows
.iter()
.map(|r| group_key(r.backend, r.generation))
.collect();
keys.sort();
keys.dedup();
let mut out: Vec<DisplayInfo> = Vec::new();
for key in keys.iter() {
// This group's members in acquire order (gen ascending) → display_index + arrangement.
// This group's members in acquire order (generation ascending) → display_index + arrangement.
let mut idx: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, row)| &group_key(row.backend, row.gen) == key)
.filter(|(_, row)| &group_key(row.backend, row.generation) == key)
.map(|(i, _)| i)
.collect();
idx.sort_by_key(|&i| rows[i].gen);
idx.sort_by_key(|&i| rows[i].generation);
let members: Vec<Member> = idx
.iter()
.map(|&i| Member {
@@ -561,7 +565,7 @@ mod pool {
let row = &rows[i];
let p = places[ord];
out.push(DisplayInfo {
slot: row.gen,
slot: row.generation,
backend: row.backend.to_string(),
mode: (row.mode.width, row.mode.height, row.mode.refresh_hz),
state: row.state.to_string(),
@@ -590,8 +594,8 @@ mod pool {
use std::sync::Arc;
/// A minimal pool entry for the pure teardown/restore tests (dummy keepalive; the
/// `hand_off_restore` logic only reads `backend` + `gen` + `topology_restore`).
fn test_entry(backend: &'static str, gen: u64, restore: Option<Restore>) -> Entry {
/// `hand_off_restore` logic only reads `backend` + `generation` + `topology_restore`).
fn test_entry(backend: &'static str, generation: u64, restore: Option<Restore>) -> Entry {
Entry {
life: lifecycle::State::default(),
keepalive: Box::new(()),
@@ -607,7 +611,7 @@ mod pool {
topology_restore: restore,
launch: None,
epoch: 0,
gen,
generation,
hw_cursor: false,
hdr: false,
}
@@ -631,7 +635,10 @@ mod pool {
/// `ids_for` against a CARRIED map — for the stability test, which needs the same state
/// across two assemblies.
fn ids_into(known: &mut BTreeMap<String, u32>, next: &mut u32, rows: &[Row]) {
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
let mut keys: Vec<String> = rows
.iter()
.map(|r| group_key(r.backend, r.generation))
.collect();
keys.sort();
keys.dedup();
assign_group_ids(known, next, &keys);
@@ -659,7 +666,7 @@ mod pool {
#[test]
fn topology_restore_floats_to_a_sibling_then_runs_on_the_last_teardown() {
let ran = Arc::new(AtomicBool::new(false));
// Two KWin displays in one group; the first (gen 1) carries the group's restore.
// Two KWin displays in one group; the first (generation 1) carries the group's restore.
let mut pool = vec![
test_entry("kwin", 1, Some(flag_restore(&ran))),
test_entry("kwin", 2, None),
@@ -697,7 +704,7 @@ mod pool {
#[test]
fn tearing_down_a_non_carrier_first_leaves_the_restore_for_last() {
let ran = Arc::new(AtomicBool::new(false));
// gen 2 carries the restore; gen 1 does not (a later exclusive session found the physical
// generation 2 carries the restore; generation 1 does not (a later exclusive session found the physical
// already disabled).
let mut pool = vec![
test_entry("kwin", 1, None),
@@ -706,7 +713,7 @@ mod pool {
// Tear down the non-carrier first → nothing to hand off, carrier untouched.
let mut e1 = pool.remove(0);
assert!(hand_off_restore(&mut pool, "kwin", 1, e1.topology_restore.take()).is_none());
// The carrier (gen 2) still holds the group's restore.
// The carrier (generation 2) still holds the group's restore.
assert!(pool[0].topology_restore.is_some());
// Now the carrier (last member) → run.
let mut e2 = pool.remove(0);
@@ -755,9 +762,9 @@ mod pool {
assert!(in_group("kwin", 3, "kwin", 2, Some(1)));
}
fn row(gen: u64, backend: &'static str, w: u32, slot: Option<u32>) -> Row {
fn row(generation: u64, backend: &'static str, w: u32, slot: Option<u32>) -> Row {
Row {
gen,
generation,
backend,
mode: Mode {
width: w,
@@ -773,7 +780,7 @@ mod pool {
#[test]
fn groups_by_backend_and_auto_rows_in_acquire_order() {
// Two KWin displays (acquired gen 5 then gen 2 — deliberately out of vec order) + a Mutter one.
// Two KWin displays (acquired generation 5 then generation 2 — deliberately out of vec order) + a Mutter one.
let rows = vec![
row(5, "kwin", 2560, Some(1)),
row(2, "kwin", 1920, Some(7)),
@@ -784,12 +791,12 @@ mod pool {
let kwin: Vec<&DisplayInfo> = out.iter().filter(|d| d.backend == "kwin").collect();
assert_eq!(kwin.len(), 2);
assert_eq!(kwin[0].slot, 2); // lower gen (earlier acquire) sorts to index 0
assert_eq!(kwin[0].slot, 2); // lower generation (earlier acquire) sorts to index 0
assert_eq!(kwin[0].display_index, 0);
assert_eq!(kwin[0].position, (0, 0));
assert_eq!(kwin[1].slot, 5);
assert_eq!(kwin[1].display_index, 1);
assert_eq!(kwin[1].position, (1920, 0)); // auto-row: after the 1920px gen-2 display
assert_eq!(kwin[1].position, (1920, 0)); // auto-row: after the 1920px generation-2 display
assert_eq!(kwin[0].topology, "exclusive");
// A distinct backend is a distinct group.
@@ -830,7 +837,7 @@ mod pool {
identity_slot: slot,
width: w,
};
// Existing group (given out of gen order): gen 8 @ 1920 acquired AFTER gen 3 @ 2560.
// Existing group (given out of generation order): generation 8 @ 1920 acquired AFTER generation 3 @ 2560.
let existing = vec![(8, m(Some(2), 1920)), (3, m(Some(1), 2560))];
// A new 1280-wide display appends to the right of 2560 + 1920.
let pos = position_for_new(existing, m(Some(5), 1280), &Layout::default());
@@ -900,30 +907,30 @@ mod pool {
use std::time::Duration;
let t0 = Instant::now();
let mut es = Vec::new();
// gen 1: lingering, deadline passed.
// generation 1: lingering, deadline passed.
let mut e1 = test_entry("kwin", 1, None);
e1.life = lifecycle::State::Lingering {
until: t0 - Duration::from_millis(1),
};
es.push(e1);
// gen 2: lingering, deadline in the future, current epoch → survives.
// generation 2: lingering, deadline in the future, current epoch → survives.
let mut e2 = test_entry("kwin", 2, None);
e2.life = lifecycle::State::Lingering {
until: t0 + Duration::from_secs(60),
};
e2.epoch = 5;
es.push(e2);
// gen 3: pinned, but from a DEAD epoch → reaped (its compositor is gone).
// generation 3: pinned, but from a DEAD epoch → reaped (its compositor is gone).
let mut e3 = test_entry("kwin", 3, None);
e3.life = lifecycle::State::Pinned;
e3.epoch = 4;
es.push(e3);
// gen 4: ACTIVE from a dead epoch → left to its own session's rebuild.
// generation 4: ACTIVE from a dead epoch → left to its own session's rebuild.
let mut e4 = test_entry("kwin", 4, None);
e4.life = lifecycle::State::Active { refs: 1 };
e4.epoch = 4;
es.push(e4);
// gen 5: a gamescope spawn from a "dead" epoch — exempt (independent nested session).
// generation 5: a gamescope spawn from a "dead" epoch — exempt (independent nested session).
let mut e5 = test_entry("gamescope", 5, None);
e5.life = lifecycle::State::Pinned;
e5.epoch = 1;
@@ -931,9 +938,9 @@ mod pool {
let (expired, restores) = take_expired(&mut es, t0, 5);
assert!(restores.is_empty());
let gone: Vec<u64> = expired.iter().map(|e| e.gen).collect();
let gone: Vec<u64> = expired.iter().map(|e| e.generation).collect();
assert_eq!(gone, vec![1, 3]);
let left: Vec<u64> = es.iter().map(|e| e.gen).collect();
let left: Vec<u64> = es.iter().map(|e| e.generation).collect();
assert_eq!(left, vec![2, 4, 5]);
}
}
@@ -974,7 +981,7 @@ mod linux {
struct Reg {
entries: Mutex<Vec<Entry>>,
gen: AtomicU64,
generation: AtomicU64,
}
static REG: OnceLock<Reg> = OnceLock::new();
@@ -982,7 +989,7 @@ mod linux {
fn reg() -> &'static Reg {
REG.get_or_init(|| Reg {
entries: Mutex::new(Vec::new()),
gen: AtomicU64::new(1),
generation: AtomicU64::new(1),
})
}
@@ -1029,25 +1036,27 @@ mod linux {
}
match std::thread::Builder::new()
.name("vdisplay-linger".into())
.spawn(|| loop {
std::thread::sleep(Duration::from_millis(500));
let (expired, restores) = {
let mut es = reg().entries.lock().unwrap();
take_expired(&mut es, Instant::now(), crate::session_epoch())
};
// Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock.
for restore in restores {
restore();
.spawn(|| {
loop {
std::thread::sleep(Duration::from_millis(500));
let (expired, restores) = {
let mut es = reg().entries.lock().unwrap();
take_expired(&mut es, Instant::now(), crate::session_epoch())
};
// Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock.
for restore in restores {
restore();
}
let reaped = expired.len();
for e in expired {
tracing::info!(
backend = e.backend,
"virtual display: linger expired — torn down"
);
drop(e); // outside the lock
}
emit_released(reaped);
}
let reaped = expired.len();
for e in expired {
tracing::info!(
backend = e.backend,
"virtual display: linger expired — torn down"
);
drop(e); // outside the lock
}
emit_released(reaped);
}) {
Ok(_) => *started = true,
Err(e) => tracing::error!(
@@ -1069,27 +1078,27 @@ mod linux {
}
}
/// Build the session-facing [`VirtualOutput`]: the kept node + a fresh gen-stamped lease. Only
/// Build the session-facing [`VirtualOutput`]: the kept node + a fresh generation-stamped lease. Only
/// the poolable (`remote_fd == None`) backends reach here, so `remote_fd` is always `None`.
fn output_for(
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
gen: u64,
generation: u64,
quit: Arc<AtomicBool>,
reused: bool,
) -> VirtualOutput {
// The pooled display is registry-owned; the session holds a gen-stamped lease as its keepalive.
// The pooled display is registry-owned; the session holds a generation-stamped lease as its keepalive.
let mut out = VirtualOutput::owned(
node_id,
preferred_mode,
Box::new(DisplayLease { gen, quit }),
Box::new(DisplayLease { generation, quit }),
);
// A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can
// `mark_failed(gen)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
out.reused_gen = reused.then_some(gen);
// H4: every pooled display carries its gen, so a mode-switch rebuild can `retire` the entry
// `mark_failed(generation)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
out.reused_gen = reused.then_some(generation);
// H4: every pooled display carries its generation, so a mode-switch rebuild can `retire` the entry
// this output's successor supersedes.
out.pool_gen = Some(gen);
out.pool_gen = Some(generation);
out
}
@@ -1129,9 +1138,9 @@ mod linux {
// gamescope spawns are independent nested sessions, exempt from the active-session epoch —
// see `epoch_matches`). The liveness probe (`kept_display_alive`, which may shell `pw-dump`
// for gamescope) must NOT run under the pool lock (it can block / hang the daemon), so:
// 1. find the candidate + snapshot (gen, node_id) UNDER the lock, then release it;
// 1. find the candidate + snapshot (generation, node_id) UNDER the lock, then release it;
// 2. probe liveness OUTSIDE the lock;
// 3. re-lock and re-find the SAME entry by its gen (another thread may have reused/removed
// 3. re-lock and re-find the SAME entry by its generation (another thread may have reused/removed
// it meanwhile — then we just miss and create fresh).
let candidate = {
let es = r.entries.lock().unwrap();
@@ -1147,16 +1156,16 @@ mod linux {
&& e.hdr == vd.hdr()
&& epoch_matches(e.backend, e.epoch, cur_epoch)
})
.map(|e| (e.gen, e.node_id))
.map(|e| (e.generation, e.node_id))
};
if let Some((cand_gen, node_id)) = candidate {
let alive = vd.kept_display_alive(node_id); // OUTSIDE the lock (may block)
let reuse = {
let mut es = r.entries.lock().unwrap();
// Re-find the SAME entry by its snapshot gen; skip if it's gone or no longer kept
// Re-find the SAME entry by its snapshot generation; skip if it's gone or no longer kept
// (a concurrent reconnect adopted it) — we then miss and create fresh.
match es.iter().position(|e| {
e.gen == cand_gen
e.generation == cand_gen
&& matches!(
e.life,
lifecycle::State::Lingering { .. } | lifecycle::State::Pinned
@@ -1164,8 +1173,8 @@ mod linux {
}) {
Some(idx) if alive => {
es[idx].life.acquire();
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
es[idx].gen = gen;
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
es[idx].generation = generation;
let preferred_mode = es[idx].preferred_mode;
tracing::info!(
backend,
@@ -1175,7 +1184,7 @@ mod linux {
ReuseOutcome::Reused(output_for(
node_id,
preferred_mode,
gen,
generation,
quit.clone(),
true,
))
@@ -1183,7 +1192,7 @@ mod linux {
Some(idx) => {
// Dead kept display: remove it, hand off its group restore, create fresh.
let mut dead = es.remove(idx);
let (b, g) = (dead.backend, dead.gen);
let (b, g) = (dead.backend, dead.generation);
let restore =
hand_off_restore(&mut es, b, g, dead.topology_restore.take());
ReuseOutcome::Dead(dead, restore)
@@ -1213,9 +1222,9 @@ mod linux {
// The new display's generation stamp, taken BEFORE the group questions below: it is this
// display's identity for the rest of the acquire, and `group_key` needs it — a gamescope
// spawn's group IS its gen. A gen burned by a failed create is harmless (they are opaque
// spawn's group IS its generation. A generation burned by a failed create is harmless (they are opaque
// and monotonic, never an index).
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
// NOTE: the operator's `max_displays` ceiling is NOT enforced here. It belongs at
// admission (`admission::admit`), which is where Windows has always applied it, and the
@@ -1240,7 +1249,7 @@ mod linux {
let first_in_group = {
let es = r.entries.lock().unwrap();
!es.iter().any(|e| {
in_group(e.backend, e.gen, backend, gen, supersedes)
in_group(e.backend, e.generation, backend, generation, supersedes)
&& matches!(e.life, lifecycle::State::Active { .. })
})
};
@@ -1292,7 +1301,7 @@ mod linux {
topology_restore,
launch: launch.clone(),
epoch: cur_epoch,
gen,
generation,
hw_cursor: vd.hw_cursor(),
hdr: vd.hdr(),
};
@@ -1314,10 +1323,10 @@ mod linux {
// width to the right on every mode switch.
let existing: Vec<(u64, Member)> = es
.iter()
.filter(|e| in_group(e.backend, e.gen, backend, gen, supersedes))
.filter(|e| in_group(e.backend, e.generation, backend, generation, supersedes))
.map(|e| {
(
e.gen,
e.generation,
Member {
identity_slot: e.identity_slot,
width: e.mode.width as i32,
@@ -1340,7 +1349,7 @@ mod linux {
if (position.x, position.y) != (0, 0) {
vd.apply_position(position.x, position.y);
}
let mut out = output_for(node_id, preferred_mode, gen, quit, false);
let mut out = output_for(node_id, preferred_mode, generation, quit, false);
out.expect_exact_dims = expect_exact_dims;
Ok(out)
}
@@ -1348,18 +1357,18 @@ mod linux {
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
/// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after*
/// the lock is released.
fn release(gen: u64, force_immediate: bool) {
fn release(generation: u64, force_immediate: bool) {
let Some(r) = REG.get() else { return };
let linger = effective_linger(force_immediate, linger());
let (torn_down, restore) = {
let mut es = r.entries.lock().unwrap();
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
let Some(idx) = es.iter().position(|e| e.generation == generation) else {
return; // stale lease (entry reused + re-stamped, or already gone) — no-op
};
match es[idx].life.release(Instant::now(), linger) {
Release::Teardown => {
let mut e = es.remove(idx);
let (backend, g) = (e.backend, e.gen);
let (backend, g) = (e.backend, e.generation);
// Per-group restore (§6.1): hand the physical re-enable to a surviving sibling, or run
// it now if this was the group's last member.
let restore = hand_off_restore(&mut es, backend, g, e.topology_restore.take());
@@ -1368,8 +1377,8 @@ mod linux {
// A release against a slot with NO live hold — a stale or duplicate lease drop. The
// machine's contract for it is "do nothing", and it was wired to the teardown arm:
// the one outcome that means the caller has no claim on this display would have torn
// the display down. Unreachable today (a lease's gen is unique per acquire and the
// lookup above is by gen, so a stale lease misses the pool entirely and returns
// the display down. Unreachable today (a lease's generation is unique per acquire and the
// lookup above is by generation, so a stale lease misses the pool entirely and returns
// early), which is exactly why it has to be right by construction rather than by
// luck — matching the Windows manager's own Noop arm.
Release::Noop => (None, None),
@@ -1434,7 +1443,7 @@ mod linux {
lifecycle::State::Idle => return None,
};
Some(Row {
gen: e.gen,
generation: e.generation,
backend: e.backend,
mode: e.mode,
identity_slot: e.identity_slot,
@@ -1455,7 +1464,10 @@ mod linux {
// Stable per-group ids, carried across polls (see `assign_group_ids`) — a new group must
// never renumber an existing one under the console. The state is process-lifetime and this
// is the only thing that touches it, so it lives here rather than in the pure core.
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
let mut keys: Vec<String> = rows
.iter()
.map(|r| group_key(r.backend, r.generation))
.collect();
keys.sort();
keys.dedup();
static GROUP_IDS: Mutex<Option<(std::collections::BTreeMap<String, u32>, u32)>> =
@@ -1475,13 +1487,13 @@ mod linux {
}
/// H4 — force-release a display superseded by a mid-stream mode switch. Same machinery as
/// [`force_release`] (kept entries only — an Active entry is refused, and a gen already torn
/// [`force_release`] (kept entries only — an Active entry is refused, and a generation already torn
/// down under `immediate` is a no-op), distinct log line.
pub(super) fn retire(gen: u64) {
release_kept(Some(gen), "retired (superseded by a mode switch)");
pub(super) fn retire(generation: u64) {
release_kept(Some(generation), "retired (superseded by a mode switch)");
}
/// Remove + tear down KEPT (lingering/pinned) entries — all of them, or one by gen — running /
/// Remove + tear down KEPT (lingering/pinned) entries — all of them, or one by generation — running /
/// handing off group topology restores, with keepalive drops outside the lock. The shared core
/// of [`force_release`] (mgmt) and [`retire`] (mode-switch supersede).
fn release_kept(slot: Option<u64>, why: &'static str) -> usize {
@@ -1492,10 +1504,10 @@ mod linux {
let mut restores = Vec::new();
let mut i = 0;
while i < es.len() {
let selected = slot.is_none_or(|s| es[i].gen == s);
let selected = slot.is_none_or(|s| es[i].generation == s);
if selected && es[i].life.force_release() {
let mut e = es.remove(i);
let (backend, g) = (e.backend, e.gen);
let (backend, g) = (e.backend, e.generation);
let restore = e.topology_restore.take();
if let Some(rst) = hand_off_restore(&mut es, backend, g, restore) {
restores.push(rst);
@@ -1522,15 +1534,15 @@ mod linux {
/// A2 — tear down a reused-but-dead pool entry by its generation stamp. Removes it (hand off /
/// run its group restore), drops the keepalive outside the lock. Idempotent (already gone → no-op).
pub(super) fn mark_failed(gen: u64) {
pub(super) fn mark_failed(generation: u64) {
let Some(r) = REG.get() else { return };
let (torn, restore) = {
let mut es = r.entries.lock().unwrap();
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
return; // already gone — the subsequent stale-gen lease drop no-ops too
let Some(idx) = es.iter().position(|e| e.generation == generation) else {
return; // already gone — the subsequent stale-generation lease drop no-ops too
};
let mut e = es.remove(idx);
let (backend, g) = (e.backend, e.gen);
let (backend, g) = (e.backend, e.generation);
let restore = hand_off_restore(&mut es, backend, g, e.topology_restore.take());
(e, restore)
};
@@ -1559,7 +1571,7 @@ mod linux {
while i < es.len() {
if es[i].backend == backend {
let mut e = es.remove(i);
let (b, g) = (e.backend, e.gen);
let (b, g) = (e.backend, e.generation);
if let Some(rst) = hand_off_restore(&mut es, b, g, e.topology_restore.take()) {
restores.push(rst);
}
@@ -1591,7 +1603,7 @@ mod linux {
/// The session's refcount handle — the `keepalive` the capturer holds. `Drop` releases the
/// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op.
struct DisplayLease {
gen: u64,
generation: u64,
/// The session's deliberate-quit flag: set when the client closes with the quit application
/// code (a user "stop", not a network drop), so this lease's `Drop` tears the display down
/// immediately instead of lingering. `false` on a bare disconnect → normal keep-alive.
@@ -1600,7 +1612,7 @@ mod linux {
impl Drop for DisplayLease {
fn drop(&mut self) {
release(self.gen, self.quit.load(Ordering::SeqCst));
release(self.generation, self.quit.load(Ordering::SeqCst));
}
}
}
+11 -4
View File
@@ -161,7 +161,10 @@ pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) -> Option<Gam
// injector as sway/river, no code change.
Compositor::Wlroots | Compositor::Hyprland => "wlr",
};
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
// SAFETY: `_env_guard` holds [`ENV_LOCK`] — the crate-wide discipline (lib.rs) serializing
// every process-env writer on the session-setup path; steady-state threads read cached
// config, not the environment.
unsafe { std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend) };
drop(_env_guard);
resolve_gamescope_route(chosen, dedicated_launch)
}
@@ -464,11 +467,15 @@ mod tests {
use super::operator_gamescope;
let first = operator_gamescope();
let restore = crate::with_env_lock(|| std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE"));
crate::with_env_lock(|| std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto"));
// SAFETY: both mutations run under `with_env_lock` — the crate's env-writer
// serialization (ENV_LOCK, lib.rs); the readers under test sample once at startup.
crate::with_env_lock(|| unsafe { std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto") });
let second = operator_gamescope();
crate::with_env_lock(|| match &restore {
Some(v) => std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", v),
None => std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE"),
// SAFETY: as above — the restore also runs under the same env-writer lock.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE") },
});
assert_eq!(
second.node, first.node,
+43 -35
View File
@@ -585,41 +585,49 @@ fn find_wayland_socket(env: &EnvProbe, runtime: &str, uid: u32) -> Option<String
pub fn apply_session_env(active: &ActiveSession) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let e = &active.env;
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
if let Some(w) = &e.wayland_display {
std::env::set_var("WAYLAND_DISPLAY", w);
}
if let Some(d) = &e.xdg_current_desktop {
std::env::set_var("XDG_CURRENT_DESKTOP", d);
}
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live compositor
// (fixes G4 for the systemd `--user` host, which never inherited it). Only set when detection
// found a Hyprland session; a stale value from a previous connect is cleared otherwise so a
// Hyprland→sway switch can't leave `hyprctl` pointed at a dead instance.
match &e.hyprland_signature {
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
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
// graphical session" handshake error. Clear them so `available()` reports the truth and the
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
if active.kind == ActiveKind::None {
std::env::remove_var("XDG_CURRENT_DESKTOP");
std::env::remove_var("WAYLAND_DISPLAY");
// SAFETY: `_env_guard` holds [`ENV_LOCK`] — the crate-wide discipline (see its doc in lib.rs)
// that serializes every process-env writer on the session-setup path; steady-state streaming
// threads read cached config, not the environment (security-review 2026-06-28 #7).
unsafe {
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
if let Some(w) = &e.wayland_display {
std::env::set_var("WAYLAND_DISPLAY", w);
}
if let Some(d) = &e.xdg_current_desktop {
std::env::set_var("XDG_CURRENT_DESKTOP", d);
}
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live
// compositor (fixes G4 for the systemd `--user` host, which never inherited it). Only set
// when detection found a Hyprland session; a stale value from a previous connect is
// cleared otherwise so a Hyprland→sway switch can't leave `hyprctl` pointed at a dead
// instance.
match &e.hyprland_signature {
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
// `mutter::is_available()` true, so a client's explicit backend request routed into the
// dead session — 45 s create timeouts and a libei error loop instead of the crisp "no
// live graphical session" handshake error. Clear them so `available()` reports the truth
// and the client fails fast (and, when configured, `try_recover_session` can bring the
// desktop back).
if active.kind == ActiveKind::None {
std::env::remove_var("XDG_CURRENT_DESKTOP");
std::env::remove_var("WAYLAND_DISPLAY");
}
}
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
// [`effective_topology`] directly at create time — the console policy, else the legacy
@@ -112,8 +112,8 @@ struct Monitor {
/// This monitor's desktop-space origin from the group layout (`(0,0)` until a multi-slot
/// arrangement places it) — reported via [`ManagedInfo`].
position: (i32, i32),
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
gen: u64,
/// Generation stamp; a [`MonitorLease`] only releases if its generation still matches (stale-lease no-op).
generation: u64,
}
impl Monitor {
@@ -178,6 +178,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 the EXPERIMENTAL `edid_lock` axis pinned AMD connector emulation at the group's
/// first isolate (`pf_win_display::adl_emul::lock_for_stream`) — last-member teardown owes the
/// unlock (pinned emulation outlives the process, so a crash journal backs this flag up).
edid_locked: bool,
/// 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.
@@ -367,10 +371,10 @@ struct MgrInner {
}
impl MgrInner {
/// Live target ids in acquire (gen) order — the CCD isolate keep-set + the layout member order.
/// Live target ids in acquire (generation) order — the CCD isolate keep-set + the layout member order.
fn target_ids(&self) -> Vec<u32> {
let mut mons: Vec<&Monitor> = self.slots.values().map(SlotState::mon).collect();
mons.sort_by_key(|m| m.gen);
mons.sort_by_key(|m| m.generation);
mons.iter().map(|m| m.target_id).collect()
}
}
@@ -425,7 +429,7 @@ pub struct VirtualDisplayManager {
/// fallback. One attempt per process, in case a future OS build honors the refresh.
update_modes_futile: AtomicBool,
/// Monotonic lease-generation counter (was the `MON_GEN` global).
gen: AtomicU64,
generation: AtomicU64,
state: Mutex<MgrInner>,
/// Serializes IDD-push session SETUP (preempt + monitor create) — MANAGER-WIDE even with slots:
/// monitor create/teardown stays serialized (the 400 ms async-departure settle and the IddCx
@@ -473,7 +477,7 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
watchdog_s: AtomicU32::new(3),
driver_proto: AtomicU32::new(0),
update_modes_futile: AtomicBool::new(false),
gen: AtomicU64::new(1),
generation: AtomicU64::new(1),
state: Mutex::new(MgrInner::default()),
setup_lock: Mutex::new(()),
idd_session_stops: Mutex::new(std::collections::HashMap::new()),
@@ -700,7 +704,7 @@ impl VirtualDisplayManager {
// IDD-push: a new connection while THIS SLOT's monitor is kept (LINGERING or PINNED) is a
// single-client RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is
// DEAD, so reusing it hands a black screen — PREEMPT: tear the kept monitor down and create a
// fresh one. The old session's lease is gen-stamped, so its later drop is a no-op. A SIBLING
// fresh one. The old session's lease is generation-stamped, so its later drop is a no-op. A SIBLING
// slot's kept monitor is never touched — that's another client's display.
//
// ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the
@@ -746,7 +750,7 @@ impl VirtualDisplayManager {
// hand the rebuild the dead monitor's target (stale wudf_pid) and starve it to the rebuild
// budget. Preempt instead: best-effort teardown (REMOVE fails harmlessly on a dead/retired
// device) and fall through to a fresh create on the auto-restarted device. Held leases are
// gen-stamped, so their eventual release is a no-op. ONE WUDFHost hosts every slot's
// generation-stamped, so their eventual release is a no-op. ONE WUDFHost hosts every slot's
// publisher, so its death is ALL-slot shared fate — but each sibling's session fails through
// its own capturer watch and rebuilds through this same path; no cross-slot teardown here.
if matches!(inner.slots.get(&slot), Some(SlotState::Active { mon, .. }) if !wudf_alive(mon.wudf_pid))
@@ -807,7 +811,7 @@ impl VirtualDisplayManager {
match unsafe { self.resize_in_place(dev_raw(&dev), mon, mode) } {
Ok(()) => {
// Same join semantics as the re-arrival: +1 ref for the new
// (build-then-drop overlap) lease; `gen` untouched, so the old
// (build-then-drop overlap) lease; `generation` untouched, so the old
// session's lease stays valid.
*refs += 1;
let refs = *refs;
@@ -858,7 +862,7 @@ impl VirtualDisplayManager {
// acked the switch; Fix 2's corrective ack tells the client the resolution
// did not change). Store the RECOVERED monitor, not the one handed in:
// that one's driver monitor was REMOVEd before the failed ADD, so its
// `key`/`target_id`/`gdi_name` are all dead. `gen`/`refs` are preserved,
// `key`/`target_id`/`gdi_name` are all dead. `generation`/`refs` are preserved,
// so leases stay valid either way.
inner.slots.insert(
slot,
@@ -875,7 +879,7 @@ impl VirtualDisplayManager {
return Err(err).context("mid-stream resize re-arrival (slot left empty)");
}
};
// `re_add` preserved `gen`, so both the old session's lease and this new one match on
// `re_add` preserved `generation`, so both the old session's lease and this new one match on
// release. +1 ref for the new (build-then-drop overlap) lease.
let out = self.output_for(slot, &new_mon, quit);
inner.slots.insert(
@@ -965,7 +969,7 @@ impl VirtualDisplayManager {
Ok(out)
}
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh generation-stamped lease) for
/// `mon` in `slot`. `quit` is the session's deliberate-quit flag, read by the lease `Drop` (see
/// [`Self::release`]).
fn output_for(
@@ -981,7 +985,7 @@ impl VirtualDisplayManager {
keepalive: Box::new(MonitorLease {
mgr: self,
slot,
gen: mon.gen,
generation: mon.generation,
quit,
}),
// The Windows manager owns the monitor lifecycle (refcount/linger/pin), so the registry
@@ -1211,18 +1215,18 @@ impl VirtualDisplayManager {
return;
}
let layout_policy = crate::policy::prefs().get().effective().layout;
// Members in acquire (gen) order — the auto-row order; identity slot 0 = anonymous (no
// manual pin can address it, so it always auto-rows). `(slot, gen, target_id, width)`
// Members in acquire (generation) order — the auto-row order; identity slot 0 = anonymous (no
// manual pin can address it, so it always auto-rows). `(slot, generation, target_id, width)`
// copied out so the arrangement below can write back through `get_mut`.
let mut ordered: Vec<(u32, u64, u32, i32)> = inner
.slots
.iter()
.map(|(slot, s)| {
let m = s.mon();
(*slot, m.gen, m.target_id, m.mode.width as i32)
(*slot, m.generation, m.target_id, m.mode.width as i32)
})
.collect();
ordered.sort_by_key(|&(_, gen, _, _)| gen);
ordered.sort_by_key(|&(_, generation, _, _)| generation);
let members: Vec<Member> = ordered
.iter()
.map(|&(slot, _, _, width)| Member {
@@ -1400,6 +1404,18 @@ impl VirtualDisplayManager {
if crate::policy::prefs().ddc_power_off() {
inner.group.ddc_panels_off = crate::ddc::panel_off_except(n);
}
// EXPERIMENTAL `edid_lock` policy axis (AMD only): pin connector EDID
// emulation BEFORE the isolate deactivates the physicals — an awake
// sink still answers the live-EDID read the lock pins (asleep sinks
// fall back to the driver's stored emulation data). With emulation at
// ADL_EMUL_MODE_ALWAYS the KMD stops servicing the sleeping sink's
// HPD/DDC/link — the standby-sink stall class at its source
// (rationale + crash journal in `pf_win_display::adl_emul`). First
// member only, like the DDC leg: the connectors are host-wide.
if crate::policy::prefs().edid_lock() {
inner.group.edid_locked =
pf_win_display::adl_emul::lock_for_stream();
}
inner.group.ccd_saved = isolate_displays_ccd_seam(&keep);
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
// additionally disable the deactivated monitors' PnP devnodes (persistent
@@ -1553,7 +1569,7 @@ impl VirtualDisplayManager {
requested_mode,
resolved_monitor_id: added.resolved_monitor_id,
position: (0, 0),
gen: self.gen.fetch_add(1, Ordering::Relaxed),
generation: self.generation.fetch_add(1, Ordering::Relaxed),
hw_cursor,
cursor_excluded: added.cursor_excluded,
})
@@ -1677,7 +1693,7 @@ impl VirtualDisplayManager {
/// / ContainerId) so the OS keeps the monitor's identity + saved per-monitor DPI. The visible cost
/// is one monitor hotplug per switch (the design's accepted "re-arrival for everything").
///
/// Refcount/lease continuity: the rebuilt `Monitor` PRESERVES the old `gen`, so the outstanding
/// Refcount/lease continuity: the rebuilt `Monitor` PRESERVES the old `generation`, so the outstanding
/// session lease(s) still match on release — the linger/refcount machine is untouched. The group
/// restore snapshot (`group.ccd_saved` / DDC / PnP) is likewise PRESERVED (a mid-session swap, not
/// a first-member create): [`reisolate_after_swap`](Self::reisolate_after_swap) re-isolates the new
@@ -1821,8 +1837,8 @@ impl VirtualDisplayManager {
added.target_id
),
}
// 5. Rebuild the Monitor from the ADD reply, PRESERVING `gen` (lease/refcount continuity) and
// the group-layout `position`. A fresh `gen` would strand the old session's lease release.
// 5. Rebuild the Monitor from the ADD reply, PRESERVING `generation` (lease/refcount continuity) and
// the group-layout `position`. A fresh `generation` would strand the old session's lease release.
let mon = Box::new(Monitor {
key: added.key,
target_id: added.target_id,
@@ -1834,7 +1850,7 @@ impl VirtualDisplayManager {
requested_mode,
resolved_monitor_id: added.resolved_monitor_id,
position: old.position,
gen: old.gen,
generation: old.generation,
hw_cursor: old.hw_cursor,
// Fresh from THIS reply, not `old`: the driver's per-target declare registry is the
// ground truth (this session may itself have declared since the original ADD).
@@ -1958,6 +1974,15 @@ impl VirtualDisplayManager {
);
inner.group.ddc_panels_off = 0;
}
// EXPERIMENTAL `edid_lock` unlock. AFTER the CCD restore + DDC wake: the re-activated
// physical paths do not depend on it (the pinned emulation IS the real monitor's
// EDID), and unlocking last keeps the driver from re-probing the sinks mid-restore.
// OUTSIDE the `ccd_saved` gate for the same reason as the DDC wake above — the lock
// was applied BEFORE the isolate, whose snapshot capture can have failed.
if inner.group.edid_locked {
pf_win_display::adl_emul::unlock_after_stream();
inner.group.edid_locked = false;
}
} else {
match shrink_action(inner.group.ccd_exclusive, inner.group.ccd_saved.is_some()) {
// Re-issue the isolate over the shrunk set (defensive — the departing monitor's
@@ -2023,10 +2048,10 @@ impl VirtualDisplayManager {
/// deliberate quit still pins — only `/display/release` frees a pinned monitor. A STALE lease
/// (its monitor was preempted + recreated under it) is a no-op, so it can't tear down the
/// CURRENT monitor.
fn release(&self, slot: u32, gen: u64, quit_now: bool) {
fn release(&self, slot: u32, generation: u64, quit_now: bool) {
let mut inner = self.state.lock().unwrap();
let stale = match inner.slots.get(&slot) {
Some(s) => s.mon().gen != gen,
Some(s) => s.mon().generation != generation,
None => true,
};
if stale {
@@ -2135,7 +2160,7 @@ impl VirtualDisplayManager {
// HERE instead. This runs at most ONCE per session (we hold `setup_lock`), so —
// unlike preempting inside `acquire` — it does not reintroduce the per-retry churn.
// The next `acquire` then sees the slot empty and creates a fresh monitor; the stale
// session's gen-stamped lease release is a no-op.
// session's generation-stamped lease release is a no-op.
if let Some(dev) = self.device_handle() {
let mut inner = self.state.lock().unwrap();
let taken = match inner.slots.get(&slot) {
@@ -2195,38 +2220,40 @@ impl VirtualDisplayManager {
TIMER.call_once(|| {
thread::Builder::new()
.name("vdisplay-linger".into())
.spawn(move || loop {
thread::sleep(Duration::from_millis(500));
let Some(dev) = self.device_handle() else {
continue;
};
let mut g = self.state.lock().unwrap();
let now = Instant::now();
let expired: Vec<u32> = g
.slots
.iter()
.filter_map(|(slot, s)| {
matches!(s, SlotState::Lingering { until, .. } if now >= *until)
.then_some(*slot)
})
.collect();
for slot in expired {
if let Some(SlotState::Lingering { mon, .. }) = g.slots.remove(&slot) {
// Teardown UNDER the state lock. Dropping the lock first (the old shape)
// let a concurrent `acquire` see the slot empty and run its ADD + CCD
// isolate while this monitor's CCD-restore / REMOVE were still in flight
// — the late restore then de-isolated (or the REMOVE churn-rejected) the
// fresh session at the linger-expiry boundary. Holding the lock makes
// the racing acquire WAIT the few teardown seconds instead of failing
// its session. Lock order stays state → device (teardown's invalidate
// path), same as every other holder; the pinger takes only the device
// lock — no inversion.
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
// Arc from `self.device_handle()` is held across this call, so the
// handle stays open (a concurrent retire drops only the manager's
// reference; see `DeviceSlot`). `mon` was moved out of the map under
// the lock, so it is exclusively owned here.
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
.spawn(move || {
loop {
thread::sleep(Duration::from_millis(500));
let Some(dev) = self.device_handle() else {
continue;
};
let mut g = self.state.lock().unwrap();
let now = Instant::now();
let expired: Vec<u32> = g
.slots
.iter()
.filter_map(|(slot, s)| {
matches!(s, SlotState::Lingering { until, .. } if now >= *until)
.then_some(*slot)
})
.collect();
for slot in expired {
if let Some(SlotState::Lingering { mon, .. }) = g.slots.remove(&slot) {
// Teardown UNDER the state lock. Dropping the lock first (the old shape)
// let a concurrent `acquire` see the slot empty and run its ADD + CCD
// isolate while this monitor's CCD-restore / REMOVE were still in flight
// — the late restore then de-isolated (or the REMOVE churn-rejected) the
// fresh session at the linger-expiry boundary. Holding the lock makes
// the racing acquire WAIT the few teardown seconds instead of failing
// its session. Lock order stays state → device (teardown's invalidate
// path), same as every other holder; the pinger takes only the device
// lock — no inversion.
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
// Arc from `self.device_handle()` is held across this call, so the
// handle stays open (a concurrent retire drops only the manager's
// reference; see `DeviceSlot`). `mon` was moved out of the map under
// the lock, so it is exclusively owned here.
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
}
}
}
})
@@ -2240,7 +2267,7 @@ impl VirtualDisplayManager {
struct MonitorLease {
mgr: &'static VirtualDisplayManager,
slot: u32,
gen: u64,
generation: u64,
/// The session's deliberate-quit flag (the client closed with the QUIT application code — a user
/// "stop", not a network drop). Read at drop time: a quit release tears the monitor down NOW
/// instead of lingering, mirroring the Linux registry's `Linger::Immediate`. `None` = no signal
@@ -2251,7 +2278,7 @@ struct MonitorLease {
impl Drop for MonitorLease {
fn drop(&mut self) {
let quit_now = self.quit.as_ref().is_some_and(|q| q.load(Ordering::SeqCst));
self.mgr.release(self.slot, self.gen, quit_now);
self.mgr.release(self.slot, self.generation, quit_now);
}
}
@@ -2319,7 +2346,7 @@ pub(crate) struct ManagedInfo {
/// Live sessions holding the monitor.
pub sessions: u32,
/// The monitor's generation stamp — a stable-enough id for the `/display/release` slot arg.
pub gen: u64,
pub generation: u64,
/// The slot key: the client's stable identity slot (`1..=15`), or `0` = anonymous/auto.
pub slot_id: u32,
/// Desktop-space origin from the group layout (`(0,0)` for a single display).
@@ -2327,7 +2354,7 @@ pub(crate) struct ManagedInfo {
}
impl VirtualDisplayManager {
/// Snapshot the managed slots for the mgmt `/display/state` endpoint, in acquire (gen) order.
/// Snapshot the managed slots for the mgmt `/display/state` endpoint, in acquire (generation) order.
/// Empty when no slot lives.
pub(crate) fn snapshot(&self) -> Vec<ManagedInfo> {
let inner = self.state.lock().unwrap();
@@ -2351,20 +2378,20 @@ impl VirtualDisplayManager {
state,
expires_in_ms,
sessions,
gen: mon.gen,
generation: mon.generation,
slot_id: *slot,
position: mon.position,
}
})
.collect();
out.sort_by_key(|i| i.gen);
out.sort_by_key(|i| i.generation);
out
}
/// Force-tear-down kept (LINGERING **or** PINNED) monitors now (the `/display/release` endpoint) —
/// so a physical-screen user gets their screen back without waiting out the linger, and it is the §8
/// escape hatch that frees a `keep_alive=forever` (Pinned) monitor. `slot` selects one kept monitor
/// by its [`ManagedInfo::gen`] stamp; `None` releases every kept one. Active monitors are refused
/// by its [`ManagedInfo::generation`] stamp; `None` releases every kept one. Active monitors are refused
/// (stopping a live session is session management, not display management). Returns the number
/// released.
pub(crate) fn force_release(&self, slot: Option<u64>) -> usize {
@@ -2377,7 +2404,7 @@ impl VirtualDisplayManager {
.iter()
.filter_map(|(k, s)| match s {
SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }
if slot.is_none_or(|g| g == mon.gen) =>
if slot.is_none_or(|g| g == mon.generation) =>
{
Some(*k)
}
@@ -2409,7 +2436,7 @@ pub(crate) fn snapshot() -> Vec<ManagedInfo> {
.unwrap_or_default()
}
/// Force-release kept monitors now (`slot` = a [`ManagedInfo::gen`] stamp, `None` = all kept); `0`
/// Force-release kept monitors now (`slot` = a [`ManagedInfo::generation`] stamp, `None` = all kept); `0`
/// if nothing was kept (or the manager is uninitialised).
pub(crate) fn force_release(slot: Option<u64>) -> usize {
VDM.get().map(|m| m.force_release(slot)).unwrap_or(0)
+1 -1
View File
@@ -268,7 +268,7 @@ fn flagged(flags: &[bool]) -> Vec<usize> {
flags
.iter()
.enumerate()
.filter(|(_, &d)| d)
.filter(|&(_, &d)| d)
.map(|(i, _)| i)
.collect()
}
+15 -5
View File
@@ -830,7 +830,9 @@ fn h264_parity_run_against(
// The decoder reads this at session creation (first decode call): pool
// images grow TRANSFER_SRC so vkCmdCopyImageToBuffer is legal.
std::env::set_var("PF_VKD_TEST_READBACK", "1");
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
// this variable's only writers and readers, and they run one at a time under that lock.
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
let goldens = golden_hashes(goldens);
assert_eq!(
@@ -940,7 +942,9 @@ fn h265_parity_run(
// As the H.264 leg: one codec at a time, `set_var` under the lock.
let _gpu = common::gpu_lock();
std::env::set_var("PF_VKD_TEST_READBACK", "1");
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
// this variable's only writers and readers, and they run one at a time under that lock.
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
let goldens = golden_hashes(goldens_file);
assert_eq!(
@@ -1124,7 +1128,9 @@ fn av1_parity_run_against(
// happens only under this lock (see `common::gpu_lock`).
let _gpu = common::gpu_lock();
std::env::set_var("PF_VKD_TEST_READBACK", "1");
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
// this variable's only writers and readers, and they run one at a time under that lock.
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
let goldens = golden_hashes(goldens_file);
// Non-vacuity, before any hardware is touched: the right number of entries, all
@@ -1265,7 +1271,9 @@ fn low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec() {
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
fn av1_frame0_pixels_say_which_plane_and_how_badly() {
let _gpu = common::gpu_lock();
std::env::set_var("PF_VKD_TEST_READBACK", "1");
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
// this variable's only writers and readers, and they run one at a time under that lock.
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
let aus = common::split_av1_aus(common::TEST_25FPS_AV1);
assert_eq!(
@@ -1338,7 +1346,9 @@ const MAX_LOOP_FILTER_LEVEL: u8 = 63;
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
fn av1_frame0_probes_whether_the_driver_reads_the_chroma_deblocking_levels() {
let _gpu = common::gpu_lock();
std::env::set_var("PF_VKD_TEST_READBACK", "1");
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
// this variable's only writers and readers, and they run one at a time under that lock.
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
let aus = common::split_av1_aus(common::TEST_25FPS_AV1);
assert_eq!(aus.len(), FRAME_COUNT);
+1 -1
View File
@@ -7,7 +7,7 @@
[package]
name = "pf-win-display"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host Windows display-topology helpers: CCD/GDI mode-set + path activation, HDR advanced colour, PnP monitor devnodes, and the display-change event watch."
+689
View File
@@ -0,0 +1,689 @@
//! AMD ADL connector/EDID emulation — the shared implementation behind the `display-disturb
//! adl-emul` probe AND the `edid_lock` display-policy axis (the software equivalent of an
//! HPD-holding dummy plug).
//!
//! Three field cases (ASUS VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI — all
//! RX 9070 XT hosts) share one mechanism: a connected-but-asleep sink whose standby HPD/DDC/link
//! servicing the KMD performs below every OS lever (CCD deactivation, devnode disable and CRU
//! EDID overrides are confirmed no-ops — `design/vdisplay-disturbance-immunity.md` §2a/§3). The
//! one software lever that can stop the servicing at its SOURCE is the driver's own connector
//! emulation: pin the live EDID with `ADL2_Adapter_ConnectionData_Set`, then
//! `ADL2_Adapter_EmulationMode_Set(ADL_EMUL_MODE_ALWAYS)` so the driver stops caring what the
//! physical pins report.
//!
//! [`run`] performs one action across every AMD adapter's connectors and returns the per-op
//! [`OpRecord`]s — the probe tool prints them as bench lines, the host tracing-logs them. The
//! `edid_lock` axis drives [`lock_for_stream`]/[`unlock_after_stream`] at the Exclusive isolate
//! (`pf-vdisplay`'s Windows manager), with a crash journal ([`startup_recover`]) because pinned
//! emulation persists across host restarts — and can persist across REBOOTS, so every lock ships
//! with its unlock (driver reinstall = the escape hatch of last resort).
//!
//! Everything is best-effort by design: no AMD driver (`atiadlxx.dll` absent) means the axis is
//! inert, and each rc is preserved so a field log answers the consumer-vs-Pro gating question
//! (`ADL_ERR_NOT_SUPPORTED(-8)` vs `ADL_OK`).
// FFI mirrors of ADL's C structs — keep AMD's field names verbatim so the header diff is
// mechanical.
#![allow(non_snake_case)]
use std::ffi::c_void;
use std::time::Instant;
use windows::core::{s, PCSTR};
use windows::Win32::Foundation::HMODULE;
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
// ---- ADL constants (adl_defines.h, GPUOpen display-library) ----
const ADL_OK: i32 = 0;
const ADL_MAX_PATH: usize = 256;
const ADL_MAX_DISPLAY_EDID_DATA_SIZE: usize = 1024;
const ADL_MAX_RAD_LINK_COUNT: usize = 15;
const ADL_EMUL_MODE_OFF: i32 = 0;
const ADL_EMUL_MODE_ALWAYS: i32 = 3;
const ADL_QUERY_REAL_DATA: i32 = 0;
const ADL_QUERY_EMULATED_DATA: i32 = 1;
const ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED: i32 = 0x1;
const ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT: i32 = 0x2;
const ADL_EMUL_STATUS_EMULATED_DEVICE_USED: i32 = 0x4;
const AMD_VENDOR_ID: i32 = 1002;
/// Decode the rc values a field log will actually contain (adl_defines.h) — `-8` vs `-1` is the
/// whole consumer-vs-Pro question, so spell them out.
pub fn rc_str(rc: i32) -> &'static str {
match rc {
0 => "ADL_OK",
1..=4 => "ADL_OK_(warning-class)",
-1 => "ADL_ERR",
-2 => "ADL_ERR_NOT_INIT",
-3 => "ADL_ERR_INVALID_PARAM",
-5 => "ADL_ERR_INVALID_ADL_IDX",
-8 => "ADL_ERR_NOT_SUPPORTED",
-9 => "ADL_ERR_NULL_POINTER",
-10 => "ADL_ERR_DISABLED_ADAPTER",
-22 => "ADL_ERR_CALL_TO_INCOMPATIABLE_DRIVER",
-23 => "ADL_ERR_NO_ADMINISTRATOR_PRIVILEGES",
_ => "?",
}
}
fn connector_type_str(t: i32) -> &'static str {
match t {
1 => "VGA",
2 => "DVI-D",
3 => "DVI-I",
8 => "HDMI-A",
9 => "HDMI-B",
10 => "DP",
11 => "eDP",
12 => "miniDP",
13 => "VIRTUAL",
14 => "USB-C",
_ => "unknown",
}
}
// ---- ADL structs (adl_structures.h, verbatim layouts) ----
#[repr(C)]
struct AdapterInfo {
iSize: i32,
iAdapterIndex: i32,
strUDID: [u8; ADL_MAX_PATH],
iBusNumber: i32,
iDeviceNumber: i32,
iFunctionNumber: i32,
iVendorID: i32,
strAdapterName: [u8; ADL_MAX_PATH],
strDisplayName: [u8; ADL_MAX_PATH],
iPresent: i32,
// _WIN32 tail — this tool only builds for Windows.
iExist: i32,
strDriverPath: [u8; ADL_MAX_PATH],
strDriverPathExt: [u8; ADL_MAX_PATH],
strPNPString: [u8; ADL_MAX_PATH],
iOSDisplayIndex: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLMSTRad {
iLinkNumber: i32,
rad: [u8; ADL_MAX_RAD_LINK_COUNT],
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLDevicePort {
iConnectorIndex: i32,
aMSTRad: ADLMSTRad,
}
impl ADLDevicePort {
/// A non-MST port at `connector` (MST RAD all-zero = "DP root / non-DP ignored" per header).
fn root(connector: i32) -> Self {
Self {
iConnectorIndex: connector,
aMSTRad: ADLMSTRad {
iLinkNumber: 0,
rad: [0; ADL_MAX_RAD_LINK_COUNT],
},
}
}
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLConnectionProperties {
iValidProperties: i32,
iBitrate: i32,
iNumberOfLanes: i32,
iColorDepth: i32,
iStereo3DCaps: i32,
iOutputBandwidth: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLConnectionData {
iConnectionType: i32,
aConnectionProperties: ADLConnectionProperties,
iNumberofPorts: i32,
iActiveConnections: i32,
iDataSize: i32,
EdidData: [u8; ADL_MAX_DISPLAY_EDID_DATA_SIZE],
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ADLConnectionState {
iEmulationStatus: i32,
iEmulationMode: i32,
iDisplayIndex: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLConnectorInfo {
iConnectorIndex: i32,
iConnectorId: i32,
iSlotIndex: i32,
iType: i32,
iOffset: i32,
iLength: i32,
}
// ---- dynamic binding (atiadlxx.dll ships with every AMD driver; absent elsewhere) ----
type AdlContext = *mut c_void;
type MallocCb = unsafe extern "C" fn(i32) -> *mut c_void;
type FnMainCreate = unsafe extern "C" fn(MallocCb, i32, *mut AdlContext) -> i32;
type FnMainDestroy = unsafe extern "C" fn(AdlContext) -> i32;
type FnNumAdapters = unsafe extern "C" fn(AdlContext, *mut i32) -> i32;
type FnAdapterInfoGet = unsafe extern "C" fn(AdlContext, *mut AdapterInfo, i32) -> i32;
type FnEdidMgmtCaps = unsafe extern "C" fn(AdlContext, i32, *mut i32) -> i32;
type FnBoardLayoutGet = unsafe extern "C" fn(
AdlContext,
i32,
*mut i32,
*mut i32,
*mut *mut c_void,
*mut i32,
*mut *mut ADLConnectorInfo,
) -> i32;
type FnConnStateGet =
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, *mut ADLConnectionState) -> i32;
type FnConnDataGet =
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32, *mut ADLConnectionData) -> i32;
type FnConnDataSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, ADLConnectionData) -> i32;
type FnConnDataRemove = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort) -> i32;
type FnEmulModeSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32) -> i32;
struct Adl {
create: FnMainCreate,
destroy: FnMainDestroy,
num_adapters: FnNumAdapters,
adapter_info: FnAdapterInfoGet,
edid_caps: FnEdidMgmtCaps,
board_layout: FnBoardLayoutGet,
conn_state: FnConnStateGet,
conn_data_get: FnConnDataGet,
conn_data_set: FnConnDataSet,
conn_data_remove: FnConnDataRemove,
emul_mode_set: FnEmulModeSet,
}
/// ADL's application-provided allocator: it hands buffers (board-layout arrays) back through
/// out-pointers and expects the app to own them.
unsafe extern "C" fn adl_malloc(size: i32) -> *mut c_void {
let size = size.max(1) as usize;
// SAFETY: non-zero size with a fixed valid alignment; the resulting buffers are deliberately
// never freed — ADL's contract wants an ADL_Main_Memory_Free symmetry, and leaking the <1 KiB
// of board-layout arrays in a one-shot probe is simpler than proving allocator parity.
unsafe {
std::alloc::alloc(std::alloc::Layout::from_size_align(size, 16).expect("tiny ADL alloc"))
as *mut c_void
}
}
impl Adl {
fn load() -> Option<Self> {
// SAFETY: plain LoadLibrary of the AMD-driver-installed ADL runtime by its well-known
// name; a foreign-DLL search-path attack would require writing to System32.
let lib: HMODULE = unsafe { LoadLibraryA(s!("atiadlxx.dll")) }.ok()?;
// One unsafe helper: resolve `name` or bail. Every Fn* type above matches the ADL
// header's C signature (x64 has a single calling convention, so `extern "C"` is exact).
unsafe fn sym<T: Copy>(lib: HMODULE, name: PCSTR) -> Option<T> {
debug_assert_eq!(std::mem::size_of::<T>(), std::mem::size_of::<usize>());
// SAFETY: caller passes a fn-pointer type T of pointer size (asserted above);
// GetProcAddress yields the export's address or None.
let f = unsafe { GetProcAddress(lib, name) }?;
// SAFETY: reinterpreting one non-null fn pointer as the export's true C signature.
Some(unsafe { std::mem::transmute_copy::<_, T>(&f) })
}
// SAFETY: `lib` is the live module handle from the successful load above.
unsafe {
Some(Self {
create: sym(lib, s!("ADL2_Main_Control_Create"))?,
destroy: sym(lib, s!("ADL2_Main_Control_Destroy"))?,
num_adapters: sym(lib, s!("ADL2_Adapter_NumberOfAdapters_Get"))?,
adapter_info: sym(lib, s!("ADL2_Adapter_AdapterInfo_Get"))?,
edid_caps: sym(lib, s!("ADL2_Adapter_EDIDManagement_Caps"))?,
board_layout: sym(lib, s!("ADL2_Adapter_BoardLayout_Get"))?,
conn_state: sym(lib, s!("ADL2_Adapter_ConnectionState_Get"))?,
conn_data_get: sym(lib, s!("ADL2_Adapter_ConnectionData_Get"))?,
conn_data_set: sym(lib, s!("ADL2_Adapter_ConnectionData_Set"))?,
conn_data_remove: sym(lib, s!("ADL2_Adapter_ConnectionData_Remove"))?,
emul_mode_set: sym(lib, s!("ADL2_Adapter_EmulationMode_Set"))?,
})
}
}
}
// ---- the library surface ----
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum EmulAction {
/// Read-only: caps + layout + per-connector state. Always safe.
Probe,
/// Pin live EDID + `ADL_EMUL_MODE_ALWAYS` on occupied (or named) connectors.
Lock,
/// `ADL_EMUL_MODE_OFF` + remove pinned EDID on all (or named) connectors.
Unlock,
}
/// One ADL call's outcome — op name, target, duration, rc (decoded via [`rc_str`]) and the
/// op-specific fields. The probe tool prints these as its bench correlation lines; the host
/// tracing-logs them. The rc IS the deliverable of a field run.
pub struct OpRecord {
pub op: &'static str,
pub target: String,
pub took_ms: u128,
pub rc: i32,
pub extra: String,
}
impl OpRecord {
pub fn ok(&self) -> bool {
self.rc == ADL_OK
}
}
impl std::fmt::Display for OpRecord {
/// The bench line minus the caller's epoch prefix: `op target took_ms ok rc=N(STR) extra`.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let sep = if self.extra.is_empty() { "" } else { " " };
write!(
f,
"{} {} took_ms={} ok={} rc={}({}){sep}{}",
self.op,
self.target,
self.took_ms,
self.ok(),
self.rc,
rc_str(self.rc),
self.extra
)
}
}
/// How a [`run`] ended: the AMD runtime was absent entirely, died at init (nothing was touched),
/// or walked the connectors (each op's rc in the records — a NOT_SUPPORTED driver still `Done`s).
pub enum RunOutcome {
/// `atiadlxx.dll` not loadable (or an export missing) — not an AMD driver install; the
/// emulation lever does not exist on this box.
NoAdl,
/// `ADL2_Main_Control_Create` / adapter enumeration failed — records hold the failing rc.
InitFailed(Vec<OpRecord>),
/// The connector walk ran; every op's outcome is in the records.
Done(Vec<OpRecord>),
}
impl RunOutcome {
pub fn records(&self) -> &[OpRecord] {
match self {
RunOutcome::NoAdl => &[],
RunOutcome::InitFailed(r) | RunOutcome::Done(r) => r,
}
}
}
fn c_str(buf: &[u8]) -> String {
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..len]).into_owned()
}
/// Perform `action` on every AMD adapter's connectors (or only `connector_filter`), returning the
/// per-op records. Read-only for [`EmulAction::Probe`]; [`EmulAction::Lock`] pins occupied
/// connectors only (unless the filter names one), matching an HPD dummy on the cables that exist.
pub fn run(action: EmulAction, connector_filter: Option<i32>) -> RunOutcome {
let mut recs: Vec<OpRecord> = Vec::new();
let mut rec = |op: &'static str, target: &str, took_ms: u128, rc: i32, extra: String| {
recs.push(OpRecord {
op,
target: target.to_owned(),
took_ms,
rc,
extra,
});
};
let Some(adl) = Adl::load() else {
return RunOutcome::NoAdl;
};
let mut ctx: AdlContext = std::ptr::null_mut();
let t = Instant::now();
// SAFETY: documented init call — our allocator callback, iEnumConnectedAdapters=0 (ALL
// adapters: an exclusively-isolated streaming host may report no "connected" display on the
// physical GPU), and a valid out-slot for the context.
let rc = unsafe { (adl.create)(adl_malloc, 0, &mut ctx) };
rec(
"adl-init",
"atiadlxx",
t.elapsed().as_millis(),
rc,
String::new(),
);
if rc != ADL_OK {
return RunOutcome::InitFailed(recs);
}
let mut count = 0i32;
// SAFETY: live context; valid out-param.
let rc = unsafe { (adl.num_adapters)(ctx, &mut count) };
if rc != ADL_OK || count <= 0 {
rec("adl-num-adapters", "all", 0, rc, format!("count={count}"));
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
let _ = unsafe { (adl.destroy)(ctx) };
return RunOutcome::InitFailed(recs);
}
let mut infos: Vec<AdapterInfo> = (0..count)
.map(|_| {
// SAFETY: AdapterInfo is plain ints + byte arrays — the all-zero pattern is valid,
// and ADL fills the array in place.
let mut a: AdapterInfo = unsafe { std::mem::zeroed() };
a.iSize = std::mem::size_of::<AdapterInfo>() as i32;
a
})
.collect();
let bytes = std::mem::size_of_val(infos.as_slice()) as i32;
// SAFETY: caller-allocated array of exactly `count` stamped entries, byte size passed as the
// API's iInputSize contract requires.
let rc = unsafe { (adl.adapter_info)(ctx, infos.as_mut_ptr(), bytes) };
rec("adl-adapters", "all", 0, rc, format!("count={count}"));
if rc != ADL_OK {
// SAFETY: as above — context teardown, nothing ADL-owned used afterwards.
let _ = unsafe { (adl.destroy)(ctx) };
return RunOutcome::InitFailed(recs);
}
// One GPU surfaces as many logical adapters — probe each bus once, AMD-present only.
let mut seen_buses: Vec<i32> = Vec::new();
for info in &infos {
if info.iPresent == 0
|| info.iVendorID != AMD_VENDOR_ID
|| seen_buses.contains(&info.iBusNumber)
{
continue;
}
seen_buses.push(info.iBusNumber);
let idx = info.iAdapterIndex;
let name = c_str(&info.strAdapterName);
let target = format!("adapter{idx}[{}]", name.trim());
let mut supported = 0i32;
let t = Instant::now();
// SAFETY: live context, adapter index from this enumeration, valid out-param.
let rc = unsafe { (adl.edid_caps)(ctx, idx, &mut supported) };
rec(
"adl-edid-caps",
&target,
t.elapsed().as_millis(),
rc,
format!("supported={supported}"),
);
let (mut valid, mut n_slots, mut n_conn) = (0i32, 0i32, 0i32);
let mut slots: *mut c_void = std::ptr::null_mut();
let mut connectors: *mut ADLConnectorInfo = std::ptr::null_mut();
let t = Instant::now();
// SAFETY: live context + adapter index; out-pointers valid; ADL allocates the two arrays
// through `adl_malloc` (deliberately leaked, see there).
let rc = unsafe {
(adl.board_layout)(
ctx,
idx,
&mut valid,
&mut n_slots,
&mut slots,
&mut n_conn,
&mut connectors,
)
};
rec(
"adl-board-layout",
&target,
t.elapsed().as_millis(),
rc,
format!("connectors={n_conn} valid_flags={valid:#x}"),
);
let connector_list: &[ADLConnectorInfo] =
if rc == ADL_OK && !connectors.is_null() && n_conn > 0 {
// SAFETY: ADL just filled `connectors` with `n_conn` entries via our allocator; the
// (leaked) buffer outlives this borrow.
unsafe { std::slice::from_raw_parts(connectors, n_conn as usize) }
} else {
&[]
};
for c in connector_list {
if connector_filter.is_some_and(|want| want != c.iConnectorIndex) {
continue;
}
let port = ADLDevicePort::root(c.iConnectorIndex);
let ctarget = format!(
"adapter{idx}.connector{}[{}]",
c.iConnectorIndex,
connector_type_str(c.iType)
);
let mut state = ADLConnectionState::default();
let t = Instant::now();
// SAFETY: live context; port is a by-value POD naming a connector this adapter just
// enumerated; valid out-param.
let rc = unsafe { (adl.conn_state)(ctx, idx, port, &mut state) };
let real = state.iEmulationStatus & ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED != 0;
rec(
"adl-conn-state",
&ctarget,
t.elapsed().as_millis(),
rc,
format!(
"status={:#x} real_connected={} emulated_present={} emulated_used={} mode={} display={}",
state.iEmulationStatus,
real,
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT != 0,
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_USED != 0,
state.iEmulationMode,
state.iDisplayIndex,
),
);
if rc != ADL_OK {
continue;
}
match action {
EmulAction::Probe => {
// SAFETY: ADLConnectionData is plain ints + a byte array; all-zero is valid
// and ADL overwrites it.
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
let t = Instant::now();
// SAFETY: live context/port as above; REAL query fills `data` in place.
let rc = unsafe {
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
};
rec(
"adl-conn-data",
&ctarget,
t.elapsed().as_millis(),
rc,
format!(
"type={} edid_bytes={}",
data.iConnectionType, data.iDataSize
),
);
}
EmulAction::Lock => {
if !real && connector_filter.is_none() {
continue; // nothing to pin — and pinning an EMPTY connector is a different experiment
}
// SAFETY: as in Probe — zeroed then driver-filled.
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
let t = Instant::now();
// SAFETY: live context/port; REAL query first — we pin exactly what the
// sink reports today, so the emulated display IS the user's monitor.
let mut rc = unsafe {
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
};
if rc != ADL_OK {
// Asleep sinks can refuse a live EDID read — fall back to whatever the
// driver already has as emulation data (Radeon-Pro-UI parity).
// SAFETY: same contract, emulated-data query.
rc = unsafe {
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_EMULATED_DATA, &mut data)
};
}
rec(
"adl-lock-read",
&ctarget,
t.elapsed().as_millis(),
rc,
format!(
"type={} edid_bytes={}",
data.iConnectionType, data.iDataSize
),
);
if rc != ADL_OK || data.iDataSize <= 0 {
continue;
}
let t = Instant::now();
// SAFETY: live context/port; `data` passed by value per the ADL signature.
let rc = unsafe { (adl.conn_data_set)(ctx, idx, port, data) };
rec(
"adl-lock-set",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
let t = Instant::now();
// SAFETY: live context/port; mode constant from the header.
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_ALWAYS) };
rec(
"adl-lock-mode-always",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
}
EmulAction::Unlock => {
let t = Instant::now();
// SAFETY: live context/port; mode constant from the header.
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_OFF) };
rec(
"adl-unlock-mode-off",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
let t = Instant::now();
// SAFETY: live context/port; removes emulation data set earlier (harmless
// where none exists — the rc says so).
let rc = unsafe { (adl.conn_data_remove)(ctx, idx, port) };
rec(
"adl-unlock-remove",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
}
}
}
}
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
let _ = unsafe { (adl.destroy)(ctx) };
RunOutcome::Done(recs)
}
// ---- the `edid_lock` display-policy axis (host-side) ----
/// The crash-recovery journal: a marker that a lock was applied and not yet unlocked. Pinned
/// emulation outlives the process (and can outlive a reboot), so a host that died mid-stream
/// must unlock on its next start ([`startup_recover`]).
fn journal_path() -> std::path::PathBuf {
pf_paths::config_dir().join("edid-lock-active.json")
}
fn tracing_log(prefix: &str, outcome: &RunOutcome) {
match outcome {
RunOutcome::NoAdl => tracing::info!(
"{prefix}: atiadlxx.dll not loadable — not an AMD driver install; the ADL \
emulation lever does not exist on this box"
),
RunOutcome::InitFailed(recs) | RunOutcome::Done(recs) => {
for r in recs {
if r.ok() {
tracing::info!("{prefix}: {r}");
} else {
tracing::warn!("{prefix}: {r}");
}
}
}
}
}
/// Apply the `edid_lock` axis at stream bring-up: pin the live EDID + `ADL_EMUL_MODE_ALWAYS` on
/// every occupied AMD connector (the software HPD dummy), journaling first so a crash still
/// unlocks on the next host start. Returns whether a later [`unlock_after_stream`] is owed —
/// true whenever the ADL runtime exists, because even a partially-failed lock may have pinned
/// some connectors (each rc is in the log).
pub fn lock_for_stream() -> bool {
// Journal BEFORE touching the driver: a crash between the first `ConnectionData_Set` and the
// journal write would otherwise leave pinned connectors with no startup unlock owed.
if let Err(e) = std::fs::write(journal_path(), b"{\"locked\":true}") {
tracing::warn!(
error = %format!("{e:#}"),
"edid_lock: crash journal write failed — continuing (the feature degrades to \
no-crash-journal)"
);
}
let outcome = run(EmulAction::Lock, None);
tracing_log("edid_lock", &outcome);
if matches!(outcome, RunOutcome::NoAdl) {
tracing::info!("edid_lock: enabled but this is not an AMD driver install — axis inert");
let _ = std::fs::remove_file(journal_path());
return false;
}
let locked = outcome
.records()
.iter()
.filter(|r| r.op == "adl-lock-mode-always" && r.ok())
.count();
tracing::info!(
connectors = locked,
"edid_lock: connector emulation pinned (software HPD dummy) — unlocked at stream teardown"
);
true
}
/// Undo [`lock_for_stream`] at teardown: `ADL_EMUL_MODE_OFF` + remove the pinned EDID on every
/// AMD connector, then clear the crash journal. Idempotent and harmless where nothing is pinned.
pub fn unlock_after_stream() {
let outcome = run(EmulAction::Unlock, None);
tracing_log("edid_lock", &outcome);
let _ = std::fs::remove_file(journal_path());
}
/// Host-startup crash recovery: a previous host that died holding the lock left connector
/// emulation pinned (it persists past the process — and can persist past a reboot). If the
/// journal marker exists, unlock everything and clear it — before any new session touches the
/// topology, mirroring `monitor_devnode::startup_recover`.
pub fn startup_recover() {
if !journal_path().exists() {
return;
}
tracing::warn!(
"edid_lock: a previous host left connector emulation pinned (crash/kill) — unlocking"
);
unlock_after_stream();
}
+2
View File
@@ -13,6 +13,8 @@
// hoist that to the crate root so the smaller modules (`input_desktop`, `monitor_devnode`,
// `display_events`) and any future one are covered by default rather than by remembering to opt in.
#[cfg(target_os = "windows")]
pub mod adl_emul;
#[cfg(target_os = "windows")]
pub mod display_events;
/// Bind display-config writes to the input desktop so a UAC / lock screen can't refuse them.
+14 -5
View File
@@ -1317,11 +1317,14 @@ pub mod isolate_journal {
let dir = std::env::temp_dir().join(format!("pf-isolate-journal-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch dir");
std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir);
// SAFETY: `_g` holds this module's ENV mutex, which serializes every test that
// writes or reads `PUNKTFUNK_CONFIG_DIR` in this binary.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir) };
clear(); // reset the LAST cache + any leftover marker from a previous run
f(&dir);
clear();
std::env::remove_var("PUNKTFUNK_CONFIG_DIR");
// SAFETY: still under `_g` — the same serialization as the set above.
unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") };
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1529,10 +1532,14 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
// and confirm no non-keep display survived. Only then is the virtual set truly the sole desktop.
let survivors = count_other_active(keep_target_ids).unwrap_or(0);
if survivors == 0 {
tracing::info!("display isolate (CCD): target set {keep_target_ids:?} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})");
tracing::info!(
"display isolate (CCD): target set {keep_target_ids:?} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})"
);
return Some(saved);
}
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
tracing::warn!(
"display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying"
);
std::thread::sleep(std::time::Duration::from_millis(250));
}
// Name the survivors instead of assuming their kind — the field logs showed this path fire
@@ -1932,7 +1939,9 @@ pub fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig> {
)
});
if rc == 0 {
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
tracing::info!(
"display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right"
);
} else {
tracing::warn!(
"display primary (CCD): SetDisplayConfig failed rc={rc:#x}{} (virtual {keep_target_id} primary, physicals kept)",
+1 -1
View File
@@ -6,7 +6,7 @@
[package]
name = "pf-zerocopy"
version.workspace = true
edition = "2021"
edition.workspace = true
# Inherit the workspace MSRV: clippy keys MSRV-gated lints off it (without this, e.g.
# `manual_is_multiple_of` fires on code the host crate compiles clean).
rust-version.workspace = true
+2 -2
View File
@@ -34,7 +34,7 @@ pub(crate) const GL_LINK_STATUS: u32 = 0x8B82;
// libglvnd's libGL dispatches these to the NVIDIA driver based on the current EGL/GL context.
#[link(name = "GL")]
extern "C" {
unsafe extern "C" {
pub(crate) fn glGenTextures(n: c_int, textures: *mut u32);
pub(crate) fn glBindTexture(target: u32, texture: u32);
pub(crate) fn glTexParameteri(target: u32, pname: u32, param: c_int);
@@ -97,7 +97,7 @@ extern "C" {
}
#[link(name = "gbm")]
extern "C" {
unsafe extern "C" {
pub(crate) fn gbm_create_device(fd: c_int) -> *mut c_void;
pub(crate) fn gbm_device_destroy(device: *mut c_void);
}
+143 -84
View File
@@ -253,7 +253,7 @@ fn new_handle(session: Session) -> *mut PunktfunkSession {
}
/// Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "C" fn punktfunk_abi_version() -> u32 {
crate::ABI_VERSION
}
@@ -271,7 +271,7 @@ pub extern "C" fn punktfunk_abi_version() -> u32 {
/// # Safety
/// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL,
/// must be a NUL-terminated string.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_wake_on_lan(
macs: *const u8,
mac_count: usize,
@@ -321,7 +321,7 @@ pub unsafe extern "C" fn punktfunk_wake_on_lan(
///
/// # Safety
/// `cfg`, `local`, `peer` must be valid pointers; the strings must be NUL-terminated.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_session_new(
cfg: *const PunktfunkConfig,
local: *const c_char,
@@ -366,7 +366,7 @@ pub unsafe extern "C" fn punktfunk_session_new(
///
/// # Safety
/// All four pointers must be valid; the two out-params receive owned handles.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_test_loopback_pair(
host_cfg: *const PunktfunkConfig,
client_cfg: *const PunktfunkConfig,
@@ -413,7 +413,7 @@ pub unsafe extern "C" fn punktfunk_test_loopback_pair(
///
/// # Safety
/// `s` must be a handle from `punktfunk_session_new`/`punktfunk_test_loopback_pair`, freed once.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_session_free(s: *mut PunktfunkSession) {
guard_void(|| {
if !s.is_null() {
@@ -428,7 +428,7 @@ pub unsafe extern "C" fn punktfunk_session_free(s: *mut PunktfunkSession) {
///
/// # Safety
/// `s` is a valid host handle; `data` points to `len` readable bytes (or `len == 0`).
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_host_submit_frame(
s: *mut PunktfunkSession,
data: *const u8,
@@ -466,7 +466,7 @@ pub unsafe extern "C" fn punktfunk_host_submit_frame(
///
/// # Safety
/// `s` is a valid client handle; `out` points to a writable `PunktfunkFrame`.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_client_poll_frame(
s: *mut PunktfunkSession,
out: *mut PunktfunkFrame,
@@ -510,7 +510,7 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
///
/// # Safety
/// `s` is a valid client handle; `ev` points to a readable `InputEvent`-sized allocation.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_send_input(
s: *mut PunktfunkSession,
ev: *const InputEvent,
@@ -566,7 +566,7 @@ unsafe fn read_input_event<'a>(ev: *const InputEvent) -> Result<&'a InputEvent,
///
/// # Safety
/// `s` is a valid host handle; `user` is passed back verbatim to `cb`.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_set_input_callback(
s: *mut PunktfunkSession,
// Written as an explicit `Option<fn>` (not the `PunktfunkInputCb` alias) so cbindgen
@@ -592,7 +592,7 @@ pub unsafe extern "C" fn punktfunk_set_input_callback(
///
/// # Safety
/// `s` is a valid host handle.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 {
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
let mut count = 0i32;
@@ -633,7 +633,7 @@ pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) ->
///
/// # Safety
/// `s` is a valid handle; `out` points to a writable `PunktfunkStats`.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_get_stats(
s: *mut PunktfunkSession,
out: *mut PunktfunkStats,
@@ -1427,7 +1427,7 @@ const _: () = {
/// `pin_sha256`/`observed_sha256_out` are each NULL or valid for 32 bytes;
/// `client_cert_pem`/`client_key_pem` are each NULL or NUL-terminated UTF-8.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect(
host: *const std::os::raw::c_char,
port: u16,
@@ -1468,7 +1468,7 @@ pub unsafe extern "C" fn punktfunk_connect(
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex(
host: *const std::os::raw::c_char,
port: u16,
@@ -1512,7 +1512,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex(
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex2(
host: *const std::os::raw::c_char,
port: u16,
@@ -1557,7 +1557,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex2(
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex3(
host: *const std::os::raw::c_char,
port: u16,
@@ -1605,7 +1605,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex3(
/// # Safety
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex4(
host: *const std::os::raw::c_char,
port: u16,
@@ -1658,7 +1658,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex4(
/// # Safety
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex5(
host: *const std::os::raw::c_char,
@@ -1711,7 +1711,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex6(
host: *const std::os::raw::c_char,
@@ -1767,7 +1767,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex6(
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex7(
host: *const std::os::raw::c_char,
@@ -1828,7 +1828,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
/// # Safety
/// Same as [`punktfunk_connect`]; `status_out`, when non-null, must point to a writable `i32`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex8(
host: *const std::os::raw::c_char,
@@ -1889,7 +1889,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex8(
/// # Safety
/// Same as [`punktfunk_connect_ex8`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex9(
host: *const std::os::raw::c_char,
@@ -2122,7 +2122,7 @@ unsafe fn connect_ex_impl(
/// # Safety
/// `cert_pem_out` is writable for `cert_cap` bytes; `key_pem_out` for `key_cap`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_generate_identity(
cert_pem_out: *mut std::os::raw::c_char,
cert_cap: usize,
@@ -2165,7 +2165,7 @@ pub unsafe extern "C" fn punktfunk_generate_identity(
/// # Safety
/// `host` must be a NUL-terminated UTF-8 string.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_probe(
host: *const std::os::raw::c_char,
port: u16,
@@ -2200,7 +2200,7 @@ pub unsafe extern "C" fn punktfunk_probe(
/// `host`/`client_cert_pem`/`client_key_pem`/`pin`/`name` are NUL-terminated UTF-8;
/// `host_sha256_out` is writable for 32 bytes.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_pair(
host: *const std::os::raw::c_char,
port: u16,
@@ -2264,7 +2264,7 @@ pub unsafe extern "C" fn punktfunk_pair(
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls video —
/// it may run concurrently with one audio-pulling and one rumble-pulling thread.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_au(
c: *mut PunktfunkConnection,
out: *mut PunktfunkFrame,
@@ -2329,7 +2329,7 @@ pub struct PunktfunkAudioPacket {
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio —
/// it may run concurrently with the video/rumble pullers.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_audio(
c: *mut PunktfunkConnection,
out: *mut PunktfunkAudioPacket,
@@ -2380,7 +2380,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio(
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_audio_channels(
c: *mut PunktfunkConnection,
out: *mut u8,
@@ -2420,7 +2420,7 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels(
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_end_reason(
c: *mut PunktfunkConnection,
out: *mut u8,
@@ -2478,7 +2478,7 @@ pub struct PunktfunkAudioPcm {
/// # Safety
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
c: *mut PunktfunkConnection,
out: *mut PunktfunkAudioPcm,
@@ -2544,7 +2544,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
/// `buf` is writable for `buf_len` bytes.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
c: *mut PunktfunkConnection,
out_pad: *mut u8,
@@ -2618,7 +2618,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
c: *mut PunktfunkConnection,
pad: u8,
@@ -2649,7 +2649,7 @@ pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At
/// most one thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble(
c: *mut PunktfunkConnection,
pad: *mut u16,
@@ -2706,7 +2706,7 @@ pub const PUNKTFUNK_RUMBLE_NO_TTL: u32 = 0xFFFF_FFFF;
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble2(
c: *mut PunktfunkConnection,
pad: *mut u16,
@@ -2786,7 +2786,7 @@ pub const PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER: u32 = 1;
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
c: *mut PunktfunkConnection,
pad: *mut u16,
@@ -2867,7 +2867,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd2(
c: *mut PunktfunkConnection,
pad: *mut u16,
@@ -2933,7 +2933,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd2(
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_set_rumble_quirks(
c: *mut PunktfunkConnection,
pad: u16,
@@ -2970,7 +2970,7 @@ pub unsafe extern "C" fn punktfunk_connection_set_rumble_quirks(
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHidOutput`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_hidout(
c: *mut PunktfunkConnection,
out: *mut PunktfunkHidOutput,
@@ -3018,7 +3018,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_hidout(
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHdrMeta`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_hdr_meta(
c: *mut PunktfunkConnection,
out: *mut PunktfunkHdrMeta,
@@ -3086,7 +3086,7 @@ pub struct PunktfunkCursorState {
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor
/// shapes; it may run concurrently with every other plane's puller.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape(
c: *mut PunktfunkConnection,
out: *mut PunktfunkCursorShape,
@@ -3138,7 +3138,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape(
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor
/// state; it may run concurrently with every other plane's puller.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_cursor_state(
c: *mut PunktfunkConnection,
out: *mut PunktfunkCursorState,
@@ -3187,7 +3187,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_cursor_state(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_set_cursor_render(
c: *mut PunktfunkConnection,
client_draws: bool,
@@ -3218,7 +3218,7 @@ pub unsafe extern "C" fn punktfunk_connection_set_cursor_render(
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHostTiming`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_host_timing(
c: *mut PunktfunkConnection,
out: *mut PunktfunkHostTiming,
@@ -3265,7 +3265,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_host_timing(
/// # Safety
/// `c` is a valid connection handle; each out pointer is NULL or writable for its scalar.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_color_info(
c: *mut PunktfunkConnection,
primaries: *mut u8,
@@ -3315,7 +3315,7 @@ pub unsafe extern "C" fn punktfunk_connection_color_info(
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_chroma_format(
c: *mut PunktfunkConnection,
out: *mut u8,
@@ -3345,7 +3345,7 @@ pub unsafe extern "C" fn punktfunk_connection_chroma_format(
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_codec(
c: *mut PunktfunkConnection,
out: *mut u8,
@@ -3375,7 +3375,7 @@ pub unsafe extern "C" fn punktfunk_connection_codec(
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u32`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_shard_payload(
c: *mut PunktfunkConnection,
out: *mut u32,
@@ -3403,7 +3403,7 @@ pub unsafe extern "C" fn punktfunk_connection_shard_payload(
/// # Safety
/// `c` is a valid connection handle; `ev` points to a readable `InputEvent`-sized allocation.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_input(
c: *mut PunktfunkConnection,
ev: *const InputEvent,
@@ -3437,7 +3437,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_input(
/// # Safety
/// `c` is a valid connection handle; `opus_data` is valid for `len` bytes (or `len == 0`).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_mic(
c: *mut PunktfunkConnection,
opus_data: *const u8,
@@ -3478,7 +3478,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_mic(
/// # Safety
/// `c` is a valid connection handle; `rich` points to a valid [`PunktfunkRichInput`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
c: *mut PunktfunkConnection,
rich: *const PunktfunkRichInput,
@@ -3516,7 +3516,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
/// `c` is a valid connection handle; `rich` is null or points to at least its declared
/// `struct_size` bytes.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
c: *mut PunktfunkConnection,
rich: *const PunktfunkRichInputEx,
@@ -3566,7 +3566,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
/// `c` is a valid connection handle; `samples` is null or points to `count` valid
/// [`PunktfunkPenSample`]s.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_pen(
c: *mut PunktfunkConnection,
samples: *const PunktfunkPenSample,
@@ -3609,7 +3609,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_pen(
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_mode(
c: *const PunktfunkConnection,
width: *mut u32,
@@ -3650,7 +3650,7 @@ pub unsafe extern "C" fn punktfunk_connection_mode(
/// # Safety
/// `c` is a valid connection handle; `gamepad` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_gamepad(
c: *const PunktfunkConnection,
gamepad: *mut u32,
@@ -3835,7 +3835,7 @@ fn build_clip_event(
/// # Safety
/// `c` is a valid connection handle; `caps` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_host_caps(
c: *const PunktfunkConnection,
caps: *mut u8,
@@ -3866,7 +3866,7 @@ pub unsafe extern "C" fn punktfunk_connection_host_caps(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_control(
c: *const PunktfunkConnection,
enabled: bool,
@@ -3895,7 +3895,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_control(
/// `c` is a valid connection handle; `kinds` points to `n` `PunktfunkClipKind`s (NULL allowed only
/// when `n == 0`), each `mime` a valid NUL-terminated UTF-8 string.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_offer(
c: *const PunktfunkConnection,
seq: u32,
@@ -3951,7 +3951,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_offer(
/// `c` is a valid connection handle; `mime` is a valid NUL-terminated UTF-8 string; `xfer_id_out`
/// is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_fetch(
c: *const PunktfunkConnection,
seq: u32,
@@ -4000,7 +4000,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_fetch(
/// `c` is a valid connection handle; `data` points to `len` bytes (NULL allowed only when
/// `len == 0`).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_serve(
c: *const PunktfunkConnection,
req_id: u32,
@@ -4039,7 +4039,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_serve(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_cancel(
c: *const PunktfunkConnection,
id: u32,
@@ -4067,7 +4067,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_cancel(
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkClipEvent`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
c: *mut PunktfunkConnection,
out: *mut PunktfunkClipEvent,
@@ -4118,7 +4118,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
/// # Safety
/// `c` is a valid connection handle; `compositor` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_compositor(
c: *const PunktfunkConnection,
compositor: *mut u32,
@@ -4149,7 +4149,7 @@ pub unsafe extern "C" fn punktfunk_connection_compositor(
/// # Safety
/// `c` is a valid connection handle; `bitrate_kbps` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_bitrate(
c: *const PunktfunkConnection,
bitrate_kbps: *mut u32,
@@ -4184,7 +4184,7 @@ pub unsafe extern "C" fn punktfunk_connection_bitrate(
/// # Safety
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clock_offset_ns(
c: *const PunktfunkConnection,
offset_ns: *mut i64,
@@ -4218,7 +4218,7 @@ pub unsafe extern "C" fn punktfunk_connection_clock_offset_ns(
/// # Safety
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clock_offset_now_ns(
c: *const PunktfunkConnection,
offset_ns: *mut i64,
@@ -4252,7 +4252,7 @@ pub unsafe extern "C" fn punktfunk_connection_clock_offset_now_ns(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_request_mode(
c: *const PunktfunkConnection,
width: u32,
@@ -4288,7 +4288,7 @@ pub unsafe extern "C" fn punktfunk_connection_request_mode(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
c: *const PunktfunkConnection,
) -> PunktfunkStatus {
@@ -4320,7 +4320,7 @@ pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_request_rfi(
c: *const PunktfunkConnection,
first_frame: u32,
@@ -4355,7 +4355,7 @@ pub unsafe extern "C" fn punktfunk_connection_request_rfi(
/// # Safety
/// `c` is a valid connection handle; `gap_out` is writable or NULL.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
c: *const PunktfunkConnection,
frame_index: u32,
@@ -4373,7 +4373,41 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
if !gap_out.is_null() {
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
// written once by value.
unsafe { *gap_out = gap };
unsafe { *gap_out = gap > 0 };
}
PunktfunkStatus::Ok
})
}
/// [`punktfunk_connection_note_frame_index`] with the gap WIDTH instead of a yes/no: writes to
/// `gap_width_out` how many frames this arrival revealed as missing (0 = contiguous/straggler).
/// A client with a post-loss display freeze passes the width to
/// [`punktfunk_reanchor_gate_arm_expecting_drops`] so the reassembler's later `frames_dropped`
/// climb for the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm
/// race — see the gate function's doc).
///
/// # Safety
/// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_note_frame_index_ex(
c: *const PunktfunkConnection,
frame_index: u32,
gap_width_out: *mut u32,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
let gap = c.inner.note_frame_index(frame_index);
if !gap_width_out.is_null() {
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
// written once by value.
unsafe { *gap_width_out = gap };
}
PunktfunkStatus::Ok
})
@@ -4389,7 +4423,7 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
/// # Safety
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
c: *const PunktfunkConnection,
out: *mut u64,
@@ -4434,7 +4468,7 @@ pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_report_decode_us(
c: *const PunktfunkConnection,
us: u32,
@@ -4461,7 +4495,7 @@ pub unsafe extern "C" fn punktfunk_connection_report_decode_us(
/// `c` is an opaque handle from a `*_new`/`*_pair` the caller has not yet freed, or null (an
/// error, not UB).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_report_phase(
c: *const PunktfunkConnection,
next_latch_host_ns: u64,
@@ -4497,7 +4531,7 @@ pub unsafe extern "C" fn punktfunk_connection_report_phase(
/// # Safety
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_wants_decode_latency(
c: *const PunktfunkConnection,
out: *mut bool,
@@ -4569,7 +4603,7 @@ pub struct PunktfunkProbeResult {
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_speed_test(
c: *const PunktfunkConnection,
target_kbps: u32,
@@ -4597,7 +4631,7 @@ pub unsafe extern "C" fn punktfunk_connection_speed_test(
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkProbeResult` (NULL is an
/// error).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_probe_result(
c: *const PunktfunkConnection,
out: *mut PunktfunkProbeResult,
@@ -4644,7 +4678,7 @@ pub unsafe extern "C" fn punktfunk_connection_probe_result(
/// # Safety
/// `c` was returned by [`punktfunk_connect`] and remains valid (closed via `punktfunk_connection_close`).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_disconnect_quit(c: *mut PunktfunkConnection) {
guard_void(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
@@ -4661,7 +4695,7 @@ pub unsafe extern "C" fn punktfunk_connection_disconnect_quit(c: *mut PunktfunkC
/// # Safety
/// `c` was returned by [`punktfunk_connect`] and is not used after this call.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_close(c: *mut PunktfunkConnection) {
guard_void(|| {
if !c.is_null() {
@@ -4687,7 +4721,7 @@ pub unsafe extern "C" fn punktfunk_connection_close(c: *mut PunktfunkConnection)
/// Create a re-anchor gate seeded with the session's current `frames_dropped` (so the first
/// [`punktfunk_reanchor_gate_poll`] doesn't read the baseline as a loss). Free with
/// [`punktfunk_reanchor_gate_free`]. Never returns NULL.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "C" fn punktfunk_reanchor_gate_new(frames_dropped: u64) -> *mut ReanchorGate {
Box::into_raw(Box::new(ReanchorGate::new(frames_dropped)))
}
@@ -4696,7 +4730,7 @@ pub extern "C" fn punktfunk_reanchor_gate_new(frames_dropped: u64) -> *mut Reanc
///
/// # Safety
/// `g` was returned by [`punktfunk_reanchor_gate_new`] and is not used after this call.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_free(g: *mut ReanchorGate) {
guard_void(|| {
if !g.is_null() {
@@ -4712,7 +4746,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_free(g: *mut ReanchorGate) {
///
/// # Safety
/// `g` is a valid gate handle.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
guard_void(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
@@ -4724,6 +4758,31 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
});
}
/// [`punktfunk_reanchor_gate_arm`] for a loss detected as a **frame-index gap**, where the caller
/// knows how many frames the gap skipped ([`punktfunk_connection_note_frame_index_ex`]). On top of
/// arming, the gate pre-credits the reassembler's `frames_dropped` climb those same lost frames
/// will produce up to ~120 ms later, so [`punktfunk_reanchor_gate_poll`] does not treat that
/// delayed bookkeeping as a SECOND loss — without the credit, a fast LTR-RFI anchor lifts the
/// freeze between the two signals and the stale climb re-freezes a healed stream (the double-arm
/// race). Use the plain arm for non-gap loss signals (decoder wedge/demotion). NULL is a no-op.
///
/// # Safety
/// `g` is a valid gate handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
g: *mut ReanchorGate,
expected_drops: u64,
) {
guard_void(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
// not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` here
// handles.
if let Some(g) = unsafe { g.as_mut() } {
g.arm_expecting_drops(std::time::Instant::now(), expected_drops);
}
});
}
/// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
/// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
/// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
@@ -4732,7 +4791,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
///
/// # Safety
/// `g` is a valid gate handle; `out_present` is writable or NULL.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_on_decoded(
g: *mut ReanchorGate,
flags: u32,
@@ -4764,7 +4823,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_on_decoded(
///
/// # Safety
/// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_on_no_output(
g: *mut ReanchorGate,
out_request_kf: *mut bool,
@@ -4793,7 +4852,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_on_no_output(
///
/// # Safety
/// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_poll(
g: *mut ReanchorGate,
frames_dropped: u64,
@@ -4822,7 +4881,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_poll(
///
/// # Safety
/// `g` is a valid gate handle; `out_holding` is writable or NULL.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
g: *const ReanchorGate,
out_holding: *mut bool,
+10 -4
View File
@@ -881,14 +881,20 @@ impl NativeClient {
///
/// Call it for EVERY received frame; it is cheap and idempotent, and the
/// [`frames_dropped`](Self::frames_dropped)-driven [`request_keyframe`](Self::request_keyframe)
/// loop stays the backstop for when the recovery frame itself is lost. Returns `true` when a
/// forward gap was detected on this call (whether or not the RFI was throttled), so a client with
/// a post-loss display freeze can (re-)arm it on the same signal.
/// loop stays the backstop for when the recovery frame itself is lost. Returns the gap WIDTH —
/// how many frames this arrival revealed as missing, `0` when none (contiguous or straggler),
/// whether or not the RFI was throttled — so a client with a post-loss display freeze can
/// (re-)arm it on the same signal AND pre-credit the reassembler's later `frames_dropped` climb
/// for the same loss ([`ReanchorGate::arm_expecting_drops`] — without the credit, a fast
/// LTR-RFI anchor lifts the freeze before the climb books the loss, and the stale climb then
/// re-freezes the healed stream).
///
/// This centralizes the loss-range detection so every embedder gets identical behavior. (The
/// in-process Vulkan session pump keeps its own copy because it gates a display freeze on the same
/// signal and shares one throttle across RFI + keyframe requests.)
pub fn note_frame_index(&self, frame_index: u32) -> bool {
///
/// [`ReanchorGate::arm_expecting_drops`]: crate::reanchor::ReanchorGate::arm_expecting_drops
pub fn note_frame_index(&self, frame_index: u32) -> u32 {
// Decide (and update state) under the lock; fire the request after releasing it.
let (gap, ask) = self
.rfi
@@ -176,9 +176,9 @@ impl DataPump {
let clock_offset_ns = pump_clock_offset.load(Ordering::Relaxed);
// An applied re-sync invalidates the staleness run measured under the OLD offset:
// reset the counters and re-arm the clock-based detector if a step had disarmed it.
let gen = pump_clock_gen.load(Ordering::Relaxed);
if gen != seen_clock_gen {
seen_clock_gen = gen;
let clock_gen = pump_clock_gen.load(Ordering::Relaxed);
if clock_gen != seen_clock_gen {
seen_clock_gen = clock_gen;
stale_since = None;
noop_clock_flushes = 0;
// Every OWD reading shifted with the offset — the standing-latency floor and
+33 -30
View File
@@ -32,14 +32,16 @@ pub(crate) enum RecoveryAsk {
impl RfiRecovery {
/// Pure decision behind [`NativeClient::note_frame_index`]: fold one received `frame_index` (in
/// receive order) observed at `now`, advancing the expectation and returning `(gap, ask)`.
/// `gap` is whether this frame revealed a forward gap (the embedder arms its post-loss display
/// freeze on it); `ask` is the (throttled) recovery request to fire — an RFI naming the exact
/// lost span, or a keyframe when the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is
/// hopeless there: no encoder holds references that old, and a huge jump is more likely a
/// resync — e.g. the first real AU after an old host's speed test — than a real loss). Split
/// out from the connection so the wrapping arithmetic + [`RFI_THROTTLE`] are unit-testable
/// without a live session (see the tests below).
pub(crate) fn observe(&mut self, frame_index: u32, now: Instant) -> (bool, RecoveryAsk) {
/// `gap` is how many frames this arrival revealed as missing — 0 for contiguous/straggler; the
/// embedder arms its post-loss display freeze on a non-zero gap, and the WIDTH lets it
/// pre-credit the reassembler's later `frames_dropped` climb for the same loss
/// ([`crate::reanchor::ReanchorGate::arm_expecting_drops`] — the double-arm race). `ask` is the
/// (throttled) recovery request to fire — an RFI naming the exact lost span, or a keyframe when
/// the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is hopeless there: no encoder holds
/// references that old, and a huge jump is more likely a resync — e.g. the first real AU after
/// an old host's speed test — than a real loss). Split out from the connection so the wrapping
/// arithmetic + [`RFI_THROTTLE`] are unit-testable without a live session (see the tests below).
pub(crate) fn observe(&mut self, frame_index: u32, now: Instant) -> (u32, RecoveryAsk) {
match self.next_expected {
Some(exp) => {
// Wrapping split at the half-space: a small positive delta is a forward gap
@@ -47,10 +49,11 @@ impl RfiRecovery {
let ahead = frame_index.wrapping_sub(exp);
if ahead == 0 {
self.next_expected = Some(frame_index.wrapping_add(1)); // contiguous
(false, RecoveryAsk::None)
(0, RecoveryAsk::None)
} else if ahead < u32::MAX / 2 {
// Forward gap: [exp, frame_index-1] lost. Advance past this frame so the same
// gap isn't re-detected, then fire a throttled recovery ask for the lost range.
// Forward gap: [exp, frame_index-1] lost (`ahead` frames). Advance past this
// frame so the same gap isn't re-detected, then fire a throttled recovery ask
// for the lost range.
self.next_expected = Some(frame_index.wrapping_add(1));
let send = self
.last_req
@@ -65,15 +68,15 @@ impl RfiRecovery {
} else {
RecoveryAsk::Rfi(exp, frame_index.wrapping_sub(1))
};
(true, ask)
(ahead, ask)
} else {
// Straggler behind the delivery point — leave the expectation.
(false, RecoveryAsk::None)
(0, RecoveryAsk::None)
}
}
None => {
self.next_expected = Some(frame_index.wrapping_add(1));
(false, RecoveryAsk::None)
(0, RecoveryAsk::None)
}
}
}
@@ -96,7 +99,7 @@ mod rfi_recovery_tests {
fn first_frame_arms_without_a_gap() {
let mut r = RfiRecovery::default();
// The opening frame only seeds the expectation — there is no prior frame to be missing.
assert_eq!(r.observe(100, base()), (false, RecoveryAsk::None));
assert_eq!(r.observe(100, base()), (0, RecoveryAsk::None));
assert_eq!(r.next_expected, Some(101));
}
@@ -105,9 +108,9 @@ mod rfi_recovery_tests {
let mut r = RfiRecovery::default();
let t = base();
r.observe(100, t);
assert_eq!(r.observe(101, t), (false, RecoveryAsk::None));
assert_eq!(r.observe(102, t), (false, RecoveryAsk::None));
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
assert_eq!(r.observe(101, t), (0, RecoveryAsk::None));
assert_eq!(r.observe(102, t), (0, RecoveryAsk::None));
assert_eq!(r.observe(103, t), (0, RecoveryAsk::None));
assert_eq!(r.next_expected, Some(104));
}
@@ -117,7 +120,7 @@ mod rfi_recovery_tests {
let t = base();
r.observe(100, t); // expecting 101 next
// 101..=104 were lost; 105 arrived. The RFI must name exactly the missing span.
assert_eq!(r.observe(105, t), (true, RecoveryAsk::Rfi(101, 104)));
assert_eq!(r.observe(105, t), (4, RecoveryAsk::Rfi(101, 104)));
// The expectation advances past the delivered frame so the same gap can't re-fire.
assert_eq!(r.next_expected, Some(106));
}
@@ -128,7 +131,7 @@ mod rfi_recovery_tests {
let t = base();
r.observe(100, t);
// Exactly one frame (101) lost → range is the single index [101, 101].
assert_eq!(r.observe(102, t), (true, RecoveryAsk::Rfi(101, 101)));
assert_eq!(r.observe(102, t), (1, RecoveryAsk::Rfi(101, 101)));
}
#[test]
@@ -137,16 +140,16 @@ mod rfi_recovery_tests {
let t0 = base();
r.observe(100, t0);
// First gap fires the request and stamps the throttle.
assert_eq!(r.observe(105, t0), (true, RecoveryAsk::Rfi(101, 104)));
assert_eq!(r.observe(105, t0), (4, RecoveryAsk::Rfi(101, 104)));
// A second gap 50 ms later is still a gap, but the request is throttled away.
assert_eq!(
r.observe(110, t0 + Duration::from_millis(50)),
(true, RecoveryAsk::None)
(4, RecoveryAsk::None)
);
// Past the window, the request re-opens for the still-accurate lost span.
assert_eq!(
r.observe(120, t0 + RFI_THROTTLE + Duration::from_millis(1)),
(true, RecoveryAsk::Rfi(111, 119))
(9, RecoveryAsk::Rfi(111, 119))
);
}
@@ -158,7 +161,7 @@ mod rfi_recovery_tests {
r.observe(105, t); // expecting 106 next
// A reordered late arrival (103, well behind 106) is neither a gap nor a request, and it
// must not rewind the expectation — otherwise the next in-order frame would false-gap.
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
assert_eq!(r.observe(103, t), (0, RecoveryAsk::None));
assert_eq!(r.next_expected, Some(106));
}
@@ -167,9 +170,9 @@ mod rfi_recovery_tests {
let mut r = RfiRecovery::default();
let t = base();
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
assert_eq!(r.observe(u32::MAX, t), (false, RecoveryAsk::None)); // contiguous, wraps to 0
assert_eq!(r.observe(u32::MAX, t), (0, RecoveryAsk::None)); // contiguous, wraps to 0
assert_eq!(r.next_expected, Some(0));
assert_eq!(r.observe(0, t), (false, RecoveryAsk::None)); // still contiguous across the wrap
assert_eq!(r.observe(0, t), (0, RecoveryAsk::None)); // still contiguous across the wrap
assert_eq!(r.next_expected, Some(1));
}
@@ -179,7 +182,7 @@ mod rfi_recovery_tests {
let t = base();
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
// u32::MAX was lost and 1 arrived → the lost span wraps: [u32::MAX, 0].
assert_eq!(r.observe(1, t), (true, RecoveryAsk::Rfi(u32::MAX, 0)));
assert_eq!(r.observe(1, t), (2, RecoveryAsk::Rfi(u32::MAX, 0)));
assert_eq!(r.next_expected, Some(2));
}
@@ -192,14 +195,14 @@ mod rfi_recovery_tests {
// reference exists for an RFI, and the jump may be a phantom (an old host's
// speed-test burst consuming video indexes) — ask for the IDR resync instead.
let jump = 100 + crate::packet::RFI_MAX_RANGE + 2;
assert_eq!(r.observe(jump, t), (true, RecoveryAsk::Keyframe));
assert_eq!(r.observe(jump, t), (jump - 101, RecoveryAsk::Keyframe));
// The expectation still advances past the delivered frame (no re-fire on the next one).
assert_eq!(r.next_expected, Some(jump + 1));
assert_eq!(r.observe(jump + 1, t), (false, RecoveryAsk::None));
assert_eq!(r.observe(jump + 1, t), (0, RecoveryAsk::None));
// A huge gap consumes the shared throttle too — an immediate follow-up gap stays quiet.
assert_eq!(
r.observe(jump + 10, t + Duration::from_millis(1)),
(true, RecoveryAsk::None)
(8, RecoveryAsk::None)
);
}
}
+1 -1
View File
@@ -162,7 +162,7 @@ impl ErasureCoder for Gf16Coder {
.iter()
.zip(have)
.enumerate()
.filter(|(_, (_, &h))| h)
.filter(|&(_, (_, &h))| h)
.map(|(i, (s, _))| (i, &**s))
.collect();
let restored = reed_solomon_simd::decode(
+12 -4
View File
@@ -417,13 +417,21 @@ mod tests {
/// --nocapture --test-threads=1`.
#[tokio::test]
#[ignore = "measurement: sets process env and takes ~15 s of wall clock"]
// Test-only exemption from the crate's `deny(unsafe_code)`: mutating the process
// environment is `unsafe` in edition 2024, and this measurement's env knob is the
// documented single-threaded kind.
#[allow(unsafe_code)]
async fn mtu_discovery_climbs_only_as_high_as_the_peer_advertises() {
async fn climb(server_jumbo: bool, client_jumbo: bool) -> (u16, u128) {
let set = |on: bool| {
if on {
std::env::set_var("PUNKTFUNK_JUMBO", "1");
} else {
std::env::remove_var("PUNKTFUNK_JUMBO");
// SAFETY: this `#[ignore]`d measurement is documented above to run alone with
// `--test-threads=1`, so no concurrent thread reads or writes the environment.
unsafe {
if on {
std::env::set_var("PUNKTFUNK_JUMBO", "1");
} else {
std::env::remove_var("PUNKTFUNK_JUMBO");
}
}
};
set(server_jumbo);
+148 -6
View File
@@ -64,6 +64,26 @@ pub const REANCHOR_MARKS_TO_LIFT: u32 = 2;
/// floor fires, so a real stall still recovers.
pub const RECOVERY_MARK_PATIENCE: Duration = Duration::from_millis(1500);
/// How long a frame-index-gap arm's expected `frames_dropped` climb stays pre-credited in
/// [`ReanchorGate::poll`]. One loss arms the gate through TWO signals: the frame-index gap the
/// instant the AU after the loss is delivered ([`ReanchorGate::arm_expecting_drops`]), and the
/// reassembler's `frames_dropped` climb once the lost frame ages out of its loss window (~120 ms
/// later, and only when at least one of its packets arrived). Without the credit, a *fast* recovery
/// — an LTR-RFI anchor typically lands within ~60 ms — lifts the freeze between the two signals,
/// and the stale climb then re-freezes a stream that is already bit-exact healed; the host swallows
/// the resulting keyframe request as an echo of the very RFI that healed it, so the picture stays
/// frozen until the [`REANCHOR_FREEZE_MAX`] overdue re-ask extracts a full IDR (the field
/// "H265 freezes on every loss, AV1 fine" signature — the slower IDR path usually lands after the
/// climb and dodged the race).
///
/// Sized to cover the reassembler's 120 ms loss window plus delivery jitter with a wide margin,
/// while staying short enough that a leftover credit (a straggler that filled the gap late, so no
/// climb ever came; or a whole-frame vanish the reassembler never saw a packet of) cannot mask a
/// genuinely unrelated future climb for long. A masked climb is also never silent in practice:
/// every unrecoverable loss reveals itself as a frame-index gap on the next delivered frame, which
/// re-arms (and re-credits) through [`ReanchorGate::arm_expecting_drops`] on its own.
pub const DROP_CREDIT_WINDOW: Duration = Duration::from_millis(1000);
/// Frames skipped when `got` arrives while `expected` was the next index, or `None` if `got` is
/// contiguous (`== expected`) or a straggler we have already passed. Frame indices are u32 counters
/// that wrap, so the "ahead" test is a wrapping subtraction split at the half-space: a small positive
@@ -185,6 +205,14 @@ pub struct ReanchorGate {
/// a client stamps the decoder's decode-order watermark whenever this counter moves and
/// discards the local recovery of anything older. Every other client ignores it.
arms: u64,
/// `frames_dropped` climb still expected from losses that already armed via a frame-index gap
/// ([`Self::arm_expecting_drops`]). [`Self::poll`] consumes climbs against this before treating
/// them as a NEW loss, so the reassembler's delayed bookkeeping of a gap-armed (and possibly
/// already anchor-healed) loss cannot re-freeze the stream — see [`DROP_CREDIT_WINDOW`].
drop_credit: u64,
/// When the outstanding [`Self::drop_credit`] lapses ([`DROP_CREDIT_WINDOW`] after the latest
/// credited arm). `None` when no credit is outstanding.
drop_credit_expiry: Option<Instant>,
}
impl ReanchorGate {
@@ -199,6 +227,8 @@ impl ReanchorGate {
last_dropped: frames_dropped,
local_sei_since_arm: false,
arms: 0,
drop_credit: 0,
drop_credit_expiry: None,
}
}
@@ -230,6 +260,20 @@ impl ReanchorGate {
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
}
/// [`arm`](Self::arm) for a loss detected as a **frame-index gap**, where the caller knows how
/// many frames the gap skipped. On top of arming, it pre-credits the reassembler's
/// `frames_dropped` climb those same lost frames will produce up to ~120 ms later (its
/// loss-window age-out), so [`poll`](Self::poll) does not treat that delayed bookkeeping as a
/// SECOND loss. Without the credit a fast LTR-RFI anchor lifts the freeze between the two
/// signals and the stale climb re-freezes a healed stream — the double-arm race
/// ([`DROP_CREDIT_WINDOW`] tells the whole story). Use plain [`arm`](Self::arm) for every
/// non-gap loss signal (decoder wedge/demotion), which has no climb to credit.
pub fn arm_expecting_drops(&mut self, now: Instant, expected_drops: u64) {
self.arm(now);
self.drop_credit = self.drop_credit.saturating_add(expected_drops);
self.drop_credit_expiry = Some(now + DROP_CREDIT_WINDOW);
}
/// Fold the client's OWN recovery-point observation for one decoded frame, BEFORE handing that
/// frame to [`on_decoded`](Self::on_decoded). Returns `true` when it lifted the freeze.
///
@@ -333,16 +377,36 @@ impl ReanchorGate {
}
/// Periodic fold of the session's `frames_dropped` counter plus the overdue backstop. Returns
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed (a
/// fresh unrecoverable loss — arm the freeze) or the freeze has held a full [`REANCHOR_FREEZE_MAX`]
/// window with no re-anchor (re-ask and keep holding — NEVER resume to the concealed picture; a
/// genuinely dead stream is the QUIC idle-timeout watchdog's job, not the gate's).
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed by
/// more than the outstanding gap-arm credit (a fresh unrecoverable loss — arm the freeze) or the
/// freeze has held a full [`REANCHOR_FREEZE_MAX`] window with no re-anchor (re-ask and keep
/// holding — NEVER resume to the concealed picture; a genuinely dead stream is the QUIC
/// idle-timeout watchdog's job, not the gate's).
///
/// A climb covered by [`arm_expecting_drops`](Self::arm_expecting_drops)' credit is the
/// reassembler's delayed bookkeeping of a loss this gate already armed for — it must neither
/// re-arm (an LTR-RFI anchor may have healed the stream in the meantime; re-freezing it is the
/// double-arm race) nor ask again (the gap already fired the precise RFI, and if THAT recovery
/// was lost the overdue backstop still re-asks at the [`REANCHOR_FREEZE_MAX`] deadline the
/// gap-arm set — which is also sooner than the deadline a re-arm here would push out to).
pub fn poll(&mut self, frames_dropped: u64, now: Instant) -> bool {
let mut want_keyframe = false;
if frames_dropped > self.last_dropped {
let climb = frames_dropped - self.last_dropped;
self.last_dropped = frames_dropped;
self.arm(now);
want_keyframe = true;
if self.drop_credit_expiry.is_some_and(|e| now >= e) {
self.drop_credit = 0;
self.drop_credit_expiry = None;
}
let credited = climb.min(self.drop_credit);
self.drop_credit -= credited;
if self.drop_credit == 0 {
self.drop_credit_expiry = None;
}
if climb > credited {
self.arm(now);
want_keyframe = true;
}
}
if self.awaiting && self.deadline.is_some_and(|d| now >= d) {
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
@@ -542,6 +606,84 @@ mod tests {
);
}
#[test]
fn an_rfi_anchor_is_not_refrozen_by_the_same_losss_drop_climb() {
// The double-arm race (field: "H265 freezes on every loss, AV1 fine"): a loss arms via
// the frame-index gap at T+10ms, the LTR-RFI anchor heals at T+60ms, and the reassembler
// books the SAME loss into frames_dropped at ~T+130ms. The credited arm must keep that
// stale climb from re-freezing the healed stream (and from re-asking — the host would
// swallow the ask as an RFI echo and the picture would freeze until a forced IDR).
let mut g = ReanchorGate::new(0);
let t = t0();
g.arm_expecting_drops(t + Duration::from_millis(10), 1); // gap of one lost frame + RFI
assert_eq!(
g.on_decoded(ANCHOR, false, t + Duration::from_millis(60)),
GateVerdict::Present,
"the anchor lifts"
);
assert!(
!g.poll(1, t + Duration::from_millis(130)),
"the credited climb must not ask again"
);
assert!(!g.is_holding(), "and must not re-freeze the healed stream");
assert_eq!(
g.on_decoded(0, false, t + Duration::from_millis(141)),
GateVerdict::Present,
"healthy P-frames keep presenting"
);
}
#[test]
fn a_climb_beyond_the_credit_is_a_fresh_loss_and_arms() {
// The credit covers exactly the gap's frames; a bigger climb means MORE loss than the gap
// accounted for (an interleaved partial-frame loss) — that part must still arm and ask.
let mut g = ReanchorGate::new(0);
let t = t0();
g.arm_expecting_drops(t, 2);
g.on_decoded(ANCHOR, false, t + Duration::from_millis(50)); // healed the credited loss
assert!(
g.poll(3, t + Duration::from_millis(130)),
"one uncredited drop → ask"
);
assert!(g.is_holding(), "and re-arm for the uncredited part");
}
#[test]
fn the_drop_credit_expires_so_a_late_climb_still_arms() {
// A straggler can fill the gap late (no climb ever comes) — the leftover credit must not
// linger and mask a genuinely NEW loss later. Past DROP_CREDIT_WINDOW the credit is void.
let mut g = ReanchorGate::new(0);
let t = t0();
g.arm_expecting_drops(t, 1);
g.on_decoded(ANCHOR, false, t + Duration::from_millis(50));
let late = t + DROP_CREDIT_WINDOW + Duration::from_millis(1);
assert!(
g.poll(1, late),
"an expired credit no longer absorbs climbs"
);
assert!(g.is_holding());
}
#[test]
fn a_credited_climb_keeps_the_unhealed_freezes_original_deadline() {
// When the recovery never arrives, consuming the climb must not silence the gate: the
// overdue backstop still re-asks — at the deadline the GAP arm set, which is sooner than
// the deadline a climb re-arm would have pushed out to.
let mut g = ReanchorGate::new(0);
let t = t0();
g.arm_expecting_drops(t, 1); // RFI fired here; assume its anchor is lost in transit
assert!(
!g.poll(1, t + Duration::from_millis(130)),
"credited climb: no early re-ask"
);
assert!(g.is_holding(), "still frozen — nothing healed it");
assert!(
g.poll(1, t + REANCHOR_FREEZE_MAX + Duration::from_millis(1)),
"the overdue backstop still re-asks on the gap-arm's own deadline"
);
assert!(g.is_holding(), "and keeps holding, never resuming to gray");
}
#[test]
fn the_no_output_streak_trips_at_three() {
let mut g = ReanchorGate::new(0);
@@ -72,7 +72,7 @@ const _: () = {
};
#[cfg(target_vendor = "apple")]
extern "C" {
unsafe extern "C" {
/// Darwin batched receive: up to `cnt` datagrams in one syscall; returns the count received and
/// sets each `msg_datalen` to its byte length. Present in libSystem on all macOS/iOS.
fn recvmsg_x(
@@ -16,7 +16,7 @@ mod android_mmsg {
pub msg_hdr: libc::msghdr,
pub msg_len: libc::c_uint,
}
extern "C" {
unsafe extern "C" {
pub fn sendmmsg(
sockfd: libc::c_int,
msgvec: *mut mmsghdr,
+1 -1
View File
@@ -12,7 +12,7 @@
[package]
name = "punktfunk-encode-worker"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk PyroWave encode worker: owns the priority-elevated Vulkan device so the host never carries a capability."
+8 -3
View File
@@ -163,15 +163,20 @@ mod tests {
impl EnvGuard {
fn set(dir: &std::path::Path) -> EnvGuard {
let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
std::env::set_var("PUNKTFUNK_CONFIG_DIR", dir);
// SAFETY: only called by tests that hold CONFIG_DIR_TEST_LOCK, which serializes
// every test that writes or reads this variable across the whole test binary.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", dir) };
EnvGuard(prev)
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var("PUNKTFUNK_CONFIG_DIR", v),
None => std::env::remove_var("PUNKTFUNK_CONFIG_DIR"),
// SAFETY: dropped while the owning test still holds CONFIG_DIR_TEST_LOCK — the
// same serialization as `EnvGuard::set`.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
}
}
}
+10 -4
View File
@@ -734,7 +734,9 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// Confine the proxy to `dir` for the duration of this test.
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
// that writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
// A real image inside the root: served, with the content type SNIFFED from the bytes.
let cover = dir.join("cover.png");
@@ -810,7 +812,8 @@ mod tests {
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
@@ -878,7 +881,9 @@ mod tests {
let outside = std::env::temp_dir().join(format!("pf-art-wr-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
// that writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
@@ -930,7 +935,8 @@ mod tests {
"an out-of-root file:// cover is still refused"
);
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
+14 -4
View File
@@ -56,8 +56,10 @@ mod gamestream;
#[path = "linux/gpuclocks.rs"]
mod gpuclocks;
mod hooks;
// Network-facing on the secure default host (see the forbid block at `mod mgmt` below).
#[forbid(unsafe_code)]
// Network-facing on the secure default host (see the forbid block at `mod mgmt` below). Test
// builds carve out like `native`: the identity tests scope `PUNKTFUNK_CONFIG_DIR` via
// `env::set_var`, which edition 2024 makes an unsafe fn; shipped code keeps the forbid.
#[cfg_attr(not(test), forbid(unsafe_code))]
mod identity;
// The input-injection backends live in the `pf-inject` subsystem crate (plan §W6); this shim keeps
// every existing `crate::inject::*` path valid (the native/gamestream input planes + devtest consume
@@ -81,8 +83,10 @@ mod log_capture;
// exposes — are safe Rust by compiler-enforced invariant (rust-safety programme): `forbid`
// here means a future edit cannot quietly introduce unsafe into a network-facing module.
// (`native` carves out its `#[cfg(test)]` C-ABI roundtrip tests, which exercise the CLIENT
// side of punktfunk-core against this host in-process and are unsafe by nature.)
#[forbid(unsafe_code)]
// side of punktfunk-core against this host in-process and are unsafe by nature; `mgmt` and
// `identity` carve out test builds too — their tests scope `PUNKTFUNK_CONFIG_DIR` via
// `env::set_var`, an unsafe fn since edition 2024.)
#[cfg_attr(not(test), forbid(unsafe_code))]
mod mgmt;
#[forbid(unsafe_code)]
mod mgmt_token;
@@ -396,6 +400,12 @@ fn real_main() -> Result<()> {
// restored (crash/kill/power loss) — before any new session touches the topology.
#[cfg(target_os = "windows")]
monitor_devnode::startup_recover();
// The same recovery for the experimental `edid_lock` axis: unpin AMD connector
// emulation a previous host locked and never unlocked — pinned emulation outlives
// the process (and can outlive a reboot), so this is the only thing standing between
// a crash and a permanently-emulated connector.
#[cfg(target_os = "windows")]
pf_win_display::adl_emul::startup_recover();
// The same recovery for the DEFAULT Exclusive path: a previous host that died holding a
// CCD isolate left the operator's panels deactivated with nothing to put them back (the
// restore snapshot was process memory). Runs AFTER the devnode leg so re-enabled
@@ -82,6 +82,10 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(),
"pnp_disable_monitors".into(),
// EXPERIMENTAL, Windows + AMD-driver-only in effect (the ADL connector-emulation lever;
// inert wherever `atiadlxx.dll` is absent). The console additionally gates the toggle's
// VISIBILITY on an AMD GPU being present, so only the hosts it can act on ever see it.
"edid_lock".into(),
];
// `capture_monitor` routes every session to the MIRROR backend, and that backend exists only on
// Linux — `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]`, because `pf-capture`
@@ -464,6 +468,7 @@ pub(crate) async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutReques
store.game_session(),
store.ddc_power_off(),
store.pnp_disable_monitors(),
store.edid_lock(),
store.get().capture_monitor,
);
if let Err(e) = store.set(policy) {
+12 -7
View File
@@ -787,8 +787,11 @@ async fn paired_clients_list_and_unpair() {
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var("PUNKTFUNK_CONFIG_DIR", v),
None => std::env::remove_var("PUNKTFUNK_CONFIG_DIR"),
// SAFETY: dropped while this test still holds CONFIG_DIR_TEST_LOCK, which
// serializes every test that writes or reads this variable in the binary.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
}
}
}
@@ -797,7 +800,9 @@ async fn paired_clients_list_and_unpair() {
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let _env = EnvGuard(std::env::var_os("PUNKTFUNK_CONFIG_DIR"));
std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path());
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK (taken above), serializing every test that
// writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
let state = test_state();
let app = test_app(state.clone(), None);
@@ -1363,9 +1368,7 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
// 1. Every LIVE route has a classification row. A new route fails here until it gets one.
for (method, path) in &live {
assert!(
expected
.iter()
.any(|(m, p, _, _)| m == method && p == path),
expected.iter().any(|(m, p, _, _)| m == method && p == path),
"route {method} {path} has no lane classification — add a row to EXPECTED in this test \
and decide, deliberately, whether the plugin token and a paired streaming cert may \
reach it"
@@ -1542,9 +1545,11 @@ async fn display_settings_surface() {
assert!(enforced.contains(&"mode_conflict"));
assert!(enforced.contains(&"identity"));
assert!(enforced.contains(&"layout"));
// The experimental DDC/CI + PnP-disable axes are acted on (Windows exclusive-isolate path).
// The experimental DDC/CI + PnP-disable + EDID-lock axes are acted on (Windows
// exclusive-isolate path; edid_lock additionally needs an AMD driver to do anything).
assert!(enforced.contains(&"ddc_power_off"));
assert!(enforced.contains(&"pnp_disable_monitors"));
assert!(enforced.contains(&"edid_lock"));
}
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
+10 -4
View File
@@ -2058,7 +2058,9 @@ mod tests {
"expected the open-loop pin, got {uncapped}"
);
// With the operator ceiling set, the Automatic pin is capped to the link rate...
std::env::set_var("PUNKTFUNK_PYROWAVE_MAX_MBPS", "4500");
// SAFETY: this test is the only writer of this variable in the process, so writers can't
// race each other; the only reader is `resolve_bitrate_kbps_for` on this same thread.
unsafe { std::env::set_var("PUNKTFUNK_PYROWAVE_MAX_MBPS", "4500") };
assert_eq!(
resolve_bitrate_kbps_for(Codec::PyroWave, 0, &mode, ChromaFormat::Yuv444, 10),
4_500_000
@@ -2078,7 +2080,8 @@ mod tests {
resolve_bitrate_kbps_for(Codec::PyroWave, 6_000_000, &mode, ChromaFormat::Yuv444, 10),
6_000_000
);
std::env::remove_var("PUNKTFUNK_PYROWAVE_MAX_MBPS");
// SAFETY: as the set above — single writer, and the readers run on this thread.
unsafe { std::env::remove_var("PUNKTFUNK_PYROWAVE_MAX_MBPS") };
}
#[test]
@@ -2434,13 +2437,16 @@ mod tests {
struct EnvGuard(&'static str);
impl Drop for EnvGuard {
fn drop(&mut self) {
std::env::remove_var(self.0);
// SAFETY: dropped while SESSION_TEST_LOCK is still held by the owning test, so
// env writers stay serialized and only the session path reads this variable.
unsafe { std::env::remove_var(self.0) };
}
}
let _env = EnvGuard("PUNKTFUNK_CLIPBOARD");
// Operator policy on. Session tests serialize on SESSION_TEST_LOCK, and only the session
// path (a session test) reads this env, so the mutation is race-free here.
std::env::set_var("PUNKTFUNK_CLIPBOARD", "1");
// SAFETY: see the serialization argument directly above.
unsafe { std::env::set_var("PUNKTFUNK_CLIPBOARD", "1") };
let host = std::thread::spawn(|| {
run_ephemeral(Punktfunk1Options {
+8 -3
View File
@@ -212,7 +212,10 @@ fn load_host_env() {
if let Some((k, v)) = line.split_once('=') {
let (k, v) = (k.trim(), v.trim().trim_matches('"'));
if !k.is_empty() {
std::env::set_var(k, v);
// SAFETY: called from the service main before this process spawns any thread —
// the network-profile warner and the supervisor's host child both start after
// `load_host_env` returns, so nothing reads the environment concurrently.
unsafe { std::env::set_var(k, v) };
n += 1;
}
}
@@ -325,11 +328,13 @@ fn run_service() -> Result<()> {
console (session 0)"
);
// BEFORE the warner thread below: `load_host_env` mutates the process env, and the warner
// spawns a child process (which snapshots the env block) — the write must not run beside it.
load_host_env();
// Best-effort: warn if this network is Public (streaming ports are firewalled off there unless
// the operator opted in). Own thread — a slow `Get-NetConnectionProfile` never delays the host.
std::thread::spawn(warn_if_public_network);
load_host_env();
let result = supervise(stop, session);
// Report STOPPED regardless of how supervise returned.
+3 -1
View File
@@ -372,7 +372,9 @@ fn notify_on_connect(hwnd: HWND) {
nid.hBalloonIcon = unsafe {
LoadImageW(
Some(GetModuleHandleW(None).unwrap_or_default().into()),
PCWSTR(1usize as *const u16),
// `without_provenance`, not `ptr::dangling()`: this is MAKEINTRESOURCE(1) — the
// ADDRESS is the resource ordinal, and dangling() would yield align_of::<u16>() = 2.
PCWSTR(std::ptr::without_provenance(1)),
IMAGE_ICON,
sm,
sm,
+27
View File
@@ -3323,6 +3323,21 @@ PunktfunkStatus punktfunk_connection_note_frame_index(const PunktfunkConnection
bool *gap_out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`punktfunk_connection_note_frame_index`] with the gap WIDTH instead of a yes/no: writes to
// `gap_width_out` how many frames this arrival revealed as missing (0 = contiguous/straggler).
// A client with a post-loss display freeze passes the width to
// [`punktfunk_reanchor_gate_arm_expecting_drops`] so the reassembler's later `frames_dropped`
// climb for the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm
// race — see the gate function's doc).
//
// # Safety
// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
PunktfunkStatus punktfunk_connection_note_frame_index_ex(const PunktfunkConnection *c,
uint32_t frame_index,
uint32_t *gap_width_out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
// rebuild them). A video loop polls this and calls [`punktfunk_connection_request_keyframe`]
@@ -3446,6 +3461,18 @@ void punktfunk_reanchor_gate_free(ReanchorGate *g);
// `g` is a valid gate handle.
void punktfunk_reanchor_gate_arm(ReanchorGate *g);
// [`punktfunk_reanchor_gate_arm`] for a loss detected as a **frame-index gap**, where the caller
// knows how many frames the gap skipped ([`punktfunk_connection_note_frame_index_ex`]). On top of
// arming, the gate pre-credits the reassembler's `frames_dropped` climb those same lost frames
// will produce up to ~120 ms later, so [`punktfunk_reanchor_gate_poll`] does not treat that
// delayed bookkeeping as a SECOND loss — without the credit, a fast LTR-RFI anchor lifts the
// freeze between the two signals and the stale climb re-freezes a healed stream (the double-arm
// race). Use the plain arm for non-gap loss signals (decoder wedge/demotion). NULL is a no-op.
//
// # Safety
// `g` is a valid gate handle.
void punktfunk_reanchor_gate_arm_expecting_drops(ReanchorGate *g, uint64_t expected_drops);
// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
@@ -69,13 +69,55 @@ fn hr_success(hr: NTSTATUS) -> bool {
hr >= 0
}
/// The `IddCxSetRealtimeGPUPriority` A/B knob: `PFVD_NO_RT_GPU` (any value, MACHINE env — the
/// driver runs in WUDFHost as LocalService, so `setx /M PFVD_NO_RT_GPU 1` + a device restart)
/// turns the priority raise OFF. Read once per process, the [`crate::log`] `OnceLock` pattern.
fn realtime_gpu_priority_enabled() -> bool {
/// How (whether) the swap-chain processing device's GPU scheduling is raised — the
/// interval-stutter program's A/B ladder, resolved once per WUDFHost process from the MACHINE
/// environment (the driver runs as LocalService: `setx /M PFVD_RT_GPU 1` + a device restart
/// applies it; the [`crate::log`] `OnceLock` pattern).
#[derive(Clone, Copy, PartialEq, Eq)]
enum RtGpuMode {
/// No raise at all — canonical-IDD scheduling, and the DEFAULT since the 2026-08 field
/// conviction (see [`rt_gpu_mode`]).
Off,
/// `PFVD_RT_GPU=thread`: `IDXGIDevice::SetGPUThreadPriority(7)` — the graduated middle rung.
/// A per-device GPU *thread* priority inside the band ordinary applications can also reach,
/// so it biases the scheduler without the REALTIME rung's unreachable-preemption hazard. Not
/// the default because it is unmeasured here — and the host process measured the same call as
/// "no help" for its encode-starvation case (`pf-frame/src/dxgi.rs`) — so it exists purely as
/// the field-A/B rung between OFF and REALTIME.
GpuThread,
/// `PFVD_RT_GPU=<anything else>`: the IddCx 1.9 `IddCxSetRealtimeGPUPriority` DDI — the old
/// default-ON behavior, "higher priority than any regular application can set".
Realtime,
}
/// Resolve the [`RtGpuMode`] ladder. Default **OFF**: no canonical IDD driver raises its
/// swap-chain device's GPU priority, and a 2026-08 field A/B on an RX 9070 XT convicted our
/// REALTIME raise as the amplifier of a metronomic ~1.8 s capture-stall class — every ~1.8 s
/// EVERY process's presents stopped for 150800 ms while the GPU stayed responsive (a starved
/// present path, not a stalled engine); clearing the raise removed the metronome entirely.
/// The raise was added as speculative "outranks GPU contention" hardening (branch-2 of the
/// disturbance-immunity program) whose CPU half — MMCSS / TIME_CRITICAL on this thread — is the
/// part that addressed the observed delivery holes and REMAINS in force; the GPU half never had
/// a measured win and now has a measured loss, so it is opt-in on every vendor (NVIDIA is
/// untested in either direction, and a vendor-split default would double the support matrix on
/// no evidence).
///
/// Precedence: the old opt-OUT (`PFVD_NO_RT_GPU`, any value) wins over the new opt-IN — a field
/// box that carried it through the default-ON era must keep meaning OFF no matter what is set
/// beside it. Both directions stay A/B-able without a rebuild.
fn rt_gpu_mode() -> RtGpuMode {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| std::env::var_os("PFVD_NO_RT_GPU").is_none())
static MODE: OnceLock<RtGpuMode> = OnceLock::new();
*MODE.get_or_init(|| {
if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
return RtGpuMode::Off;
}
match std::env::var_os("PFVD_RT_GPU") {
None => RtGpuMode::Off,
Some(v) if v.eq_ignore_ascii_case("thread") => RtGpuMode::GpuThread,
Some(_) => RtGpuMode::Realtime,
}
})
}
/// A minimal newtype to move a raw pointer / handle across the thread boundary. The wrapped value is a
@@ -252,32 +294,47 @@ impl SwapChainProcessor {
}
thread::sleep(Duration::from_millis(50));
}
// IddCx 1.9 realtime GPU scheduling priority for the processing device (stall-immunity
// program, branch-2 hardening): swap-chain buffer processing outruns ordinary GPU
// contention — "higher priority than any regular application can set". The slot is
// guaranteed populated (`IddMinimumVersionRequired = 10`, lib.rs); the DDI itself may
// still decline (e.g. E_NOTIMPL on pre-WDDM-3.0 hardware) — best-effort, never fatal.
// Called while our borrowed device reference is still alive; IddCx uses it synchronously.
// GPU-scheduling raise for the swap-chain processing device — OPT-IN, default none (see
// [`rt_gpu_mode`] for the field conviction that inverted the old default-ON). What used
// to be sold as stall immunity ("swap-chain buffer processing outruns ordinary GPU
// contention") preempts the game's and DWM's own queues at a level apps can't reach, and
// on an AMD field box that manifested as the metronomic content-starving stall class the
// stall program spent weeks attributing. The CPU-side half of that hardening (MMCSS /
// TIME_CRITICAL, above) is untouched — it addressed the delivery holes actually observed.
//
// Knobbed (PFVD_NO_RT_GPU, machine env, read in the WUDFHost process): no canonical IDD
// driver raises this priority, and it preempts the game's and DWM's own queues at a level
// apps can't reach — a candidate aggravator in the interval-stutter program that must
// stay A/B-able on a field box without a rebuild. Default ON (today's behavior).
if set_ok && realtime_gpu_priority_enabled() {
let mut rt = pod_init!(IDARG_IN_SETREALTIMEGPUPRIORITY);
rt.pDevice = dxgi_device.as_raw().cast();
// SAFETY: driver is loaded; `swap_chain` is the live assigned swap-chain whose device
// bind just succeeded; `rt.pDevice` is that same bound DXGI device, alive across the
// synchronous call; `rt` points to valid local storage.
let hr = unsafe { wdk_iddcx::IddCxSetRealtimeGPUPriority(swap_chain, &rt) };
if hr_success(hr) {
dbglog!(
"[pf-vd] swap-chain: processing device raised to REALTIME GPU priority (target={target_id})"
);
} else {
dbglog!(
"[pf-vd] swap-chain: realtime GPU priority declined ({hr:#x}) — normal scheduling (target={target_id})"
);
// Both raises are best-effort, never fatal, and issued while our borrowed device
// reference is still alive (IddCx uses it synchronously; the DXGI call is direct). The
// REALTIME slot is guaranteed populated (`IddMinimumVersionRequired = 10`, lib.rs), but
// the DDI may still decline (e.g. E_NOTIMPL on pre-WDDM-3.0 hardware).
if set_ok {
match rt_gpu_mode() {
RtGpuMode::Off => {}
RtGpuMode::GpuThread => {
// SAFETY: `dxgi_device` is the live device just bound to the swap-chain; the
// call takes a scalar in the documented 7..=7 band and retains nothing.
let res = unsafe { dxgi_device.SetGPUThreadPriority(7) };
dbglog!(
"[pf-vd] swap-chain: GPU thread priority +7 (PFVD_RT_GPU=thread) — ok={} (target={target_id})",
res.is_ok()
);
}
RtGpuMode::Realtime => {
let mut rt = pod_init!(IDARG_IN_SETREALTIMEGPUPRIORITY);
rt.pDevice = dxgi_device.as_raw().cast();
// SAFETY: driver is loaded; `swap_chain` is the live assigned swap-chain whose
// device bind just succeeded; `rt.pDevice` is that same bound DXGI device,
// alive across the synchronous call; `rt` points to valid local storage.
let hr = unsafe { wdk_iddcx::IddCxSetRealtimeGPUPriority(swap_chain, &rt) };
if hr_success(hr) {
dbglog!(
"[pf-vd] swap-chain: processing device raised to REALTIME GPU priority (PFVD_RT_GPU) (target={target_id})"
);
} else {
dbglog!(
"[pf-vd] swap-chain: realtime GPU priority declined ({hr:#x}) — normal scheduling (target={target_id})"
);
}
}
}
}
// Release our borrowed device reference — IddCx holds its own now, or we gave up. (Explicit drop
+5
View File
@@ -0,0 +1,5 @@
# This workspace has been edition 2024 (and formatted under the 2024 style edition) since
# before the main workspace migrated. The repo-root rustfmt.toml pins style_edition = "2021"
# to keep that migration's diff reviewable; without this file, config discovery would walk up
# to the root pin and demand a 2021-style reformat of this already-2024 tree.
style_edition = "2024"
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "pf-vkhdr-layer"
version = "0.1.0"
edition = "2021"
edition = "2024"
description = "punktfunk Vulkan implicit layer: inject HDR10/scRGB surface formats on the virtual display so Vulkan games (id Tech, etc.) detect HDR over an IddCx virtual display"
license = "MIT OR Apache-2.0"
publish = false
+8 -8
View File
@@ -380,7 +380,7 @@ mod hdr {
}
#[link(name = "user32")]
extern "system" {
unsafe extern "system" {
fn MonitorFromWindow(h: HWND, flags: u32) -> HMONITOR;
fn GetMonitorInfoW(h: HMONITOR, mi: *mut MonitorInfoExW) -> i32;
fn GetDisplayConfigBufferSizes(flags: u32, np: *mut u32, nm: *mut u32) -> i32;
@@ -513,7 +513,7 @@ fn should_inject(surface: vk::SurfaceKHR) -> bool {
/// `p` must be null or point to a live `NegotiateLayerInterface` the caller has exclusive access
/// to for the duration of the call. The Vulkan loader — the only intended caller of this
/// export — guarantees exactly that for the negotiate handshake.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "system" fn vkNegotiateLoaderLayerInterfaceVersion(
p: *mut NegotiateLayerInterface,
) -> vk::Result {
@@ -767,12 +767,12 @@ unsafe extern "system" fn destroy_instance(inst: vk::Instance, p_alloc: *const c
// SAFETY: `inst` is non-null, and vkDestroyInstance requires a live instance handle —
// still live during this call — whose first word is the dispatch key.
.and_then(|mut g| g.remove(&unsafe { key(inst.as_raw()) }));
if let Some(d) = data {
if let Some(f) = d.destroy_instance {
// SAFETY: `f` is the down-chain vkDestroyInstance resolved for this very instance at
// create time; forwarding the caller's own arguments unchanged.
unsafe { f(inst, p_alloc) };
}
if let Some(d) = data
&& let Some(f) = d.destroy_instance
{
// SAFETY: `f` is the down-chain vkDestroyInstance resolved for this very instance at
// create time; forwarding the caller's own arguments unchanged.
unsafe { f(inst, p_alloc) };
}
}
+8
View File
@@ -0,0 +1,8 @@
# Pin the STYLE edition independently of the language edition. Migrating the workspace to
# edition 2024 would otherwise flip rustfmt to the 2024 style edition in the same commit and
# reformat ~370 files nobody touched — noise that buries the real migration diff and makes a
# bisect useless. Adopting the 2024 style is fine, but it is its own one-line-plus-reformat
# commit, not a side effect of this one. (packaging/windows/drivers pins 2024 in its own
# rustfmt.toml — it was already formatted under that style; config discovery would otherwise
# walk up to this file.)
style_edition = "2021"
+10 -8
View File
@@ -22,12 +22,14 @@
# above the fn.
#
# C. Safe-but-process-global APIs: `env::set_var`/`remove_var`, `sigaction`, `setlocale`,
# `set_current_dir`. Each is safe to call and unsound (or racy) from a live multithreaded
# process — the 972af299 environ data race lived in a file with ZERO occurrences of the word
# `unsafe`, invisible to the census. Edition 2024 makes `env::set_var` unsafe; until that
# migration this count-ratchet is the control. The baseline below enumerates today's debt
# per file; ANY increase (or a new file) fails. Shrink a file's count? Lower its baseline in
# the same commit.
# `set_current_dir`. Each is (or was) callable without `unsafe` and unsound (or racy) from a
# live multithreaded process — the 972af299 environ data race lived in a file with ZERO
# occurrences of the word `unsafe`, invisible to the census. Since the edition-2024
# migration the env pair is `unsafe fn` (compiler-enforced, SAFETY proof per site, counted
# by the census); this ratchet stays for the still-safe APIs (`sigaction`, `setlocale`,
# `set_current_dir`) and as a growth brake on env mutation generally. The baseline below
# enumerates today's debt per file; ANY increase (or a new file) fails. Shrink a file's
# count? Lower its baseline in the same commit.
#
# All three gates were shown to FAIL on deliberately planted instances before being made blocking
# (the gate-of-the-gate rule that caught cd72f77a's `0 * SLOT`).
@@ -167,8 +169,8 @@ clients/linux/src/app.rs:1
clients/linux/src/spawn.rs:1
clients/session/src/main.rs:4
crates/pf-console-ui/src/screens/settings.rs:1
crates/pf-console-ui/src/shell/tests.rs:2
crates/pf-encode/src/enc/linux/nvenc_cuda.rs:49
crates/pf-console-ui/src/shell/tests.rs:1
crates/pf-encode/src/enc/linux/nvenc_cuda.rs:2
crates/pf-encode/src/enc/linux/worker.rs:1
crates/pf-encode/src/enc/windows/nvenc.rs:4
crates/pf-inject/src/inject/linux/steam_gadget.rs:5
+5
View File
@@ -9,10 +9,15 @@ authors.workspace = true
repository.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
# The `adl-emul` subcommand is a printer around `pf_win_display::adl_emul` — one ADL FFI surface
# shared with the host's `edid_lock` display-policy axis, so the probe a reporter runs and the
# toggle the console flips exercise byte-identical driver calls.
pf-win-display = { path = "../../crates/pf-win-display" }
windows = { version = "0.62", features = [
"Win32_Devices_Display",
"Win32_Graphics_Gdi",
"Win32_Foundation",
"Win32_System_LibraryLoader",
] }
[lints]
+49
View File
@@ -0,0 +1,49 @@
//! `adl-emul` — the AMD ADL EDID-emulation probe (immunity design doc §3's "probe once, log rc",
//! promoted to a field A/B after the third RX 9070 XT standby-sink case).
//!
//! The implementation lives in `pf_win_display::adl_emul` — one FFI surface shared with the
//! host's `edid_lock` display-policy axis, so the probe a reporter runs and the toggle the
//! console flips exercise byte-identical driver calls. This subcommand is the bench-line
//! printer + exit-code contract around it:
//!
//! * `adl-emul` — read-only: caps, board layout, per-connector state.
//! * `adl-emul --lock [--connector N]` — pin the live EDID + `ADL_EMUL_MODE_ALWAYS` (occupied
//! connectors only unless `--connector` names one).
//! * `adl-emul --unlock [--connector N]` — `ADL_EMUL_MODE_OFF` + remove the pinned EDID.
//!
//! Every call prints the bench's `epoch_ms op target took_ms ok` line plus `rc=` (decoded): the
//! rc IS the deliverable — `ADL2_Adapter_EDIDManagement_Caps` answering `supported=1` with
//! `--lock` returning `ADL_OK` on a consumer RX card kills the Pro-gating assumption, and a
//! locked connector during a stream with the sink asleep is the direct A/B for the metronomic
//! stall. `--unlock` (or a driver reinstall) restores; emulation state can persist across
//! reboots, so a `--lock` run must always be paired with a later `--unlock`.
use std::time::{SystemTime, UNIX_EPOCH};
pub use pf_win_display::adl_emul::EmulAction;
use pf_win_display::adl_emul::{run as adl_run, RunOutcome};
fn epoch_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
pub fn run(action: EmulAction, connector_filter: Option<i32>) -> ! {
let outcome = adl_run(action, connector_filter);
for r in outcome.records() {
println!("{} {r}", epoch_ms());
}
match outcome {
RunOutcome::NoAdl => {
eprintln!(
"atiadlxx.dll not loadable (or an export is missing) — not an AMD driver \
install; the ADL emulation lever does not exist on this box"
);
std::process::exit(2);
}
RunOutcome::InitFailed(_) => std::process::exit(1),
RunOutcome::Done(_) => std::process::exit(0),
}
}
+25 -2
View File
@@ -11,6 +11,9 @@
//! * `modeset` — Class 1: a same-mode `ChangeDisplaySettingsExW(CDS_RESET)` re-commit — a
//! Level-Two modeset-class DDI entry that idles the whole adapter ("the graphics hardware is
//! idle") without changing anything Win32-visible.
//! * `adl-emul` — not a disturbance but the LEVER probe for the standby-sink class: AMD ADL
//! connector-emulation caps/state, and `--lock`/`--unlock` to pin the live EDID +
//! `ADL_EMUL_MODE_ALWAYS` (the software HPD-dummy experiment — see `adl.rs`).
//!
//! Every operation prints `epoch_ms op target duration_ms result` so stalls in a concurrent
//! stream's host.log correlate line-for-line. The per-op duration is itself measurement: it is
@@ -18,6 +21,7 @@
//!
//! Usage: `display-disturb ddc [--interval-ms 2000] [--caps] [--vcp 0x10]`
//! `display-disturb modeset [--interval-ms 2000]`
//! `display-disturb adl-emul [--lock|--unlock] [--connector N]`
// Unsafe-proof program: every `unsafe {}` in this tool carries a `// SAFETY:` proof.
@@ -32,6 +36,9 @@ fn main() {
win::main()
}
#[cfg(target_os = "windows")]
mod adl;
#[cfg(target_os = "windows")]
mod win {
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -76,14 +83,17 @@ mod win {
interval: Duration,
caps: bool,
vcp: u8,
emul: crate::adl::EmulAction,
connector: Option<i32>,
}
fn parse_args() -> Args {
let argv: Vec<String> = std::env::args().collect();
let mode = argv.get(1).cloned().unwrap_or_default();
if !matches!(mode.as_str(), "ddc" | "modeset" | "extend") {
if !matches!(mode.as_str(), "ddc" | "modeset" | "extend" | "adl-emul") {
eprintln!(
"usage: display-disturb <ddc|modeset|extend> [--interval-ms N] [--caps] [--vcp 0xNN]"
"usage: display-disturb <ddc|modeset|extend|adl-emul> [--interval-ms N] [--caps] \
[--vcp 0xNN] [--lock|--unlock] [--connector N]"
);
std::process::exit(2);
}
@@ -92,6 +102,8 @@ mod win {
interval: Duration::from_millis(2000),
caps: false,
vcp: 0x10, // brightness — universally implemented, read-only harmless
emul: crate::adl::EmulAction::Probe,
connector: None,
};
let mut i = 2;
while i < argv.len() {
@@ -108,6 +120,16 @@ mod win {
let s = argv.get(i).map(String::as_str).unwrap_or("0x10");
a.vcp = u8::from_str_radix(s.trim_start_matches("0x"), 16).unwrap_or(0x10);
}
"--lock" => a.emul = crate::adl::EmulAction::Lock,
"--unlock" => a.emul = crate::adl::EmulAction::Unlock,
"--connector" => {
i += 1;
a.connector = argv.get(i).and_then(|s| s.parse().ok());
if a.connector.is_none() {
eprintln!("--connector needs a numeric connector index");
std::process::exit(2);
}
}
other => {
eprintln!("unknown arg: {other}");
std::process::exit(2);
@@ -129,6 +151,7 @@ mod win {
match args.mode.as_str() {
"ddc" => ddc_loop(&args),
"extend" => extend_once(),
"adl-emul" => crate::adl::run(args.emul, args.connector),
_ => modeset_loop(&args),
}
}
+5
View File
@@ -217,6 +217,11 @@
"display_pnp_disabled": "Aus",
"display_pnp_enabled": "Ein",
"display_pnp_badge": "Monitor-Geräte deaktiviert (PnP)",
"display_edid": "Monitor-Identität beim Streamen festhalten (EDID)",
"display_edid_help": "Nur AMD-Grafikkarten unter Windows, wirkt bei Topologie Exklusiv. Während des Streams weist der Host den AMD-Treiber an, jeden angeschlossenen Monitor mit seiner aktuellen Identität (EDID) weiterhin als vorhanden zu behandeln — auch wenn der Monitor schläft; das Software-Äquivalent eines Dummy-Steckers. Das verhindert, dass der Treiber die Verbindung eines schlafenden Monitors periodisch abfragt, was auf manchen Systemen ein rhythmisches Stottern im Sekundentakt verursacht, solange nur das virtuelle Display aktiv ist. Beim Stream-Ende wird die Fixierung entfernt; stürzt der Host mitten im Stream ab, geschieht das beim nächsten Start. Verhält sich ein Monitor danach seltsam, diese Option ausschalten und den Monitor kurz ab- und wieder anstecken (im schlimmsten Fall den Grafiktreiber neu installieren).",
"display_edid_disabled": "Aus",
"display_edid_enabled": "Ein",
"display_edid_badge": "Monitor-Identität fixiert (EDID)",
"display_identity_shared": "Geteilt",
"display_identity_per_client": "Pro Client",
"display_identity_per_client_mode": "Pro Client + Auflösung",
+5
View File
@@ -217,6 +217,11 @@
"display_pnp_disabled": "Off",
"display_pnp_enabled": "On",
"display_pnp_badge": "Monitor devices disabled (PnP)",
"display_edid": "Pin monitor identity while streaming (EDID)",
"display_edid_help": "AMD graphics cards on Windows only, takes effect with Exclusive topology. While streaming, the host tells the AMD driver to keep treating each connected monitor as present with its current identity (EDID), even while the monitor sleeps — the software equivalent of a dummy plug. This stops the driver from periodically probing a sleeping monitor's connection, which on some setups causes a rhythmic stutter every couple of seconds while the virtual display is the only active one. The pin is removed when the stream ends; if the host crashes mid-stream, it is removed the next time the host starts. If a monitor misbehaves afterwards, turn this off and unplug/replug the monitor (or reinstall the graphics driver in the worst case).",
"display_edid_disabled": "Off",
"display_edid_enabled": "On",
"display_edid_badge": "Monitor identity pinned (EDID)",
"display_identity_shared": "Shared",
"display_identity_per_client": "Per client",
"display_identity_per_client_mode": "Per client + resolution",
+26
View File
@@ -23,6 +23,7 @@ import {
useSetDisplaySettings,
useUpdateCustomPreset,
} from "@/api/gen/display/display";
import { useListGpus } from "@/api/gen/gpu/gpu";
import type {
ApiDisplayInfo,
CustomPreset,
@@ -341,6 +342,11 @@ export const DisplayForm: FC<{
}) => {
const qc = useQueryClient();
const { confirm, promptText } = useDialogs();
// The EDID-lock toggle is gated on an AMD GPU being present — the axis is the AMD driver's
// ADL connector-emulation lever and exists nowhere else. GPUs don't hot-swap; one fetch with
// the section's lifetime is plenty (no refetch interval).
const gpus = useListGpus();
const amdHost = (gpus.data?.gpus ?? []).some((g) => g.vendor === "amd");
const createPreset = useCreateCustomPreset();
const updatePreset = useUpdateCustomPreset();
const deletePreset = useDeleteCustomPreset();
@@ -393,6 +399,7 @@ export const DisplayForm: FC<{
game_session: draft.game_session ?? "auto",
ddc_power_off: draft.ddc_power_off ?? false,
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
edid_lock: draft.edid_lock ?? false,
// Which screen we stream is not a display-behavior axis at all — swapping the
// streamed screen out from under the operator because they changed a preset would be
// the worst kind of surprise. From the SERVER, not the draft (see serverCaptureMonitor).
@@ -415,6 +422,7 @@ export const DisplayForm: FC<{
// The experimental axes aren't part of a preset — keep the current settings.
ddc_power_off: draft.ddc_power_off ?? false,
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
edid_lock: draft.edid_lock ?? false,
// Nor is the streamed screen: this builds a FRESH policy object rather than spreading
// the draft, so anything not named here is silently dropped — which is exactly how
// applying a saved preset used to switch a mirroring host back to a virtual display
@@ -839,6 +847,21 @@ export const DisplayForm: FC<{
busy={busy}
onSet={(on) => applyAxis({ pnp_disable_monitors: on })}
/>
{/* AMD hosts only: the axis is the driver's ADL connector-emulation lever, which
exists nowhere else a toggle NVIDIA/Intel operators could flip but that can
never do anything would be the "saved and then did nothing" trap the enforced
list exists to prevent. */}
{amdHost && (
<ExperimentalToggle
label={m.display_edid()}
help={m.display_edid_help()}
value={draft.edid_lock ?? false}
offLabel={m.display_edid_disabled()}
onLabel={m.display_edid_enabled()}
busy={busy}
onSet={(on) => applyAxis({ edid_lock: on })}
/>
)}
{/* What's in force right now read from the API's `effective`, not from the local draft.
Deriving it from the draft meant the row restated the operator's unsaved edits back to
@@ -874,6 +897,9 @@ export const DisplayForm: FC<{
{(draft.pnp_disable_monitors ?? false) && (
<Badge variant="outline">{m.display_pnp_badge()}</Badge>
)}
{(draft.edid_lock ?? false) && (
<Badge variant="outline">{m.display_edid_badge()}</Badge>
)}
</div>
<p className="max-w-prose text-xs text-muted-foreground">