Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6774c4e7a2 | ||
|
|
f06b84be63 | ||
|
|
d6dbb391d6 | ||
|
|
907080f92b | ||
|
|
9425c6d40a | ||
|
|
ab8c7ec37c | ||
|
|
64e2af17c5 | ||
|
|
72189b29ec | ||
|
|
339a1d70f9 | ||
|
|
79dba7f95a | ||
|
|
d7430fe2bd | ||
|
|
6f81ec24ba | ||
|
|
539236de91 | ||
|
|
118758ff0b | ||
|
|
dcde856178 | ||
|
|
77918674c3 | ||
|
|
faefbae830 | ||
|
|
44fa12a298 | ||
|
|
55a3d8b919 | ||
|
|
a02014ec19 | ||
|
|
5f55b820bc | ||
|
|
57703fe31c | ||
|
|
90a3304c2b | ||
|
|
017867f211 | ||
|
|
ef0af3b558 | ||
|
|
535e95c4c0 | ||
|
|
b385f0a031 | ||
|
|
7528fd48a9 | ||
|
|
c68e0be688 | ||
|
|
71c1970b93 | ||
|
|
f373dffb5e | ||
|
|
28b6633058 | ||
|
|
c58217e403 | ||
|
|
23f9b1130e | ||
|
|
c2c71f0ac5 | ||
|
|
6a506a8fa9 | ||
|
|
712ee935d6 | ||
|
|
a2aa0a5f97 | ||
|
|
d6b9862f1e | ||
|
|
f4e39a442b | ||
|
|
a0577cb86e | ||
|
|
66ba61b12c | ||
|
|
5002849737 | ||
|
|
9a59504ba4 | ||
|
|
e8c306b9c0 | ||
|
|
c3b57438e1 | ||
|
|
e20b614059 |
@@ -21,6 +21,11 @@
|
||||
# workflow_dispatch, the rust-ci container, the same cache pattern) and because
|
||||
# ci.yml runs on every push against a fleet where 37 of 46 jobs contend for
|
||||
# ubuntu-24.04. See the `miri:` job below for what it does and does not buy.
|
||||
# * c-abi-asan → NON-BLOCKING ASAN+LSAN run of the C ABI harness (tests/c/run.sh under
|
||||
# PF_SAN=address): both sides of the abi.rs boundary instrumented at once, and
|
||||
# the only automated check on its Box::into_raw/from_raw leak contract. Same
|
||||
# here-not-ci.yml reasoning as miri — plus -Zbuild-std defeats sccache, so it
|
||||
# must not ride the per-push leg.
|
||||
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
|
||||
# change, and on demand.
|
||||
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
|
||||
@@ -364,3 +369,80 @@ jobs:
|
||||
-p punktfunk-core --lib -- fec::gf8 2>&1 | tee /tmp/miri-gf8.log || ok=0
|
||||
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-gf8.log || ok=0
|
||||
[ "$ok" = 1 ] || echo "::warning::miri (punktfunk-core fec::gf8, AVX2/SSSE3) did not pass — non-blocking; see design/rust-safety-programme.md §7"
|
||||
|
||||
# ASAN + LSAN over the C ABI harness — §6.1 of design/rust-safety-programme.md, its rank-1
|
||||
# tooling item. crates/punktfunk-core/tests/c/run.sh already proves the staticlib links and
|
||||
# round-trips 4 frames byte-exact from C on every push (ci.yml); PF_SAN=address rebuilds BOTH
|
||||
# sides instrumented — the staticlib on nightly with -Zsanitizer/-Zbuild-std (std itself
|
||||
# included), the harness with clang -fsanitize — so ASAN sees the seam a Rust-only tool cannot,
|
||||
# and LSAN (detect_leaks=1, the script's default) becomes the one automated check on abi.rs's
|
||||
# Box::into_raw/from_raw leak contract.
|
||||
# Proven to fail on 192.168.1.25: deleting a single punktfunk_session_free() from harness.c
|
||||
# makes LSAN report the ~308 Rust-side allocations behind the handle and run.sh exit 1.
|
||||
# What it does NOT see: the invalid-InputKind-discriminant UB at abi.rs (that needs the
|
||||
# validator, tracked in §5 of the programme doc), and nothing GPU/Windows — this is the
|
||||
# default-feature (quic-less, opus-less) core only.
|
||||
c-abi-asan:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# The SAME dated pin as the miri job above, deliberately — one nightly date to bump for
|
||||
# both jobs (they have no toolchain interaction; sharing the date just halves the chores).
|
||||
SAN_TOOLCHAIN: nightly-2026-08-10
|
||||
# Same guard as the miri job: audit.yml sets no sccache today, and -Zbuild-std could not
|
||||
# use it anyway. Keeps a future workflow-level sccache from becoming a puzzle.
|
||||
RUSTC_WRAPPER: ""
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Own `san-` key prefixes — never shared with the miri caches, per the cache-poisoning
|
||||
# note there (and so an incomplete save from one job can never starve the other).
|
||||
- name: cache the nightly toolchain
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/rustup/toolchains/${{ env.SAN_TOOLCHAIN }}-x86_64-unknown-linux-gnu
|
||||
key: san-toolchain-v1-${{ env.SAN_TOOLCHAIN }}
|
||||
- name: cache the cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/cargo/registry
|
||||
key: san-registry-v1-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: san-registry-v1-
|
||||
|
||||
# rust-src is required: -Zbuild-std compiles std from source so it is instrumented too —
|
||||
# without that, LSAN cannot attribute allocations made inside std (Vec, Box, HashMap).
|
||||
- name: install the pinned nightly + rust-src
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
rustup toolchain install "$SAN_TOOLCHAIN" --profile minimal --component rust-src
|
||||
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
|
||||
cargo +"$SAN_TOOLCHAIN" --version
|
||||
|
||||
# The image installs clang but Ubuntu does not always pull the compiler-rt sanitizer
|
||||
# runtime with it (verified absent on a stock 26.04 box). Probe with an actual ASAN link
|
||||
# and self-heal via apt if it fails — container jobs on this fleet run as root (the
|
||||
# bun-audit job's apt-get above relies on the same fact).
|
||||
- name: ensure clang's ASAN runtime
|
||||
run: |
|
||||
if ! echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe 2>/dev/null; then
|
||||
apt-get update && apt-get install -y --no-install-recommends "libclang-rt-$(clang -dumpversion | cut -d. -f1)-dev"
|
||||
echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe
|
||||
fi
|
||||
|
||||
# run.sh handles everything behind PF_SAN (nightly build, target path, clang flags,
|
||||
# ASAN_OPTIONS=detect_leaks=1) and exits non-zero on any report. The grep is the
|
||||
# proved-it-ran guard, same reasoning as the miri steps: a script change that silently
|
||||
# skips the harness must not read as green. run.sh expects bash and PATH cargo — both true
|
||||
# in this container. PF_SAN_TOOLCHAIN pins the script's `cargo +<toolchain>` to the dated
|
||||
# nightly installed above — without it the script would ask for the ROLLING `nightly`
|
||||
# channel, which this job deliberately does not install.
|
||||
- name: C ABI harness under ASAN+LSAN
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=1
|
||||
PF_SAN=address PF_SAN_TOOLCHAIN="$SAN_TOOLCHAIN" \
|
||||
bash crates/punktfunk-core/tests/c/run.sh 2>&1 | tee /tmp/asan-harness.log || ok=0
|
||||
grep -q 'PASS: 4 frames round-tripped byte-exact' /tmp/asan-harness.log || ok=0
|
||||
[ "$ok" = 1 ] || echo "::warning::c-abi-asan did not pass — non-blocking on day one; see design/rust-safety-programme.md §6.1. An LSAN report here means the abi.rs into_raw/from_raw contract broke."
|
||||
|
||||
+11
-1
@@ -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)
|
||||
|
||||
@@ -184,6 +184,42 @@ but latches nothing; only the full-length attempts that follow hand down negotia
|
||||
classification is a pure function with tests
|
||||
(`pf_capture::linux::first_frame_timeout_tests`).
|
||||
|
||||
### Windows host — an idle box can sleep again (virtual-mic stream idle-stop)
|
||||
|
||||
🛑 **Installing the host blocked system sleep forever, client connected or not.** The
|
||||
host-lifetime mic pump kept a WASAPI render stream RUNNING on the virtual-mic device
|
||||
(typically the Steam Streaming Microphone), writing silence 24/7 — and any running stream makes
|
||||
the Windows audio stack hold a kernel power request ("An audio stream is currently in use" in
|
||||
`powercfg /requests`, attributed to that device) that vetoes sleep. The render loop now stops
|
||||
the stream (`IAudioClient::Stop`; the client stays initialized and the mic *endpoint* keeps
|
||||
existing for apps to bind) after 10 s of silence-only output and resumes on the next mic frame
|
||||
within one device period — below the jitter buffer's prime depth, so nothing is audible.
|
||||
Streaming sessions still hold the box awake through their own `PowerRequest` assertions, as
|
||||
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
|
||||
third-party virtual audio driver misbehaves while its render side is paused.
|
||||
|
||||
### Windows host — audio no longer costs local-game frame time
|
||||
|
||||
🛑 **The host could tank a locally-played game's frame lows** (field-reported 2026-08-12:
|
||||
Helldivers 2 at 1% lows of 2–5 FPS, cured by uninstalling). Two mechanisms, both fixed:
|
||||
|
||||
- **The minted-endpoint retry storm.** The virtual-mic resolve ran a FULL provisioning pass on
|
||||
every reopen with no cooldown, no in-flight guard, and no give-up — and the pass reached
|
||||
`UpdateDriverForPlugAndPlayDevicesW` even over an already-existing devnode. On a box where
|
||||
minting cannot converge, the pump's reopen backoff (capped 60 s) turned that into a SetupAPI
|
||||
sweep + PnP driver re-bind + default-device writes roughly once a minute, forever — each
|
||||
raising the system-wide device-change broadcast games service by rebuilding their audio
|
||||
graphs. Provisioning now short-circuits to a no-PnP fast path while the minted devices are
|
||||
healthy, waits on an in-flight pass instead of racing a second one, honours the 60 s retry
|
||||
cooldown from the blocking path too, and stops for the host lifetime after five unlatched
|
||||
passes (a service restart re-arms minting).
|
||||
- **Session tuning never reverted.** The first streaming session put the whole host process at
|
||||
HIGH priority class with a 1 ms global timer (`timeBeginPeriod`) and DWM MMCSS, documented as
|
||||
"reverts at process exit" — but the host is a 24/7 service, so after one stream it competed
|
||||
at HIGH priority against whatever the user played locally, forever. The process-wide tuning
|
||||
is now refcounted across the hot stream threads and reverts when the last one exits
|
||||
(= session teardown), the same lifetime the per-thread MMCSS effects already ride.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
Generated
+1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 (~250–500 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -57,6 +57,8 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
|
||||
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
||||
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
|
||||
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
|
||||
/// Pump-side events (loss recovery, format seeding) — the stage-2 sibling of StreamPump's log.
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "pump")
|
||||
|
||||
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
|
||||
/// user's presentation intent (design/apple-presentation-rebuild.md — the 2026-07 rebuild that
|
||||
@@ -936,6 +938,21 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if format == nil {
|
||||
// No decodable format yet: the opening IDR's parameter sets never
|
||||
// arrived (or never parsed), and under the host's infinite GOP nothing
|
||||
// re-delivers them unless we ASK. Without this the guard below drops
|
||||
// every AU silently, forever — the field "black stream, zero recovery
|
||||
// requests" state (2026-08-12): the host streams perfectly, the client
|
||||
// shows nothing and says nothing. awaitingIDR routes through the same
|
||||
// 100 ms-throttled recovery.request() at the top of the loop.
|
||||
if !awaitingIDR {
|
||||
pumpLog.warning(
|
||||
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
|
||||
)
|
||||
}
|
||||
awaitingIDR = true
|
||||
}
|
||||
guard let f = format, !token.isStopped else { return true }
|
||||
if decoder.decode(au: au, format: f) {
|
||||
decodeFailRun = 0
|
||||
|
||||
@@ -120,6 +120,21 @@ final class StreamPump {
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if format == nil {
|
||||
// No decodable format yet: the opening IDR's parameter sets never
|
||||
// arrived (or never parsed), and under the host's infinite GOP nothing
|
||||
// re-delivers them unless we ASK. Without this the format guard below
|
||||
// drops every AU silently, forever — the field "black stream, zero
|
||||
// recovery requests" state (2026-08-12). awaitingIDR routes through the
|
||||
// same 100 ms-throttled recovery.request() at the top of the loop.
|
||||
if !awaitingIDR {
|
||||
awaitingSince = Date()
|
||||
pumpLog.warning(
|
||||
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
|
||||
)
|
||||
}
|
||||
awaitingIDR = true
|
||||
}
|
||||
let failed = layer.status == .failed
|
||||
if failed {
|
||||
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||
|
||||
@@ -215,9 +215,10 @@ export function useHosts() {
|
||||
const [views, setViews] = useState<HostView[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
|
||||
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// any of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// "client-outdated" — the installed client predates `punktfunk discover`
|
||||
// "client-unavailable" — there is no client installed at all
|
||||
// "list-failed" — the refresh itself blew up (backend down, call threw)
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -236,7 +237,11 @@ export function useHosts() {
|
||||
);
|
||||
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
|
||||
// Inline, not a toast: the panel remounts (and refreshes) on every QAM open, so while
|
||||
// the backend is unhappy a toast here nagged on each open. The panel row also sits next
|
||||
// to the Refresh button that retries it, which is where the eyes already are.
|
||||
console.warn("punktfunk: host list refresh failed", e);
|
||||
setProblem("list-failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -454,9 +459,12 @@ export async function startStream(
|
||||
): Promise<void> {
|
||||
try {
|
||||
await launchStream(v.ref, opts);
|
||||
// No success toast: the user just pressed the button that names this host/card, the QAM
|
||||
// closes, and Steam's own launch UI takes over — a toast here fired on EVERY launch and
|
||||
// then sat on top of the starting stream. Failure still toasts (the QAM may already be
|
||||
// closed, so inline error state would go unseen).
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed${label ? ` (${label})` : ""}: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,15 +46,23 @@ import { OsMark } from "./os-icon";
|
||||
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
|
||||
import { TrustSheet } from "./trust";
|
||||
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut
|
||||
// and sweeps duplicate entries (the piles a boot race used to mint, one per Steam start).
|
||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
|
||||
// that works even without a reload. Always ends in a toast so the tap has feedback.
|
||||
async function recreatePunktfunkShortcut(): Promise<void> {
|
||||
const appId = await recreateShortcuts();
|
||||
const { appId, removedDuplicates } = await recreateShortcuts();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
|
||||
body:
|
||||
appId == null
|
||||
? "Couldn't create the shortcut"
|
||||
: removedDuplicates > 0
|
||||
? `Shortcut restored — removed ${removedDuplicates} duplicate ${
|
||||
removedDuplicates === 1 ? "entry" : "entries"
|
||||
}`
|
||||
: "Shortcut restored to your library",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,12 +230,16 @@ const QamPanel: FC = () => {
|
||||
label={
|
||||
problem === "client-unavailable"
|
||||
? "Punktfunk isn’t installed"
|
||||
: "Update the Punktfunk client"
|
||||
: problem === "list-failed"
|
||||
? "Couldn’t scan for hosts"
|
||||
: "Update the Punktfunk client"
|
||||
}
|
||||
description={
|
||||
problem === "client-unavailable"
|
||||
? "This panel launches the Punktfunk app, which isn’t on this Deck yet. Install it in Desktop Mode."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
: problem === "list-failed"
|
||||
? "Something went wrong while scanning — Refresh tries again."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
@@ -313,7 +325,7 @@ const QamPanel: FC = () => {
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Missing the Punktfunk entry in your library? This puts it back."
|
||||
description="Missing the Punktfunk entry in your library, or seeing several? This puts one back and removes the rest."
|
||||
onClick={() => void recreatePunktfunkShortcut()}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
|
||||
+220
-39
@@ -44,6 +44,7 @@ declare const SteamClient: {
|
||||
): Promise<unknown>;
|
||||
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
||||
TerminateApp(gameId: string, _b: boolean): void;
|
||||
RemoveShortcut(appId: number): void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,29 +63,114 @@ declare const collectionStore:
|
||||
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
|
||||
// entry never comes back.
|
||||
declare const appStore:
|
||||
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
|
||||
| {
|
||||
GetAppOverviewByAppID?: (appId: number) => unknown | null;
|
||||
allApps?: SteamAppOverviewLike[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
|
||||
* we can't tell, so assume it exists — better to keep reusing than risk a duplicate library
|
||||
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||
function shortcutStillExists(appId: number): boolean {
|
||||
// The overview surface we read when scanning the library — Steam internals, so everything is
|
||||
// optional and accessed defensively.
|
||||
interface SteamAppOverviewLike {
|
||||
appid?: number;
|
||||
display_name?: string;
|
||||
BIsShortcut?: () => boolean;
|
||||
}
|
||||
|
||||
// Steam-injected global whose WaitForServicesInitialized resolves once the client's app
|
||||
// services are up (the MoonDeck-verified readiness signal). Services-init alone doesn't
|
||||
// guarantee the overview map is populated, so it's paired with the hydration witness below.
|
||||
declare const App:
|
||||
| { WaitForServicesInitialized?: () => Promise<boolean> }
|
||||
| undefined;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let servicesInitialized: Promise<void> | undefined;
|
||||
function waitForServicesInitialized(): Promise<void> {
|
||||
servicesInitialized ??= (async () => {
|
||||
try {
|
||||
if (typeof App !== "undefined" && App?.WaitForServicesInitialized) {
|
||||
await App.WaitForServicesInitialized();
|
||||
}
|
||||
} catch {
|
||||
/* no signal — the hydration witness still gates the verdict */
|
||||
}
|
||||
})();
|
||||
return servicesInitialized;
|
||||
}
|
||||
|
||||
/** Has appStore demonstrably finished its initial load? An empty `allApps` means "not yet":
|
||||
* any account that ever had our shortcut has at least one app, so a populated map is the
|
||||
* witness that a null overview lookup is an ANSWER rather than a not-loaded-yet. null =
|
||||
* can't tell (missing global, API drift). */
|
||||
function appStoreHydrated(): boolean | null {
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !appStore) {
|
||||
return null;
|
||||
}
|
||||
const apps = appStore.allApps;
|
||||
return Array.isArray(apps) ? apps.length > 0 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One overview lookup: true = live, false = absent, null = can't tell. */
|
||||
function queryShortcutAlive(appId: number): boolean | null {
|
||||
try {
|
||||
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
|
||||
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
|
||||
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
|
||||
// "true". That is not a stale-data bug but a total one: the guard then answers "still
|
||||
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
|
||||
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
|
||||
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
|
||||
// one is a ReferenceError that optional chaining does NOT prevent.
|
||||
// "can't tell". `typeof` first: `appStore` is a Steam-injected global, and a bare
|
||||
// reference to a missing one is a ReferenceError that optional chaining does NOT prevent.
|
||||
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
|
||||
return true; // no way to verify — preserve the reuse path
|
||||
return null;
|
||||
}
|
||||
return appStore.GetAppOverviewByAppID(appId) != null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// How long to wait for the app store before conceding liveness can't be verified. A Deck boot
|
||||
// hydrates the store within a few seconds of plugin mount; 30 s is comfortably past any real
|
||||
// boot, and the wait only burns on the absent/unverifiable paths — a live overview answers on
|
||||
// the first query. Overview registration can trail the bulk hydration by a beat, so a
|
||||
// "hydrated but absent" verdict gets one grace recheck before it counts as deleted.
|
||||
const STORE_WAIT_MS = 30_000;
|
||||
const STORE_POLL_MS = 1_000;
|
||||
const STORE_GRACE_MS = 2_000;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut.
|
||||
*
|
||||
* The dangerous verdict is FALSE — it sends the caller to AddShortcut, so a wrong "deleted"
|
||||
* mints a duplicate library entry. And a bare null-overview check gets it wrong on EVERY
|
||||
* boot: the plugin mounts while Steam is still starting up, before appStore has registered
|
||||
* its overviews, so the remembered (perfectly live) appId looks up as null and each boot
|
||||
* added another visible "Punktfunk" — the field-reported duplicate pile. Absent is therefore
|
||||
* only believed once the store is demonstrably hydrated; if that can't be established within
|
||||
* budget the answer is true, because a false "alive" merely no-ops Set-calls until the next
|
||||
* ask (and the recreate button re-asks when the store IS ready) while a false "dead"
|
||||
* duplicates forever. */
|
||||
async function shortcutStillExists(appId: number): Promise<boolean> {
|
||||
if (queryShortcutAlive(appId) === true) {
|
||||
return true;
|
||||
}
|
||||
// Race the init signal against the same budget the poll loop gets: a signal that never
|
||||
// resolves must not wedge the guard (the single-flight ensure would stay occupied forever).
|
||||
await Promise.race([waitForServicesInitialized(), sleep(STORE_WAIT_MS)]);
|
||||
for (let waited = 0; waited < STORE_WAIT_MS; waited += STORE_POLL_MS) {
|
||||
if (queryShortcutAlive(appId) === true) {
|
||||
return true;
|
||||
}
|
||||
if (appStoreHydrated() === true) {
|
||||
await sleep(STORE_GRACE_MS);
|
||||
return queryShortcutAlive(appId) !== false; // null = unverifiable → reuse
|
||||
}
|
||||
await sleep(STORE_POLL_MS);
|
||||
}
|
||||
return true; // store never became inspectable — reusing beats duplicating
|
||||
}
|
||||
|
||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||
@@ -156,6 +242,67 @@ async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
||||
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
|
||||
const SHORTCUT_NAME = "Punktfunk";
|
||||
|
||||
/** Find an existing "Punktfunk" shortcut to ADOPT instead of minting a new library entry — the
|
||||
* healing path for a lost/wiped appId, and for the duplicate piles the boot race left behind
|
||||
* in the field: rebind one of the existing entries to the role rather than adding an N+1th.
|
||||
* (The caller rewrites exe/dir/opts/visibility anyway, so any of them serves.) Only overviews
|
||||
* Steam itself says are shortcuts qualify, and the other role's remembered id is excluded so
|
||||
* the two roles never collapse onto one shortcut. */
|
||||
function findAdoptableShortcut(excludeAppId: number | null): number | null {
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
|
||||
return null;
|
||||
}
|
||||
for (const app of appStore.allApps) {
|
||||
if (
|
||||
app?.display_name === SHORTCUT_NAME &&
|
||||
typeof app.appid === "number" &&
|
||||
app.appid !== excludeAppId &&
|
||||
app.BIsShortcut?.() === true
|
||||
) {
|
||||
return app.appid;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* Steam internals drifted — AddShortcut is the fallback */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Remove every "Punktfunk" shortcut beyond the two remembered role ids — the cleanup for
|
||||
* piles already minted by the boot race. Deliberately reachable ONLY from the user-pressed
|
||||
* recreate button, never from mount: automatic library deletion at boot is a bigger hazard
|
||||
* than the mess it would tidy. Returns how many entries were removed. */
|
||||
function removeDuplicateShortcuts(): number {
|
||||
let removed = 0;
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
|
||||
return 0;
|
||||
}
|
||||
const keep = [recall(STORAGE_KEY_STREAM), recall(STORAGE_KEY_UI)];
|
||||
// Snapshot before removing — RemoveShortcut mutates the store's list under the iteration.
|
||||
const surplus = appStore.allApps.filter(
|
||||
(app) =>
|
||||
app?.display_name === SHORTCUT_NAME &&
|
||||
typeof app.appid === "number" &&
|
||||
!keep.includes(app.appid) &&
|
||||
app.BIsShortcut?.() === true,
|
||||
);
|
||||
for (const app of surplus) {
|
||||
SteamClient.Apps.RemoveShortcut(app.appid as number);
|
||||
try {
|
||||
localStorage.removeItem(artKey(app.appid as number));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
removed++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: duplicate-shortcut sweep incomplete", e);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
|
||||
// backend can't chmod it back on. Passing the script as an argument to the always-executable
|
||||
@@ -223,7 +370,7 @@ async function ensureControllerConfig(): Promise<void> {
|
||||
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
|
||||
* across reinstalls, and pre-two-shortcut installs had this one visible).
|
||||
*/
|
||||
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
async function doEnsureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
@@ -232,25 +379,38 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
|
||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||
|
||||
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
|
||||
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
|
||||
// outlived it across a reinstall) must fall through, not be silently repointed. On a lost id,
|
||||
// ADOPT an existing same-named shortcut before AddShortcut so a wiped key never duplicates.
|
||||
const remembered = recall(STORAGE_KEY_STREAM);
|
||||
if (remembered != null && shortcutStillExists(remembered)) {
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(remembered);
|
||||
return { appId: remembered, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
let appId =
|
||||
remembered != null && (await shortcutStillExists(remembered)) ? remembered : null;
|
||||
if (appId == null) {
|
||||
appId =
|
||||
findAdoptableShortcut(recall(STORAGE_KEY_UI)) ??
|
||||
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
setShortcutHidden(appId, true);
|
||||
setShortcutHidden(appId, true); // also migrates pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(appId);
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
return { appId, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
}
|
||||
|
||||
// Concurrent ensure calls share one run per role — two ensures racing past the liveness check
|
||||
// would each AddShortcut, which is exactly the duplicate class this file exists to prevent (and
|
||||
// the store-readiness wait makes the window real: mount's fire-and-forget ensure can be mid-wait
|
||||
// when a QAM press arrives). Sequential calls still re-run, so per-launch repointing is kept.
|
||||
let streamEnsureInFlight: Promise<{ appId: number; runner: string; clientBin: string }> | null =
|
||||
null;
|
||||
function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
streamEnsureInFlight ??= doEnsureStreamShortcut().finally(() => {
|
||||
streamEnsureInFlight = null;
|
||||
});
|
||||
return streamEnsureInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry
|
||||
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
|
||||
@@ -258,7 +418,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
|
||||
* kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays
|
||||
* repointed to the current plugin dir. Best-effort: returns null on any failure.
|
||||
*/
|
||||
export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
async function doEnsureGamepadUiShortcut(): Promise<number | null> {
|
||||
try {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
@@ -275,18 +435,20 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
|
||||
// library entry actually comes back instead of repointing a dead id.
|
||||
// localStorage key survived a plugin reinstall) falls through so the visible library entry
|
||||
// actually comes back instead of repointing a dead id. On a lost id, ADOPT an existing
|
||||
// same-named shortcut (a boot-race duplicate, or the entry whose key was wiped) before
|
||||
// AddShortcut — creation is the last resort, never the response to a mere lookup miss.
|
||||
let appId = recall(STORAGE_KEY_UI);
|
||||
if (appId != null && shortcutStillExists(appId)) {
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
} else {
|
||||
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
if (appId == null || !(await shortcutStillExists(appId))) {
|
||||
appId =
|
||||
findAdoptableShortcut(recall(STORAGE_KEY_STREAM)) ??
|
||||
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
|
||||
remember(STORAGE_KEY_UI, appId);
|
||||
}
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
|
||||
setShortcutHidden(appId, false); // the visible library entry
|
||||
void applyArtwork(appId);
|
||||
@@ -297,18 +459,32 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Same single-flight rule as the stream role (see ensureStreamShortcut).
|
||||
let uiEnsureInFlight: Promise<number | null> | null = null;
|
||||
export function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
uiEnsureInFlight ??= doEnsureGamepadUiShortcut().finally(() => {
|
||||
uiEnsureInFlight = null;
|
||||
});
|
||||
return uiEnsureInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the visible "Punktfunk" library entry back into existence — the recovery button for
|
||||
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
|
||||
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
|
||||
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
|
||||
* self-heal-on-mount can't — deleting the shortcut WITHOUT reinstalling (no mount → no ensure).
|
||||
* Returns the (new or existing) visible appId, or null on failure.
|
||||
* Also sweeps surplus "Punktfunk" entries (the piles the boot race minted before the store-
|
||||
* readiness gate existed) — the button is where that cleanup lives, never mount. Returns the
|
||||
* (new or existing) visible appId (null on failure) plus how many duplicates were removed.
|
||||
*/
|
||||
export async function recreateShortcuts(): Promise<number | null> {
|
||||
export async function recreateShortcuts(): Promise<{
|
||||
appId: number | null;
|
||||
removedDuplicates: number;
|
||||
}> {
|
||||
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
|
||||
const id = recall(key);
|
||||
if (id != null && !shortcutStillExists(id)) {
|
||||
if (id != null && !(await shortcutStillExists(id))) {
|
||||
try {
|
||||
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
|
||||
localStorage.removeItem(key);
|
||||
@@ -317,8 +493,13 @@ export async function recreateShortcuts(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
|
||||
return ensureGamepadUiShortcut();
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next
|
||||
// launch. Sweep AFTER the ensure so the remembered ids are fresh — and only when the ensure
|
||||
// succeeded: on a failed ensure the "keep" list can't be trusted, and deleting candidates a
|
||||
// later ensure would adopt could leave the library with no entry at all.
|
||||
const appId = await ensureGamepadUiShortcut();
|
||||
const removedDuplicates = appId != null ? removeDuplicateShortcuts() : 0;
|
||||
return { appId, removedDuplicates };
|
||||
}
|
||||
|
||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||
|
||||
@@ -870,6 +870,23 @@ fn deliver_deep_link(url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The crate's one runtime env mutation, isolated so `main.rs`'s `deny(unsafe_code)` covers
|
||||
/// everything else and the exemption is a named function rather than a whole call site.
|
||||
#[allow(unsafe_code)]
|
||||
fn clear_steam_sdl_device_filter() {
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
// SAFETY: called at the 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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
@@ -879,15 +896,7 @@ pub fn run() -> glib::ExitCode {
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
||||
// physical pad Steam Input has virtualized; the Settings controller list needs the
|
||||
// real devices (same rationale as the session binary).
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
}
|
||||
clear_steam_sdl_device_filter();
|
||||
// Headless paths (no GTK window).
|
||||
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||
return crate::cli::headless_pair(&pin);
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||
#![forbid(unsafe_code)]
|
||||
// `deny`, not `forbid`, since edition 2024: clearing Steam's SDL device filter and the spawn
|
||||
// test's `HOME` scoping mutate the process env, which is now an unsafe call. Both carry a named
|
||||
// `#[allow(unsafe_code)]` with the proof at the site; everything else stays compiler-refused.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
||||
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
|
||||
|
||||
@@ -128,6 +128,8 @@ mod tests {
|
||||
/// that is merely capped. One test, one `HOME` — the stores are read from it, so this
|
||||
/// deliberately does not split into several that would race over the same env var.
|
||||
#[test]
|
||||
// The crate's one test env mutation (the `HOME` scoping below) — see main.rs's deny note.
|
||||
#[allow(unsafe_code)]
|
||||
fn the_plan_carries_resolved_settings_not_defaults() {
|
||||
use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile};
|
||||
use pf_client_core::trust::{KnownHost, KnownHosts, Settings};
|
||||
@@ -135,7 +137,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+88
-11
@@ -13,7 +13,13 @@
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
|
||||
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
|
||||
#![forbid(unsafe_code)]
|
||||
// `deny`, not `forbid`: edition 2024 makes the std process-environment mutators unsafe
|
||||
// (WP20 — the env-mutation class made visible; named-API mentions here would count against
|
||||
// the unsafe-hygiene gate C baseline, which tracks this file's real call sites), and this
|
||||
// bin's three single-threaded-startup env writes carry documented SAFETY comments under
|
||||
// localized `#[allow(unsafe_code)]` (the pf-update idiom). A `forbid` cannot be overridden
|
||||
// at those sites and refuses the file.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
@@ -116,6 +122,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 +448,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
|
||||
@@ -505,12 +539,18 @@ mod session_main {
|
||||
/// initialises, so a call placed after them leaves the triage tool describing a device
|
||||
/// that cannot decode while the streaming path decodes on it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unsafe_code)] // the two SAFETY-commented single-threaded-startup env writes below
|
||||
fn enable_radv_video_decode() {
|
||||
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 +844,13 @@ 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.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::set_var(var, value)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -818,7 +864,12 @@ 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.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::remove_var(var)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,12 +977,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 +1046,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))]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -119,6 +119,88 @@ impl Drop for AvFilterGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/// An owned `AVFrame`, freed exactly once when it drops.
|
||||
///
|
||||
/// The house pattern (`AvBuffer` above): `alloc` rejects the allocator's null once, `as_ptr`
|
||||
/// lends, `Drop` frees, no `Clone`. Before this type existed the crate held 8 `av_frame_alloc`
|
||||
/// sites matched by 22 hand-placed `av_frame_free`s — an ownership contract upheld by nobody,
|
||||
/// and broken in practice: the Windows zero-copy submit path leaked the frame AND a pooled
|
||||
/// hwframe surface on three `?` exits, under a comment asserting the opposite (fixed in the
|
||||
/// same change that introduced this type).
|
||||
///
|
||||
/// Why not ffmpeg-next's own RAII frame (`frame::Video::empty()`, already used as `VideoFrame`
|
||||
/// in the Linux NVENC path): `Frame::empty()` does not null-check — on allocator failure it
|
||||
/// wraps null and the next field write through it is UB — whereas every open-coded site here
|
||||
/// null-checked. This type keeps that: `alloc` returns `Option`, mirroring
|
||||
/// `AvFilterGraph::alloc`.
|
||||
pub(crate) struct AvFrame(std::ptr::NonNull<ffi::AVFrame>);
|
||||
|
||||
impl AvFrame {
|
||||
/// Allocate a frame, rejecting the null `av_frame_alloc` returns on OOM.
|
||||
///
|
||||
/// Safe: the call takes no arguments and has no precondition a caller could violate — the
|
||||
/// only contract is what happens to the result, and that is exactly what this type owns.
|
||||
pub(crate) fn alloc() -> Option<Self> {
|
||||
// SAFETY: parameterless allocator; it returns either a fresh, uniquely-owned frame whose
|
||||
// ownership passes to the value returned here, or null (rejected by NonNull::new).
|
||||
std::ptr::NonNull::new(unsafe { ffi::av_frame_alloc() }).map(AvFrame)
|
||||
}
|
||||
|
||||
/// The borrowed pointer, for the ffmpeg calls that fill or read the frame without taking
|
||||
/// ownership of it. Borrowed only — the `AvFrame` stays the owner, so callers must not free
|
||||
/// or move-from what this returns.
|
||||
pub(crate) fn as_ptr(&self) -> *mut ffi::AVFrame {
|
||||
self.0.as_ptr()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvFrame {
|
||||
fn drop(&mut self) {
|
||||
let mut p = self.0.as_ptr();
|
||||
// SAFETY: `p` is the non-null frame `alloc` took ownership of, and this type is its
|
||||
// sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs exactly
|
||||
// once. `av_frame_free` unrefs any buffers the frame holds (returning pooled hwframe
|
||||
// surfaces to their pool) and frees the frame; it nulls only the local copy.
|
||||
unsafe { ffi::av_frame_free(&mut p) };
|
||||
}
|
||||
}
|
||||
|
||||
/// An owned swscale context, freed exactly once when it drops.
|
||||
///
|
||||
/// Same ownership question as the frame above — `sws_getContext` at 3 sites was matched by 5
|
||||
/// hand-placed `sws_freeContext`s, two of them inside hand-written `Drop` impls whose real job
|
||||
/// this type absorbs.
|
||||
pub(crate) struct AvSwsContext(std::ptr::NonNull<ffi::SwsContext>);
|
||||
|
||||
impl AvSwsContext {
|
||||
/// Take ownership of a freshly-created `SwsContext`, rejecting the null `sws_getContext`
|
||||
/// returns on failure (unsupported conversion or OOM).
|
||||
///
|
||||
// unsafe-fn-no-op-ok: contract-deferring constructor (`Vec::set_len` shape) — the body is
|
||||
// safe; the ownership transfer promised here is what Drop/as_ptr later rely on.
|
||||
/// # Safety
|
||||
/// `p` must be null, or a live `SwsContext` whose ownership passes to the returned value —
|
||||
/// nothing else may free it.
|
||||
pub(crate) unsafe fn from_raw(p: *mut ffi::SwsContext) -> Option<Self> {
|
||||
std::ptr::NonNull::new(p).map(AvSwsContext)
|
||||
}
|
||||
|
||||
/// The borrowed pointer, for `sws_scale` calls. Borrowed only — the `AvSwsContext` stays
|
||||
/// the owner.
|
||||
pub(crate) fn as_ptr(&self) -> *mut ffi::SwsContext {
|
||||
self.0.as_ptr()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvSwsContext {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the non-null context `from_raw` took ownership of, and this type
|
||||
// is its sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs
|
||||
// exactly once.
|
||||
unsafe { ffi::sws_freeContext(self.0.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
/// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can
|
||||
/// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse
|
||||
/// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop.
|
||||
|
||||
@@ -24,8 +24,8 @@ use std::os::raw::c_int;
|
||||
use std::ptr;
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_ITU709,
|
||||
SWS_POINT,
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
|
||||
SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
@@ -191,6 +191,17 @@ struct OpenArgs {
|
||||
}
|
||||
|
||||
pub struct NvencEncoder {
|
||||
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced ran before any field
|
||||
// drop, freeing `sws_csc` ahead of `enc`/`frame`/`cuda` — and this path runs on every
|
||||
// stall-watchdog recovery via `*self = fresh` in `reset`. Declaration order is what
|
||||
// preserves that sequence now (drop order follows declaration; an offset_of assert cannot
|
||||
// pin it — repr(Rust) may lay memory out in any order).
|
||||
/// CPU CSC paths only: swscale context converting the captured packed source into
|
||||
/// [`Self::frame`] — RGB/BGR → planar YUV444P for a 4:4:4 session (`hevc_nvenc` only emits
|
||||
/// 4:4:4 from a YUV444 *input*; RGB-in is always 4:2:0), or X2RGB10/X2BGR10 → P010 (BT.2020
|
||||
/// limited) for an HDR session. `None` on the plain RGB paths AND on the zero-copy paths (the
|
||||
/// worker's GPU convert delivers ready CUDA frames).
|
||||
sws_csc: Option<AvSwsContext>,
|
||||
enc: encoder::video::Encoder,
|
||||
/// Reusable 4-bpp CPU input frame (CPU path only; `None` for the zero-copy/CUDA path).
|
||||
/// Mutating it in place across frames is sound only because the encoder is opened with
|
||||
@@ -199,12 +210,6 @@ pub struct NvencEncoder {
|
||||
frame: Option<VideoFrame>,
|
||||
/// Zero-copy path: CUDA hwdevice/hwframes contexts (the encoder takes `AV_PIX_FMT_CUDA`).
|
||||
cuda: Option<CudaHw>,
|
||||
/// CPU CSC paths only: swscale context converting the captured packed source into
|
||||
/// [`Self::frame`] — RGB/BGR → planar YUV444P for a 4:4:4 session (`hevc_nvenc` only emits
|
||||
/// 4:4:4 from a YUV444 *input*; RGB-in is always 4:2:0), or X2RGB10/X2BGR10 → P010 (BT.2020
|
||||
/// limited) for an HDR session. `None` on the plain RGB paths AND on the zero-copy paths (the
|
||||
/// worker's GPU convert delivers ready CUDA frames). Freed in `Drop`.
|
||||
sws_csc: Option<*mut ffi::SwsContext>,
|
||||
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
||||
want_444: bool,
|
||||
src_format: PixelFormat,
|
||||
@@ -226,7 +231,7 @@ pub struct NvencEncoder {
|
||||
args: OpenArgs,
|
||||
}
|
||||
|
||||
// `CudaHw` holds raw `AVBufferRef`s and `sws_csc` a raw `SwsContext`; the encoder lives on a single
|
||||
// `CudaHw` holds raw `AVBufferRef`s and `sws_csc` an owned `SwsContext`; the encoder lives on a single
|
||||
// thread. The CPU encoder is already `Send` via ffmpeg-next; assert it for the raw fields too.
|
||||
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
|
||||
// holding raw `AVBufferRef`s and an optional raw `SwsContext`, none of which are `Send` by default.
|
||||
@@ -608,14 +613,13 @@ impl NvencEncoder {
|
||||
);
|
||||
}
|
||||
|
||||
// Built HERE, below the fallible encoder open, NOT above it. `sws_getContext` returns a raw
|
||||
// pointer whose only free is `Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED
|
||||
// `Self`, which does not exist on `open`'s early returns (the intra-refresh-unsupported
|
||||
// retry, which recurses into `Self::open`, and the plain error return). Creating the
|
||||
// context above them leaked one per failed attempt, and `open_nvenc_probed`'s EINVAL
|
||||
// bitrate ladder calls `open` up to ~10 times, so a host stepping its bitrate down leaked a
|
||||
// context per step. Nothing between here and the `Ok(NvencEncoder { … })` below can return,
|
||||
// so this placement makes the leak unrepresentable rather than merely unlikely.
|
||||
// Built HERE, below the fallible encoder open, NOT above it — historically because the
|
||||
// context's only free was `Drop for NvencEncoder`, which needs a CONSTRUCTED `Self` that
|
||||
// does not exist on `open`'s early returns; creating it above them leaked one per failed
|
||||
// attempt, and `open_nvenc_probed`'s EINVAL bitrate ladder calls `open` up to ~10 times.
|
||||
// The owned `AvSwsContext` now frees itself on any exit, but the placement stays: it
|
||||
// documents the dependency on the post-open `nvenc_pixel`, and there is no reason to
|
||||
// build a context an early return would just throw away.
|
||||
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
||||
// input frame. THREE users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag), HDR
|
||||
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
||||
@@ -640,10 +644,10 @@ impl NvencEncoder {
|
||||
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
|
||||
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
||||
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
||||
// null-checked below.
|
||||
// defaults" (documented as accepted). No Rust memory is borrowed; ownership of the
|
||||
// returned context passes to the `AvSwsContext` (null rejected by `from_raw`).
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
AvSwsContext::from_raw(ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
@@ -654,11 +658,11 @@ impl NvencEncoder {
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
))
|
||||
};
|
||||
if sws.is_null() {
|
||||
let Some(sws) = sws else {
|
||||
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
||||
}
|
||||
};
|
||||
// Colour math applies to the CSC users ONLY. The expand is a pure byte shuffle —
|
||||
// packed 3-bpp RGB/BGR to the same channels in 4 bytes, `nvenc_pixel` being `rgb0`/
|
||||
// `bgr0` — and NVENC does the RGB→YUV itself downstream. Handing it a matrix + range
|
||||
@@ -678,7 +682,16 @@ impl NvencEncoder {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
let dst_range = i32::from(full_range_444);
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
|
||||
ffi::sws_setColorspaceDetails(
|
||||
sws.as_ptr(),
|
||||
cs,
|
||||
1,
|
||||
cs,
|
||||
dst_range,
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(sws)
|
||||
@@ -692,10 +705,10 @@ impl NvencEncoder {
|
||||
Some(VideoFrame::new(nvenc_pixel, width, height))
|
||||
};
|
||||
Ok(NvencEncoder {
|
||||
sws_csc,
|
||||
enc,
|
||||
frame,
|
||||
cuda: cuda_hw,
|
||||
sws_csc,
|
||||
want_444,
|
||||
src_format: format,
|
||||
width,
|
||||
@@ -838,7 +851,7 @@ impl NvencEncoder {
|
||||
// three CSC users (see `open`): 4:4:4 → planar YUV444P, HDR → P010, and the packed 3-bpp
|
||||
// expand → `rgb0`/`bgr0`. The remaining branch below is the 4-bpp source, which needs no
|
||||
// conversion at all — just a row copy honouring the destination stride.
|
||||
if let Some(sws) = self.sws_csc {
|
||||
if let Some(sws) = self.sws_csc.as_ref().map(AvSwsContext::as_ptr) {
|
||||
let frame = self
|
||||
.frame
|
||||
.as_mut()
|
||||
@@ -927,27 +940,23 @@ impl NvencEncoder {
|
||||
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via
|
||||
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
|
||||
// (`make_current()?`), the precondition for the device-pointer copies below.
|
||||
// * `av_frame_alloc` → `f` (null-checked). `av_hwframe_get_buffer(frames_ref, f, 0)` fills `f`
|
||||
// with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`); on
|
||||
// failure we free `f` and bail.
|
||||
// * For NV12 we read `(*f).data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
|
||||
// `data[0]`/`linesize[0]` — in-struct fields of the non-null `f`, valid for the surface dims
|
||||
// ffmpeg allocated — and pass them to the cuda copy helpers, which device→device copy `buf`
|
||||
// (the imported `DeviceBuffer`, owned by the caller and live for this call) into the surface.
|
||||
// * On copy error we free `f` and return. Otherwise we write `pts`/`pict_type` through `f` and
|
||||
// `avcodec_send_frame` it into the live owned `self.enc` context (which takes its own ref of
|
||||
// the pooled surface), then free our `f` ref exactly once. Single-threaded encoder → no race.
|
||||
// * `f` is an owned `AvFrame` — every exit below (bail, copy error, success) drops it
|
||||
// exactly once, releasing its ref on the pooled surface. `av_hwframe_get_buffer` fills
|
||||
// it with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`).
|
||||
// * For NV12 we read `data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
|
||||
// `data[0]`/`linesize[0]` — in-struct fields of the live frame, valid for the surface
|
||||
// dims ffmpeg allocated — and pass them to the cuda copy helpers, which device→device
|
||||
// copy `buf` (the imported `DeviceBuffer`, owned by the caller and live for this call)
|
||||
// into the surface.
|
||||
// * `avcodec_send_frame` takes its own ref of the pooled surface, so the drop afterwards
|
||||
// is the sole owning free. Single-threaded encoder → no race.
|
||||
unsafe {
|
||||
let mut f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
bail!("av_frame_alloc failed");
|
||||
}
|
||||
let f = AvFrame::alloc().context("av_frame_alloc failed")?;
|
||||
// Pooled CUDA surface: sets format, width/height, data[0]/linesize[0], buf[0] and
|
||||
// hw_frames_ctx. Reused across frames (the pool recycles), keeping NVENC's
|
||||
// registration cache warm.
|
||||
let r = ffi::av_hwframe_get_buffer(frames_ref, f, 0);
|
||||
let r = ffi::av_hwframe_get_buffer(frames_ref, f.as_ptr(), 0);
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut f);
|
||||
bail!("av_hwframe_get_buffer(CUDA) failed ({r})");
|
||||
}
|
||||
// NV12 surfaces are two-plane (Y in data[0], interleaved UV in data[1]); YUV444
|
||||
@@ -958,41 +967,36 @@ impl NvencEncoder {
|
||||
let copy_res = if buf.yuv444 {
|
||||
let dsts = core::array::from_fn(|i| {
|
||||
(
|
||||
(*f).data[i] as pf_zerocopy::cuda::CUdeviceptr,
|
||||
(*f).linesize[i] as usize,
|
||||
(*f.as_ptr()).data[i] as pf_zerocopy::cuda::CUdeviceptr,
|
||||
(*f.as_ptr()).linesize[i] as usize,
|
||||
)
|
||||
});
|
||||
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true)
|
||||
} else if self.want_444 {
|
||||
ffi::av_frame_free(&mut f);
|
||||
bail!(
|
||||
"4:4:4 session but the zero-copy frame is not YUV444 (LINEAR/gamescope \
|
||||
capture has no GPU 4:4:4 convert) — unset PUNKTFUNK_ZEROCOPY to use the \
|
||||
CPU 4:4:4 path on this compositor"
|
||||
);
|
||||
} else if buf.is_nv12() {
|
||||
let y_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let y_pitch = (*f).linesize[0] as usize;
|
||||
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let uv_pitch = (*f).linesize[1] as usize;
|
||||
let y_ptr = (*f.as_ptr()).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let y_pitch = (*f.as_ptr()).linesize[0] as usize;
|
||||
let uv_ptr = (*f.as_ptr()).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let uv_pitch = (*f.as_ptr()).linesize[1] as usize;
|
||||
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch, true)
|
||||
} else {
|
||||
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let dst_pitch = (*f).linesize[0] as usize;
|
||||
let dst_ptr = (*f.as_ptr()).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let dst_pitch = (*f.as_ptr()).linesize[0] as usize;
|
||||
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true)
|
||||
};
|
||||
if let Err(e) = copy_res {
|
||||
ffi::av_frame_free(&mut f);
|
||||
return Err(e).context("copy imported buffer into NVENC surface");
|
||||
}
|
||||
(*f).pts = pts;
|
||||
(*f).pict_type = if idr {
|
||||
copy_res.context("copy imported buffer into NVENC surface")?;
|
||||
(*f.as_ptr()).pts = pts;
|
||||
(*f.as_ptr()).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), f);
|
||||
ffi::av_frame_free(&mut f);
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), f.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame(CUDA) failed ({r})");
|
||||
}
|
||||
@@ -1001,16 +1005,9 @@ impl NvencEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NvencEncoder {
|
||||
fn drop(&mut self) {
|
||||
if let Some(sws) = self.sws_csc.take() {
|
||||
// SAFETY: `sws` is the non-null `SwsContext` allocated by `sws_getContext` in `open` and
|
||||
// owned exclusively by this encoder (taken out of the field so it can't be freed twice).
|
||||
// `sws_freeContext` frees it; nothing else references it after this single-threaded drop.
|
||||
unsafe { ffi::sws_freeContext(sws) };
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `NvencEncoder`: `sws_csc` (`Option<AvSwsContext>`) frees itself, and as field #1
|
||||
// it does so ahead of `enc`/`frame`/`cuda` — the same sequence the hand-written `Drop` performed
|
||||
// (see the field-order note on the struct).
|
||||
|
||||
/// Serialises the save → `AV_LOG_FATAL` → restore window that every capability probe opens around
|
||||
/// an encoder open it *expects* to fail.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -34,8 +34,8 @@ use std::ptr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, PollOutcome,
|
||||
SWS_CS_ITU709, SWS_POINT,
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, AvFrame,
|
||||
AvSwsContext, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
@@ -544,8 +544,13 @@ impl VaapiHw {
|
||||
struct CpuInner {
|
||||
enc: encoder::video::Encoder,
|
||||
hw: VaapiHw,
|
||||
sws: *mut ffi::SwsContext,
|
||||
nv12: *mut ffi::AVFrame, // reusable software NV12 staging frame (swscale dst → upload src)
|
||||
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced freed `nv12` BEFORE
|
||||
// `sws` — the reverse of the old declaration order — and field-DECLARATION order is what
|
||||
// preserves that now (drop order follows declaration; an offset_of assert cannot pin it,
|
||||
// repr(Rust) may lay memory out in any order).
|
||||
/// Reusable software NV12/P010 staging frame (swscale dst → upload src).
|
||||
nv12: AvFrame,
|
||||
sws: AvSwsContext,
|
||||
src_format: PixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -600,10 +605,10 @@ impl CpuInner {
|
||||
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
|
||||
// `src_pixel`), the dst is NV12/P010. The three trailing pointers (srcFilter, dstFilter,
|
||||
// param) are explicitly null = "use defaults", which the API documents as accepted. No Rust
|
||||
// memory is borrowed — only by-value ints/enums — and the returned pointer is null-checked
|
||||
// just below.
|
||||
// memory is borrowed — only by-value ints/enums — and ownership of the returned context
|
||||
// passes to the `AvSwsContext` (null rejected by `from_raw`).
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
AvSwsContext::from_raw(ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
@@ -614,16 +619,15 @@ impl CpuInner {
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
))
|
||||
};
|
||||
if sws.is_null() {
|
||||
let Some(sws) = sws else {
|
||||
bail!(
|
||||
"sws_getContext(RGB→{})",
|
||||
if ten_bit { "P010" } else { "NV12" }
|
||||
);
|
||||
}
|
||||
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
|
||||
// check immediately preceding returned false). The coefficient table from
|
||||
};
|
||||
// SAFETY: `sws` is the live owned context from above. The coefficient table from
|
||||
// `sws_getCoefficients` (ITU-709, or BT.2020 NCL for the HDR path — matching the VUI) is a
|
||||
// libswscale static const valid for the whole process, reused here for both the inverse
|
||||
// (src) and forward (dst) matrices. `sws_setColorspaceDetails` only reads those tables and
|
||||
@@ -635,32 +639,22 @@ impl CpuInner {
|
||||
} else {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
|
||||
ffi::sws_setColorspaceDetails(sws.as_ptr(), cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
|
||||
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
|
||||
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
|
||||
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
|
||||
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
|
||||
// NV12/P010 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a
|
||||
// unique fresh pointer, so none of these writes alias anything.
|
||||
let nv12 = unsafe {
|
||||
let f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
ffi::sws_freeContext(sws);
|
||||
bail!("av_frame_alloc(staging) failed");
|
||||
}
|
||||
(*f).format = staging_av as c_int;
|
||||
(*f).width = width as c_int;
|
||||
(*f).height = height as c_int;
|
||||
if ffi::av_frame_get_buffer(f, 0) < 0 {
|
||||
let mut f = f;
|
||||
ffi::av_frame_free(&mut f);
|
||||
ffi::sws_freeContext(sws);
|
||||
let nv12 = AvFrame::alloc().context("av_frame_alloc(staging) failed")?;
|
||||
// SAFETY: writing the plain `format`/`width`/`height` fields through the owned frame's
|
||||
// pointer stays inside its allocation (sole owner, not yet shared).
|
||||
// `av_frame_get_buffer` allocates backing storage for those dims/format; on failure the
|
||||
// owned `nv12` (and the `sws` above it) simply drop — the hand-written unwind this
|
||||
// replaced had to free both by hand on every branch.
|
||||
unsafe {
|
||||
(*nv12.as_ptr()).format = staging_av as c_int;
|
||||
(*nv12.as_ptr()).width = width as c_int;
|
||||
(*nv12.as_ptr()).height = height as c_int;
|
||||
if ffi::av_frame_get_buffer(nv12.as_ptr(), 0) < 0 {
|
||||
bail!("av_frame_get_buffer(staging) failed");
|
||||
}
|
||||
f
|
||||
};
|
||||
}
|
||||
tracing::info!(
|
||||
encoder = codec.vaapi_name(),
|
||||
"VAAPI encode active ({width}x{height}@{fps}, CPU→{} upload path)",
|
||||
@@ -669,8 +663,8 @@ impl CpuInner {
|
||||
Ok(CpuInner {
|
||||
enc,
|
||||
hw,
|
||||
sws,
|
||||
nv12,
|
||||
sws,
|
||||
src_format: format,
|
||||
width,
|
||||
height,
|
||||
@@ -691,49 +685,43 @@ impl CpuInner {
|
||||
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from
|
||||
// `src_data[0] = bytes.as_ptr()` (the other planes null/0 — packed RGB is single-plane), all
|
||||
// in bounds; `bytes`, `src_data`, `src_stride` are live locals for this synchronous call.
|
||||
// `self.sws` is the non-null context built in `open`; it writes into `self.nv12` (a non-null
|
||||
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
|
||||
// `av_frame_alloc` (null-checked) yields a fresh `hwf`; `av_hwframe_get_buffer` pulls a pooled
|
||||
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads
|
||||
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write
|
||||
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live
|
||||
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once.
|
||||
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race.
|
||||
// `self.sws` is the owned context built in `open`; it writes into `self.nv12` (an owned
|
||||
// frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
|
||||
// `hwf` is an owned `AvFrame` — every exit below drops it exactly once, releasing its ref
|
||||
// on the pooled VAAPI surface. `av_hwframe_get_buffer` pulls that surface from the live
|
||||
// non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads the staged NV12 into
|
||||
// it. `avcodec_send_frame` takes its own ref, so the drop afterwards is the sole owning
|
||||
// free. The encoder runs only on this thread (see `unsafe impl Send`), so no
|
||||
// aliasing/data race.
|
||||
unsafe {
|
||||
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
|
||||
if ffi::sws_scale(
|
||||
self.sws,
|
||||
self.sws.as_ptr(),
|
||||
src_data.as_ptr(),
|
||||
src_stride.as_ptr(),
|
||||
0,
|
||||
h as c_int,
|
||||
(*self.nv12).data.as_ptr(),
|
||||
(*self.nv12).linesize.as_ptr(),
|
||||
(*self.nv12.as_ptr()).data.as_ptr(),
|
||||
(*self.nv12.as_ptr()).linesize.as_ptr(),
|
||||
) < 0
|
||||
{
|
||||
bail!("sws_scale RGB→NV12 failed");
|
||||
}
|
||||
let mut hwf = ffi::av_frame_alloc();
|
||||
if hwf.is_null() {
|
||||
bail!("av_frame_alloc(hw) failed");
|
||||
}
|
||||
if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf, 0) < 0 {
|
||||
ffi::av_frame_free(&mut hwf);
|
||||
let hwf = AvFrame::alloc().context("av_frame_alloc(hw) failed")?;
|
||||
if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf.as_ptr(), 0) < 0 {
|
||||
bail!("av_hwframe_get_buffer(VAAPI) failed");
|
||||
}
|
||||
if ffi::av_hwframe_transfer_data(hwf, self.nv12, 0) < 0 {
|
||||
ffi::av_frame_free(&mut hwf);
|
||||
if ffi::av_hwframe_transfer_data(hwf.as_ptr(), self.nv12.as_ptr(), 0) < 0 {
|
||||
bail!("av_hwframe_transfer_data(→VAAPI) failed");
|
||||
}
|
||||
(*hwf).pts = pts;
|
||||
(*hwf).pict_type = if idr {
|
||||
(*hwf.as_ptr()).pts = pts;
|
||||
(*hwf.as_ptr()).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf);
|
||||
ffi::av_frame_free(&mut hwf);
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame(VAAPI) failed ({r})");
|
||||
}
|
||||
@@ -742,24 +730,10 @@ impl CpuInner {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CpuInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each
|
||||
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double
|
||||
// free). `CpuInner` owns both exclusively and `Drop` runs once. `av_frame_free` takes `&mut`
|
||||
// and nulls the pointer. `self.enc`/`self.hw` are freed afterward by their own `Drop` impls;
|
||||
// the encoder holds its own `av_buffer_ref`'d device/frames copies, so field-drop order is
|
||||
// irrelevant to soundness.
|
||||
unsafe {
|
||||
if !self.nv12.is_null() {
|
||||
ffi::av_frame_free(&mut self.nv12);
|
||||
}
|
||||
if !self.sws.is_null() {
|
||||
ffi::sws_freeContext(self.sws);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `CpuInner`: `nv12` (`AvFrame`) and `sws` (`AvSwsContext`) free themselves, in
|
||||
// field-declaration order — the same nv12-then-sws sequence the hand-written `Drop` performed
|
||||
// (see the field-order note on the struct). The encoder holds its own `av_buffer_ref`'d
|
||||
// device/frames copies, so their order against `enc`/`hw` is irrelevant to soundness.
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Zero-copy dmabuf path: DRM-PRIME → hwmap(vaapi) → scale_vaapi(nv12) filter graph → encode.
|
||||
@@ -1041,16 +1015,20 @@ impl DmabufInner {
|
||||
// whole synchronous `submit`; we describe one object/layer/plane from its
|
||||
// fourcc/modifier/offset/stride and its `lseek`-queried size. `libc::lseek` on that live
|
||||
// fd only reads the description's size and returns it (or -1); it touches no Rust memory.
|
||||
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
|
||||
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
|
||||
// * `drm`/`nv12` are owned `AvFrame`s — every exit drops each exactly once (the
|
||||
// hand-placed frees this replaced were branch-clean, but only by inspection). We set
|
||||
// `drm`'s scalar fields and `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref
|
||||
// of the live owned ctx).
|
||||
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
|
||||
// av_buffer_create(.., free_desc, ..)` registers a destructor that reclaims it exactly once
|
||||
// when the buffer's refcount hits zero — matched alloc/free, no leak/double-free.
|
||||
// * `av_buffersrc_add_frame_flags(self.src, drm, KEEP_REF)` pushes a ref into the live
|
||||
// buffersrc; KEEP_REF keeps our own `drm` ref, which we then `av_frame_free`. We pull the
|
||||
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the
|
||||
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned
|
||||
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race.
|
||||
// buffersrc; KEEP_REF keeps our own `drm` ref, dropped explicitly right after the push
|
||||
// (the same point the hand-written free sat, kept so the descriptor's release timing
|
||||
// across the pull does not change). We pull the converted surface with
|
||||
// `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the dmabuf (owned by
|
||||
// the caller) is read while still valid. `nv12` is sent into the live owned `self.enc`
|
||||
// (takes its own ref) and dropped. Single-threaded encoder → no race.
|
||||
unsafe {
|
||||
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
|
||||
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
|
||||
@@ -1075,21 +1053,18 @@ impl DmabufInner {
|
||||
desc.layers[0].planes[0].offset = dmabuf.offset as isize;
|
||||
desc.layers[0].planes[0].pitch = dmabuf.stride as isize;
|
||||
|
||||
let mut drm = ffi::av_frame_alloc();
|
||||
if drm.is_null() {
|
||||
bail!("av_frame_alloc(drm) failed");
|
||||
}
|
||||
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
|
||||
(*drm).width = self.width as c_int;
|
||||
(*drm).height = self.height as c_int;
|
||||
let drm = AvFrame::alloc().context("av_frame_alloc(drm) failed")?;
|
||||
(*drm.as_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
|
||||
(*drm.as_ptr()).width = self.width as c_int;
|
||||
(*drm.as_ptr()).height = self.height as c_int;
|
||||
// The dmabuf is the compositor's rendered desktop: full-range RGB. Tag the frame so
|
||||
// the VPP's colour negotiation sees the real input instead of "unspecified" (an
|
||||
// untagged input lets the driver pick its own default for the RGB→NV12 conversion —
|
||||
// Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
|
||||
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
|
||||
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
|
||||
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr());
|
||||
(*drm).data[0] = Box::into_raw(desc) as *mut u8;
|
||||
(*drm.as_ptr()).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
|
||||
(*drm.as_ptr()).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
|
||||
(*drm.as_ptr()).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr());
|
||||
(*drm.as_ptr()).data[0] = Box::into_raw(desc) as *mut u8;
|
||||
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
|
||||
// which outlives this call — the graph reads the surface before submit returns).
|
||||
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
|
||||
@@ -1100,8 +1075,8 @@ impl DmabufInner {
|
||||
// reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
|
||||
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
|
||||
}
|
||||
(*drm).buf[0] = ffi::av_buffer_create(
|
||||
(*drm).data[0],
|
||||
(*drm.as_ptr()).buf[0] = ffi::av_buffer_create(
|
||||
(*drm.as_ptr()).data[0],
|
||||
std::mem::size_of::<ffi::AVDRMFrameDescriptor>(),
|
||||
Some(free_desc),
|
||||
ptr::null_mut(),
|
||||
@@ -1111,45 +1086,40 @@ impl DmabufInner {
|
||||
// Push through hwmap → scale_vaapi; pull the NV12 surface back out.
|
||||
let r = ffi::av_buffersrc_add_frame_flags(
|
||||
self.src,
|
||||
drm,
|
||||
drm.as_ptr(),
|
||||
ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int,
|
||||
);
|
||||
ffi::av_frame_free(&mut drm);
|
||||
// These two stages ARE the import: the push hands libav our DRM-PRIME descriptor, and
|
||||
// the pull is where `hwmap` actually maps it into a VA surface (and `scale_vaapi` runs
|
||||
// the CSC). A failure here means this driver would not take this compositor's dmabuf —
|
||||
// which no encoder rebuild can fix — so tell the process-wide latch, and capture
|
||||
// negotiates CPU frames from the next session on. `avcodec_send_frame` below is
|
||||
// deliberately NOT counted: that one is the encoder stalling, which the in-place
|
||||
// rebuild above us exists to recover, and disabling zero-copy over it would be a
|
||||
// permanent penalty for a transient fault.
|
||||
drop(drm); // release our ref where the hand-written free sat (see the SAFETY note)
|
||||
// These two stages ARE the import: the push hands libav our DRM-PRIME descriptor, and
|
||||
// the pull is where `hwmap` actually maps it into a VA surface (and `scale_vaapi` runs
|
||||
// the CSC). A failure here means this driver would not take this compositor's dmabuf —
|
||||
// which no encoder rebuild can fix — so tell the process-wide latch, and capture
|
||||
// negotiates CPU frames from the next session on. `avcodec_send_frame` below is
|
||||
// deliberately NOT counted: that one is the encoder stalling, which the in-place
|
||||
// rebuild above us exists to recover, and disabling zero-copy over it would be a
|
||||
// permanent penalty for a transient fault.
|
||||
if r < 0 {
|
||||
let e = format!("av_buffersrc_add_frame failed ({r})");
|
||||
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
|
||||
bail!("{e}");
|
||||
}
|
||||
t_push = t0.elapsed();
|
||||
let mut nv12 = ffi::av_frame_alloc();
|
||||
if nv12.is_null() {
|
||||
bail!("av_frame_alloc(nv12) failed");
|
||||
}
|
||||
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
|
||||
let nv12 = AvFrame::alloc().context("av_frame_alloc(nv12) failed")?;
|
||||
let r = ffi::av_buffersink_get_frame(self.sink, nv12.as_ptr());
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut nv12);
|
||||
let e = format!("av_buffersink_get_frame failed ({r})");
|
||||
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
|
||||
bail!("{e}");
|
||||
}
|
||||
pf_zerocopy::note_raw_dmabuf_import_ok();
|
||||
t_pull = t0.elapsed() - t_push;
|
||||
(*nv12).pts = pts;
|
||||
(*nv12).pict_type = if idr {
|
||||
(*nv12.as_ptr()).pts = pts;
|
||||
(*nv12.as_ptr()).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12);
|
||||
ffi::av_frame_free(&mut nv12);
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame(VAAPI) failed ({r})");
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ use windows::Win32::Graphics::Dxgi::Common::{
|
||||
};
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_BT2020,
|
||||
SWS_CS_ITU709, SWS_POINT,
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
|
||||
SWS_CS_BT2020, SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
@@ -497,10 +497,14 @@ fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
|
||||
|
||||
struct SystemInner {
|
||||
enc: encoder::video::Encoder,
|
||||
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced freed `sw_frame`
|
||||
// before `sws`, and field-DECLARATION order is what preserves that now (an offset_of assert
|
||||
// cannot pin this — repr(Rust) may reorder memory independently of declaration order, and
|
||||
// drop order follows declaration).
|
||||
/// Reusable software NV12/P010 frame: swscale dst / readback dst, and the `send_frame` src.
|
||||
sw_frame: *mut ffi::AVFrame,
|
||||
/// swscale ctx for the BGRA→NV12 fallback (built lazily; null for the YUV-readback path).
|
||||
sws: *mut ffi::SwsContext,
|
||||
sw_frame: AvFrame,
|
||||
/// swscale ctx for the BGRA→NV12 fallback (built lazily; `None` for the YUV-readback path).
|
||||
sws: Option<AvSwsContext>,
|
||||
/// CPU-readable staging texture for the D3D11 readback (built lazily on the captured device).
|
||||
staging: Option<ID3D11Texture2D>,
|
||||
ctx: Option<ID3D11DeviceContext>,
|
||||
@@ -547,26 +551,18 @@ impl SystemInner {
|
||||
ptr::null_mut(),
|
||||
)?
|
||||
};
|
||||
// SAFETY: `av_frame_alloc` returns a freshly-allocated, uniquely-owned `AVFrame` (null-checked
|
||||
// before any deref); writing `format`/`width`/`height` through `*f` stays inside that
|
||||
// allocation. `av_frame_get_buffer(f, 0)` allocates the backing planes — on failure we
|
||||
// `av_frame_free` the sole owner (no double-free) and bail; on success the raw `f` is moved into
|
||||
// `self.sw_frame` and freed exactly once in `Drop`.
|
||||
let sw_frame = unsafe {
|
||||
let f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
bail!("av_frame_alloc(sw) failed");
|
||||
}
|
||||
(*f).format = sw_av as c_int;
|
||||
(*f).width = width as c_int;
|
||||
(*f).height = height as c_int;
|
||||
if ffi::av_frame_get_buffer(f, 0) < 0 {
|
||||
let mut f = f;
|
||||
ffi::av_frame_free(&mut f);
|
||||
let sw_frame = AvFrame::alloc().context("av_frame_alloc(sw) failed")?;
|
||||
// SAFETY: writing `format`/`width`/`height` through the owned frame's pointer stays inside
|
||||
// its allocation. `av_frame_get_buffer` allocates the backing planes — on failure the
|
||||
// owned `sw_frame` simply drops (freed once, by the wrapper).
|
||||
unsafe {
|
||||
(*sw_frame.as_ptr()).format = sw_av as c_int;
|
||||
(*sw_frame.as_ptr()).width = width as c_int;
|
||||
(*sw_frame.as_ptr()).height = height as c_int;
|
||||
if ffi::av_frame_get_buffer(sw_frame.as_ptr(), 0) < 0 {
|
||||
bail!("av_frame_get_buffer(sw) failed");
|
||||
}
|
||||
f
|
||||
};
|
||||
}
|
||||
tracing::info!(
|
||||
encoder = vendor.encoder_name(codec),
|
||||
"{} encode active ({width}x{height}@{fps}, system-memory {} path)",
|
||||
@@ -576,7 +572,7 @@ impl SystemInner {
|
||||
Ok(SystemInner {
|
||||
enc,
|
||||
sw_frame,
|
||||
sws: ptr::null_mut(),
|
||||
sws: None,
|
||||
staging: None,
|
||||
ctx: None,
|
||||
format,
|
||||
@@ -632,13 +628,13 @@ impl SystemInner {
|
||||
// frame and `self.enc`'s own context, both live for the call and neither retained by libav
|
||||
// (it references the frame's buffers itself).
|
||||
unsafe {
|
||||
(*self.sw_frame).pts = pts;
|
||||
(*self.sw_frame).pict_type = if idr {
|
||||
(*self.sw_frame.as_ptr()).pts = pts;
|
||||
(*self.sw_frame.as_ptr()).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
|
||||
}
|
||||
@@ -703,10 +699,10 @@ impl SystemInner {
|
||||
let total = pitch.saturating_mul(h + h.div_ceil(2));
|
||||
let mapped = std::slice::from_raw_parts(base, total);
|
||||
let chroma_off = pitch * h;
|
||||
let y_dst = (*self.sw_frame).data[0];
|
||||
let y_stride = (*self.sw_frame).linesize[0] as usize;
|
||||
let uv_dst = (*self.sw_frame).data[1];
|
||||
let uv_stride = (*self.sw_frame).linesize[1] as usize;
|
||||
let y_dst = (*self.sw_frame.as_ptr()).data[0];
|
||||
let y_stride = (*self.sw_frame.as_ptr()).linesize[0] as usize;
|
||||
let uv_dst = (*self.sw_frame.as_ptr()).data[1];
|
||||
let uv_stride = (*self.sw_frame.as_ptr()).linesize[1] as usize;
|
||||
for y in 0..h {
|
||||
let s = &mapped[y * pitch..y * pitch + row_bytes];
|
||||
ptr::copy_nonoverlapping(s.as_ptr(), y_dst.add(y * y_stride), row_bytes);
|
||||
@@ -746,7 +742,7 @@ impl SystemInner {
|
||||
let pitch = map.RowPitch as usize;
|
||||
let h = self.height as usize;
|
||||
let base = map.pData as *const u8;
|
||||
self.ensure_sws(
|
||||
let sws = self.ensure_sws(
|
||||
pixel_to_av(Pixel::BGRA),
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
SWS_CS_ITU709,
|
||||
@@ -754,13 +750,13 @@ impl SystemInner {
|
||||
let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
|
||||
let r = ffi::sws_scale(
|
||||
self.sws,
|
||||
sws,
|
||||
src_data.as_ptr(),
|
||||
src_stride.as_ptr(),
|
||||
0,
|
||||
h as c_int,
|
||||
(*self.sw_frame).data.as_ptr(),
|
||||
(*self.sw_frame).linesize.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).data.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
|
||||
);
|
||||
ctx.Unmap(&staging, 0);
|
||||
if r < 0 {
|
||||
@@ -796,7 +792,7 @@ impl SystemInner {
|
||||
let h = self.height as usize;
|
||||
let base = map.pData as *const u8;
|
||||
// RGB(BT.2020 PQ) → YUV(BT.2020 PQ): a matrix-only repack (same PQ transfer), full→limited.
|
||||
self.ensure_sws(
|
||||
let sws = self.ensure_sws(
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_X2BGR10LE,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE,
|
||||
SWS_CS_BT2020,
|
||||
@@ -804,13 +800,13 @@ impl SystemInner {
|
||||
let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
|
||||
let r = ffi::sws_scale(
|
||||
self.sws,
|
||||
sws,
|
||||
src_data.as_ptr(),
|
||||
src_stride.as_ptr(),
|
||||
0,
|
||||
h as c_int,
|
||||
(*self.sw_frame).data.as_ptr(),
|
||||
(*self.sw_frame).linesize.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).data.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
|
||||
);
|
||||
ctx.Unmap(&staging, 0);
|
||||
if r < 0 {
|
||||
@@ -842,7 +838,7 @@ impl SystemInner {
|
||||
// `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned
|
||||
// `sw_frame`. `send` then hands `sw_frame` to the encoder.
|
||||
unsafe {
|
||||
self.ensure_sws(
|
||||
let sws = self.ensure_sws(
|
||||
pixel_to_av(sws_src(format)?),
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
SWS_CS_ITU709,
|
||||
@@ -850,13 +846,13 @@ impl SystemInner {
|
||||
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
|
||||
if ffi::sws_scale(
|
||||
self.sws,
|
||||
sws,
|
||||
src_data.as_ptr(),
|
||||
src_stride.as_ptr(),
|
||||
0,
|
||||
h as c_int,
|
||||
(*self.sw_frame).data.as_ptr(),
|
||||
(*self.sw_frame).linesize.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).data.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
|
||||
) < 0
|
||||
{
|
||||
bail!("sws_scale RGB→NV12 failed");
|
||||
@@ -870,23 +866,24 @@ impl SystemInner {
|
||||
/// 10-bit RGB10→P010 BT.2020), so caching a single context is sound.
|
||||
///
|
||||
/// Safe: every argument is a plain libav enum/int, and the context it caches belongs to `self`
|
||||
/// (freed once in `Drop`).
|
||||
/// (an owned `AvSwsContext`, freed by its own drop). Returns the borrowed pointer for the
|
||||
/// caller's `sws_scale` — borrowed only, `self.sws` stays the owner.
|
||||
fn ensure_sws(
|
||||
&mut self,
|
||||
src_av: ffi::AVPixelFormat,
|
||||
dst_av: ffi::AVPixelFormat,
|
||||
cs: c_int,
|
||||
) -> Result<()> {
|
||||
if !self.sws.is_null() {
|
||||
return Ok(());
|
||||
) -> Result<*mut ffi::SwsContext> {
|
||||
if let Some(sws) = &self.sws {
|
||||
return Ok(sws.as_ptr());
|
||||
}
|
||||
// SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params"
|
||||
// null trio, and returns an owned context or null — which is checked before use, so
|
||||
// `sws_setColorspaceDetails` and the store below only ever see a live one.
|
||||
// `sws_getCoefficients` returns a pointer into libav's own static tables, valid for the
|
||||
// process, and the call only reads it.
|
||||
// null trio, and returns an owned context or null — `from_raw` rejects the null, so
|
||||
// `sws_setColorspaceDetails` only ever sees a live one, and ownership passes to the
|
||||
// `AvSwsContext`. `sws_getCoefficients` returns a pointer into libav's own static tables,
|
||||
// valid for the process, and the call only reads it.
|
||||
let sws = unsafe {
|
||||
let sws = ffi::sws_getContext(
|
||||
let raw = ffi::sws_getContext(
|
||||
self.width as c_int,
|
||||
self.height as c_int,
|
||||
src_av,
|
||||
@@ -898,36 +895,22 @@ impl SystemInner {
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
);
|
||||
if sws.is_null() {
|
||||
let Some(owned) = AvSwsContext::from_raw(raw) else {
|
||||
bail!("sws_getContext(RGB→YUV) failed");
|
||||
}
|
||||
};
|
||||
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI
|
||||
// we signal). For RGB input the src coefficient table is unused; pass dst for both.
|
||||
let coeff = ffi::sws_getCoefficients(cs);
|
||||
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
|
||||
sws
|
||||
ffi::sws_setColorspaceDetails(owned.as_ptr(), coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
|
||||
owned
|
||||
};
|
||||
self.sws = sws;
|
||||
Ok(())
|
||||
Ok(self.sws.insert(sws).as_ptr())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SystemInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `sw_frame` is the `AVFrame` allocated in `open` (or null) — `av_frame_free` drops it
|
||||
// once and nulls the pointer through the `&mut`; `sws` is the cached `SwsContext` (or null) —
|
||||
// `sws_freeContext` frees it once. This `Drop` runs exactly once and `SystemInner` owns both
|
||||
// exclusively, so there is no double-free or use-after-free.
|
||||
unsafe {
|
||||
if !self.sw_frame.is_null() {
|
||||
ffi::av_frame_free(&mut self.sw_frame);
|
||||
}
|
||||
if !self.sws.is_null() {
|
||||
ffi::sws_freeContext(self.sws);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `SystemInner`: `sw_frame` (`AvFrame`) and `sws` (`Option<AvSwsContext>`) free
|
||||
// themselves, in field-declaration order — the same sw_frame-then-sws sequence the hand-written
|
||||
// `Drop` performed, pinned by the offset_of assert at the struct.
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Zero-copy D3D11 path (the AMF default; QSV opt-in — see `zerocopy_enabled`): share the capture
|
||||
@@ -1212,32 +1195,29 @@ impl ZeroCopyInner {
|
||||
}
|
||||
|
||||
fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
|
||||
// SAFETY: `d3d = av_frame_alloc()` is a fresh owned frame (null-checked) and is `av_frame_free`d
|
||||
// exactly once on every path below. `av_hwframe_get_buffer` fills it from the pool — on failure
|
||||
// we free it and bail. `(*d3d).data[0]` is the pool's texture-array and `data[1]` the array
|
||||
// index; `from_raw_borrowed` borrows that `ID3D11Texture2D` WITHOUT taking ownership (no Release
|
||||
// — the frame owns it) and is null-checked. `src` (the captured texture) and `dst` (the pooled
|
||||
// slice) live on the SAME D3D11 device wrapped by `self.hw`, and the caller guarantees
|
||||
// `captured.format == pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, ..,
|
||||
// src, 0, ..)` on the single-threaded immediate context `self.ctx` is a valid same-format GPU
|
||||
// copy. For QSV the mapped `qsv` frame is a fresh owned frame whose `hw_frames_ctx` takes an
|
||||
// `av_buffer_ref` of `self.qsv_frames`; it is `av_frame_free`d (releasing that ref) on both the
|
||||
// map-failure and success paths. `avcodec_send_frame` only internally refs the input frame, so
|
||||
// the `av_frame_free(d3d)`/`av_frame_free(qsv)` afterwards are the sole owning frees — no leak,
|
||||
// no double-free, no use-after-free.
|
||||
// SAFETY: `d3d`/`qsv` are owned `AvFrame`s, so EVERY exit — including the three `?` exits
|
||||
// between the pool pull and the send, which as hand-placed frees previously leaked the
|
||||
// frame plus one of the POOL-sized hwframe surfaces per failure (eight failures wedged
|
||||
// the encoder permanently) — unrefs the pooled surface back to the pool. `(*d3d).data[0]`
|
||||
// is the pool's texture-array and `data[1]` the array index; `from_raw_borrowed` borrows
|
||||
// that `ID3D11Texture2D` WITHOUT taking ownership (no Release — the frame owns it) and is
|
||||
// null-checked. `src` (the captured texture) and `dst` (the pooled slice) live on the
|
||||
// SAME D3D11 device wrapped by `self.hw`, and the caller guarantees `captured.format ==
|
||||
// pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, .., src, 0, ..)`
|
||||
// on the single-threaded immediate context `self.ctx` is a valid same-format GPU copy.
|
||||
// For QSV the mapped `qsv` frame's `hw_frames_ctx` takes an `av_buffer_ref` of
|
||||
// `self.qsv_frames`; its drop at the end of the arm releases that ref at the same point
|
||||
// the hand-written free did. `avcodec_send_frame` only internally refs the input frame,
|
||||
// so the drops are the sole owning frees — no leak, no double-free, no use-after-free.
|
||||
unsafe {
|
||||
// Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice.
|
||||
let mut d3d = ffi::av_frame_alloc();
|
||||
if d3d.is_null() {
|
||||
bail!("av_frame_alloc(d3d11) failed");
|
||||
}
|
||||
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d, 0);
|
||||
let d3d = AvFrame::alloc().context("av_frame_alloc(d3d11) failed")?;
|
||||
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d.as_ptr(), 0);
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("av_hwframe_get_buffer(D3D11) failed ({r})");
|
||||
}
|
||||
let dst_ptr = (*d3d).data[0] as *mut c_void;
|
||||
let dst_index = (*d3d).data[1] as usize as u32;
|
||||
let dst_ptr = (*d3d.as_ptr()).data[0] as *mut c_void;
|
||||
let dst_index = (*d3d.as_ptr()).data[1] as usize as u32;
|
||||
let dst_tex = ID3D11Texture2D::from_raw_borrowed(&dst_ptr)
|
||||
.ok_or_else(|| anyhow!("pooled D3D11 frame has null texture"))?;
|
||||
// GPU-local copy of the captured slice into the pooled array slice (like NVENC's CUDA
|
||||
@@ -1247,58 +1227,50 @@ impl ZeroCopyInner {
|
||||
self.ctx
|
||||
.CopySubresourceRegion(&dst, dst_index, 0, 0, 0, &src, 0, None);
|
||||
|
||||
(*d3d).pts = pts;
|
||||
(*d3d).pict_type = if idr {
|
||||
(*d3d.as_ptr()).pts = pts;
|
||||
(*d3d.as_ptr()).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
|
||||
let send = match self.vendor {
|
||||
WinVendor::Amf => ffi::avcodec_send_frame(self.enc.as_mut_ptr(), d3d),
|
||||
WinVendor::Amf => ffi::avcodec_send_frame(self.enc.as_mut_ptr(), d3d.as_ptr()),
|
||||
WinVendor::Qsv => {
|
||||
// Map the D3D11 frame to a QSV surface (1:1, no copy), then send the mapped frame.
|
||||
let mut qsv = ffi::av_frame_alloc();
|
||||
if qsv.is_null() {
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("av_frame_alloc(qsv) failed");
|
||||
}
|
||||
let qsv = AvFrame::alloc().context("av_frame_alloc(qsv) failed")?;
|
||||
// Always `Some` on this arm — `open` fills the pair for `WinVendor::Qsv` and
|
||||
// leaves it `None` only for AMF — but say so with a bail rather than an unwrap,
|
||||
// matching the null check above it. The `Option` is what the raw pointer's
|
||||
// "null means AMF" convention was already encoding.
|
||||
let Some(qsv_frames) = self.qsv_frames.as_ref() else {
|
||||
ffi::av_frame_free(&mut qsv);
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("QSV send path without a derived QSV frames context");
|
||||
};
|
||||
(*qsv).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
|
||||
(*qsv).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
|
||||
(*qsv.as_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
|
||||
(*qsv.as_ptr()).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
|
||||
// The map flags are a bindgen enum (no BitOr) — cast each to int before OR-ing.
|
||||
let r = ffi::av_hwframe_map(
|
||||
qsv,
|
||||
d3d,
|
||||
qsv.as_ptr(),
|
||||
d3d.as_ptr(),
|
||||
ffi::AV_HWFRAME_MAP_DIRECT as c_int | ffi::AV_HWFRAME_MAP_READ as c_int,
|
||||
);
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut qsv);
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("av_hwframe_map(D3D11→QSV) failed ({r})");
|
||||
}
|
||||
(*qsv).pts = pts;
|
||||
(*qsv).pict_type = (*d3d).pict_type;
|
||||
let s = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv);
|
||||
ffi::av_frame_free(&mut qsv);
|
||||
s
|
||||
(*qsv.as_ptr()).pts = pts;
|
||||
(*qsv.as_ptr()).pict_type = (*d3d.as_ptr()).pict_type;
|
||||
ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv.as_ptr())
|
||||
// `qsv` drops here — releasing the mapped frame and its frames-ctx ref at the
|
||||
// same point the hand-written `av_frame_free(&mut qsv)` did.
|
||||
}
|
||||
};
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
if send < 0 {
|
||||
bail!(
|
||||
"avcodec_send_frame({}) failed ({send})",
|
||||
self.vendor.label()
|
||||
);
|
||||
}
|
||||
// `d3d` drops here (and on every early exit above), returning the pooled surface.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
// 150–800 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;
|
||||
|
||||
@@ -8,24 +8,27 @@
|
||||
//!
|
||||
//! Raw C-ABI FFI (winmm/kernel32/dwmapi/avrt) rather than the `windows` crate so it builds without
|
||||
//! pulling new windows-rs features. No-op on non-Windows. Per-thread effects (MMCSS, execution
|
||||
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
|
||||
//! state) auto-revert at thread exit (= session end); the process-wide bits are refcounted over
|
||||
//! the hot threads and revert when the LAST one exits — the host must not keep HIGH priority and
|
||||
//! a 1 ms global timer while a local game runs and nobody streams (2026-08-12 field report).
|
||||
//! See `design/host-latency-plan.md` Tier 3A.
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
#![allow(non_snake_case)]
|
||||
use std::ffi::c_void;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::Mutex;
|
||||
|
||||
type Handle = *mut c_void;
|
||||
type Bool = i32;
|
||||
|
||||
#[link(name = "winmm")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn timeBeginPeriod(uPeriod: u32) -> u32;
|
||||
fn timeEndPeriod(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,15 +49,16 @@ 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;
|
||||
}
|
||||
|
||||
const HIGH_PRIORITY_CLASS: u32 = 0x0000_0080;
|
||||
const NORMAL_PRIORITY_CLASS: u32 = 0x0000_0020;
|
||||
const ES_CONTINUOUS: u32 = 0x8000_0000;
|
||||
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
|
||||
const ES_DISPLAY_REQUIRED: u32 = 0x0000_0002;
|
||||
@@ -114,16 +118,19 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
static PROCESS_TUNED: OnceLock<()> = OnceLock::new();
|
||||
/// Live hot (session) threads. A Mutex, not an atomic: the 0↔1 transitions carry the
|
||||
/// apply/revert side effects, and an interleaved fetch_add/fetch_sub pair could otherwise
|
||||
/// finish with a running session untuned (transitions are rare — thread start/exit only).
|
||||
static HOT_THREADS: Mutex<usize> = Mutex::new(0);
|
||||
|
||||
/// Process-wide tuning, applied exactly once. Reverts at process exit. Best-effort: each call is
|
||||
/// independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
||||
fn tune_process_once() {
|
||||
/// Process-wide tuning, applied when the FIRST hot thread registers. Best-effort: each call
|
||||
/// is independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
||||
fn tune_process() {
|
||||
// SAFETY: each call is a C-ABI FFI into winmm/kernel32/dwmapi declared with a matching
|
||||
// `extern "system"` signature; every argument is a plain integer (no pointers/buffers escape),
|
||||
// and `GetCurrentProcess()` returns the current-process pseudo-handle (a constant, always valid,
|
||||
// never closed). The body runs inside `get_or_init`, so it executes exactly once per process.
|
||||
PROCESS_TUNED.get_or_init(|| unsafe {
|
||||
// never closed).
|
||||
unsafe {
|
||||
// 1 ms timer granularity (default ~15.6 ms) — the floor for precise frame pacing and the
|
||||
// encode|send split's sub-ms sleeps.
|
||||
timeBeginPeriod(1);
|
||||
@@ -134,16 +141,66 @@ mod imp {
|
||||
// control/capture/encode/send threads on the CPU (Apollo does the same).
|
||||
SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS);
|
||||
tracing::info!("windows session tuning applied (timer 1ms, DWM MMCSS, HIGH priority)");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Call at the start of each capture/encode/send (hot stream) thread. Applies the process-wide
|
||||
/// tuning once, registers the calling thread with MMCSS ("Games"), and asserts the display/system
|
||||
/// must stay awake for as long as this thread lives. The MMCSS handle is intentionally leaked and
|
||||
/// the execution-state assertion is bound to this thread — both are reverted by the OS when the
|
||||
/// thread exits, so a session that ends tears them down without explicit bookkeeping.
|
||||
/// The mirror of [`tune_process`], run when the LAST hot thread exits. Leaving the tuning in
|
||||
/// place used to be the design ("reverts at process exit") — but the host is a 24/7 service,
|
||||
/// so after one stream it competed at HIGH class with a 1 ms global timer against whatever
|
||||
/// the user played locally, forever.
|
||||
fn untune_process() {
|
||||
// SAFETY: same FFI surface as `tune_process` — plain-integer arguments, constant
|
||||
// pseudo-handle, no pointers or buffers.
|
||||
unsafe {
|
||||
timeEndPeriod(1); // pairs the timeBeginPeriod(1)
|
||||
DwmEnableMMCSS(0);
|
||||
SetPriorityClass(GetCurrentProcess(), NORMAL_PRIORITY_CLASS);
|
||||
tracing::info!("windows session tuning reverted (timer, DWM MMCSS, NORMAL priority)");
|
||||
}
|
||||
}
|
||||
|
||||
/// One per hot thread, parked in TLS by [`on_hot_thread`]; its Drop runs at thread exit
|
||||
/// (= session teardown), the same lifetime the MMCSS/execution-state effects already ride.
|
||||
struct HotThreadGuard;
|
||||
|
||||
impl Drop for HotThreadGuard {
|
||||
fn drop(&mut self) {
|
||||
// A poisoned lock skips the revert (best-effort, like every call here) instead of
|
||||
// panicking inside a TLS destructor.
|
||||
if let Ok(mut n) = HOT_THREADS.lock() {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
untune_process();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOT_THREAD: std::cell::OnceCell<HotThreadGuard> =
|
||||
const { std::cell::OnceCell::new() };
|
||||
}
|
||||
|
||||
/// Call at the start of each capture/encode/send (hot stream) thread. Registers the thread in
|
||||
/// the process-tuning refcount (first in applies, last out reverts), registers it with MMCSS
|
||||
/// ("Games"), and asserts the display/system must stay awake for as long as this thread lives.
|
||||
/// The MMCSS handle is intentionally leaked and the execution-state assertion is bound to this
|
||||
/// thread — both are reverted by the OS when the thread exits, and the refcount guard's TLS
|
||||
/// Drop runs there too, so a session that ends tears everything down without explicit
|
||||
/// bookkeeping.
|
||||
pub fn on_hot_thread() {
|
||||
tune_process_once();
|
||||
HOT_THREAD.with(|slot| {
|
||||
if slot.get().is_none() {
|
||||
{
|
||||
let mut n = HOT_THREADS.lock().unwrap();
|
||||
*n += 1;
|
||||
if *n == 1 {
|
||||
tune_process();
|
||||
}
|
||||
}
|
||||
let _ = slot.set(HotThreadGuard);
|
||||
}
|
||||
});
|
||||
// SAFETY: C-ABI FFI declared with matching `extern "system"` signatures. SetThreadExecutionState
|
||||
// takes only flag bits. `task` is a local NUL-terminated UTF-16 buffer ("Games\0") alive for the
|
||||
// whole block, so `task.as_ptr()` is a valid LPCWSTR for the call, and `&mut idx` is a live local
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +63,17 @@ pub(crate) fn stamp_window_icon(window: &sdl3::video::Window) {
|
||||
let module = GetModuleHandleW(std::ptr::null());
|
||||
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
|
||||
let px = GetSystemMetrics(metric);
|
||||
let icon = LoadImageW(module, 1 as *const u16, IMAGE_ICON, px, px, LR_DEFAULTCOLOR);
|
||||
// MAKEINTRESOURCE(1): an integer resource ordinal smuggled through the name
|
||||
// pointer, never dereferenced — `without_provenance` says exactly that (and
|
||||
// `1 as *const u16` reads as a dangling pointer to clippy 1.96).
|
||||
let icon = LoadImageW(
|
||||
module,
|
||||
std::ptr::without_provenance(1),
|
||||
IMAGE_ICON,
|
||||
px,
|
||||
px,
|
||||
LR_DEFAULTCOLOR,
|
||||
);
|
||||
if !icon.is_null() {
|
||||
SendMessageW(hwnd, WM_SETICON, which as WPARAM, icon as LPARAM);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -46,6 +46,7 @@ use pf_bitstream::h264::PlanError;
|
||||
use pf_bitstream::h264::PlanWarning;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::caps::derive_caps;
|
||||
use crate::caps::query_h264_caps;
|
||||
@@ -685,6 +686,9 @@ pub struct VkH264Decoder {
|
||||
/// Session generation: bumped on every rebuild, stamped into frames.
|
||||
generation: u64,
|
||||
device_lost: bool,
|
||||
/// The over-declared-level warning has fired (once per decoder — the condition
|
||||
/// is a property of the stream's SPS, so repeating it per AU is noise).
|
||||
level_clamp_warned: bool,
|
||||
}
|
||||
|
||||
impl VkH264Decoder {
|
||||
@@ -723,6 +727,7 @@ impl VkH264Decoder {
|
||||
decoded: 0,
|
||||
generation: 0,
|
||||
device_lost: false,
|
||||
level_clamp_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1365,18 +1370,26 @@ impl VkH264Decoder {
|
||||
unsafe { query_h264_caps(&self.dev, std_profile) }.map_err(VkDecodeError::from)?;
|
||||
self.caps = Some((std_profile, derive_caps(&raw)?));
|
||||
}
|
||||
// The level gate: a stream above the device's maxLevelIdc is refused up
|
||||
// front (within one codec the Std code points ascend with the level, so
|
||||
// the comparison is numeric), never submitted on a hope. The ceiling came
|
||||
// from an H.264 caps query, so it is compared against an H.264 code point
|
||||
// — the pairing MaxLevelIdc's tag exists to keep honest.
|
||||
// The declared level vs the device ceiling: a DECLARED level above
|
||||
// `maxLevelIdc` is NOT a refusal — encoders over-claim levels in the wild
|
||||
// (the H.265 twin carries the field evidence: AMF stamps the codec
|
||||
// maximum). The stream's REAL demands are enforced where they are
|
||||
// physical facts — coded extent and DPB depth, checked in
|
||||
// `rebuild_state` — and the session's parameter sets are clamped to the
|
||||
// ceiling (`SessionConfig::max_level_idc`) so the driver is never handed
|
||||
// a level above its caps. The comparison stays within one codec's Std
|
||||
// code space (`MaxLevelIdc`'s tag carries that argument).
|
||||
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||
let stream_level = level_to_std(plan.picture.level_idc);
|
||||
if stream_level > caps_max_level.code_point() {
|
||||
return Err(VkDecodeError::Unsupported(format!(
|
||||
"stream level (Std code point {stream_level}) above the device's \
|
||||
maxLevelIdc ({caps_max_level})"
|
||||
)));
|
||||
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
|
||||
self.level_clamp_warned = true;
|
||||
warn!(
|
||||
stream_level,
|
||||
ceiling = %caps_max_level,
|
||||
"stream declares an H.264 level above the device ceiling — the \
|
||||
declared level is advisory (over-declared by some encoders); \
|
||||
proceeding with the parameter sets clamped to the ceiling"
|
||||
);
|
||||
}
|
||||
let coded = vk::Extent2D {
|
||||
width: plan.picture.coded_width,
|
||||
@@ -1488,6 +1501,7 @@ impl VkH264Decoder {
|
||||
max_dpb_slots: required_slots,
|
||||
max_active_references: (required_slots - 1).min(caps.max_active_references),
|
||||
std_profile_idc: std_profile,
|
||||
max_level_idc: caps.max_level_idc.code_point(),
|
||||
};
|
||||
let mut pool_plan = plan_pools(caps, required_slots);
|
||||
// TEST-ONLY readback hook: the GPU parity test (tests/gpu_parity.rs)
|
||||
|
||||
@@ -57,6 +57,7 @@ use pf_bitstream::h265::PlanError;
|
||||
use pf_bitstream::h265::PlanWarning;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::caps::DecodeCaps;
|
||||
use crate::caps::DecodeProfile;
|
||||
@@ -219,6 +220,9 @@ pub struct VkH265Decoder {
|
||||
/// Recovery owed after a failed AU whose planning had already advanced
|
||||
/// ([`RecoveryLatch`] docs for the whole argument).
|
||||
recovery: RecoveryLatch,
|
||||
/// The over-declared-level warning has fired (once per decoder — the condition
|
||||
/// is a property of the stream's SPS, so repeating it per AU is noise).
|
||||
level_clamp_warned: bool,
|
||||
}
|
||||
|
||||
impl VkH265Decoder {
|
||||
@@ -266,6 +270,7 @@ impl VkH265Decoder {
|
||||
generation: 0,
|
||||
device_lost: false,
|
||||
recovery: RecoveryLatch::default(),
|
||||
level_clamp_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1004,18 +1009,28 @@ impl VkH265Decoder {
|
||||
let raw = unsafe { query_h265_caps(&self.dev, key) }.map_err(VkDecodeError::from)?;
|
||||
self.caps = Some((key, derive_caps_h265(&raw, wanted)?));
|
||||
}
|
||||
// The level gate: a stream above the device's maxLevelIdc is refused up
|
||||
// front (within one codec the Std code points ascend with the level, so
|
||||
// the comparison is numeric), never submitted on a hope. The ceiling came
|
||||
// from an H.265 caps query, so it is compared against an H.265 code point
|
||||
// — the pairing MaxLevelIdc's tag exists to keep honest.
|
||||
// The declared level vs the device ceiling: a DECLARED level above
|
||||
// `maxLevelIdc` is NOT a refusal. The level in an SPS is a claim, and
|
||||
// encoders over-claim in the wild — AMF stamps 6.2 (the codec maximum)
|
||||
// on 4K120 streams that need 5.2, which on an RTX 5060 (ceiling 6.1)
|
||||
// demoted every HEVC session to D3D11VA (2026-08-12 field report). The
|
||||
// stream's REAL demands are enforced where they are physical facts:
|
||||
// coded extent and DPB depth, checked in `rebuild_state`. The session's
|
||||
// parameter sets are clamped to the ceiling (`SessionConfigH265::
|
||||
// max_level_idc`) so the driver is never handed a level above its caps,
|
||||
// and the comparison stays within one codec's Std code space
|
||||
// (`MaxLevelIdc`'s tag carries that argument).
|
||||
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||
let stream_level = level_to_std_h265(plan.picture.level_idc);
|
||||
if stream_level > caps_max_level.code_point() {
|
||||
return Err(VkDecodeError::Unsupported(format!(
|
||||
"stream level (Std code point {stream_level}) above the device's \
|
||||
maxLevelIdc ({caps_max_level})"
|
||||
)));
|
||||
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
|
||||
self.level_clamp_warned = true;
|
||||
warn!(
|
||||
stream_level,
|
||||
ceiling = %caps_max_level,
|
||||
"stream declares an H.265 level above the device ceiling — the \
|
||||
declared level is advisory (over-declared by some encoders); \
|
||||
proceeding with the parameter sets clamped to the ceiling"
|
||||
);
|
||||
}
|
||||
let coded = vk::Extent2D {
|
||||
width: plan.picture.coded_width,
|
||||
@@ -1108,6 +1123,7 @@ impl VkH265Decoder {
|
||||
max_dpb_slots: required_slots,
|
||||
max_active_references: (required_slots - 1).min(caps.max_active_references),
|
||||
profile: key,
|
||||
max_level_idc: caps.max_level_idc.code_point(),
|
||||
};
|
||||
let mut pool_plan = plan_pools(caps, required_slots);
|
||||
// TEST-ONLY readback hook, exactly as the H.264 decoder's: the parity
|
||||
|
||||
@@ -115,6 +115,17 @@ impl OwnedStdSps {
|
||||
pub fn std(&self) -> &hh::StdVideoH264SequenceParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower `level_idc` to `max` when the stream declares a higher one. The
|
||||
/// declared level is a claim encoders over-state in the wild, and a set above
|
||||
/// the device's `maxLevelIdc` is invalid usage; the stream's real demands are
|
||||
/// enforced by the session's coded extent and DPB depth. The "no mutation"
|
||||
/// contract above is about a LIVE object's blocks — this runs before handover.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH264LevelIdc) {
|
||||
if self.std.level_idc > max {
|
||||
self.std.level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted PPS plus the scaling-list allocation its `pScalingLists` targets.
|
||||
@@ -831,4 +842,24 @@ mod tests {
|
||||
ParamsError::InvalidWeightedBipredIdc(3)
|
||||
);
|
||||
}
|
||||
|
||||
/// The over-declared-level clamp ([`OwnedStdSps::clamp_level`]): lowering
|
||||
/// writes the ceiling into the Std SPS; a ceiling at or above the declared
|
||||
/// level changes nothing.
|
||||
#[test]
|
||||
fn clamp_level_lowers_and_only_lowers() {
|
||||
let sps = full_sps();
|
||||
let declared = level_to_std(sps.level_idc);
|
||||
|
||||
let mut owned = sps_to_std(&sps).unwrap();
|
||||
assert_eq!(owned.std().level_idc, declared);
|
||||
// A ceiling above the declared level is a no-op.
|
||||
owned.clamp_level(hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2);
|
||||
assert_eq!(owned.std().level_idc, declared);
|
||||
// A ceiling below it is written through.
|
||||
let ceiling = hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_1;
|
||||
assert!(ceiling < declared, "fixture declares above 3.1");
|
||||
owned.clamp_level(ceiling);
|
||||
assert_eq!(owned.std().level_idc, ceiling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,19 @@ impl OwnedStdH265Vps {
|
||||
pub fn std(&self) -> &hh::StdVideoH265VideoParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower the profile/tier/level block's `general_level_idc` to `max` when the
|
||||
/// stream declares a higher one. The declared level is a CLAIM, and encoders
|
||||
/// over-claim in the wild (AMF stamps 6.2 — the codec maximum — on streams that
|
||||
/// need 5.2); handing the driver a level above its `maxLevelIdc` is invalid
|
||||
/// usage, while the stream's real demands are enforced by the session's coded
|
||||
/// extent and DPB depth. The "no mutation" ownership contract is about blocks a
|
||||
/// LIVE parameters object points at; this runs before the set is handed over.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
|
||||
if self._ptl_backing.general_level_idc > max {
|
||||
self._ptl_backing.general_level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted SPS plus the heap allocations its embedded pointers target.
|
||||
@@ -229,6 +242,14 @@ impl OwnedStdH265Sps {
|
||||
pub fn std(&self) -> &hh::StdVideoH265SequenceParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower `general_level_idc` to the device ceiling — [`OwnedStdH265Vps::clamp_level`]
|
||||
/// carries the argument.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
|
||||
if self._ptl_backing.general_level_idc > max {
|
||||
self._ptl_backing.general_level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted PPS plus the scaling-list allocation its `pScalingLists`
|
||||
@@ -2000,4 +2021,37 @@ mod tests {
|
||||
"the vector opens with VPS + SPS + PPS"
|
||||
);
|
||||
}
|
||||
|
||||
/// The over-declared-level clamp (the AMF 6.2-on-everything field case):
|
||||
/// lowering writes the ceiling into the PTL backing the driver will read;
|
||||
/// a ceiling at or above the declared level changes nothing.
|
||||
#[test]
|
||||
fn clamp_level_lowers_the_ptl_and_only_lowers() {
|
||||
let sps = full_sps();
|
||||
let declared = level_to_std(sps.profile_tier_level.general_level_idc);
|
||||
|
||||
let mut owned = sps_to_std_h265(&sps).unwrap();
|
||||
// SAFETY: pProfileTierLevel targets `owned`'s boxed backing.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, declared);
|
||||
// A ceiling above the declared level is a no-op.
|
||||
owned.clamp_level(hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_2);
|
||||
// SAFETY: as above.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, declared);
|
||||
// A ceiling below it is written through — and the pointer still targets
|
||||
// the wrapper's own backing (the clamp mutates in place, never re-points).
|
||||
let ceiling = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_1;
|
||||
assert!(ceiling < declared, "fixture declares above 3.1");
|
||||
owned.clamp_level(ceiling);
|
||||
// SAFETY: as above.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, ceiling);
|
||||
|
||||
let mut owned_vps = fallback_vps_from_sps(&sps).unwrap();
|
||||
owned_vps.clamp_level(ceiling);
|
||||
// SAFETY: as above, the VPS wrapper's own backing.
|
||||
let vps_level = unsafe { (*owned_vps.std().pProfileTierLevel).general_level_idc };
|
||||
assert!(vps_level <= ceiling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@ pub struct SessionConfig {
|
||||
/// The Std profile the session was created against (a profile change is a
|
||||
/// renegotiation too).
|
||||
pub std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
/// The device's `maxLevelIdc` for this profile (Std code point). Every SPS
|
||||
/// handed to the parameters object has its declared level clamped to this —
|
||||
/// see `SessionConfigH265::max_level_idc` for the whole argument.
|
||||
pub max_level_idc: hh::StdVideoH264LevelIdc,
|
||||
}
|
||||
|
||||
/// Session creation/parameter failures the decoder maps into its error type.
|
||||
@@ -593,11 +597,14 @@ impl VideoSession {
|
||||
match action {
|
||||
ParamsAction::Current => Ok(()),
|
||||
ParamsAction::Add { add_sps, add_pps } => {
|
||||
let owned_sps = if add_sps {
|
||||
let mut owned_sps = if add_sps {
|
||||
Some(sps_to_std(sps)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(s) = owned_sps.as_mut() {
|
||||
s.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
let owned_pps = if add_pps {
|
||||
Some(pps_to_std(pps)?)
|
||||
} else {
|
||||
@@ -643,7 +650,8 @@ impl VideoSession {
|
||||
pps_id = pps.pic_parameter_set_id,
|
||||
"recreating session parameters (content change or capacity)"
|
||||
);
|
||||
let owned_sps = sps_to_std(sps)?;
|
||||
let mut owned_sps = sps_to_std(sps)?;
|
||||
owned_sps.clamp_level(self.config.max_level_idc);
|
||||
let owned_pps = pps_to_std(pps)?;
|
||||
// SAFETY: fn contract — live device + live session. The wrappers
|
||||
// are MOVED IN and come back owned by the fresh object, so they
|
||||
|
||||
@@ -260,6 +260,12 @@ pub struct SessionConfigH265 {
|
||||
/// format / bit depths, all four of which a stream can renegotiate (an SPS
|
||||
/// switching Main→Main 10 mid-stream is a session rebuild, not an update).
|
||||
pub profile: H265ProfileKey,
|
||||
/// The device's `maxLevelIdc` for this profile (Std code point). Every VPS/SPS
|
||||
/// handed to the parameters object has its declared level clamped to this —
|
||||
/// over-declared levels are common (AMF stamps 6.2 on 4K streams) and a set
|
||||
/// above the ceiling is invalid usage, while the stream's real demands are
|
||||
/// already enforced by `max_coded_extent` / `max_dpb_slots`.
|
||||
pub max_level_idc: hh::StdVideoH265LevelIdc,
|
||||
}
|
||||
|
||||
/// A live parameters object **and every Std parameter set it was given**, in one
|
||||
@@ -525,12 +531,18 @@ impl VideoSessionH265 {
|
||||
} => {
|
||||
// Every owned wrapper below stays alive until after the update
|
||||
// call: the Std structs embed pointers into their heap blocks.
|
||||
let owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
|
||||
let owned_sps = if add_sps {
|
||||
let mut owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
|
||||
let mut owned_sps = if add_sps {
|
||||
Some(sps_to_std_h265(sps)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(v) = owned_vps.as_mut() {
|
||||
v.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
if let Some(s) = owned_sps.as_mut() {
|
||||
s.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
let owned_pps = if add_pps {
|
||||
Some(pps_to_std_h265(pps)?)
|
||||
} else {
|
||||
@@ -582,8 +594,10 @@ impl VideoSessionH265 {
|
||||
pps_id = pps.pic_parameter_set_id,
|
||||
"recreating H.265 session parameters (content change or capacity)"
|
||||
);
|
||||
let owned_vps = vps.to_std()?;
|
||||
let owned_sps = sps_to_std_h265(sps)?;
|
||||
let mut owned_vps = vps.to_std()?;
|
||||
let mut owned_sps = sps_to_std_h265(sps)?;
|
||||
owned_vps.clamp_level(self.config.max_level_idc);
|
||||
owned_sps.clamp_level(self.config.max_level_idc);
|
||||
let owned_pps = pps_to_std_h265(pps)?;
|
||||
// SAFETY: fn contract — live device + live session. The wrappers
|
||||
// are MOVED IN and come back owned by the fresh object, so they
|
||||
|
||||
@@ -268,7 +268,7 @@ fn flagged(flags: &[bool]) -> Vec<usize> {
|
||||
flags
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &d)| d)
|
||||
.filter(|&(_, &d)| d)
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
//! 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;
|
||||
// Panic-free: a panic here would cross the extern boundary and abort the host. The Err arm
|
||||
// is unreachable in practice (size ≤ i32::MAX can't overflow the layout), and ADL treats a
|
||||
// null from its allocator as an ordinary failure.
|
||||
let Ok(layout) = std::alloc::Layout::from_size_align(size, 16) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
// 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(layout) 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();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -217,18 +217,32 @@ fn journal_and_disable(targets: Vec<(String, String)>) -> Vec<String> {
|
||||
disabled
|
||||
}
|
||||
|
||||
/// Re-enable `ids` (teardown / recovery) and clear them from the journal.
|
||||
/// Re-enable `ids` (teardown / recovery) and clear the ones that actually re-enabled from the
|
||||
/// journal. A FAILED re-enable must keep its journal entry: it is the only record that the
|
||||
/// devnode is still disabled, and the next host start's [`startup_recover`] is the only thing
|
||||
/// left that will retry it. (The old behavior cleared every requested id unconditionally — a
|
||||
/// mid-life re-enable failure erased its own crash-recovery entry, leaving the operator's
|
||||
/// monitor invisible to Windows AND to every display listing until they re-enabled it by hand
|
||||
/// in Device Manager: the "my displays are gone until I restart everything" field class.)
|
||||
pub fn enable_instances(ids: &[String]) -> u32 {
|
||||
let mut ok = 0u32;
|
||||
let mut reenabled: Vec<&String> = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
if set_devnode(id, false) {
|
||||
tracing::info!(id, "PnP-disable: monitor devnode re-enabled");
|
||||
reenabled.push(id);
|
||||
ok += 1;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
id,
|
||||
"PnP-disable: monitor devnode re-enable FAILED — keeping its crash-journal \
|
||||
entry so the next host start retries (until then this monitor stays disabled)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let journal: Vec<String> = read_journal()
|
||||
.into_iter()
|
||||
.filter(|j| !ids.contains(j))
|
||||
.filter(|j| !reenabled.contains(&j))
|
||||
.collect();
|
||||
write_journal(&journal);
|
||||
ok
|
||||
|
||||
@@ -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)",
|
||||
@@ -1962,29 +1971,173 @@ pub fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
isolate_journal::clear();
|
||||
}
|
||||
|
||||
/// Every display target that still EXISTS right now — `(adapter LUID low, high, target id)` keys
|
||||
/// from a full `QDC_ALL_PATHS` sweep, counting a target present when the OS says a monitor is
|
||||
/// attached (`targetAvailable`) OR an active path drives it (the flag reads FALSE transiently
|
||||
/// right after a removal — same rule as [`target_inventory`]). `None` when the CCD query itself
|
||||
/// fails, so the caller can fall back to trusting its snapshot verbatim.
|
||||
fn available_target_keys() -> Option<Vec<(u32, i32, u32)>> {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
// SAFETY: the CCD contract at the top of this file — `&mut np`/`&mut nm` are live locals the
|
||||
// OS fills with the counts it wants for these flags.
|
||||
if unsafe { GetDisplayConfigBufferSizes(QDC_ALL_PATHS, &mut np, &mut nm) }.is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
// SAFETY: the CCD contract — `paths`/`modes` were just allocated with exactly `np`/`nm`
|
||||
// elements from the sizing call above, and are handed over with those same counts.
|
||||
if unsafe {
|
||||
QueryDisplayConfig(
|
||||
QDC_ALL_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
paths.truncate(np as usize);
|
||||
let mut keys: Vec<(u32, i32, u32)> = Vec::new();
|
||||
for p in &paths {
|
||||
let t = &p.targetInfo;
|
||||
let key = (t.adapterId.LowPart, t.adapterId.HighPart, t.id);
|
||||
let present = t.targetAvailable.as_bool() || p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0;
|
||||
if present && !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
Some(keys)
|
||||
}
|
||||
|
||||
/// Drop every snapshot path whose TARGET no longer exists (`avail` — the live
|
||||
/// [`available_target_keys`] sweep) and rebuild the mode table with only the entries the
|
||||
/// survivors reference, remapping their `modeInfoIdx` slots. Both halves matter:
|
||||
/// `SetDisplayConfig(SDC_USE_SUPPLIED_DISPLAY_CONFIG)` validates the WHOLE submission, so one
|
||||
/// stale path — or one orphaned mode entry left behind by a dropped path — fails the entire
|
||||
/// restore with 0x57 ERROR_INVALID_PARAMETER. Returns `(paths, modes, dropped_path_count)`;
|
||||
/// pure over its inputs so the remap arithmetic is unit-testable without a live CCD.
|
||||
fn prune_saved_config_for_targets(
|
||||
paths: &[DISPLAYCONFIG_PATH_INFO],
|
||||
modes: &[DISPLAYCONFIG_MODE_INFO],
|
||||
avail: &[(u32, i32, u32)],
|
||||
) -> (
|
||||
Vec<DISPLAYCONFIG_PATH_INFO>,
|
||||
Vec<DISPLAYCONFIG_MODE_INFO>,
|
||||
usize,
|
||||
) {
|
||||
let mut kept: Vec<DISPLAYCONFIG_PATH_INFO> = Vec::with_capacity(paths.len());
|
||||
let mut new_modes: Vec<DISPLAYCONFIG_MODE_INFO> = Vec::with_capacity(modes.len());
|
||||
// old mode index → new mode index, memoized: clone configs legitimately share a source mode
|
||||
// entry between paths, and it must land in the rebuilt table exactly once.
|
||||
let mut remap: Vec<Option<u32>> = vec![None; modes.len()];
|
||||
let take =
|
||||
|idx: u32, new_modes: &mut Vec<DISPLAYCONFIG_MODE_INFO>, remap: &mut Vec<Option<u32>>| {
|
||||
if idx == DISPLAYCONFIG_PATH_MODE_IDX_INVALID {
|
||||
return DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
|
||||
}
|
||||
match modes.get(idx as usize) {
|
||||
// An out-of-range index could never have applied — un-pin the mode rather than
|
||||
// shipping a table the whole submission fails on.
|
||||
None => DISPLAYCONFIG_PATH_MODE_IDX_INVALID,
|
||||
Some(m) => match remap[idx as usize] {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let n = new_modes.len() as u32;
|
||||
new_modes.push(*m);
|
||||
remap[idx as usize] = Some(n);
|
||||
n
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
let mut dropped = 0usize;
|
||||
for p in paths {
|
||||
let t = &p.targetInfo;
|
||||
if !avail.contains(&(t.adapterId.LowPart, t.adapterId.HighPart, t.id)) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
let mut p = *p;
|
||||
// SAFETY: POD union reads (CCD header contract) — `modeInfoIdx` overlays a same-sized
|
||||
// bitfield struct, both valid for every bit pattern; used only as bounds-checked indices.
|
||||
let (src_idx, tgt_idx) = unsafe {
|
||||
(
|
||||
p.sourceInfo.Anonymous.modeInfoIdx,
|
||||
p.targetInfo.Anonymous.modeInfoIdx,
|
||||
)
|
||||
};
|
||||
p.sourceInfo.Anonymous.modeInfoIdx = take(src_idx, &mut new_modes, &mut remap);
|
||||
p.targetInfo.Anonymous.modeInfoIdx = take(tgt_idx, &mut new_modes, &mut remap);
|
||||
kept.push(p);
|
||||
}
|
||||
(kept, new_modes, dropped)
|
||||
}
|
||||
|
||||
fn restore_displays_ccd_inner(saved: &SavedConfig) {
|
||||
let (paths, modes) = saved;
|
||||
if paths.is_empty() {
|
||||
let (saved_paths, saved_modes) = saved;
|
||||
if saved_paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: the CCD contract at the top of this file — the path/mode arrays go over as
|
||||
// slices, so pointer and length cannot disagree, and both outlive this synchronous
|
||||
// call. `retry_set_display_config` binds it to the input desktop, which is the one
|
||||
// precondition a caller of this global-state write could otherwise get wrong.
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| unsafe {
|
||||
SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||
)
|
||||
});
|
||||
if rc == 0 {
|
||||
tracing::info!("display isolate (CCD): restored original topology");
|
||||
} else {
|
||||
// Prune the snapshot against what is STILL ATTACHED before replaying it. A monitor unplugged
|
||||
// mid-session leaves the snapshot referencing an absent target, and SetDisplayConfig rejects
|
||||
// the WHOLE array with 0x57 ERROR_INVALID_PARAMETER — nothing restores, the desk stays dark,
|
||||
// and the next session snapshots that wreckage (the poisoned-snapshot chain's first link;
|
||||
// field 2026-08-12: rc=0x57 across a mid-session unplug, then sessions flipping between
|
||||
// black/working at random). Dropping the stale paths lets the surviving displays restore
|
||||
// normally; when NOTHING survives there is nothing to replay and the dark-desk backstop
|
||||
// below is the whole answer.
|
||||
let (kept, pruned_modes, dropped);
|
||||
let (paths, modes): (&Vec<_>, &Vec<_>) = match available_target_keys() {
|
||||
Some(avail) => {
|
||||
(kept, pruned_modes, dropped) =
|
||||
prune_saved_config_for_targets(saved_paths, saved_modes, &avail);
|
||||
if dropped > 0 {
|
||||
tracing::warn!(
|
||||
dropped,
|
||||
kept = kept.len(),
|
||||
"display isolate (CCD): snapshot references target(s) that are no longer \
|
||||
attached (unplugged mid-session?) — pruned them so the survivors can restore \
|
||||
(a verbatim replay fails whole with rc=0x57)"
|
||||
);
|
||||
}
|
||||
(&kept, &pruned_modes)
|
||||
}
|
||||
// The availability query itself failed — replay verbatim, exactly the old behavior.
|
||||
None => (saved_paths, saved_modes),
|
||||
};
|
||||
let mut apply_rc = 0i32; // 0 also when the replay was skipped (nothing left to apply)
|
||||
if paths.is_empty() {
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): topology restore failed rc={rc:#x}{} — physical displays may be left deactivated",
|
||||
sdc_access_denied_hint(rc)
|
||||
"display isolate (CCD): nothing from the topology snapshot is still attached — \
|
||||
skipping the replay (the dark-desk backstop decides what lights up)"
|
||||
);
|
||||
} else {
|
||||
// SAFETY: the CCD contract at the top of this file — the path/mode arrays go over as
|
||||
// slices, so pointer and length cannot disagree, and both outlive this synchronous
|
||||
// call. `retry_set_display_config` binds it to the input desktop, which is the one
|
||||
// precondition a caller of this global-state write could otherwise get wrong.
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| unsafe {
|
||||
SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||
)
|
||||
});
|
||||
apply_rc = rc;
|
||||
if rc == 0 {
|
||||
tracing::info!("display isolate (CCD): restored original topology");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): topology restore failed rc={rc:#x}{} — physical displays may be left deactivated",
|
||||
sdc_access_denied_hint(rc)
|
||||
);
|
||||
}
|
||||
}
|
||||
// GUARANTEE the desk is never left all-dark. The saved config can be unappliable (field
|
||||
// rc=0x64a ERROR_BAD_CONFIGURATION: it pinned a virtual target incarnation that was since
|
||||
@@ -2020,7 +2173,7 @@ fn restore_displays_ccd_inner(saved: &SavedConfig) {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
|
||||
"display isolate (CCD): no external physical display active after the restore (rc={apply_rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
|
||||
);
|
||||
force_extend_topology();
|
||||
// Measure what the force achieved: a sink still dark AFTER the EXTEND preset can never
|
||||
@@ -2128,3 +2281,124 @@ mod live_tests {
|
||||
tracing::info!("live CCD query: {n} active display path(s)");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prune_saved_config_tests {
|
||||
//! The snapshot-prune remap arithmetic (`prune_saved_config_for_targets`) — pure over its
|
||||
//! inputs, so the 0x57-poisoned-restore fix is testable without a live CCD: a stale target's
|
||||
//! path must vanish, its modes must not orphan (an orphaned entry fails the whole
|
||||
//! SetDisplayConfig exactly like the stale path did), and clone-shared modes must land once.
|
||||
use super::*;
|
||||
|
||||
fn path(
|
||||
luid_low: u32,
|
||||
target_id: u32,
|
||||
src_mode: u32,
|
||||
tgt_mode: u32,
|
||||
) -> DISPLAYCONFIG_PATH_INFO {
|
||||
let mut p = DISPLAYCONFIG_PATH_INFO::default();
|
||||
p.targetInfo.adapterId.LowPart = luid_low;
|
||||
p.targetInfo.id = target_id;
|
||||
p.sourceInfo.adapterId.LowPart = luid_low;
|
||||
p.sourceInfo.Anonymous.modeInfoIdx = src_mode;
|
||||
p.targetInfo.Anonymous.modeInfoIdx = tgt_mode;
|
||||
p
|
||||
}
|
||||
|
||||
fn mode(marker: u32) -> DISPLAYCONFIG_MODE_INFO {
|
||||
DISPLAYCONFIG_MODE_INFO {
|
||||
id: marker,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn indices(p: &DISPLAYCONFIG_PATH_INFO) -> (u32, u32) {
|
||||
// SAFETY: POD union reads — `modeInfoIdx` overlays a same-sized bitfield struct, both
|
||||
// valid for every bit pattern (the same contract the production reads rely on).
|
||||
unsafe {
|
||||
(
|
||||
p.sourceInfo.Anonymous.modeInfoIdx,
|
||||
p.targetInfo.Anonymous.modeInfoIdx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_attached_survives_with_dense_indices() {
|
||||
let paths = vec![path(1, 100, 0, 1), path(1, 200, 2, 3)];
|
||||
let modes = vec![mode(10), mode(11), mode(12), mode(13)];
|
||||
let avail = vec![(1, 0, 100), (1, 0, 200)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert_eq!(new_modes.len(), 4);
|
||||
assert_eq!(indices(&kept[0]), (0, 1));
|
||||
assert_eq!(indices(&kept[1]), (2, 3));
|
||||
assert_eq!(new_modes[3].id, 13, "mode entries follow their paths");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gone_target_drops_its_path_and_modes() {
|
||||
// Target 200 was unplugged mid-session (the field rc=0x57 case): its path AND its two
|
||||
// mode entries must vanish, and the survivor's indices must be remapped dense.
|
||||
let paths = vec![path(1, 100, 0, 1), path(1, 200, 2, 3)];
|
||||
let modes = vec![mode(10), mode(11), mode(12), mode(13)];
|
||||
let avail = vec![(1, 0, 100)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 1);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].targetInfo.id, 100);
|
||||
assert_eq!(
|
||||
new_modes.len(),
|
||||
2,
|
||||
"the dropped path's modes must not orphan"
|
||||
);
|
||||
assert_eq!((new_modes[0].id, new_modes[1].id), (10, 11));
|
||||
assert_eq!(indices(&kept[0]), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clone_shared_source_mode_lands_exactly_once() {
|
||||
// Clone configs share one source mode entry between paths — the rebuilt table must
|
||||
// contain it once, referenced by both survivors.
|
||||
let paths = vec![path(1, 100, 0, 1), path(1, 200, 0, 2)];
|
||||
let modes = vec![mode(10), mode(11), mode(12)];
|
||||
let avail = vec![(1, 0, 100), (1, 0, 200)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(new_modes.len(), 3);
|
||||
let (a_src, _) = indices(&kept[0]);
|
||||
let (b_src, _) = indices(&kept[1]);
|
||||
assert_eq!(a_src, b_src, "shared source mode keeps one table entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpinned_and_corrupt_indices_stay_unpinned() {
|
||||
// The INVALID sentinel must pass through, and an out-of-range index (a corrupt snapshot)
|
||||
// must degrade to unpinned rather than shipping a table the whole apply fails on.
|
||||
let paths = vec![path(1, 100, DISPLAYCONFIG_PATH_MODE_IDX_INVALID, 99)];
|
||||
let modes = vec![mode(10)];
|
||||
let avail = vec![(1, 0, 100)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 0);
|
||||
assert!(new_modes.is_empty());
|
||||
assert_eq!(
|
||||
indices(&kept[0]),
|
||||
(
|
||||
DISPLAYCONFIG_PATH_MODE_IDX_INVALID,
|
||||
DISPLAYCONFIG_PATH_MODE_IDX_INVALID
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_adapters_do_not_alias_the_same_target_id() {
|
||||
// Target ids are only unique per adapter LUID — a survivor on adapter 2 must not keep a
|
||||
// stale path alive on adapter 1 just because the ids match.
|
||||
let paths = vec![path(1, 100, 0, 1)];
|
||||
let modes = vec![mode(10), mode(11)];
|
||||
let avail = vec![(2, 0, 100)];
|
||||
let (kept, _, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!((kept.len(), dropped), (0, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -506,9 +506,11 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
|
||||
|
||||
/// Client: serialize and send one input event to the host.
|
||||
///
|
||||
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` is a valid client handle; `ev` points to a valid [`InputEvent`].
|
||||
#[no_mangle]
|
||||
/// `s` is a valid client handle; `ev` points to a readable `InputEvent`-sized allocation.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_send_input(
|
||||
s: *mut PunktfunkSession,
|
||||
ev: *const InputEvent,
|
||||
@@ -521,12 +523,11 @@ pub unsafe extern "C" fn punktfunk_send_input(
|
||||
Some(s) => s,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
// 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 ev = match unsafe { ev.as_ref() } {
|
||||
Some(e) => e,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
// SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
|
||||
// for the one field where a reference formed too early would be UB instead.
|
||||
let ev = match unsafe { read_input_event(ev) } {
|
||||
Ok(e) => e,
|
||||
Err(status) => return status,
|
||||
};
|
||||
match s.inner.send_input(ev) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
@@ -535,12 +536,37 @@ pub unsafe extern "C" fn punktfunk_send_input(
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate caller memory as an [`InputEvent`] WITHOUT forming the reference first.
|
||||
///
|
||||
/// `InputEvent.kind` is a `#[repr(u8)]` enum with 16 valid discriminants, and a C embedder
|
||||
/// writing `ev->kind = 42` is not a decodable error once `&InputEvent` exists — forming the
|
||||
/// reference IS the UB, by the language's validity rule. So the tag is read as a raw byte and
|
||||
/// validated through the same `InputKind::from_u8` the wire path uses (`input.rs::decode`),
|
||||
/// and the typed reference comes into existence only afterwards. Every other field is a plain
|
||||
/// integer (or the `[u8; 3]` pad), valid for any bit pattern.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ev` is null (reported as a status) or readable for `size_of::<InputEvent>()` bytes.
|
||||
unsafe fn read_input_event<'a>(ev: *const InputEvent) -> Result<&'a InputEvent, PunktfunkStatus> {
|
||||
if ev.is_null() {
|
||||
return Err(PunktfunkStatus::NullPointer);
|
||||
}
|
||||
// SAFETY: non-null per the check above, readable per this fn's contract; a one-byte read
|
||||
// at offset 0 (the `kind` tag — repr(C) puts it first) cannot itself be UB for any value.
|
||||
if crate::input::InputKind::from_u8(unsafe { ev.cast::<u8>().read() }).is_none() {
|
||||
return Err(PunktfunkStatus::InvalidArg);
|
||||
}
|
||||
// SAFETY: non-null, readable, and the discriminant byte was just validated — every field
|
||||
// of the repr(C) struct now holds a valid bit pattern for its type.
|
||||
Ok(unsafe { &*ev })
|
||||
}
|
||||
|
||||
/// Register the host-side input callback (pass a NULL fn pointer to clear). The callback
|
||||
/// fires from within [`punktfunk_host_poll_input`], on the calling thread.
|
||||
///
|
||||
/// # 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
|
||||
@@ -566,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;
|
||||
@@ -607,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,
|
||||
@@ -1401,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,
|
||||
@@ -1442,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,
|
||||
@@ -1486,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,
|
||||
@@ -1531,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,
|
||||
@@ -1579,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,
|
||||
@@ -1632,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,
|
||||
@@ -1685,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,
|
||||
@@ -1741,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,
|
||||
@@ -1802,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,
|
||||
@@ -1863,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,
|
||||
@@ -2096,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,
|
||||
@@ -2139,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,
|
||||
@@ -2174,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,
|
||||
@@ -2238,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,
|
||||
@@ -2303,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,
|
||||
@@ -2354,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,
|
||||
@@ -2394,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,
|
||||
@@ -2452,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,
|
||||
@@ -2518,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,
|
||||
@@ -2592,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,
|
||||
@@ -2623,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,
|
||||
@@ -2680,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,
|
||||
@@ -2760,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,
|
||||
@@ -2841,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,
|
||||
@@ -2907,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,
|
||||
@@ -2944,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,
|
||||
@@ -2992,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,
|
||||
@@ -3060,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,
|
||||
@@ -3112,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,
|
||||
@@ -3161,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,
|
||||
@@ -3192,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,
|
||||
@@ -3239,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,
|
||||
@@ -3289,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,
|
||||
@@ -3319,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,
|
||||
@@ -3349,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,
|
||||
@@ -3372,10 +3398,12 @@ pub unsafe extern "C" fn punktfunk_connection_shard_payload(
|
||||
|
||||
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||
///
|
||||
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `ev` points to a valid [`InputEvent`].
|
||||
/// `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,
|
||||
@@ -3388,12 +3416,11 @@ pub unsafe extern "C" fn punktfunk_connection_send_input(
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
// 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 ev = match unsafe { ev.as_ref() } {
|
||||
Some(e) => e,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
// SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
|
||||
// for the one field where a reference formed too early would be UB instead.
|
||||
let ev = match unsafe { read_input_event(ev) } {
|
||||
Ok(e) => e,
|
||||
Err(status) => return status,
|
||||
};
|
||||
match c.inner.send_input(ev) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
@@ -3410,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,
|
||||
@@ -3451,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,
|
||||
@@ -3489,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,
|
||||
@@ -3539,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,
|
||||
@@ -3582,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,
|
||||
@@ -3623,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,
|
||||
@@ -3808,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,
|
||||
@@ -3839,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,
|
||||
@@ -3868,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,
|
||||
@@ -3924,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,
|
||||
@@ -3973,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,
|
||||
@@ -4012,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,
|
||||
@@ -4040,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,
|
||||
@@ -4091,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,
|
||||
@@ -4122,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,
|
||||
@@ -4157,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,
|
||||
@@ -4191,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,
|
||||
@@ -4225,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,
|
||||
@@ -4261,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 {
|
||||
@@ -4293,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,
|
||||
@@ -4328,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,
|
||||
@@ -4362,7 +4389,7 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_connection_note_frame_index_ex(
|
||||
c: *const PunktfunkConnection,
|
||||
frame_index: u32,
|
||||
@@ -4396,7 +4423,7 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index_ex(
|
||||
/// # 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,
|
||||
@@ -4441,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,
|
||||
@@ -4468,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,
|
||||
@@ -4504,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,
|
||||
@@ -4576,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,
|
||||
@@ -4604,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,
|
||||
@@ -4651,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
|
||||
@@ -4668,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() {
|
||||
@@ -4694,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)))
|
||||
}
|
||||
@@ -4703,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() {
|
||||
@@ -4719,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
|
||||
@@ -4741,7 +4768,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
|
||||
g: *mut ReanchorGate,
|
||||
expected_drops: u64,
|
||||
@@ -4764,7 +4791,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
|
||||
///
|
||||
/// # 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,
|
||||
@@ -4796,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,
|
||||
@@ -4825,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,
|
||||
@@ -4854,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,
|
||||
@@ -4877,6 +4904,30 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A C embedder writing `ev->kind = 42` must come back as a status code, not UB. The test
|
||||
/// stages the event in `MaybeUninit` storage so no `&InputEvent` to an invalid value ever
|
||||
/// exists on the test's own side either.
|
||||
#[test]
|
||||
fn read_input_event_rejects_null_and_bad_discriminant() {
|
||||
// SAFETY: null is the documented reported-not-UB case.
|
||||
let null_result = unsafe { read_input_event(std::ptr::null()) };
|
||||
assert_eq!(null_result.unwrap_err(), PunktfunkStatus::NullPointer);
|
||||
|
||||
let mut slot = core::mem::MaybeUninit::<InputEvent>::zeroed();
|
||||
let p = slot.as_mut_ptr();
|
||||
// SAFETY: writing one byte at offset 0 of aligned, sized storage.
|
||||
unsafe { p.cast::<u8>().write(42) };
|
||||
// SAFETY: `p` is aligned and readable for the full struct.
|
||||
let bad_tag = unsafe { read_input_event(p) };
|
||||
assert_eq!(bad_tag.unwrap_err(), PunktfunkStatus::InvalidArg);
|
||||
|
||||
// SAFETY: as above; tag 0 (KeyDown) + zeroed fields is a fully valid event.
|
||||
unsafe { p.cast::<u8>().write(0) };
|
||||
// SAFETY: as above.
|
||||
let ev = unsafe { read_input_event(p) }.expect("valid tag must pass");
|
||||
assert_eq!(ev.kind, crate::input::InputKind::KeyDown);
|
||||
}
|
||||
|
||||
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
|
||||
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
|
||||
/// packing idiom — no struct growth, so the size guard above stays at 19).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,14 +7,22 @@
|
||||
//!
|
||||
//! Reliability (this is the whole point — a sleeping host has no ARP entry, so a plain unicast
|
||||
//! can't wake it, and `255.255.255.255` alone leaves only via the default route). For each
|
||||
//! known host MAC we send the 102-byte packet to:
|
||||
//! * every non-loopback IPv4 interface's **subnet-directed broadcast** (routes to that NIC's
|
||||
//! segment — this is what covers multi-homed clients on VPN/docker/multiple LANs), and
|
||||
//! * the **limited broadcast** `255.255.255.255`, and
|
||||
//! * optionally a **unicast** to the host's last-known IP (covers the brief window where the
|
||||
//! host is reachable but hasn't re-advertised, and NICs that wake on a directed unicast),
|
||||
//! known host MAC we send the 102-byte packet:
|
||||
//! * **out of every non-loopback IPv4 interface**, from a socket bound to that interface's own
|
||||
//! address, to both that NIC's **subnet-directed broadcast** and the **limited broadcast**
|
||||
//! `255.255.255.255` — binding the source is what forces the datagram onto that segment
|
||||
//! instead of whatever the default route happens to be (a VPN/mesh interface, typically), and
|
||||
//! * from an unbound socket to `255.255.255.255` and, when known, a **unicast** to the host's
|
||||
//! last-known IP (covers the brief window where the host is reachable but hasn't
|
||||
//! re-advertised, and NICs that wake on a directed unicast),
|
||||
//!
|
||||
//! on the two conventional WoL ports (9 and 7), repeated a few times to survive UDP loss.
|
||||
//!
|
||||
//! **Wi-Fi hosts (WoWLAN) ride the same path**, and the per-interface egress above is what makes
|
||||
//! them work: a station in WoWLAN sleep stays associated, and the AP buffers broadcast frames for
|
||||
//! its sleeping stations and flushes them on the next DTIM beacon — so the broadcast does reach
|
||||
//! the sleeping NIC, but only if the datagram actually leaves via the wireless interface. The
|
||||
//! host end of it (arming the NIC's magic-packet trigger) is `punktfunk-host`'s `wol` module.
|
||||
|
||||
use std::io;
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
||||
@@ -64,41 +72,63 @@ pub fn build_magic_packet(mac: Mac) -> [u8; 102] {
|
||||
/// directed broadcast with no route) doesn't fail the whole wake. Errors only if no socket
|
||||
/// could be opened or nothing could be sent at all.
|
||||
pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::Result<()> {
|
||||
send_magic_packet_on(macs, last_known_ip, &WOL_PORTS)
|
||||
}
|
||||
|
||||
/// [`send_magic_packet`] with the destination ports spelled out. Private because the ports are
|
||||
/// not a caller's business — it exists so the tests can aim a real send at a port they're allowed
|
||||
/// to bind (9 and 7 are privileged) and assert the bytes that come off the wire.
|
||||
fn send_magic_packet_on(
|
||||
macs: &[Mac],
|
||||
last_known_ip: Option<Ipv4Addr>,
|
||||
ports: &[u16],
|
||||
) -> io::Result<()> {
|
||||
if macs.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"no MAC addresses",
|
||||
));
|
||||
}
|
||||
let packets: Vec<[u8; 102]> = macs.iter().map(|m| build_magic_packet(*m)).collect();
|
||||
|
||||
// Build the target IP set: each interface's directed broadcast, the limited broadcast, and
|
||||
// the optional last-known unicast. Dedup so a single-NIC client doesn't send twice.
|
||||
let mut targets = broadcast_addrs();
|
||||
targets.push(Ipv4Addr::BROADCAST); // 255.255.255.255
|
||||
// Targets that go out the default route (or wherever the routing table sends them): the
|
||||
// limited broadcast as a baseline, plus the optional unicast — destination routing picks the
|
||||
// right NIC for a unicast, so it doesn't need per-interface treatment.
|
||||
let mut routed: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
|
||||
if let Some(ip) = last_known_ip {
|
||||
targets.push(ip);
|
||||
routed.push(ip);
|
||||
}
|
||||
targets.sort_unstable();
|
||||
targets.dedup();
|
||||
|
||||
// One broadcast-enabled socket bound to all interfaces. Directed broadcasts route to the
|
||||
// matching NIC via the routing table; the limited broadcast leaves via the default route.
|
||||
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
|
||||
sock.set_broadcast(true)?;
|
||||
|
||||
let mut sent_any = false;
|
||||
for _ in 0..BURST {
|
||||
for mac in macs {
|
||||
let pkt = build_magic_packet(*mac);
|
||||
for ip in &targets {
|
||||
for port in WOL_PORTS {
|
||||
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, port));
|
||||
if sock.send_to(&pkt, dst).is_ok() {
|
||||
sent_any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-interface pass. One socket per non-loopback IPv4 address, bound to that address so the
|
||||
// datagram leaves on THAT segment: without this, `255.255.255.255` follows the default route
|
||||
// only (a VPN/mesh NIC on most of these machines) and never touches the LAN — or the Wi-Fi
|
||||
// segment the sleeping WoWLAN station is associated to.
|
||||
for (local, bcast) in local_v4_segments() {
|
||||
let Ok(sock) = UdpSocket::bind(SocketAddrV4::new(local, 0)) else {
|
||||
// Bind failed (address just went away, or the OS refuses it) — fall back to the
|
||||
// routed socket below, which still reaches this segment's directed broadcast.
|
||||
routed.push(bcast);
|
||||
continue;
|
||||
};
|
||||
if sock.set_broadcast(true).is_err() {
|
||||
routed.push(bcast);
|
||||
continue;
|
||||
}
|
||||
sent_any |= blast(&sock, &packets, &[bcast, Ipv4Addr::BROADCAST], ports);
|
||||
}
|
||||
|
||||
// Routed pass, and the only pass on a machine whose interfaces can't be enumerated.
|
||||
if let Ok(sock) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) {
|
||||
// A refused SO_BROADCAST doesn't abort the pass: the unicast target still goes out, and
|
||||
// the per-interface sockets above may already have carried the broadcast.
|
||||
let _ = sock.set_broadcast(true);
|
||||
routed.sort_unstable();
|
||||
routed.dedup();
|
||||
sent_any |= blast(&sock, &packets, &routed, ports);
|
||||
} else if !sent_any {
|
||||
return Err(io::Error::other("no socket could be opened for the wake"));
|
||||
}
|
||||
|
||||
if sent_any {
|
||||
@@ -108,10 +138,33 @@ pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::R
|
||||
}
|
||||
}
|
||||
|
||||
/// Subnet-directed broadcast address of every non-loopback IPv4 interface (`ip | !netmask`,
|
||||
/// or the OS-provided broadcast when present). Best-effort: interface enumeration failing
|
||||
/// (permissions, exotic platform) yields an empty list, and the limited broadcast still fires.
|
||||
fn broadcast_addrs() -> Vec<Ipv4Addr> {
|
||||
/// Send every packet to every target, on every port, [`BURST`] times. Returns whether any
|
||||
/// single datagram made it out — an unroutable target is expected and never fails the wake.
|
||||
fn blast(sock: &UdpSocket, packets: &[[u8; 102]], targets: &[Ipv4Addr], ports: &[u16]) -> bool {
|
||||
let mut sent_any = false;
|
||||
for _ in 0..BURST {
|
||||
for pkt in packets {
|
||||
for ip in targets {
|
||||
// A degenerate 0.0.0.0 (unconfigured NIC) is not a destination.
|
||||
if ip.is_unspecified() {
|
||||
continue;
|
||||
}
|
||||
for port in ports {
|
||||
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, *port));
|
||||
if sock.send_to(pkt, dst).is_ok() {
|
||||
sent_any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sent_any
|
||||
}
|
||||
|
||||
/// Every non-loopback IPv4 interface as `(its own address, its subnet-directed broadcast)`. The
|
||||
/// broadcast is the OS-provided one where present, else `ip | !netmask`. Best-effort: enumeration
|
||||
/// failing (permissions, exotic platform) yields an empty list and the routed pass still fires.
|
||||
fn local_v4_segments() -> Vec<(Ipv4Addr, Ipv4Addr)> {
|
||||
let mut out = Vec::new();
|
||||
let ifaces = match if_addrs::get_if_addrs() {
|
||||
Ok(i) => i,
|
||||
@@ -122,14 +175,13 @@ fn broadcast_addrs() -> Vec<Ipv4Addr> {
|
||||
continue;
|
||||
}
|
||||
if let if_addrs::IfAddr::V4(v4) = iface.addr {
|
||||
if v4.ip.is_unspecified() {
|
||||
continue; // nothing to bind to
|
||||
}
|
||||
let bcast = v4
|
||||
.broadcast
|
||||
.unwrap_or_else(|| Ipv4Addr::from(u32::from(v4.ip) | !u32::from(v4.netmask)));
|
||||
// Skip a degenerate 0.0.0.0 (unconfigured) and the all-ones limited broadcast we
|
||||
// already add unconditionally.
|
||||
if !bcast.is_unspecified() && bcast != Ipv4Addr::BROADCAST {
|
||||
out.push(bcast);
|
||||
}
|
||||
out.push((v4.ip, bcast));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -183,10 +235,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_addrs_never_contains_limited_or_unspecified() {
|
||||
for b in broadcast_addrs() {
|
||||
assert_ne!(b, Ipv4Addr::BROADCAST);
|
||||
assert!(!b.is_unspecified());
|
||||
fn local_segments_are_bindable_and_have_a_broadcast() {
|
||||
for (local, bcast) in local_v4_segments() {
|
||||
// The local address is what we bind the per-interface socket to, so it must be a
|
||||
// real address — and it must never be the loopback (filtered) or unspecified.
|
||||
assert!(!local.is_unspecified());
|
||||
assert!(!local.is_loopback());
|
||||
assert!(!bcast.is_unspecified());
|
||||
// Binding to an address the OS just reported must work; a failure here would mean
|
||||
// the per-interface pass silently degrades to the routed one.
|
||||
assert!(UdpSocket::bind(SocketAddrV4::new(local, 0)).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blast_reports_nothing_sent_for_an_empty_target_list() {
|
||||
let sock = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback");
|
||||
let pkt = [build_magic_packet([1, 2, 3, 4, 5, 6])];
|
||||
assert!(!blast(&sock, &pkt, &[], &WOL_PORTS));
|
||||
// An unconfigured 0.0.0.0 target is skipped rather than sent to.
|
||||
assert!(!blast(&sock, &pkt, &[Ipv4Addr::UNSPECIFIED], &WOL_PORTS));
|
||||
// Loopback is a real destination — this one must go out.
|
||||
assert!(blast(&sock, &pkt, &[Ipv4Addr::LOCALHOST], &[9999]));
|
||||
}
|
||||
|
||||
/// The whole send path, end to end: a real receiver gets a real magic packet with the right
|
||||
/// bytes. Aimed at loopback on an unprivileged port (WoL's own 9 and 7 need root to bind),
|
||||
/// which exercises the routed pass's unicast leg — the one a WoWLAN host is woken by when
|
||||
/// the AP filters broadcast to sleeping stations.
|
||||
#[test]
|
||||
fn send_delivers_the_magic_packet_to_a_listener() {
|
||||
let rx = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind receiver");
|
||||
let port = rx.local_addr().expect("local addr").port();
|
||||
rx.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.expect("read timeout");
|
||||
|
||||
let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
|
||||
send_magic_packet_on(&[mac], Some(Ipv4Addr::LOCALHOST), &[port]).expect("send");
|
||||
|
||||
let mut buf = [0u8; 256];
|
||||
let (n, _from) = rx.recv_from(&mut buf).expect("a magic packet must arrive");
|
||||
assert_eq!(n, 102);
|
||||
assert_eq!(buf[..102], build_magic_packet(mac));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,24 +11,52 @@ profile="${1:-debug}"
|
||||
build_flag=""
|
||||
[ "$profile" = "release" ] && build_flag="--release"
|
||||
|
||||
echo ">> building punktfunk-core staticlib ($profile)"
|
||||
cargo build -p punktfunk-core $build_flag >/dev/null
|
||||
# PF_SAN=address instruments BOTH sides of the C boundary at once: the staticlib via
|
||||
# -Zsanitizer (nightly + -Zbuild-std, so std itself is instrumented) and the harness via
|
||||
# clang -fsanitize. LSAN rides along (detect_leaks=1) and is the only automated check on
|
||||
# the Box::into_raw/from_raw leak contract in abi.rs. Linux x86_64 only; -Zbuild-std
|
||||
# defeats sccache, so this belongs on a cron/dispatch job, not the per-push leg.
|
||||
san="${PF_SAN:-}"
|
||||
toolchain=""
|
||||
target_args=""
|
||||
target_sub=""
|
||||
if [ -n "$san" ]; then
|
||||
san_target="x86_64-unknown-linux-gnu"
|
||||
# -Zsanitizer/-Zbuild-std need a nightly; PF_SAN_TOOLCHAIN pins a dated one (CI does).
|
||||
toolchain="+${PF_SAN_TOOLCHAIN:-nightly}"
|
||||
target_args="-Z build-std --target $san_target"
|
||||
target_sub="$san_target/"
|
||||
export RUSTFLAGS="-Zsanitizer=$san${RUSTFLAGS:+ $RUSTFLAGS}"
|
||||
fi
|
||||
|
||||
staticlib="$ws/target/$profile/libpunktfunk_core.a"
|
||||
echo ">> building punktfunk-core staticlib ($profile${san:+, sanitizer=$san})"
|
||||
cargo $toolchain build $target_args -p punktfunk-core $build_flag >/dev/null
|
||||
|
||||
staticlib="$ws/target/${target_sub}$profile/libpunktfunk_core.a"
|
||||
header_dir="$ws/include"
|
||||
[ -f "$staticlib" ] || { echo "missing $staticlib"; exit 1; }
|
||||
[ -f "$header_dir/punktfunk_core.h" ] || { echo "missing generated header"; exit 1; }
|
||||
|
||||
# Ask rustc what native libs the staticlib needs to link into a C program.
|
||||
native_libs="$(cargo rustc -p punktfunk-core --lib --crate-type staticlib $build_flag -- \
|
||||
native_libs="$(cargo $toolchain rustc $target_args -p punktfunk-core --lib --crate-type staticlib $build_flag -- \
|
||||
--print native-static-libs 2>&1 | sed -n 's/.*native-static-libs: //p' | tail -1)"
|
||||
echo ">> native libs: ${native_libs:-<none>}"
|
||||
|
||||
out="$(mktemp -d)/punktfunk_harness"
|
||||
# Not mktemp: a debug+ASAN static binary can exceed a tmpfs /tmp; target/ is real disk.
|
||||
out="$ws/target/${target_sub}$profile/punktfunk_harness"
|
||||
cc="${CC:-cc}"
|
||||
cflags=""
|
||||
if [ -n "$san" ]; then
|
||||
cc="${CC:-clang}"
|
||||
cflags="-fsanitize=$san -fno-omit-frame-pointer"
|
||||
fi
|
||||
echo ">> compiling + linking harness"
|
||||
$cc -std=c11 -Wall -Wextra -O2 -I "$header_dir" \
|
||||
$cc -std=c11 -Wall -Wextra -O2 $cflags ${CFLAGS:-} -I "$header_dir" \
|
||||
"$here/harness.c" "$staticlib" $native_libs -o "$out"
|
||||
|
||||
echo ">> running"
|
||||
"$out"
|
||||
if [ -n "$san" ]; then
|
||||
ASAN_OPTIONS="detect_leaks=1${ASAN_OPTIONS:+:$ASAN_OPTIONS}" "$out"
|
||||
else
|
||||
"$out"
|
||||
fi
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -183,6 +183,10 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
mod audio_control;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
// DualSense pad-audio sink + capture, the Linux analogue of `pad_endpoint` below: the session
|
||||
// layer mints per-pad sinks and the CLI exposes the `pad-sink-test` devtest.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use linux::pad_sink;
|
||||
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
|
||||
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
|
||||
// `pad-endpoint` devtest.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
//! surround session can replace a stereo capturer without leaking a PipeWire consumer (see
|
||||
//! CLAUDE.md: a wedged link head-blocks the daemon).
|
||||
|
||||
pub(crate) mod pad_sink;
|
||||
mod stream_sink;
|
||||
|
||||
use super::{AudioCapturer, MicBackendStats, VirtualMic, SAMPLE_RATE};
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! Per-pad DualSense audio sink (Linux): one PipeWire `Audio/Sink` stream node per
|
||||
//! DualSense-family pad, wearing the identity DS5-native titles and GE-Proton's
|
||||
//! controller-audio routing match on — so a game that renders voice-coil haptics or pad-speaker
|
||||
//! audio finds "the controller's audio device" and plays into us. We own the sink, so the
|
||||
//! `process()` callback IS the capture: 4-ch F32 48 kHz (FL FR RL RR — front pair = speaker,
|
||||
//! back pair = voice coils, the same quad layout the Windows endpoint is stamped with) lands
|
||||
//! directly in the chunk channel that feeds the 0xD1 lanes (`native/pad_audio.rs`).
|
||||
//!
|
||||
//! Modeled on the stream-sink mode of [`super::PwAudioCapturer`] (same MainLoop-on-a-thread,
|
||||
//! Terminate channel, ready handshake, bounded lossy chunk hand-off) with two deliberate
|
||||
//! differences: **no default-sink claim** (nothing may auto-route here — games target it BY
|
||||
//! IDENTITY) and a low `priority.session` so WirePlumber never elects it against real hardware.
|
||||
//!
|
||||
//! **Identity** (design `dualsense-audio-haptics-and-speaker.md` §3/§5): GE-Proton 11-2+
|
||||
//! matches layered — pulse proplist (`device.bus == "usb"`, `device.vendor.id == 0x054c`,
|
||||
//! `device.product.id ∈ {0x0ce6, 0x0df2}`), then name substrings
|
||||
//! (`Sony_Interactive_Entertainment…Wireless_Controller`, `DualSense`); the community
|
||||
//! WirePlumber rule keys on the node-name substring and sets `node.description =
|
||||
//! "Wireless Controller"` (we mint it that way from the start). A pure PipeWire node cannot
|
||||
//! satisfy wine's ContainerId derivation (udev walk to a `usb_device` parent → `GUID_NULL`)
|
||||
//! nor GE's raw-ALSA fast path — both fall back to the Pulse-routed leg, which winepulse
|
||||
//! serves from exactly this node (it enumerates sinks). Every identity string has an env
|
||||
//! override for field debugging (`PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC`, with
|
||||
//! `{pad}` / `{mac}` placeholders).
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Message asking the PipeWire loop thread to quit (sent from `Drop`).
|
||||
struct Terminate;
|
||||
|
||||
/// The pad sink's fixed channel count — quad, mirroring the Windows endpoint stamp
|
||||
/// (`native/pad_audio.rs::CAP_CHANNELS` splits on the same layout).
|
||||
const PAD_CHANNELS: u32 = 4;
|
||||
|
||||
/// How many pad slots may carry a sink (`PUNKTFUNK_PAD_AUDIO_SLOTS`, default all 4 — a PipeWire
|
||||
/// stream node is cheap, unlike the Windows devnode mint whose default is 1).
|
||||
pub(crate) fn pad_audio_slots() -> u8 {
|
||||
std::env::var("PUNKTFUNK_PAD_AUDIO_SLOTS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(1, 4)
|
||||
}
|
||||
|
||||
/// Whether a PipeWire daemon is plausibly reachable from this process — the Linux analogue of
|
||||
/// "startup provisioning published at least one endpoint" for [`host_cap`]'s existence leg
|
||||
/// (`native/pad_audio.rs`). A stat, not a connect: the handshake path runs per-Hello and must
|
||||
/// not block. `PIPEWIRE_REMOTE` names a non-default socket — trust it (the session capturer
|
||||
/// honors it via libpipewire, and a wrong value degrades to spawn-time failure, pad kept).
|
||||
pub(crate) fn pipewire_reachable() -> bool {
|
||||
if std::env::var_os("PIPEWIRE_REMOTE").is_some() {
|
||||
return true;
|
||||
}
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(|dir| std::path::Path::new(&dir).join("pipewire-0").exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The pad's virtual MAC as colon-separated display hex — [`ds_pairing_reply`]'s bytes 1..7
|
||||
/// are LSB-first (the report layout `hid-playstation` adopts as the HID `uniq` via `%pMR`,
|
||||
/// i.e. printed reversed), so the display form reverses them. Unique per pad (the low octet
|
||||
/// carries the pad index), which keeps multi-pad sinks distinct for the same reason the MAC
|
||||
/// itself must be: SDL/Steam and the matchers dedup by serial.
|
||||
///
|
||||
/// [`ds_pairing_reply`]: pf_inject::dualsense_proto::ds_pairing_reply
|
||||
fn pad_mac(pad: u8) -> String {
|
||||
let reply = crate::inject::dualsense_proto::ds_pairing_reply(pad);
|
||||
let m = &reply[1..7];
|
||||
format!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
m[5], m[4], m[3], m[2], m[1], m[0]
|
||||
)
|
||||
}
|
||||
|
||||
/// Expand the `{pad}` / `{mac}` placeholders of an identity template. Callers pass the MAC in
|
||||
/// the form the surrounding string wants: colon display form for proplist values, bare hex for
|
||||
/// the ALSA-style node name (udev serials carry no colons).
|
||||
fn expand(template: &str, pad: u8, mac: &str) -> String {
|
||||
template
|
||||
.replace("{pad}", &pad.to_string())
|
||||
.replace("{mac}", mac)
|
||||
}
|
||||
|
||||
/// The full identity a pad sink wears, resolved once at open.
|
||||
struct PadSinkIdentity {
|
||||
node_name: String,
|
||||
description: String,
|
||||
serial: String,
|
||||
product_id: &'static str,
|
||||
product_name: &'static str,
|
||||
}
|
||||
|
||||
impl PadSinkIdentity {
|
||||
fn new(pad: u8, edge: bool) -> PadSinkIdentity {
|
||||
let mac = pad_mac(pad);
|
||||
let mac_bare: String = mac.chars().filter(|c| *c != ':').collect();
|
||||
let (model, product_id, product_name) = if edge {
|
||||
(
|
||||
"DualSense_Edge",
|
||||
"0df2",
|
||||
"DualSense Edge Wireless Controller",
|
||||
)
|
||||
} else {
|
||||
("DualSense", "0ce6", "DualSense Wireless Controller")
|
||||
};
|
||||
// The ALSA-style name a REAL pad's card gets from udev (vendor_product_serial), which
|
||||
// is what every known name-substring matcher was written against. `-00.analog-surround-40`
|
||||
// = card profile suffix for the quad layout.
|
||||
let node_name = match std::env::var("PUNKTFUNK_PAD_SINK_NAME") {
|
||||
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac_bare),
|
||||
_ => format!(
|
||||
"alsa_output.usb-Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}-00.analog-surround-40"
|
||||
),
|
||||
};
|
||||
// What the community WirePlumber rule renames real pads TO — minted that way directly.
|
||||
let description = match std::env::var("PUNKTFUNK_PAD_SINK_DESC") {
|
||||
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac),
|
||||
_ => "Wireless Controller".to_string(),
|
||||
};
|
||||
PadSinkIdentity {
|
||||
node_name,
|
||||
description,
|
||||
serial: format!(
|
||||
"Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}"
|
||||
),
|
||||
product_id,
|
||||
product_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A live per-pad sink + its capture. Same next-chunk contract as every
|
||||
/// [`AudioCapturer`](crate::audio::AudioCapturer): empty chunk = quiet sink (keep me), `Err` =
|
||||
/// dead loop thread (reopen me). Dropping tears the sink node down promptly via the Terminate
|
||||
/// channel (a wedged PipeWire link head-blocks the daemon — see the session capturer's docs).
|
||||
pub struct PadSinkCapturer {
|
||||
chunks: Receiver<Vec<f32>>,
|
||||
quit: pipewire::channel::Sender<Terminate>,
|
||||
/// The minted node name, for logs and the devtest.
|
||||
pub node_name: String,
|
||||
}
|
||||
|
||||
impl PadSinkCapturer {
|
||||
/// Mint the sink for wire pad `pad` (`edge` = DualSense Edge identity) and start capturing.
|
||||
/// Fails if PipeWire is unreachable — the caller's reopen-with-backoff owns the retry.
|
||||
pub fn open(pad: u8, edge: bool) -> Result<PadSinkCapturer> {
|
||||
let identity = PadSinkIdentity::new(pad, edge);
|
||||
let node_name = identity.node_name.clone();
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(64);
|
||||
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||
// Bring-up handshake (the session capturer's discipline): a PipeWire that isn't running
|
||||
// must surface as an open ERROR, engaging the caller's backoff — not a zombie thread.
|
||||
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
||||
thread::Builder::new()
|
||||
.name(format!("punktfunk-pw-pad{pad}"))
|
||||
.spawn(move || {
|
||||
if let Err(e) = pad_sink_thread(tx, quit_rx, identity, ready_tx) {
|
||||
tracing::warn!(pad, error = %format!("{e:#}"), "pipewire pad-sink thread failed");
|
||||
}
|
||||
})
|
||||
.context("spawn pipewire pad-sink thread")?;
|
||||
match ready_rx.recv_timeout(Duration::from_secs(5)) {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Err(anyhow!("pipewire pad-sink init timed out")),
|
||||
}
|
||||
Ok(PadSinkCapturer {
|
||||
chunks: rx,
|
||||
quit: quit_tx,
|
||||
node_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PadSinkCapturer {
|
||||
fn drop(&mut self) {
|
||||
// A failed send means the loop thread already exited — nothing to tear down.
|
||||
let _ = self.quit.send(Terminate);
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::audio::AudioCapturer for PadSinkCapturer {
|
||||
fn next_chunk(&mut self) -> Result<Vec<f32>> {
|
||||
match self.chunks.recv_timeout(Duration::from_secs(5)) {
|
||||
Ok(c) => Ok(c),
|
||||
// A quiet pad sink (no game rendering pad audio — the common case) is NOT a
|
||||
// failure; the per-pad streamer keeps us and its silence gate stays closed.
|
||||
Err(RecvTimeoutError::Timeout) => Ok(Vec::new()),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(anyhow!("pipewire pad-sink thread ended")),
|
||||
}
|
||||
}
|
||||
|
||||
fn channels(&self) -> u32 {
|
||||
PAD_CHANNELS
|
||||
}
|
||||
}
|
||||
|
||||
/// SPA channel positions for the pad quad: AUX0..AUX3 (`enum spa_audio_channel`:
|
||||
/// `SPA_AUDIO_CHANNEL_START_Aux` = 0x1000), NOT a positioned FL FR RL RR layout. This is the
|
||||
/// shape a REAL DualSense exposes on the PipeWire path GE-Proton's haptics were built and
|
||||
/// field-validated against: its `open_dualsense_haptic_pcm` targets the node through the
|
||||
/// bundled pipewire-alsa plugin with `aux_channels=1` — "the hidden PipeWire parent for a
|
||||
/// DualSense output exposes AUX0 through AUX3" (proton-ds5-haptic patch 0115) — and its pulse
|
||||
/// fallback forces a `PA_CHANNEL_POSITION_AUX0..3` map. On a real pad that shape is the card's
|
||||
/// Pro Audio profile (the community-reported requirement for GE ≥11-4). Aux positions carry no
|
||||
/// spatial meaning, so nothing in the graph position-remixes into (or out of) the sink —
|
||||
/// writers land by INDEX, exactly the raw quad the pad speaks: ch0/1 = speaker, ch2/3 = voice
|
||||
/// coils (the same order the Windows endpoint is stamped with and `split_quad` assumes).
|
||||
fn pad_positions() -> [u32; 64] {
|
||||
const AUX0: u32 = 0x1000;
|
||||
let mut pos = [0u32; 64];
|
||||
pos[..4].copy_from_slice(&[AUX0, AUX0 + 1, AUX0 + 2, AUX0 + 3]);
|
||||
pos
|
||||
}
|
||||
|
||||
/// The `!Send` MainLoop/Stream thread: mint the sink, hand capture chunks over, run until
|
||||
/// Terminate / daemon death. Mirrors the session capturer's `pw_thread` stream-sink arm minus
|
||||
/// the default-sink claim and the desktop-plane stats (the pad plane's observability lives in
|
||||
/// the streamer's gate/encode logs).
|
||||
fn pad_sink_thread(
|
||||
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
|
||||
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||
identity: PadSinkIdentity,
|
||||
ready: std::sync::mpsc::SyncSender<Result<()>>,
|
||||
) -> Result<()> {
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
use spa::param::audio::{AudioFormat, AudioInfoRaw};
|
||||
use spa::pod::Pod;
|
||||
|
||||
let result = (|| -> Result<()> {
|
||||
pf_capture::pwinit::ensure_init();
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw pad-sink MainLoop")?;
|
||||
let context =
|
||||
pw::context::ContextRc::new(&mainloop, None).context("pw pad-sink Context")?;
|
||||
let core = context
|
||||
.connect_rc(None)
|
||||
.context("pw pad-sink connect (is PipeWire running in this session?)")?;
|
||||
|
||||
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
|
||||
let mainloop = mainloop.clone();
|
||||
move |_| mainloop.quit()
|
||||
});
|
||||
|
||||
// Daemon death ends this thread → the chunk channel disconnects → `next_chunk` errors →
|
||||
// the per-pad streamer reopens with backoff (the session capturer's zombie-thread fix).
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.error({
|
||||
let mainloop = mainloop.clone();
|
||||
move |id, _seq, res, message| {
|
||||
tracing::warn!(id, res, message, "pipewire core error — pad sink ends");
|
||||
mainloop.quit();
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CLASS => "Audio/Sink",
|
||||
// One Opus-haptics frame (~5 ms) per quantum, like the session sink — haptics are
|
||||
// felt latency; bursty delivery would ride through to the client's jitter buffer.
|
||||
*pw::keys::NODE_LATENCY => "240/48000",
|
||||
// Must NEVER win WirePlumber's default election against real hardware — games reach
|
||||
// this sink BY IDENTITY, nothing auto-routes here (no stream_sink claim either).
|
||||
"priority.session" => "50",
|
||||
// The pulse-proplist leg of GE-Proton's match (§3): bus + vendor/product ids, plus
|
||||
// the human-readable pair pavucontrol and the game view show.
|
||||
"device.bus" => "usb",
|
||||
"device.vendor.id" => "054c",
|
||||
"device.vendor.name" => "Sony Interactive Entertainment",
|
||||
"device.form_factor" => "gamepad",
|
||||
};
|
||||
props.insert(*pw::keys::NODE_NAME, identity.node_name.as_str());
|
||||
props.insert(*pw::keys::NODE_DESCRIPTION, identity.description.as_str());
|
||||
props.insert(*pw::keys::NODE_NICK, identity.description.as_str());
|
||||
props.insert("device.serial", identity.serial.as_str());
|
||||
props.insert("device.product.id", identity.product_id);
|
||||
props.insert("device.product.name", identity.product_name);
|
||||
let stream = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props)
|
||||
.context("pw pad-sink Stream")?;
|
||||
|
||||
// Lossy-drop counter: a full channel means the 0xD1 encode thread stalled. Invisible
|
||||
// drops cost a field investigation on the desktop plane once — count and warn here too,
|
||||
// power-of-two throttled (this callback runs at the graph quantum).
|
||||
struct PadUd {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
|
||||
dropped: u64,
|
||||
}
|
||||
let ud = PadUd { tx, dropped: 0 };
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(ud)
|
||||
.state_changed({
|
||||
let mainloop = mainloop.clone();
|
||||
move |_s, _ud, old, new| {
|
||||
tracing::debug!(?old, ?new, "pipewire pad-sink stream state");
|
||||
if matches!(new, pw::stream::StreamState::Error(_)) {
|
||||
mainloop.quit();
|
||||
}
|
||||
}
|
||||
})
|
||||
.param_changed(move |_stream, _ud, id, param| {
|
||||
let Some(param) = param else { return };
|
||||
if id != pw::spa::param::ParamType::Format.as_raw() {
|
||||
return;
|
||||
}
|
||||
let mut info = AudioInfoRaw::default();
|
||||
if info.parse(param).is_ok() {
|
||||
// We own the sink, so this IS the format games render into (nothing can
|
||||
// have narrowed it upstream — the same guarantee as stream-sink mode).
|
||||
tracing::info!(
|
||||
format = ?info.format(),
|
||||
rate = info.rate(),
|
||||
channels = info.channels(),
|
||||
"pad-sink format negotiated"
|
||||
);
|
||||
}
|
||||
})
|
||||
.process(|stream, ud| {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
};
|
||||
let datas = buffer.datas_mut();
|
||||
if datas.is_empty() {
|
||||
return;
|
||||
}
|
||||
let d = &mut datas[0];
|
||||
let (offset, size) = {
|
||||
let c = d.chunk();
|
||||
(c.offset() as usize, c.size() as usize)
|
||||
};
|
||||
let Some(buf) = d.data() else { return };
|
||||
if offset > buf.len() {
|
||||
return;
|
||||
}
|
||||
let region = &buf[offset..(offset + size).min(buf.len())];
|
||||
// Negotiated as F32LE; reinterpret the byte region as interleaved f32.
|
||||
let n = region.len() / 4;
|
||||
let mut samples = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let b = [
|
||||
region[i * 4],
|
||||
region[i * 4 + 1],
|
||||
region[i * 4 + 2],
|
||||
region[i * 4 + 3],
|
||||
];
|
||||
samples.push(f32::from_le_bytes(b));
|
||||
}
|
||||
if ud.tx.try_send(samples).is_err() {
|
||||
ud.dropped += 1;
|
||||
if ud.dropped.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
dropped = ud.dropped,
|
||||
"pad-audio encode thread not keeping up — captured pad audio \
|
||||
dropped (haptics will click)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}));
|
||||
if outcome.is_err() {
|
||||
tracing::error!("panic in pipewire pad-sink callback — chunk dropped");
|
||||
}
|
||||
})
|
||||
.register()
|
||||
.context("register pad-sink stream listener")?;
|
||||
|
||||
let mut info = AudioInfoRaw::new();
|
||||
info.set_format(AudioFormat::F32LE);
|
||||
info.set_rate(crate::audio::SAMPLE_RATE);
|
||||
info.set_channels(PAD_CHANNELS);
|
||||
info.set_position(pad_positions());
|
||||
let obj = pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
||||
properties: info.into(),
|
||||
};
|
||||
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&pw::spa::pod::Value::Object(obj),
|
||||
)
|
||||
.context("serialize pad-sink format pod")?
|
||||
.0
|
||||
.into_inner();
|
||||
let mut params = [Pod::from_bytes(&values).context("pad-sink pod from bytes")?];
|
||||
|
||||
// RT_PROCESS for the same reason as every host-owned stream node here: the sink must be
|
||||
// a synchronous graph member that joins its producers' driver group, or `process()`
|
||||
// never fires on a busy graph (see the mic's connect comment in mod.rs).
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Input, // we CONSUME what games render into the sink
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||
| pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.context("pw pad-sink stream connect")?;
|
||||
|
||||
let _ = ready.send(Ok(()));
|
||||
mainloop.run();
|
||||
tracing::debug!("pipewire pad-sink loop exited (capturer dropped)");
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = &result {
|
||||
let _ = ready.send(Err(anyhow!("{e:#}")));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pad_mac_is_reversed_display_form_and_per_pad_unique() {
|
||||
// DS_FEATURE_PAIRING bytes 1..7 are 74 E7 D6 3A 53 35 LSB-first → display reverses.
|
||||
assert_eq!(pad_mac(0), "35:53:3A:D6:E7:74");
|
||||
// The pad index offsets the LOW octet — the LAST display octet.
|
||||
assert_eq!(pad_mac(1), "35:53:3A:D6:E7:75");
|
||||
assert_ne!(pad_mac(2), pad_mac(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_carries_every_match_surface() {
|
||||
let id = PadSinkIdentity::new(0, false);
|
||||
// The name-substring matchers (GE-Proton + the community WirePlumber rule).
|
||||
assert!(id.node_name.contains("Sony_Interactive_Entertainment"));
|
||||
assert!(id.node_name.contains("Wireless_Controller"));
|
||||
assert!(id.node_name.contains("DualSense"));
|
||||
assert!(id.node_name.ends_with("-00.analog-surround-40"));
|
||||
// No colons in a udev-style serial/name.
|
||||
assert!(!id.node_name.contains(':'));
|
||||
assert_eq!(id.description, "Wireless Controller");
|
||||
assert_eq!(id.product_id, "0ce6");
|
||||
let edge = PadSinkIdentity::new(1, true);
|
||||
assert!(edge.node_name.contains("DualSense_Edge"));
|
||||
assert_eq!(edge.product_id, "0df2");
|
||||
// Distinct pads mint distinct names (the serial octet).
|
||||
assert_ne!(id.node_name, PadSinkIdentity::new(1, false).node_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_expansion() {
|
||||
assert_eq!(expand("pad{pad}-{mac}", 2, "AABB"), "pad2-AABB");
|
||||
assert_eq!(expand("static", 0, "x"), "static");
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
use super::pad_endpoint as pe;
|
||||
use super::{audio_control, wiring_plan};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -40,6 +40,17 @@ const ENDPOINT_WAIT: Duration = Duration::from_secs(15);
|
||||
/// Minimum spacing between provisioning retries once the startup attempt failed
|
||||
/// ([`ensure_provisioned`] is called from wiring passes, which recur freely).
|
||||
const RETRY_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
/// Full passes that ended unlatched before minting gives up for this host lifetime (a service
|
||||
/// restart re-arms). An unlatched pass that reaches the PnP surface costs the whole BOX, not
|
||||
/// just us: the driver (re)bind raises a device-change broadcast every running app services,
|
||||
/// and games rebuild their audio graph on it — a box that cannot mint must not pay that on
|
||||
/// every retry forever (field-measured 2026-08-12 as Helldivers 2 hitching to 2–5 FPS 1% lows,
|
||||
/// one hitch per mic-pump reopen).
|
||||
const MAX_UNLATCHED_ATTEMPTS: u32 = 5;
|
||||
/// How long [`ensure_blocking`] waits on a pass another thread already runs before giving the
|
||||
/// wiring plan the unlatched answer (a full cold-boot pass worst-cases around two
|
||||
/// [`ENDPOINT_WAIT`]s plus the stamp settles).
|
||||
const BLOCKING_WAIT: Duration = Duration::from_secs(90);
|
||||
|
||||
/// The two minted roles. `value` is the persisted marker; the needles drive
|
||||
/// [`discover_driver`].
|
||||
@@ -107,6 +118,26 @@ static PROVISIONED: OnceLock<Arc<MintedAudio>> = OnceLock::new();
|
||||
static PROVISIONING: AtomicBool = AtomicBool::new(false);
|
||||
/// When the last attempt STARTED — the [`RETRY_COOLDOWN`] anchor.
|
||||
static LAST_ATTEMPT: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
/// Completed passes that did not latch, across the worker and the blocking path — the
|
||||
/// [`MAX_UNLATCHED_ATTEMPTS`] give-up counter.
|
||||
static UNLATCHED_ATTEMPTS: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Count one finished-but-unlatched pass; the crossing attempt logs the give-up exactly once.
|
||||
fn record_unlatched_attempt() {
|
||||
let n = UNLATCHED_ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if n == MAX_UNLATCHED_ATTEMPTS {
|
||||
tracing::warn!(
|
||||
attempts = n,
|
||||
"minted-audio provisioning keeps failing — giving up for this host lifetime so \
|
||||
retries stop broadcasting device changes at the whole box; the wiring plan keeps \
|
||||
the name-based ladder, a service restart re-arms minting"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn gave_up() -> bool {
|
||||
UNLATCHED_ATTEMPTS.load(Ordering::SeqCst) >= MAX_UNLATCHED_ATTEMPTS
|
||||
}
|
||||
|
||||
/// The wiring plan's tier-0 input: the minted ids, or all-empty while nothing is provisioned.
|
||||
///
|
||||
@@ -135,7 +166,7 @@ pub(crate) fn provisioned() -> Option<Arc<MintedAudio>> {
|
||||
/// Spawn the provisioning worker (idempotent; returns immediately). Called at host start next
|
||||
/// to the pad provider, and again from [`ensure_provisioned`] on the retry path.
|
||||
pub(crate) fn provision_at_startup() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || gave_up() {
|
||||
return;
|
||||
}
|
||||
if PROVISIONED.get().is_some() || PROVISIONING.swap(true, Ordering::SeqCst) {
|
||||
@@ -155,13 +186,19 @@ pub(crate) fn provision_at_startup() {
|
||||
);
|
||||
let _ = PROVISIONED.set(Arc::new(m));
|
||||
}
|
||||
Ok(_) => tracing::info!(
|
||||
"no minted audio endpoints (Steam's streaming drivers absent?) — the \
|
||||
wiring plan keeps the name-based ladder"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"minted-audio provisioning failed — the wiring plan keeps the name-based \
|
||||
ladder and a later wiring pass retries"),
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"no minted audio endpoints (Steam's streaming drivers absent?) — the \
|
||||
wiring plan keeps the name-based ladder"
|
||||
);
|
||||
record_unlatched_attempt();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"minted-audio provisioning failed — the wiring plan keeps the name-based \
|
||||
ladder and a later wiring pass retries");
|
||||
record_unlatched_attempt();
|
||||
}
|
||||
}
|
||||
PROVISIONING.store(false, Ordering::SeqCst);
|
||||
});
|
||||
@@ -175,7 +212,7 @@ pub(crate) fn provision_at_startup() {
|
||||
/// [`RETRY_COOLDOWN`] — a box where Steam arrives later mints on a later pass instead of at
|
||||
/// the next reboot.
|
||||
pub(crate) fn ensure_provisioned() {
|
||||
if PROVISIONED.get().is_some() {
|
||||
if PROVISIONED.get().is_some() || gave_up() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
@@ -219,6 +256,19 @@ fn ensure_all() -> Result<MintedAudio> {
|
||||
/// back any default device the fresh endpoint grabbed (measured on the pad program: a newly
|
||||
/// registered endpoint can take either default).
|
||||
fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
|
||||
// Steady state: a previous run's devnode with all endpoints live — resolve by marker and
|
||||
// return without touching PnP or the default-device policy. The full pass below (re)binds
|
||||
// the driver even over an existing devnode, and that bind raises a device-change broadcast
|
||||
// every running app services — right at first mint, ruinous from a retry path (each
|
||||
// broadcast makes games rebuild their audio graph; see [`MAX_UNLATCHED_ATTEMPTS`]).
|
||||
if let Some((devnode, render, capture)) = find_healthy_role(role)? {
|
||||
stamp_identity(&render, role, false);
|
||||
if let Some(cap) = capture.as_ref() {
|
||||
stamp_identity(cap, role, true);
|
||||
}
|
||||
return Ok((devnode, render, capture));
|
||||
}
|
||||
|
||||
let prev_render = audio_control::default_render_id();
|
||||
let prev_capture = audio_control::default_capture_id();
|
||||
|
||||
@@ -284,6 +334,27 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
|
||||
Ok((devnode, render, capture))
|
||||
}
|
||||
|
||||
/// The role's marker devnode with EVERY endpoint the role owes already registered, or `None`
|
||||
/// (missing devnode, missing endpoint, or an enumeration error → the caller runs the full
|
||||
/// pass). Same endpoint resolvers [`wait_for`] polls, so "healthy" here is exactly the state
|
||||
/// the full pass would declare ready.
|
||||
fn find_healthy_role(role: Role) -> Result<Option<(String, String, Option<String>)>> {
|
||||
let Some(devnode) = find_role_devnode(role)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(render) = pe::find_endpoint_for_devnode(&devnode)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let capture = match role {
|
||||
Role::Mic => match pe::find_capture_endpoint_for_devnode(&devnode)? {
|
||||
Some(cap) => Some(cap),
|
||||
None => return Ok(None),
|
||||
},
|
||||
Role::Speakers => None,
|
||||
};
|
||||
Ok(Some((devnode, render, capture)))
|
||||
}
|
||||
|
||||
/// How many stamp/settle passes a name gets before we accept "stored but not yet served"
|
||||
/// (a settled endpoint takes the stamp on the first pass; a freshly minted one may need the
|
||||
/// audio stack to notice — it serves after the next Audiosrv restart/reboot at the latest).
|
||||
@@ -510,7 +581,6 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S
|
||||
)
|
||||
}
|
||||
|
||||
/// `audio-probe mint` devtest body: one synchronous provisioning pass, results printed.
|
||||
/// Synchronous provisioning — for the mic pump's resolve and the devtests.
|
||||
///
|
||||
/// The pump's FIRST open must not race the startup worker: measured on the target box, the
|
||||
@@ -520,15 +590,50 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S
|
||||
/// (existing marker devnodes re-resolve in milliseconds; a cold boot pays the one-time mint)
|
||||
/// keeps the pump's target and the plan's verdict the same thing. Latched calls return
|
||||
/// immediately; the opt-out env is honoured like everywhere else.
|
||||
///
|
||||
/// While UNLATCHED this is where the pump's reopen backoff (capped at 60 s) used to meet an
|
||||
/// unguarded full pass: one PnP rebind + device-change broadcast roughly every minute, forever,
|
||||
/// on any box where minting cannot converge (the 2026-08-12 Helldivers 2 field report). Now a
|
||||
/// pass someone else already runs is WAITED for instead of raced, a failed pass repeats at most
|
||||
/// every [`RETRY_COOLDOWN`], and [`MAX_UNLATCHED_ATTEMPTS`] failures stop retrying for the
|
||||
/// host lifetime.
|
||||
pub(crate) fn ensure_blocking() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || PROVISIONED.get().is_some() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some()
|
||||
|| PROVISIONED.get().is_some()
|
||||
|| gave_up()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok(m) = ensure_all() {
|
||||
if m.any() {
|
||||
let _ = PROVISIONED.set(Arc::new(m));
|
||||
// A pass is in flight (the startup worker, or a concurrent resolve): wait for its verdict
|
||||
// rather than racing a second SetupAPI/PnP sweep against it — that race is how the pump
|
||||
// once ended up wired to the cable while the worker minted (the dead-mic-air deploy race).
|
||||
if PROVISIONING.swap(true, Ordering::SeqCst) {
|
||||
let deadline = Instant::now() + BLOCKING_WAIT;
|
||||
while PROVISIONING.load(Ordering::SeqCst) && Instant::now() < deadline {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// We own the slot. First-ever resolve runs unconditionally (the cold-boot mint the doc
|
||||
// above insists on); after a failed pass the cooldown answers instead of a re-run.
|
||||
let run = {
|
||||
let mut last = LAST_ATTEMPT.lock().unwrap();
|
||||
if last.is_some_and(|t| t.elapsed() < RETRY_COOLDOWN) {
|
||||
false
|
||||
} else {
|
||||
*last = Some(Instant::now());
|
||||
true
|
||||
}
|
||||
};
|
||||
if run {
|
||||
match ensure_all() {
|
||||
Ok(m) if m.any() => {
|
||||
let _ = PROVISIONED.set(Arc::new(m));
|
||||
}
|
||||
_ => record_unlatched_attempt(),
|
||||
}
|
||||
}
|
||||
PROVISIONING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub(crate) fn devtest_mint() -> Result<()> {
|
||||
|
||||
@@ -27,15 +27,28 @@
|
||||
//! device pulls per-period) whose prime depth the mic pump sets from measured uplink jitter
|
||||
//! ([`VirtualMic::set_target_depth`]), filling silence when the client isn't talking. WASAPI
|
||||
//! objects are `!Send`, so they live entirely on that thread (mirrors `WasapiLoopbackCapturer`).
|
||||
//!
|
||||
//! **Idle stop (host must be able to SLEEP).** A RUNNING WASAPI stream makes the audio stack
|
||||
//! hold a kernel power request ("An audio stream is currently in use", attributed to the
|
||||
//! target device in `powercfg /requests`) that vetoes system sleep — and this pump is
|
||||
//! host-lifetime, so rendering silence 24/7 kept every idle Punktfunk box awake forever
|
||||
//! (field report 2026-08-12: "doesn't go to sleep anymore; powercfg shows the Steam Streaming
|
||||
//! Microphone"). So after [`IDLE_STOP_AFTER`] of silence-only output the render loop stops the
|
||||
//! stream (`IAudioClient::Stop` — the client stays initialized, the mic ENDPOINT keeps
|
||||
//! existing, only the power request is released) and parks on the queue's condvar; the next
|
||||
//! pushed frame resumes it within one device period. During a session the box is kept awake by
|
||||
//! the session's own `DisplayWakeRequest` (pf-frame), never by this silence.
|
||||
//! `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a virtual audio
|
||||
//! driver misbehaves when its render side pauses.
|
||||
|
||||
use super::{audio_control, MicBackendStats, VirtualMic, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{sync_channel, SyncSender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use wasapi::{Direction, SampleType, StreamMode, WaveFormat};
|
||||
|
||||
const CHANNELS: u32 = 2;
|
||||
@@ -60,9 +73,30 @@ const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 120 / 1000) * BLOCK_ALIGN
|
||||
/// Producer-side overflow headroom (~32 ms) over the render loop's prime threshold when the
|
||||
/// adaptive target drives the ring.
|
||||
const CAP_HEADROOM_BYTES: usize = (SAMPLE_RATE as usize * 32 / 1000) * BLOCK_ALIGN;
|
||||
/// Stop the render stream after this long of silence-only output (unprimed, nothing queued), so
|
||||
/// an idle host releases the audio stack's sleep-blocking power request (see the module docs).
|
||||
/// Long enough that mid-conversation pauses never cycle the stream; resume is one condvar
|
||||
/// notify + `IAudioClient::Start`, well under the jitter buffer's prime depth.
|
||||
const IDLE_STOP_AFTER: Duration = Duration::from_secs(10);
|
||||
/// While the stream is idle-stopped the render thread parks on the queue condvar; this timeout
|
||||
/// only bounds how long a host-shutdown `stop` can go unnoticed (same discipline as the pump's
|
||||
/// `drain_sleep`).
|
||||
const IDLE_WAKE_CHECK: Duration = Duration::from_millis(250);
|
||||
|
||||
/// The mic inject ring plus the wake signal for an idle-stopped render thread: `push` notifies
|
||||
/// on the empty→non-empty transition, which is exactly the moment a stopped stream must resume.
|
||||
type MicQueue = (Mutex<VecDeque<u8>>, Condvar);
|
||||
|
||||
/// `PUNKTFUNK_MIC_ALWAYS_ON=1`: never idle-stop the render stream (pre-2026-08 behaviour — the
|
||||
/// host then blocks system sleep for its whole life). Escape hatch in case a virtual audio
|
||||
/// driver's capture side misbehaves while its render side is paused.
|
||||
fn mic_always_on() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("PUNKTFUNK_MIC_ALWAYS_ON").is_some_and(|v| v != "0"))
|
||||
}
|
||||
|
||||
pub struct WasapiVirtualMic {
|
||||
queue: Arc<Mutex<VecDeque<u8>>>,
|
||||
queue: Arc<MicQueue>,
|
||||
stop: Arc<AtomicBool>,
|
||||
/// False once the render thread has exited (device error or stop) — the pump's reopen signal.
|
||||
alive: Arc<AtomicBool>,
|
||||
@@ -94,7 +128,7 @@ impl WasapiVirtualMic {
|
||||
channels == CHANNELS,
|
||||
"virtual mic is stereo-only (got {channels})"
|
||||
);
|
||||
let queue = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
||||
let queue = Arc::new((Mutex::new(VecDeque::<u8>::new()), Condvar::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let ring = Arc::new(RingShared::default());
|
||||
@@ -144,9 +178,11 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
if !self.alive.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut q) = self.queue.lock() else {
|
||||
let (lock, wake) = &*self.queue;
|
||||
let Ok(mut q) = lock.lock() else {
|
||||
return false;
|
||||
};
|
||||
let was_empty = q.is_empty();
|
||||
q.reserve(pcm.len() * 4);
|
||||
for &s in pcm {
|
||||
q.extend(s.to_le_bytes());
|
||||
@@ -171,6 +207,12 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
.overflow
|
||||
.fetch_add((excess / BLOCK_ALIGN) as u64, Ordering::Relaxed);
|
||||
}
|
||||
drop(q);
|
||||
if was_empty {
|
||||
// Empty→non-empty is the resume moment for an idle-stopped stream (the waiter
|
||||
// re-checks the queue under the lock, so this cannot be a lost wakeup).
|
||||
wake.notify_one();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -179,7 +221,7 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
}
|
||||
|
||||
fn discard(&self) {
|
||||
if let Ok(mut q) = self.queue.lock() {
|
||||
if let Ok(mut q) = self.queue.0.lock() {
|
||||
q.clear();
|
||||
}
|
||||
}
|
||||
@@ -199,7 +241,7 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
if prime == 0 {
|
||||
return None; // render loop hasn't run yet
|
||||
}
|
||||
let q = self.queue.lock().ok()?;
|
||||
let q = self.queue.0.lock().ok()?;
|
||||
Some((q.len() / BLOCK_ALIGN, prime / BLOCK_ALIGN))
|
||||
}
|
||||
|
||||
@@ -387,7 +429,7 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
}
|
||||
|
||||
fn render_thread(
|
||||
queue: Arc<Mutex<VecDeque<u8>>>,
|
||||
queue: Arc<MicQueue>,
|
||||
stop: Arc<AtomicBool>,
|
||||
shared: Arc<RingShared>,
|
||||
ready: SyncSender<Result<String>>,
|
||||
@@ -461,7 +503,40 @@ fn render_thread(
|
||||
// the target).
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut primed = false;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// Idle stop (see the module docs): `running` mirrors the stream's Start/Stop state, and
|
||||
// `idle_mark` is the queue length last seen while unprimed plus when it was first seen at
|
||||
// that length — IDLE_STOP_AFTER of unprimed output at an UNCHANGED length is the idle
|
||||
// verdict. Keying on the length (not just emptiness) covers a sub-prime tail a vanished
|
||||
// client left behind, while any genuinely new audio moves the length (a push appends a
|
||||
// whole frame and the drop-oldest cap sits above the prime threshold, so an unprimed queue
|
||||
// can never coincidentally return to its marked length) and restarts the window.
|
||||
let always_on = mic_always_on();
|
||||
let mut running = true;
|
||||
let mut idle_mark: Option<(usize, Instant)> = None;
|
||||
'render: while !stop.load(Ordering::Relaxed) {
|
||||
if !running {
|
||||
// Idle-stopped: park on the condvar until mic audio arrives (push notifies on the
|
||||
// empty→non-empty edge); the timeout only keeps `stop` responsive.
|
||||
{
|
||||
let (lock, wake) = &*queue;
|
||||
let mut q = lock.lock().unwrap();
|
||||
while q.is_empty() {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break 'render;
|
||||
}
|
||||
let (guard, _timed_out) = wake.wait_timeout(q, IDLE_WAKE_CHECK).unwrap();
|
||||
q = guard;
|
||||
}
|
||||
}
|
||||
// A resume failure means the endpoint died while we slept — propagate, so the
|
||||
// thread exits, `alive` flips, and the pump reopens (fresh plan) as for any death.
|
||||
audio_client
|
||||
.start_stream()
|
||||
.context("resume render stream")?;
|
||||
running = true;
|
||||
idle_mark = None;
|
||||
tracing::debug!("virtual mic stream resumed (client mic audio arrived)");
|
||||
}
|
||||
// The device signals when it wants more data; finite timeout keeps `stop` responsive.
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
continue;
|
||||
@@ -486,7 +561,7 @@ fn render_thread(
|
||||
// Silence base; overwrite with queued mic PCM once the cushion is primed.
|
||||
buf[..need].fill(0);
|
||||
{
|
||||
let mut q = queue.lock().unwrap();
|
||||
let mut q = queue.0.lock().unwrap();
|
||||
if !primed && q.len() >= prime {
|
||||
primed = true;
|
||||
}
|
||||
@@ -500,6 +575,35 @@ fn render_thread(
|
||||
shared.reprimes.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
if primed {
|
||||
idle_mark = None;
|
||||
} else if !always_on {
|
||||
// Unprimed = this period was pure silence. After IDLE_STOP_AFTER of that at an
|
||||
// unchanged queue length, stop the stream so the box can sleep. Decided under
|
||||
// the queue lock, and only when nothing has arrived for the whole window (see
|
||||
// `idle_mark`'s docs) — a burst landing at the boundary moves the length and
|
||||
// resets the window instead of being cleared. What IS cleared is ≥10 s stale
|
||||
// (the pump's stale-gap discard would drop it before the next real frame
|
||||
// anyway), which keeps "queue empty" ⇔ "nothing to play" for the wait above.
|
||||
match idle_mark {
|
||||
Some((len, since)) if len == q.len() => {
|
||||
if since.elapsed() >= IDLE_STOP_AFTER {
|
||||
q.clear();
|
||||
audio_client
|
||||
.stop_stream()
|
||||
.context("idle-stop render stream")?;
|
||||
running = false;
|
||||
idle_mark = None;
|
||||
tracing::debug!(
|
||||
"virtual mic stream idle-stopped (releases the sleep-blocking \
|
||||
audio power request; next mic frame resumes it)"
|
||||
);
|
||||
continue 'render;
|
||||
}
|
||||
}
|
||||
_ => idle_mark = Some((q.len(), Instant::now())),
|
||||
}
|
||||
}
|
||||
}
|
||||
render_client
|
||||
.write_to_device(space, &buf[..need], None)
|
||||
|
||||
@@ -231,6 +231,66 @@ pub fn dualsense_test(args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mint one pad-audio PipeWire sink (the Linux 0xD1 source, `audio::pad_sink`) and capture
|
||||
/// from it — the WP3 on-glass gate with no client involved. Verify the identity with
|
||||
/// `pactl list sinks` (name/description/proplist) and drive it with
|
||||
/// `pw-play --target <node.name> <file>` (or `paplay -d <node.name>`); captured chunks print
|
||||
/// a per-second summary here. `--pad N` (default 0), `--edge`, `--seconds N` (default 30).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn pad_sink_test(args: &[String]) -> Result<()> {
|
||||
use crate::audio::AudioCapturer as _;
|
||||
use std::time::{Duration, Instant};
|
||||
let secs: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--seconds")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(30);
|
||||
let pad: u8 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--pad")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let edge = args.iter().any(|a| a == "--edge");
|
||||
let mut cap = crate::audio::pad_sink::PadSinkCapturer::open(pad, edge)
|
||||
.context("mint pad-audio sink (is PipeWire running in this session?)")?;
|
||||
println!(
|
||||
"pad sink minted: node.name = {}\n inspect: pactl list sinks | grep -A20 punktfunk-pad\n \
|
||||
drive it: pw-play --target '{}' <48k-file>\nCapturing for {secs}s…",
|
||||
cap.node_name, cap.node_name
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let (mut chunks, mut samples) = (0u64, 0u64);
|
||||
// Per-pair peaks: ch0/1 = speaker, ch2/3 = voice coils — the split_quad contract. Proving
|
||||
// the pairs separately is the point of this devtest: a positional remix upstream would
|
||||
// smear or zero one pair while a global peak still looks healthy.
|
||||
let (mut peak_spk, mut peak_coil) = (0f32, 0f32);
|
||||
let mut last_report = Instant::now();
|
||||
while Instant::now() < deadline {
|
||||
let c = cap.next_chunk().context("pad sink capture")?;
|
||||
if !c.is_empty() {
|
||||
chunks += 1;
|
||||
samples += c.len() as u64;
|
||||
for f in c.chunks_exact(4) {
|
||||
peak_spk = peak_spk.max(f[0].abs()).max(f[1].abs());
|
||||
peak_coil = peak_coil.max(f[2].abs()).max(f[3].abs());
|
||||
}
|
||||
}
|
||||
if last_report.elapsed() >= Duration::from_secs(1) {
|
||||
last_report = Instant::now();
|
||||
println!(
|
||||
" chunks={chunks} samples={samples} (~{:.1}ms of 4ch audio) \
|
||||
peak_speaker={peak_spk:.4} peak_coils={peak_coil:.4}",
|
||||
samples as f64 / (4.0 * 48.0)
|
||||
);
|
||||
(chunks, samples, peak_spk, peak_coil) = (0, 0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
println!("pad-sink-test: done");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
|
||||
/// streaming session): answers the full hid-nintendo probe conversation, then cycles the
|
||||
/// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
|
||||
|
||||
@@ -508,15 +508,25 @@ fn running_as_system() -> bool {
|
||||
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() {
|
||||
return true; // fail closed
|
||||
}
|
||||
let mut buf = [0u8; 256];
|
||||
// TOKEN_USER is align-8; a bare `[u8; 256]` is align-1, and forming `&TOKEN_USER` out of it
|
||||
// below would be UB by the language rule whenever the stack slot happens to land misaligned.
|
||||
// (Shipped codegen happens to 8-align it today — that is luck, not a guarantee.) The wrapper
|
||||
// keeps the buffer at 256 BYTES: redeclaring as `[u64; 32]` would silently turn the length
|
||||
// argument below into 32 — `len()` counts elements — and a console operator's 44-byte
|
||||
// TOKEN_USER+SID would then fail with ERROR_INSUFFICIENT_BUFFER, misclassifying every
|
||||
// hand-run host as SYSTEM (it fits exactly for SYSTEM's own 16-byte S-1-5-18, so a
|
||||
// SYSTEM-side test would not catch it).
|
||||
#[repr(align(8))]
|
||||
struct TokenUserBuf([u8; 256]);
|
||||
let mut buf = TokenUserBuf([0u8; 256]);
|
||||
let mut len = 0u32;
|
||||
// SAFETY: `buf` is a writable local of the length passed; `len` is a live out-param.
|
||||
let got = unsafe {
|
||||
GetTokenInformation(
|
||||
token,
|
||||
TokenUser,
|
||||
Some(buf.as_mut_ptr().cast()),
|
||||
buf.len() as u32,
|
||||
Some(buf.0.as_mut_ptr().cast()),
|
||||
std::mem::size_of_val(&buf) as u32,
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
@@ -542,11 +552,22 @@ fn running_as_system() -> bool {
|
||||
{
|
||||
return true; // fail closed
|
||||
}
|
||||
// SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation; its `User.Sid` points into
|
||||
// the same buffer, and both SIDs are valid for this comparison.
|
||||
// SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation (align guaranteed by
|
||||
// TokenUserBuf); its `User.Sid` points into the same buffer, and both SIDs are valid for
|
||||
// this comparison.
|
||||
unsafe {
|
||||
let tu = &*(buf.as_ptr() as *const TOKEN_USER);
|
||||
EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())).is_ok()
|
||||
let tu = &*(buf.0.as_ptr() as *const TOKEN_USER);
|
||||
// windows-rs maps EqualSid's BOOL(0) to Err BOTH for "SIDs differ" and for a genuine
|
||||
// failure, telling them apart only via GetLastError — so clear it first (a stale value
|
||||
// from an earlier call would otherwise read as failure) and split three ways. `.is_ok()`
|
||||
// here previously meant an EqualSid ERROR yielded "not SYSTEM" — the fail-OPEN
|
||||
// direction, contradicting the contract in the doc comment above.
|
||||
windows::Win32::Foundation::SetLastError(windows::Win32::Foundation::WIN32_ERROR(0));
|
||||
match EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())) {
|
||||
Ok(()) => true, // equal: we are SYSTEM
|
||||
Err(e) if e.code().is_ok() => false, // BOOL(0), last-error 0: genuinely not equal
|
||||
Err(_) => true, // EqualSid itself failed: fail closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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") },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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` by mutating
|
||||
// the process env, which edition 2024 makes an unsafe call; 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` by mutating
|
||||
// the process env, an unsafe call 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
|
||||
@@ -613,6 +623,9 @@ fn real_main() -> Result<()> {
|
||||
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session).
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("dualsense-test") => devtest::dualsense_test(&args),
|
||||
// Mint one pad-audio PipeWire sink and capture from it — the Linux 0xD1 source gate.
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("pad-sink-test") => devtest::pad_sink_test(&args),
|
||||
// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no session).
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("switchpro-test") => devtest::switchpro_test(&args),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user